Skip to content

Instantly share code, notes, and snippets.

@jchiatt
Created May 22, 2020 15:31
Show Gist options
  • Save jchiatt/5de574843015b87add26100856497472 to your computer and use it in GitHub Desktop.
Save jchiatt/5de574843015b87add26100856497472 to your computer and use it in GitHub Desktop.
Sentry intstruments/monkeypatching
/* 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;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment