Created
May 22, 2020 15:31
-
-
Save jchiatt/5de574843015b87add26100856497472 to your computer and use it in GitHub Desktop.
Sentry intstruments/monkeypatching
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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