diff --git a/CHANGELOG.md b/CHANGELOG.md index ad38f01fe37a..39548a2b82bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- [browser] feat: Refactor breadcrumbs integration to allow for custom handlers - [browser] ref: Fix regression with bundle size ## 5.9.0 diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index d24e7f6cb4c0..db53056dfd39 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -1,23 +1,23 @@ +// 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 { Breadcrumb, BreadcrumbHint, Integration, Severity, WrappedFunction } from '@sentry/types'; -import { - fill, - getEventDescription, - getGlobalObject, - isString, - logger, - normalize, - parseUrl, - safeJoin, - supportsHistory, - supportsNativeFetch, -} from '@sentry/utils'; +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 { + defaultHandlers, + InstrumentHandler, + InstrumentHandlerCallback, + InstrumentHandlerType, +} from './instrumenthandlers'; + const global = getGlobalObject(); let lastHref: string | undefined; + /** * @hidden */ @@ -38,11 +38,15 @@ interface BreadcrumbIntegrations { history?: boolean; sentry?: boolean; xhr?: boolean; + handlers?: InstrumentHandler[]; } type XMLHttpRequestProp = 'onload' | 'onerror' | 'onprogress'; -/** Default Breadcrumbs instrumentations */ +/** + * Default Breadcrumbs instrumentations + * @deprecated With v6, this will be renamed + */ export class Breadcrumbs implements Integration { /** * @inheritDoc @@ -57,6 +61,16 @@ 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 */ @@ -70,6 +84,40 @@ 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; + } + this._handlers[handler.type].push(handler.callback); + } + } + + /** JSDoc */ + private _triggerHandlers(type: InstrumentHandlerType, data: any): void { + if (!getCurrentHub().getIntegration(Breadcrumbs)) { + return; + } + + if (!type || !this._handlers[type]) { + 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}`, + ); + } + } } /** JSDoc */ @@ -77,6 +125,9 @@ export class Breadcrumbs implements Integration { 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)) { return; @@ -84,33 +135,11 @@ export class Breadcrumbs implements Integration { fill(global.console, level, function(originalConsoleLevel: () => any): Function { return function(...args: any[]): void { - const breadcrumbData = { - category: 'console', - data: { - extra: { - arguments: normalize(args, 3), - }, - logger: 'console', - }, - level: Severity.fromString(level), - message: safeJoin(args, ' '), + const handlerData = { + args, + level, }; - - if (level === 'assert') { - if (args[0] === false) { - breadcrumbData.message = `Assertion failed: ${safeJoin(args.slice(1), ' ') || 'console.assert'}`; - breadcrumbData.data.extra.arguments = normalize(args.slice(1), 3); - Breadcrumbs.addBreadcrumb(breadcrumbData, { - input: args, - level, - }); - } - } else { - Breadcrumbs.addBreadcrumb(breadcrumbData, { - input: args, - level, - }); - } + triggerHandlers(handlerData); // this fails for some browsers. :( if (originalConsoleLevel) { @@ -215,83 +244,35 @@ export class Breadcrumbs implements Integration { return; } + const triggerHandlers = this._triggerHandlers.bind(this, 'fetch'); + fill(global, 'fetch', function(originalFetch: () => void): () => void { return function(...args: any[]): void { - const fetchInput = args[0]; - let method = 'GET'; - let url; - - if (typeof fetchInput === 'string') { - url = fetchInput; - } else if ('Request' in global && fetchInput instanceof Request) { - url = fetchInput.url; - if (fetchInput.method) { - method = fetchInput.method; - } - } else { - url = String(fetchInput); - } - - if (args[1] && args[1].method) { - method = args[1].method; - } - - 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 && url.indexOf(filterUrl) !== -1) { - if (method === 'POST' && args[1] && args[1].body) { - addSentryBreadcrumb(args[1].body); - } - return originalFetch.apply(global, args); - } - } - - const fetchData: { - method: string; - url: string; - status_code?: number; - } = { - method: isString(method) ? method.toUpperCase() : method, - url, + const handlerData: { [key: string]: any } = { + args, + endTimestamp: Date.now(), + fetchData: { + method: getFetchMethod(args), + url: getFetchUrl(args), + }, + startTimestamp: Date.now(), }; - return originalFetch - .apply(global, args) - .then((response: Response) => { - fetchData.status_code = response.status; - Breadcrumbs.addBreadcrumb( - { - category: 'fetch', - data: fetchData, - type: 'http', - }, - { - input: args, - response, - }, - ); + return originalFetch.apply(global, args).then( + (response: Response) => { + handlerData.endTimestamp = Date.now(); + handlerData.response = response; + handlerData.fetchData.status_code = response.status; + triggerHandlers(handlerData); return response; - }) - .then(null, (error: Error) => { - Breadcrumbs.addBreadcrumb( - { - category: 'fetch', - data: fetchData, - level: Severity.Error, - type: 'http', - }, - { - error, - input: args, - }, - ); - + }, + (error: Error) => { + handlerData.endTimestamp = Date.now(); + handlerData.error = error; + triggerHandlers(handlerData); throw error; - }); + }, + ); }; }); } @@ -302,63 +283,37 @@ export class Breadcrumbs implements Integration { return; } - const captureUrlChange = (from: string | undefined, to: string | undefined): void => { - const parsedLoc = parseUrl(global.location.href); - const parsedTo = parseUrl(to as string); - let parsedFrom = parseUrl(from as string); - - // Initial pushState doesn't provide `from` information - if (!parsedFrom.path) { - parsedFrom = parsedLoc; - } - - // because onpopstate only tells you the "new" (to) value of location.href, and - // not the previous (from) value, we need to track the value of the current URL - // state ourselves - lastHref = to; + const triggerHandlers = this._triggerHandlers.bind(this, 'history'); - // 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; - } - - Breadcrumbs.addBreadcrumb({ - category: 'navigation', - data: { - from, - to, - }, - }); - }; - - // record navigation (URL) changes const oldOnPopState = global.onpopstate; global.onpopstate = (...args: any[]) => { - const currentHref = global.location.href; - captureUrlChange(lastHref, currentHref); + 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); } }; - /** - * @hidden - */ + /** @hidden */ function historyReplacementFunction(originalHistoryFunction: () => void): () => void { - // note history.pushState.length is 0; intentionally not declaring - // params to preserve 0 arity return function(this: History, ...args: any[]): void { const url = args.length > 2 ? args[2] : undefined; - // url argument is optional if (url) { // coerce to string (this is what pushState does) - captureUrlChange(lastHref, String(url)); + 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); }; @@ -374,6 +329,8 @@ export class Breadcrumbs implements Integration { return; } + const triggerHandlers = this._triggerHandlers.bind(this, 'xhr'); + /** * @hidden */ @@ -427,19 +384,20 @@ export class Breadcrumbs implements Integration { 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(), + startTimestamp: Date.now(), + xhr, + }; - if (xhr.__sentry_own_request__) { - addSentryBreadcrumb(args[0]); - } + triggerHandlers(handlerData); /** * @hidden */ function onreadystatechangeHandler(): void { if (xhr.readyState === 4) { - if (xhr.__sentry_own_request__) { - return; - } try { // touching statusCode in some platforms throws // an exception @@ -449,16 +407,9 @@ export class Breadcrumbs implements Integration { } catch (e) { /* do nothing */ } - Breadcrumbs.addBreadcrumb( - { - category: 'xhr', - data: xhr.__sentry_xhr__, - type: 'http', - }, - { - xhr, - }, - ); + handlerData.endTimestamp = Date.now(); + handlerData.requestComplete = true; + triggerHandlers(handlerData); } } @@ -494,17 +445,6 @@ export class Breadcrumbs implements Integration { ); } - /** - * Helper that checks if integration is enabled on the client. - * @param breadcrumb Breadcrumb - * @param hint BreadcrumbHint - */ - public static addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void { - if (getCurrentHub().getIntegration(Breadcrumbs)) { - getCurrentHub().addBreadcrumb(breadcrumb, hint); - } - } - /** * Instrument browser built-ins w/ breadcrumb capturing * - Console API @@ -532,23 +472,24 @@ export class Breadcrumbs implements Integration { } } -/** JSDoc */ -function addSentryBreadcrumb(serializedData: string): void { - // There's always something that can go wrong with deserialization... - try { - const event = JSON.parse(serializedData); - Breadcrumbs.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'); +/** 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]); } diff --git a/packages/browser/src/integrations/instrumenthandlers.ts b/packages/browser/src/integrations/instrumenthandlers.ts new file mode 100644 index 000000000000..3eeb702b4ed8 --- /dev/null +++ b/packages/browser/src/integrations/instrumenthandlers.ts @@ -0,0 +1,222 @@ +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 { + 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, +];