Skip to content

Instantly share code, notes, and snippets.

@hoon-ho-jung
Forked from jchiatt/instrument.ts
Created September 27, 2023 02:33
Show Gist options
  • Select an option

  • Save hoon-ho-jung/63f41d4db42d25c028f1394c447f9728 to your computer and use it in GitHub Desktop.

Select an option

Save hoon-ho-jung/63f41d4db42d25c028f1394c447f9728 to your computer and use it in GitHub Desktop.

Revisions

  1. @jchiatt jchiatt created this gist May 22, 2020.
    527 changes: 527 additions & 0 deletions instrument.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,527 @@
    /* tslint:disable:only-arrow-functions no-unsafe-any */

    import { WrappedFunction } from '@sentry/types';

    import { isInstanceOf, isString } from './is';
    import { logger } from './logger';
    import { getFunctionName, getGlobalObject } from './misc';
    import { fill } from './object';
    import { supportsHistory, supportsNativeFetch } from './supports';

    const global = getGlobalObject<Window>();

    /** Object describing handler that will be triggered for a given `type` of instrumentation */
    interface InstrumentHandler {
    type: InstrumentHandlerType;
    callback: InstrumentHandlerCallback;
    }
    type InstrumentHandlerType =
    | 'console'
    | 'dom'
    | 'fetch'
    | 'history'
    | 'sentry'
    | 'xhr'
    | 'error'
    | 'unhandledrejection';
    type InstrumentHandlerCallback = (data: any) => void;

    /**
    * Instrument native APIs to call handlers that can be used to create breadcrumbs, APM spans etc.
    * - Console API
    * - Fetch API
    * - XHR API
    * - History API
    * - DOM API (click/typing)
    * - Error API
    * - UnhandledRejection API
    */

    const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } = {};
    const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};

    /** Instruments given API */
    function instrument(type: InstrumentHandlerType): void {
    if (instrumented[type]) {
    return;
    }

    instrumented[type] = true;

    switch (type) {
    case 'console':
    instrumentConsole();
    break;
    case 'dom':
    instrumentDOM();
    break;
    case 'xhr':
    instrumentXHR();
    break;
    case 'fetch':
    instrumentFetch();
    break;
    case 'history':
    instrumentHistory();
    break;
    case 'error':
    instrumentError();
    break;
    case 'unhandledrejection':
    instrumentUnhandledRejection();
    break;
    default:
    logger.warn('unknown instrumentation type:', type);
    }
    }

    /**
    * Add handler that will be called when given type of instrumentation triggers.
    * Use at your own risk, this might break without changelog notice, only used internally.
    * @hidden
    */
    export function addInstrumentationHandler(handler: InstrumentHandler): void {
    // tslint:disable-next-line:strict-type-predicates
    if (!handler || typeof handler.type !== 'string' || typeof handler.callback !== 'function') {
    return;
    }
    handlers[handler.type] = handlers[handler.type] || [];
    (handlers[handler.type] as InstrumentHandlerCallback[]).push(handler.callback);
    instrument(handler.type);
    }

    /** JSDoc */
    function triggerHandlers(type: InstrumentHandlerType, data: any): void {
    if (!type || !handlers[type]) {
    return;
    }

    for (const handler of handlers[type] || []) {
    try {
    handler(data);
    } catch (e) {
    logger.error(
    `Error while triggering instrumentation handler.\nType: ${type}\nName: ${getFunctionName(
    handler,
    )}\nError: ${e}`,
    );
    }
    }
    }

    /** JSDoc */
    function instrumentConsole(): void {
    if (!('console' in global)) {
    return;
    }

    ['debug', 'info', 'warn', 'error', 'log', 'assert'].forEach(function(level: string): void {
    if (!(level in global.console)) {
    return;
    }

    fill(global.console, level, function(originalConsoleLevel: () => any): Function {
    return function(...args: any[]): void {
    triggerHandlers('console', { args, level });

    // this fails for some browsers. :(
    if (originalConsoleLevel) {
    Function.prototype.apply.call(originalConsoleLevel, global.console, args);
    }
    };
    });
    });
    }

    /** JSDoc */
    function instrumentFetch(): void {
    if (!supportsNativeFetch()) {
    return;
    }

    fill(global, 'fetch', function(originalFetch: () => void): () => void {
    return function(...args: any[]): void {
    const commonHandlerData = {
    args,
    fetchData: {
    method: getFetchMethod(args),
    url: getFetchUrl(args),
    },
    startTimestamp: Date.now(),
    };

    triggerHandlers('fetch', {
    ...commonHandlerData,
    });

    return originalFetch.apply(global, args).then(
    (response: Response) => {
    triggerHandlers('fetch', {
    ...commonHandlerData,
    endTimestamp: Date.now(),
    response,
    });
    return response;
    },
    (error: Error) => {
    triggerHandlers('fetch', {
    ...commonHandlerData,
    endTimestamp: Date.now(),
    error,
    });
    throw error;
    },
    );
    };
    });
    }

    /** JSDoc */
    interface SentryWrappedXMLHttpRequest extends XMLHttpRequest {
    [key: string]: any;
    __sentry_xhr__?: {
    method?: string;
    url?: string;
    status_code?: number;
    };
    }

    /** Extract `method` from fetch call arguments */
    function getFetchMethod(fetchArgs: any[] = []): string {
    if ('Request' in global && isInstanceOf(fetchArgs[0], Request) && fetchArgs[0].method) {
    return String(fetchArgs[0].method).toUpperCase();
    }
    if (fetchArgs[1] && fetchArgs[1].method) {
    return String(fetchArgs[1].method).toUpperCase();
    }
    return 'GET';
    }

    /** Extract `url` from fetch call arguments */
    function getFetchUrl(fetchArgs: any[] = []): string {
    if (typeof fetchArgs[0] === 'string') {
    return fetchArgs[0];
    }
    if ('Request' in global && isInstanceOf(fetchArgs[0], Request)) {
    return fetchArgs[0].url;
    }
    return String(fetchArgs[0]);
    }

    /** JSDoc */
    function instrumentXHR(): void {
    if (!('XMLHttpRequest' in global)) {
    return;
    }

    const xhrproto = XMLHttpRequest.prototype;

    fill(xhrproto, 'open', function(originalOpen: () => void): () => void {
    return function(this: SentryWrappedXMLHttpRequest, ...args: any[]): void {
    const url = args[1];
    this.__sentry_xhr__ = {
    method: isString(args[0]) ? args[0].toUpperCase() : args[0],
    url: args[1],
    };

    // if Sentry key appears in URL, don't capture it as a request
    if (isString(url) && this.__sentry_xhr__.method === 'POST' && url.match(/sentry_key/)) {
    this.__sentry_own_request__ = true;
    }

    return originalOpen.apply(this, args);
    };
    });

    fill(xhrproto, 'send', function(originalSend: () => void): () => void {
    return function(this: SentryWrappedXMLHttpRequest, ...args: any[]): void {
    const xhr = this; // tslint:disable-line:no-this-assignment
    const commonHandlerData = {
    args,
    startTimestamp: Date.now(),
    xhr,
    };

    triggerHandlers('xhr', {
    ...commonHandlerData,
    });

    xhr.addEventListener('readystatechange', function(): void {
    if (xhr.readyState === 4) {
    try {
    // touching statusCode in some platforms throws
    // an exception
    if (xhr.__sentry_xhr__) {
    xhr.__sentry_xhr__.status_code = xhr.status;
    }
    } catch (e) {
    /* do nothing */
    }
    triggerHandlers('xhr', {
    ...commonHandlerData,
    endTimestamp: Date.now(),
    });
    }
    });

    return originalSend.apply(this, args);
    };
    });
    }

    let lastHref: string;

    /** JSDoc */
    function instrumentHistory(): void {
    if (!supportsHistory()) {
    return;
    }

    const oldOnPopState = global.onpopstate;
    global.onpopstate = function(this: WindowEventHandlers, ...args: any[]): any {
    const to = global.location.href;
    // keep track of the current URL state, as we always receive only the updated state
    const from = lastHref;
    lastHref = to;
    triggerHandlers('history', {
    from,
    to,
    });
    if (oldOnPopState) {
    return oldOnPopState.apply(this, args);
    }
    };

    /** @hidden */
    function historyReplacementFunction(originalHistoryFunction: () => void): () => void {
    return function(this: History, ...args: any[]): void {
    const url = args.length > 2 ? args[2] : undefined;
    if (url) {
    // coerce to string (this is what pushState does)
    const from = lastHref;
    const to = String(url);
    // keep track of the current URL state, as we always receive only the updated state
    lastHref = to;
    triggerHandlers('history', {
    from,
    to,
    });
    }
    return originalHistoryFunction.apply(this, args);
    };
    }

    fill(global.history, 'pushState', historyReplacementFunction);
    fill(global.history, 'replaceState', historyReplacementFunction);
    }

    /** JSDoc */
    function instrumentDOM(): void {
    if (!('document' in global)) {
    return;
    }

    // Capture breadcrumbs from any click that is unhandled / bubbled up all the way
    // to the document. Do this before we instrument addEventListener.
    global.document.addEventListener('click', domEventHandler('click', triggerHandlers.bind(null, 'dom')), false);
    global.document.addEventListener('keypress', keypressEventHandler(triggerHandlers.bind(null, 'dom')), false);

    // After hooking into document bubbled up click and keypresses events, we also hook into user handled click & keypresses.
    ['EventTarget', 'Node'].forEach((target: string) => {
    const proto = (global as any)[target] && (global as any)[target].prototype;

    if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) {
    return;
    }

    fill(proto, 'addEventListener', function(
    original: () => void,
    ): (
    eventName: string,
    fn: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
    ) => void {
    return function(
    this: any,
    eventName: string,
    fn: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
    ): (eventName: string, fn: EventListenerOrEventListenerObject, capture?: boolean, secure?: boolean) => void {
    if (fn && (fn as EventListenerObject).handleEvent) {
    if (eventName === 'click') {
    fill(fn, 'handleEvent', function(innerOriginal: () => void): (caughtEvent: Event) => void {
    return function(this: any, event: Event): (event: Event) => void {
    domEventHandler('click', triggerHandlers.bind(null, 'dom'))(event);
    return innerOriginal.call(this, event);
    };
    });
    }
    if (eventName === 'keypress') {
    fill(fn, 'handleEvent', function(innerOriginal: () => void): (caughtEvent: Event) => void {
    return function(this: any, event: Event): (event: Event) => void {
    keypressEventHandler(triggerHandlers.bind(null, 'dom'))(event);
    return innerOriginal.call(this, event);
    };
    });
    }
    } else {
    if (eventName === 'click') {
    domEventHandler('click', triggerHandlers.bind(null, 'dom'), true)(this);
    }
    if (eventName === 'keypress') {
    keypressEventHandler(triggerHandlers.bind(null, 'dom'))(this);
    }
    }

    return original.call(this, eventName, fn, options);
    };
    });

    fill(proto, 'removeEventListener', function(
    original: () => void,
    ): (
    this: any,
    eventName: string,
    fn: EventListenerOrEventListenerObject,
    options?: boolean | EventListenerOptions,
    ) => () => void {
    return function(
    this: any,
    eventName: string,
    fn: EventListenerOrEventListenerObject,
    options?: boolean | EventListenerOptions,
    ): () => void {
    let callback = fn as WrappedFunction;
    try {
    callback = callback && (callback.__sentry_wrapped__ || callback);
    } catch (e) {
    // ignore, accessing __sentry_wrapped__ will throw in some Selenium environments
    }
    return original.call(this, eventName, callback, options);
    };
    });
    });
    }

    const debounceDuration: number = 1000;
    let debounceTimer: number = 0;
    let keypressTimeout: number | undefined;
    let lastCapturedEvent: Event | undefined;

    /**
    * Wraps addEventListener to capture UI breadcrumbs
    * @param name the event name (e.g. "click")
    * @param handler function that will be triggered
    * @param debounce decides whether it should wait till another event loop
    * @returns wrapped breadcrumb events handler
    * @hidden
    */
    function domEventHandler(name: string, handler: Function, debounce: boolean = false): (event: Event) => void {
    return (event: Event) => {
    // reset keypress timeout; e.g. triggering a 'click' after
    // a 'keypress' will reset the keypress debounce so that a new
    // set of keypresses can be recorded
    keypressTimeout = undefined;
    // It's possible this handler might trigger multiple times for the same
    // event (e.g. event propagation through node ancestors). Ignore if we've
    // already captured the event.
    if (!event || lastCapturedEvent === event) {
    return;
    }

    lastCapturedEvent = event;

    if (debounceTimer) {
    clearTimeout(debounceTimer);
    }

    if (debounce) {
    debounceTimer = setTimeout(() => {
    handler({ event, name });
    });
    } else {
    handler({ event, name });
    }
    };
    }

    /**
    * Wraps addEventListener to capture keypress UI events
    * @param handler function that will be triggered
    * @returns wrapped keypress events handler
    * @hidden
    */
    function keypressEventHandler(handler: Function): (event: Event) => void {
    // TODO: if somehow user switches keypress target before
    // debounce timeout is triggered, we will only capture
    // a single breadcrumb from the FIRST target (acceptable?)
    return (event: Event) => {
    let target;

    try {
    target = event.target;
    } catch (e) {
    // just accessing event properties can throw an exception in some rare circumstances
    // see: https://github.com/getsentry/raven-js/issues/838
    return;
    }

    const tagName = target && (target as HTMLElement).tagName;

    // only consider keypress events on actual input elements
    // this will disregard keypresses targeting body (e.g. tabbing
    // through elements, hotkeys, etc)
    if (!tagName || (tagName !== 'INPUT' && tagName !== 'TEXTAREA' && !(target as HTMLElement).isContentEditable)) {
    return;
    }

    // record first keypress in a series, but ignore subsequent
    // keypresses until debounce clears
    if (!keypressTimeout) {
    domEventHandler('input', handler)(event);
    }
    clearTimeout(keypressTimeout);

    keypressTimeout = (setTimeout(() => {
    keypressTimeout = undefined;
    }, debounceDuration) as any) as number;
    };
    }

    let _oldOnErrorHandler: OnErrorEventHandler = null;
    /** JSDoc */
    function instrumentError(): void {
    _oldOnErrorHandler = global.onerror;

    global.onerror = function(msg: any, url: any, line: any, column: any, error: any): boolean {
    triggerHandlers('error', {
    column,
    error,
    line,
    msg,
    url,
    });

    if (_oldOnErrorHandler) {
    return _oldOnErrorHandler.apply(this, arguments);
    }

    return false;
    };
    }

    let _oldOnUnhandledRejectionHandler: ((e: any) => void) | null = null;
    /** JSDoc */
    function instrumentUnhandledRejection(): void {
    _oldOnUnhandledRejectionHandler = global.onunhandledrejection;

    global.onunhandledrejection = function(e: any): boolean {
    triggerHandlers('unhandledrejection', e);

    if (_oldOnUnhandledRejectionHandler) {
    return _oldOnUnhandledRejectionHandler.apply(this, arguments);
    }

    return true;
    };
    }