diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 88c4b6126ba2..d6eb86b70a7d 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -1,10 +1,7 @@ -import { captureException, getCurrentHub, withScope } from '@sentry/core'; +import { captureException, withScope } from '@sentry/core'; import { Event as SentryEvent, Mechanism, Scope, WrappedFunction } from '@sentry/types'; -import { addExceptionMechanism, addExceptionTypeValue, htmlTreeAsString, normalize } from '@sentry/utils'; +import { addExceptionMechanism, addExceptionTypeValue, normalize } from '@sentry/utils'; -const debounceDuration: number = 1000; -let keypressTimeout: number | undefined; -let lastCapturedEvent: Event | undefined; let ignoreOnError: number = 0; /** @@ -37,7 +34,6 @@ export function wrap( fn: WrappedFunction, options: { mechanism?: Mechanism; - capture?: boolean; } = {}, before?: WrappedFunction, ): any { @@ -64,15 +60,15 @@ export function wrap( } const sentryWrapped: WrappedFunction = function(this: any): void { - // tslint:disable-next-line:strict-type-predicates - if (before && typeof before === 'function') { - before.apply(this, arguments); - } - const args = Array.prototype.slice.call(arguments); // tslint:disable:no-unsafe-any try { + // tslint:disable-next-line:strict-type-predicates + if (before && typeof before === 'function') { + before.apply(this, arguments); + } + const wrappedArguments = args.map((arg: any) => wrap(arg, options)); if (fn.handleEvent) { @@ -82,7 +78,6 @@ export function wrap( // is expected behavior and NOT indicative of a bug with sentry.javascript. return fn.handleEvent.apply(this, wrappedArguments); } - // Attempt to invoke user-land function // NOTE: If you are a Sentry user, and you are seeing this stack frame, it // means the sentry.javascript SDK caught an error invoking your application code. This @@ -163,106 +158,3 @@ export function wrap( return sentryWrapped; } - -let debounceTimer: number = 0; - -/** - * Wraps addEventListener to capture UI breadcrumbs - * @param eventName the event name (e.g. "click") - * @returns wrapped breadcrumb events handler - * @hidden - */ -export function breadcrumbEventHandler(eventName: string, 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; - - const captureBreadcrumb = () => { - let target; - - // Accessing event.target can throw (see getsentry/raven-js#838, #768) - try { - target = event.target ? htmlTreeAsString(event.target as Node) : htmlTreeAsString((event as unknown) as Node); - } catch (e) { - target = ''; - } - - if (target.length === 0) { - return; - } - - getCurrentHub().addBreadcrumb( - { - category: `ui.${eventName}`, // e.g. ui.click, ui.input - message: target, - }, - { - event, - name: eventName, - }, - ); - }; - - if (debounceTimer) { - clearTimeout(debounceTimer); - } - - if (debounce) { - debounceTimer = setTimeout(captureBreadcrumb); - } else { - captureBreadcrumb(); - } - }; -} - -/** - * Wraps addEventListener to capture keypress UI events - * @returns wrapped keypress events handler - * @hidden - */ -export function keypressEventHandler(): (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) { - breadcrumbEventHandler('input')(event); - } - clearTimeout(keypressTimeout); - - keypressTimeout = (setTimeout(() => { - keypressTimeout = undefined; - }, debounceDuration) as any) as number; - }; -} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index a9fa8558bd28..80db0d2e0bdd 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -38,6 +38,7 @@ export { BrowserOptions } from './backend'; export { BrowserClient, ReportDialogOptions } from './client'; export { defaultIntegrations, forceLoad, init, lastEventId, onLoad, showReportDialog, flush, close, wrap } from './sdk'; export { SDK_NAME, SDK_VERSION } from './version'; +export { addInstrumentationHandler } from './instrument'; import { Integrations as CoreIntegrations } from '@sentry/core'; import { getGlobalObject } from '@sentry/utils'; diff --git a/packages/browser/src/instrument.ts b/packages/browser/src/instrument.ts new file mode 100644 index 000000000000..a5365b27ea0c --- /dev/null +++ b/packages/browser/src/instrument.ts @@ -0,0 +1,501 @@ +/* tslint:disable:only-arrow-functions no-unsafe-any */ + +import { API, getCurrentHub } from '@sentry/core'; +import { WrappedFunction } from '@sentry/types'; +import { + fill, + getFunctionName, + getGlobalObject, + isString, + logger, + supportsHistory, + supportsNativeFetch, +} from '@sentry/utils'; + +import { BrowserClient } from './client'; + +const global = getGlobalObject(); + +/** 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'; +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) + */ + +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; + 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 && fetchArgs[0] instanceof 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 && fetchArgs[0] instanceof 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], + }; + + const client = getCurrentHub().getClient(); + const dsn = client && client.getDsn(); + if (dsn) { + const filterUrl = new API(dsn).getStoreEndpoint(); + // if Sentry key appears in URL, don't capture it as a request + // but rather as our own 'sentry' type breadcrumb + if (isString(url) && (filterUrl && url.indexOf(filterUrl) !== -1)) { + 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, + }); + + /** + * @hidden + */ + function onreadystatechangeHandler(): 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(), + }); + } + } + + if ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') { + fill(xhr, 'onreadystatechange', function(original: WrappedFunction): Function { + return function(...readyStateArgs: any[]): void { + onreadystatechangeHandler(); + return original.apply(xhr, readyStateArgs); + }; + }); + } else { + // if onreadystatechange wasn't actually set by the page on this xhr, we + // are free to set our own and capture the breadcrumb + xhr.onreadystatechange = onreadystatechangeHandler; + } + + 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; + }; +} diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 378f574456e3..0f0b118d7cb6 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -1,22 +1,17 @@ -// TODO: Rename this whole file to `instrument.ts` and make a distinction between instrumenting (wrapping the API) -// and creating a breadcrumb (writing an `InstrumentHandler`) - import { API, getCurrentHub } from '@sentry/core'; -import { Integration, WrappedFunction } from '@sentry/types'; -import { fill, getGlobalObject, isString, logger, supportsHistory, supportsNativeFetch } from '@sentry/utils'; - -import { BrowserClient } from '../client'; -import { breadcrumbEventHandler, keypressEventHandler, wrap } from '../helpers'; - +import { Integration, Severity } from '@sentry/types'; import { - defaultHandlers, - InstrumentHandler, - InstrumentHandlerCallback, - InstrumentHandlerType, -} from './instrumenthandlers'; + getEventDescription, + getGlobalObject, + htmlTreeAsString, + logger, + normalize, + parseUrl, + safeJoin, +} from '@sentry/utils'; -const global = getGlobalObject(); -let lastHref: string | undefined; +import { BrowserClient } from '../client'; +import { addInstrumentationHandler } from '../instrument'; /** * @hidden @@ -38,11 +33,8 @@ interface BreadcrumbIntegrations { history?: boolean; sentry?: boolean; xhr?: boolean; - handlers?: InstrumentHandler[]; } -type XMLHttpRequestProp = 'onload' | 'onerror' | 'onprogress'; - /** * Default Breadcrumbs instrumentations * TODO: Deprecated - with v6, this will be renamed to `Instrument` @@ -61,16 +53,6 @@ export class Breadcrumbs implements Integration { /** JSDoc */ private readonly _options: BreadcrumbIntegrations; - /** JSDoc */ - private readonly _handlers: { [key in InstrumentHandlerType]: InstrumentHandlerCallback[] } = { - console: [], - dom: [], - fetch: [], - history: [], - sentry: [], - xhr: [], - }; - /** * @inheritDoc */ @@ -84,371 +66,197 @@ export class Breadcrumbs implements Integration { xhr: true, ...options, }; - this._setupHandlers([...defaultHandlers, ...(this._options.handlers || [])]); } - /** JSDoc */ - private _setupHandlers(handlers: InstrumentHandler[]): void { - for (const handler of handlers) { - // tslint:disable-next-line:strict-type-predicates - if (!handler || typeof handler.type !== 'string' || typeof handler.callback !== 'function') { - continue; + /** + * Creates breadcrumbs from console API calls + */ + private _consoleBreadcrumb(handlerData: { [key: string]: any }): void { + const breadcrumb = { + category: 'console', + data: { + extra: { + arguments: normalize(handlerData.args, 3), + }, + logger: 'console', + }, + level: Severity.fromString(handlerData.level), + message: safeJoin(handlerData.args, ' '), + }; + + if (handlerData.level === 'assert') { + if (handlerData.args[0] === false) { + breadcrumb.message = `Assertion failed: ${safeJoin(handlerData.args.slice(1), ' ') || 'console.assert'}`; + breadcrumb.data.extra.arguments = normalize(handlerData.args.slice(1), 3); + } else { + // Don't capture a breadcrumb for passed assertions + return; } - this._handlers[handler.type].push(handler.callback); } + + getCurrentHub().addBreadcrumb(breadcrumb, { + input: handlerData.args, + level: handlerData.level, + }); } - /** JSDoc */ - private _triggerHandlers(type: InstrumentHandlerType, data: any): void { - if (!getCurrentHub().getIntegration(Breadcrumbs)) { - return; + /** + * Creates breadcrumbs from DOM API calls + */ + private _domBreadcrumb(handlerData: { [key: string]: any }): void { + let target; + + // Accessing event.target can throw (see getsentry/raven-js#838, #768) + try { + target = handlerData.event.target + ? htmlTreeAsString(handlerData.event.target as Node) + : htmlTreeAsString((handlerData.event as unknown) as Node); + } catch (e) { + target = ''; } - if (!type || !this._handlers[type]) { + if (target.length === 0) { return; } - for (const handler of this._handlers[type]) { - try { - handler(data); - } catch (e) { - logger.error( - `Error while triggering instrumentation handler.\nType: ${type}\nName: ${handler.name || - ''}\nError: ${e}`, - ); - } - } + getCurrentHub().addBreadcrumb( + { + category: `ui.${handlerData.name}`, + message: target, + }, + { + event, + name: handlerData.name, + }, + ); } - /** JSDoc */ - private _instrumentConsole(): void { - if (!('console' in global)) { - return; - } - - const triggerHandlers = this._triggerHandlers.bind(this, 'console'); - - ['debug', 'info', 'warn', 'error', 'log', 'assert'].forEach(function(level: string): void { - if (!(level in global.console)) { + /** + * Creates breadcrumbs from XHR API calls + */ + private _xhrBreadcrumb(handlerData: { [key: string]: any }): void { + if (handlerData.endTimestamp) { + // We only capture complete, non-sentry requests + if (handlerData.xhr.__sentry_own_request__) { return; } - fill(global.console, level, function(originalConsoleLevel: () => any): Function { - return function(...args: any[]): void { - const handlerData = { - args, - level, - }; - triggerHandlers(handlerData); - - // this fails for some browsers. :( - if (originalConsoleLevel) { - Function.prototype.apply.call(originalConsoleLevel, global.console, args); - } - }; - }); - }); - } + getCurrentHub().addBreadcrumb( + { + category: 'xhr', + data: handlerData.xhr.__sentry_xhr__, + type: 'http', + }, + { + xhr: handlerData.xhr, + }, + ); - /** JSDoc */ - private _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', breadcrumbEventHandler('click'), false); - global.document.addEventListener('keypress', keypressEventHandler(), 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 { - breadcrumbEventHandler('click')(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()(event); - return innerOriginal.call(this, event); - }; - }); - } - } else { - if (eventName === 'click') { - breadcrumbEventHandler('click', true)(this); - } - if (eventName === 'keypress') { - keypressEventHandler()(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); - }; - }); - }); - } - - /** JSDoc */ - private _instrumentFetch(): void { - if (!supportsNativeFetch()) { - return; + // We only capture issued sentry requests + if (handlerData.xhr.__sentry_own_request__) { + addSentryBreadcrumb(handlerData.args[0]); } - - const triggerHandlers = this._triggerHandlers.bind(this, 'fetch'); - - fill(global, 'fetch', function(originalFetch: () => void): () => void { - return function(...args: any[]): void { - const handlerData: { [key: string]: any } = { - args, - endTimestamp: Date.now(), - fetchData: { - method: getFetchMethod(args), - url: getFetchUrl(args), - }, - requestComplete: false, - startTimestamp: Date.now(), - }; - - triggerHandlers(handlerData); - - return originalFetch.apply(global, args).then( - (response: Response) => { - handlerData.endTimestamp = Date.now(); - handlerData.requestComplete = true; - handlerData.response = response; - handlerData.fetchData.status_code = response.status; - triggerHandlers(handlerData); - return response; - }, - (error: Error) => { - handlerData.endTimestamp = Date.now(); - handlerData.requestComplete = true; - handlerData.error = error; - triggerHandlers(handlerData); - throw error; - }, - ); - }; - }); } - /** JSDoc */ - private _instrumentHistory(): void { - if (!supportsHistory()) { + /** + * Creates breadcrumbs from fetch API calls + */ + private _fetchBreadcrumb(handlerData: { [key: string]: any }): void { + // We only capture complete fetch requests + if (!handlerData.endTimestamp) { return; } - const triggerHandlers = this._triggerHandlers.bind(this, 'history'); - - const oldOnPopState = global.onpopstate; - global.onpopstate = (...args: any[]) => { - const to = global.location.href; - const handlerData = { - from: lastHref, - to, - }; - // keep track of the current URL state, as we always receive only the updated state - lastHref = to; - triggerHandlers(handlerData); - if (oldOnPopState) { - return oldOnPopState.apply(this, args); + const client = getCurrentHub().getClient(); + const dsn = client && client.getDsn(); + + if (dsn) { + const filterUrl = new API(dsn).getStoreEndpoint(); + // if Sentry key appears in URL, don't capture it as a request + // but rather as our own 'sentry' type breadcrumb + if ( + filterUrl && + handlerData.fetchData.url.indexOf(filterUrl) !== -1 && + handlerData.fetchData.method === 'POST' && + handlerData.args[1] && + handlerData.args[1].body + ) { + addSentryBreadcrumb(handlerData.args[1].body); + return; } - }; - - /** @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 to = String(url); - const handlerData = { - from: lastHref, - to, - }; - // keep track of the current URL state, as we always receive only the updated state - lastHref = to; - triggerHandlers(handlerData); - } - return originalHistoryFunction.apply(this, args); - }; } - fill(global.history, 'pushState', historyReplacementFunction); - fill(global.history, 'replaceState', historyReplacementFunction); + if (handlerData.error) { + getCurrentHub().addBreadcrumb( + { + category: 'fetch', + data: { + ...handlerData.fetchData, + status_code: handlerData.response.status, + }, + level: Severity.Error, + type: 'http', + }, + { + data: handlerData.error, + input: handlerData.args, + }, + ); + } else { + getCurrentHub().addBreadcrumb( + { + category: 'fetch', + data: { + ...handlerData.fetchData, + status_code: handlerData.response.status, + }, + type: 'http', + }, + { + input: handlerData.args, + response: handlerData.response, + }, + ); + } } - /** JSDoc */ - private _instrumentXHR(): void { - if (!('XMLHttpRequest' in global)) { - return; + /** + * Creates breadcrumbs from history API calls + */ + private _historyBreadcrumb(handlerData: { [key: string]: any }): void { + const global = getGlobalObject(); + let from = handlerData.from; + let to = handlerData.to; + const parsedLoc = parseUrl(global.location.href); + let parsedFrom = parseUrl(from); + const parsedTo = parseUrl(to); + + // Initial pushState doesn't provide `from` information + if (!parsedFrom.path) { + parsedFrom = parsedLoc; } - const triggerHandlers = this._triggerHandlers.bind(this, 'xhr'); - - /** - * @hidden - */ - function wrapProp(prop: XMLHttpRequestProp, xhr: XMLHttpRequest): void { - if (prop in xhr && typeof xhr[prop] === 'function') { - fill(xhr, prop, original => - wrap(original, { - mechanism: { - data: { - function: prop, - handler: (original && original.name) || '', - }, - handled: true, - type: 'instrument', - }, - }), - ); - } + // Use only the path component of the URL if the URL matches the current + // document (almost all the time when using pushState) + if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) { + // tslint:disable-next-line:no-parameter-reassignment + to = parsedTo.relative; + } + if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) { + // tslint:disable-next-line:no-parameter-reassignment + from = parsedFrom.relative; } - const xhrproto = XMLHttpRequest.prototype; - fill( - xhrproto, - 'open', - originalOpen => - 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], - }; - - const client = getCurrentHub().getClient(); - const dsn = client && client.getDsn(); - if (dsn) { - const filterUrl = new API(dsn).getStoreEndpoint(); - // if Sentry key appears in URL, don't capture it as a request - // but rather as our own 'sentry' type breadcrumb - if (isString(url) && (filterUrl && url.indexOf(filterUrl) !== -1)) { - this.__sentry_own_request__ = true; - } - } - - return originalOpen.apply(this, args); - }, - ); - - fill( - xhrproto, - 'send', - originalSend => - function(this: SentryWrappedXMLHttpRequest, ...args: any[]): void { - const xhr = this; // tslint:disable-line:no-this-assignment - const handlerData: { [key: string]: any } = { - args, - endTimestamp: Date.now(), - requestComplete: false, - startTimestamp: Date.now(), - xhr, - }; - - triggerHandlers(handlerData); - - /** - * @hidden - */ - function onreadystatechangeHandler(): 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 */ - } - handlerData.endTimestamp = Date.now(); - handlerData.requestComplete = true; - triggerHandlers(handlerData); - } - } - - const xmlHttpRequestProps: XMLHttpRequestProp[] = ['onload', 'onerror', 'onprogress']; - xmlHttpRequestProps.forEach(prop => { - wrapProp(prop, xhr); - }); - - if ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') { - fill(xhr, 'onreadystatechange', function(original: () => void): Function { - return wrap( - original, - { - mechanism: { - data: { - function: 'onreadystatechange', - handler: (original && original.name) || '', - }, - handled: true, - type: 'instrument', - }, - }, - onreadystatechangeHandler, - ); - }); - } else { - // if onreadystatechange wasn't actually set by the page on this xhr, we - // are free to set our own and capture the breadcrumb - xhr.onreadystatechange = onreadystatechangeHandler; - } - return originalSend.apply(this, args); - }, - ); + getCurrentHub().addBreadcrumb({ + category: 'navigation', + data: { + from, + to, + }, + }); } /** @@ -461,41 +269,67 @@ export class Breadcrumbs implements Integration { */ public setupOnce(): void { if (this._options.console) { - this._instrumentConsole(); + addInstrumentationHandler({ + callback: (...args) => { + this._consoleBreadcrumb(...args); + }, + type: 'console', + }); } if (this._options.dom) { - this._instrumentDOM(); + addInstrumentationHandler({ + callback: (...args) => { + this._domBreadcrumb(...args); + }, + type: 'dom', + }); } if (this._options.xhr) { - this._instrumentXHR(); + addInstrumentationHandler({ + callback: (...args) => { + this._xhrBreadcrumb(...args); + }, + type: 'xhr', + }); } if (this._options.fetch) { - this._instrumentFetch(); + addInstrumentationHandler({ + callback: (...args) => { + this._fetchBreadcrumb(...args); + }, + type: 'fetch', + }); } if (this._options.history) { - this._instrumentHistory(); + addInstrumentationHandler({ + callback: (...args) => { + this._historyBreadcrumb(...args); + }, + type: 'history', + }); } } } -/** Extract `method` from fetch call arguments */ -function getFetchMethod(fetchArgs: any[] = []): string { - if ('Request' in global && fetchArgs[0] instanceof 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 && fetchArgs[0] instanceof Request) { - return fetchArgs[0].url; +/** + * Create a breadcrumb of `sentry` from the events themselves + */ +function addSentryBreadcrumb(serializedData: string): void { + // There's always something that can go wrong with deserialization... + try { + const event = JSON.parse(serializedData); + getCurrentHub().addBreadcrumb( + { + category: 'sentry', + event_id: event.event_id, + level: event.level || Severity.fromString('error'), + message: getEventDescription(event), + }, + { + event, + }, + ); + } catch (_oO) { + logger.error('Error while adding sentry type breadcrumb'); } - return String(fetchArgs[0]); } diff --git a/packages/browser/src/integrations/instrumenthandlers.ts b/packages/browser/src/integrations/instrumenthandlers.ts deleted file mode 100644 index 4cd570aaa241..000000000000 --- a/packages/browser/src/integrations/instrumenthandlers.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { API, getCurrentHub } from '@sentry/core'; -import { Severity } from '@sentry/types'; -import { getEventDescription, getGlobalObject, logger, normalize, parseUrl, safeJoin } from '@sentry/utils'; - -import { BrowserClient } from '../client'; - -const global = getGlobalObject(); - -/** Object describing handler that will be triggered for a given `type` of instrumentation */ -export interface InstrumentHandler { - type: InstrumentHandlerType; - callback: InstrumentHandlerCallback; -} -export type InstrumentHandlerType = 'console' | 'dom' | 'fetch' | 'history' | 'sentry' | 'xhr'; -export type InstrumentHandlerCallback = (data: any) => void; - -/** - * Create a breadcrumb of `sentry` from the events themselves - */ -function addSentryBreadcrumb(serializedData: string): void { - // There's always something that can go wrong with deserialization... - try { - const event = JSON.parse(serializedData); - getCurrentHub().addBreadcrumb( - { - category: 'sentry', - event_id: event.event_id, - level: event.level || Severity.fromString('error'), - message: getEventDescription(event), - }, - { - event, - }, - ); - } catch (_oO) { - logger.error('Error while adding sentry type breadcrumb'); - } -} - -/** - * Creates breadcrumbs from console API calls - */ -function consoleBreadcrumb(handlerData: { [key: string]: any }): void { - const breadcrumb = { - category: 'console', - data: { - extra: { - arguments: normalize(handlerData.args, 3), - }, - logger: 'console', - }, - level: Severity.fromString(handlerData.level), - message: safeJoin(handlerData.args, ' '), - }; - - if (handlerData.level === 'assert') { - if (handlerData.args[0] === false) { - breadcrumb.message = `Assertion failed: ${safeJoin(handlerData.args.slice(1), ' ') || 'console.assert'}`; - breadcrumb.data.extra.arguments = normalize(handlerData.args.slice(1), 3); - } else { - // Don't capture a breadcrumb for passed assertions - return; - } - } - - getCurrentHub().addBreadcrumb(breadcrumb, { - input: handlerData.args, - level: handlerData.level, - }); -} - -/** - * Creates breadcrumbs from XHR API calls - */ -function xhrBreadcrumb(handlerData: { [key: string]: any }): void { - if (handlerData.requestComplete) { - // We only capture complete, non-sentry requests - if (handlerData.xhr.__sentry_own_request__) { - return; - } - - getCurrentHub().addBreadcrumb( - { - category: 'xhr', - data: handlerData.xhr.__sentry_xhr__, - type: 'http', - }, - { - xhr: handlerData.xhr, - }, - ); - - return; - } - - // We only capture issued sentry requests - if (handlerData.xhr.__sentry_own_request__) { - addSentryBreadcrumb(handlerData.args[0]); - } -} - -/** - * Creates breadcrumbs from fetch API calls - */ -function fetchBreadcrumb(handlerData: { [key: string]: any }): void { - // We only capture complete fetch requests - if (!handlerData.requestComplete) { - return; - } - - const client = getCurrentHub().getClient(); - const dsn = client && client.getDsn(); - - if (dsn) { - const filterUrl = new API(dsn).getStoreEndpoint(); - // if Sentry key appears in URL, don't capture it as a request - // but rather as our own 'sentry' type breadcrumb - if ( - filterUrl && - handlerData.fetchData.url.indexOf(filterUrl) !== -1 && - handlerData.fetchData.method === 'POST' && - handlerData.args[1] && - handlerData.args[1].body - ) { - addSentryBreadcrumb(handlerData.args[1].body); - return; - } - } - - if (handlerData.error) { - getCurrentHub().addBreadcrumb( - { - category: 'fetch', - data: handlerData.fetchData, - level: Severity.Error, - type: 'http', - }, - { - data: handlerData.error, - input: handlerData.args, - }, - ); - } else { - getCurrentHub().addBreadcrumb( - { - category: 'fetch', - data: handlerData.fetchData, - type: 'http', - }, - { - input: handlerData.args, - response: handlerData.response, - }, - ); - } -} - -/** - * Creates breadcrumbs from history API calls - */ -function historyBreadcrumb(handlerData: { [key: string]: any }): void { - let from = handlerData.from; - let to = handlerData.to; - - const parsedLoc = parseUrl(global.location.href); - let parsedFrom = parseUrl(from); - const parsedTo = parseUrl(to); - - // Initial pushState doesn't provide `from` information - if (!parsedFrom.path) { - parsedFrom = parsedLoc; - } - - // Use only the path component of the URL if the URL matches the current - // document (almost all the time when using pushState) - if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) { - // tslint:disable-next-line:no-parameter-reassignment - to = parsedTo.relative; - } - if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) { - // tslint:disable-next-line:no-parameter-reassignment - from = parsedFrom.relative; - } - - getCurrentHub().addBreadcrumb({ - category: 'navigation', - data: { - from, - to, - }, - }); -} - -export const consoleBreadcrumbHandler: InstrumentHandler = { - callback: consoleBreadcrumb, - type: 'console', -}; - -export const domBreadcrumbHandler: InstrumentHandler = { - callback: () => { - // TODO - }, - type: 'dom', -}; - -export const xhrBreadcrumbHandler: InstrumentHandler = { - callback: xhrBreadcrumb, - type: 'xhr', -}; - -export const fetchBreadcrumbHandler: InstrumentHandler = { - callback: fetchBreadcrumb, - type: 'fetch', -}; - -export const historyBreadcrumbHandler: InstrumentHandler = { - callback: historyBreadcrumb, - type: 'history', -}; - -export const defaultHandlers = [ - consoleBreadcrumbHandler, - domBreadcrumbHandler, - xhrBreadcrumbHandler, - fetchBreadcrumbHandler, - historyBreadcrumbHandler, -]; diff --git a/packages/browser/src/integrations/trycatch.ts b/packages/browser/src/integrations/trycatch.ts index 5bcfdc97b9c4..f7d4514ede15 100644 --- a/packages/browser/src/integrations/trycatch.ts +++ b/packages/browser/src/integrations/trycatch.ts @@ -1,8 +1,10 @@ import { Integration, WrappedFunction } from '@sentry/types'; -import { fill, getGlobalObject } from '@sentry/utils'; +import { fill, getFunctionName, getGlobalObject } from '@sentry/utils'; import { wrap } from '../helpers'; +type XMLHttpRequestProp = 'onload' | 'onerror' | 'onprogress'; + /** Wrap timer functions and event targets to catch errors and provide better meta data */ export class TryCatch implements Integration { /** JSDoc */ @@ -127,6 +129,57 @@ export class TryCatch implements Integration { }); } + /** JSDoc */ + private _wrapXHR(originalSend: () => void): () => void { + return function(this: XMLHttpRequest, ...args: any[]): void { + const xhr = this; // tslint:disable-line:no-this-assignment + const xmlHttpRequestProps: XMLHttpRequestProp[] = ['onload', 'onerror', 'onprogress']; + + xmlHttpRequestProps.forEach(prop => { + if (prop in this && typeof this[prop] === 'function') { + fill(this, prop, original => + wrap(original, { + mechanism: { + data: { + function: prop, + handler: getFunctionName(original), + }, + handled: true, + type: 'instrument', + }, + }), + ); + } + }); + + if ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') { + fill(xhr, 'onreadystatechange', function(original: WrappedFunction): Function { + const wrapOptions = { + mechanism: { + data: { + function: 'onreadystatechange', + handler: getFunctionName(original), + }, + handled: true, + type: 'instrument', + }, + }; + // If Instrument integration has been called before TryCatch + // use it as "before" callback in the wrap and use the original instead + if (original.__sentry__ && original.__sentry_original__) { + wrapOptions.mechanism.data.handler = getFunctionName(original.__sentry_original__); + return wrap(original.__sentry_original__, wrapOptions, original.__sentry_wrapped__); + } + + // Otherwise wrap directly + return wrap(original, wrapOptions); + }); + } + + return originalSend.apply(this, args); + }; + } + /** * Wrap timer functions and event targets to catch errors * and provide better metadata. @@ -140,6 +193,10 @@ export class TryCatch implements Integration { fill(global, 'setInterval', this._wrapTimeFunction.bind(this)); fill(global, 'requestAnimationFrame', this._wrapRAF.bind(this)); + if ('XMLHttpRequest' in global) { + fill(XMLHttpRequest.prototype, 'send', this._wrapXHR.bind(this)); + } + [ 'EventTarget', 'Window', @@ -173,16 +230,3 @@ export class TryCatch implements Integration { ].forEach(this._wrapEventTarget.bind(this)); } } - -/** - * Safely extract function name from itself - */ -function getFunctionName(fn: any): string { - try { - return (fn && fn.name) || ''; - } catch (e) { - // Just accessing custom props in some Selenium environments - // can cause a "Permission denied" exception (see raven-js#495). - return ''; - } -} diff --git a/packages/browser/test/integration/suites/builtins.js b/packages/browser/test/integration/suites/builtins.js index b46b5d66b6eb..5cd3bd7c5ed7 100644 --- a/packages/browser/test/integration/suites/builtins.js +++ b/packages/browser/test/integration/suites/builtins.js @@ -354,7 +354,7 @@ describe("wrapped built-ins", function() { var xhr = new XMLHttpRequest(); xhr.open("GET", "/base/subjects/example.json"); // intentionally assign event handlers *after* open, since this is what jQuery does - xhr.onreadystatechange = function() { + xhr.onreadystatechange = function wat() { window.finalizeManualTest(); // replace onreadystatechange with no-op so exception doesn't // fire more than once as XHR changes loading state @@ -364,6 +364,28 @@ describe("wrapped built-ins", function() { xhr.send(); }).then(function(summary) { assert.match(summary.events[0].exception.values[0].value, /baz/); + + if (IS_LOADER) { + assert.ok(summary.events[0].exception.values[0].mechanism); + } else { + var handler = + summary.events[0].exception.values[0].mechanism.data.handler; + delete summary.events[0].exception.values[0].mechanism.data.handler; + + if (summary.window.canReadFunctionName()) { + assert.equal(handler, "wat"); + } else { + assert.equal(handler, ""); + } + + assert.deepEqual(summary.events[0].exception.values[0].mechanism, { + type: "instrument", + handled: true, + data: { + function: "onreadystatechange", + }, + }); + } }); }); diff --git a/packages/utils/src/misc.ts b/packages/utils/src/misc.ts index 4a9d426ee9c0..375875465a21 100644 --- a/packages/utils/src/misc.ts +++ b/packages/utils/src/misc.ts @@ -369,3 +369,21 @@ export function parseRetryAfterHeader(now: number, header?: string | number | nu return defaultRetryAfter; } + +const defaultFunctionName = ''; + +/** + * Safely extract function name from itself + */ +export function getFunctionName(fn: unknown): string { + try { + if (!fn || typeof fn !== 'function') { + return defaultFunctionName; + } + return fn.name || defaultFunctionName; + } catch (e) { + // Just accessing custom props in some Selenium environments + // can cause a "Permission denied" exception (see raven-js#495). + return defaultFunctionName; + } +} diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 67cda283d902..1f1e0073fd60 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -2,7 +2,7 @@ import { ExtendedError, WrappedFunction } from '@sentry/types'; import { isElement, isError, isEvent, isPrimitive, isSyntheticEvent } from './is'; import { Memo } from './memo'; -import { htmlTreeAsString } from './misc'; +import { getFunctionName, htmlTreeAsString } from './misc'; import { truncate } from './string'; /** @@ -246,7 +246,7 @@ function normalizeValue(value: T, key?: any): T | string { } if (typeof value === 'function') { - return `[Function: ${value.name || ''}]`; + return `[Function: ${getFunctionName(value)}]`; } return value; diff --git a/packages/utils/test/object.test.ts b/packages/utils/test/object.test.ts index 4316c316ae13..12922dee98ef 100644 --- a/packages/utils/test/object.test.ts +++ b/packages/utils/test/object.test.ts @@ -382,7 +382,7 @@ describe('normalize()', () => { normalize(() => { /* no-empty */ }), - ).toEqual('[Function: ]'); + ).toEqual('[Function: ]'); const foo = () => { /* no-empty */ };