diff --git a/packages/gatsby/README.md b/packages/gatsby/README.md index b08c9852ea86..ae8febaed913 100644 --- a/packages/gatsby/README.md +++ b/packages/gatsby/README.md @@ -40,7 +40,7 @@ To automatically capture the `release` value on Vercel you will need to register ## Sentry Performance -To enable Tracing support, supply the `tracesSampleRate` to the options and make sure you have installed the `@sentry/tracing` package. This will also turn on the `BrowserTracing` integration for automatic instrumentation of the browser. +To enable tracing, supply either `tracesSampleRate` or `tracesSampler` to the options and make sure you have installed the `@sentry/tracing` package. This will also turn on the `BrowserTracing` integration for automatic instrumentation of pageloads and navigations. ```javascript { @@ -49,8 +49,31 @@ To enable Tracing support, supply the `tracesSampleRate` to the options and make { resolve: "@sentry/gatsby", options: { - dsn: process.env.SENTRY_DSN, // this is the default - tracesSampleRate: 1, // this is just to test, you should lower this in production + dsn: process.env.SENTRY_DSN, // this is the default + + // A rate of 1 means all traces will be sent, so it's good for testing. + // In production, you'll likely want to either choose a lower rate or use `tracesSampler` instead (see below). + tracesSampleRate: 1, + + // Alternatively: + tracesSampler: samplingContext => { + // Examine provided context data (along with anything in the global namespace) to decide the sample rate + // for this transaction. + // Can return 0 to drop the transaction entirely. + + if ("...") { + return 0.5 // These are important - take a big sample + } + else if ("...") { + return 0.01 // These are less important or happen much more frequently - only take 1% of them + } + else if ("...") { + return 0 // These aren't something worth tracking - drop all transactions like this + } + else { + return 0.1 // Default sample rate + } + } } }, // ... @@ -68,7 +91,7 @@ If you want to supply options to the `BrowserTracing` integration, use the `brow resolve: "@sentry/gatsby", options: { dsn: process.env.SENTRY_DSN, // this is the default - tracesSampleRate: 1, // this is just to test, you should lower this in production + tracesSampleRate: 1, // or tracesSampler (see above) browserTracingOptions: { // disable creating spans for XHR requests traceXHR: false, diff --git a/packages/gatsby/gatsby-browser.js b/packages/gatsby/gatsby-browser.js index 573389128096..42d5f606f2c8 100644 --- a/packages/gatsby/gatsby-browser.js +++ b/packages/gatsby/gatsby-browser.js @@ -6,10 +6,9 @@ exports.onClientEntry = function(_, pluginParams) { return; } - const tracesSampleRate = pluginParams.tracesSampleRate !== undefined ? pluginParams.tracesSampleRate : 0; const integrations = [...(pluginParams.integrations || [])]; - if (tracesSampleRate && !integrations.some(ele => ele.name === 'BrowserTracing')) { + if (Tracing.hasTracingEnabled(pluginParams) && !integrations.some(ele => ele.name === 'BrowserTracing')) { integrations.push(new Tracing.Integrations.BrowserTracing(pluginParams.browserTracingOptions)); } @@ -22,7 +21,6 @@ exports.onClientEntry = function(_, pluginParams) { // eslint-disable-next-line no-undef dsn: __SENTRY_DSN__, ...pluginParams, - tracesSampleRate, integrations, }); diff --git a/packages/gatsby/test/gatsby-browser.test.ts b/packages/gatsby/test/gatsby-browser.test.ts index bef0b169652d..ba83b407002f 100644 --- a/packages/gatsby/test/gatsby-browser.test.ts +++ b/packages/gatsby/test/gatsby-browser.test.ts @@ -53,7 +53,6 @@ describe('onClientEntry', () => { environment: process.env.NODE_ENV, integrations: [], release: (global as any).__SENTRY_RELEASE__, - tracesSampleRate: 0, }); }); @@ -100,6 +99,16 @@ describe('onClientEntry', () => { ); }); + it('sets a tracesSampler if defined as option', () => { + const tracesSampler = jest.fn(); + onClientEntry(undefined, { tracesSampler }); + expect(sentryInit).toHaveBeenLastCalledWith( + expect.objectContaining({ + tracesSampler, + }), + ); + }); + it('adds `BrowserTracing` integration if tracesSampleRate is defined', () => { onClientEntry(undefined, { tracesSampleRate: 0.5 }); expect(sentryInit).toHaveBeenLastCalledWith( @@ -109,6 +118,16 @@ describe('onClientEntry', () => { ); }); + it('adds `BrowserTracing` integration if tracesSampler is defined', () => { + const tracesSampler = jest.fn(); + onClientEntry(undefined, { tracesSampler }); + expect(sentryInit).toHaveBeenLastCalledWith( + expect.objectContaining({ + integrations: [expect.objectContaining({ name: 'BrowserTracing' })], + }), + ); + }); + it('only defines a single `BrowserTracing` integration', () => { const Tracing = jest.requireActual('@sentry/tracing'); const integrations = [new Tracing.Integrations.BrowserTracing()]; diff --git a/packages/hub/src/hub.ts b/packages/hub/src/hub.ts index 4f3ab5a6476d..61d4609a333e 100644 --- a/packages/hub/src/hub.ts +++ b/packages/hub/src/hub.ts @@ -3,6 +3,7 @@ import { Breadcrumb, BreadcrumbHint, Client, + CustomSamplingContext, Event, EventHint, Extra, @@ -19,7 +20,7 @@ import { } from '@sentry/types'; import { consoleSandbox, getGlobalObject, isNodeEnv, logger, timestampWithMs, uuid4 } from '@sentry/utils'; -import { Carrier, Layer } from './interfaces'; +import { Carrier, DomainAsCarrier, Layer } from './interfaces'; import { Scope } from './scope'; /** @@ -369,8 +370,8 @@ export class Hub implements HubInterface { /** * @inheritDoc */ - public startTransaction(context: TransactionContext): Transaction { - return this._callExtensionMethod('startTransaction', context); + public startTransaction(context: TransactionContext, customSamplingContext?: CustomSamplingContext): Transaction { + return this._callExtensionMethod('startTransaction', context, customSamplingContext); } /** @@ -456,22 +457,24 @@ export function getCurrentHub(): Hub { return getHubFromCarrier(registry); } +/** + * Returns the active domain, if one exists + * + * @returns The domain, or undefined if there is no active domain + */ +export function getActiveDomain(): DomainAsCarrier | undefined { + const sentry = getMainCarrier().__SENTRY__; + + return sentry && sentry.extensions && sentry.extensions.domain && sentry.extensions.domain.active; +} + /** * Try to read the hub from an active domain, and fallback to the registry if one doesn't exist * @returns discovered hub */ function getHubFromActiveDomain(registry: Carrier): Hub { try { - const property = 'domain'; - const carrier = getMainCarrier(); - const sentry = carrier.__SENTRY__; - if (!sentry || !sentry.extensions || !sentry.extensions[property]) { - return getHubFromCarrier(registry); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const domain = sentry.extensions[property] as any; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const activeDomain = domain.active; + const activeDomain = getActiveDomain(); // If there's no active domain, just return global hub if (!activeDomain) { diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index 6656b1b24df9..04e8be2aee4f 100644 --- a/packages/hub/src/index.ts +++ b/packages/hub/src/index.ts @@ -1,3 +1,11 @@ -export { Carrier, Layer } from './interfaces'; +export { Carrier, DomainAsCarrier, Layer } from './interfaces'; export { addGlobalEventProcessor, Scope } from './scope'; -export { getCurrentHub, getHubFromCarrier, getMainCarrier, Hub, makeMain, setHubOnCarrier } from './hub'; +export { + getActiveDomain, + getCurrentHub, + getHubFromCarrier, + getMainCarrier, + Hub, + makeMain, + setHubOnCarrier, +} from './hub'; diff --git a/packages/hub/src/interfaces.ts b/packages/hub/src/interfaces.ts index c1585bbe1916..ddaf71c80c06 100644 --- a/packages/hub/src/interfaces.ts +++ b/packages/hub/src/interfaces.ts @@ -1,4 +1,5 @@ import { Client } from '@sentry/types'; +import * as domain from 'domain'; import { Hub } from './hub'; import { Scope } from './scope'; @@ -20,9 +21,23 @@ export interface Carrier { __SENTRY__?: { hub?: Hub; /** - * These are extension methods for the hub, the current instance of the hub will be bound to it + * Extra Hub properties injected by various SDKs */ - // eslint-disable-next-line @typescript-eslint/ban-types - extensions?: { [key: string]: Function }; + extensions?: { + /** Hack to prevent bundlers from breaking our usage of the domain package in the cross-platform Hub package */ + domain?: typeof domain & { + /** + * The currently active domain. This is part of the domain package, but for some reason not declared in the + * package's typedef. + */ + active?: domain.Domain; + }; + } & { + /** Extension methods for the hub, which are bound to the current Hub instance */ + // eslint-disable-next-line @typescript-eslint/ban-types + [key: string]: Function; + }; }; } + +export interface DomainAsCarrier extends domain.Domain, Carrier {} diff --git a/packages/minimal/src/index.ts b/packages/minimal/src/index.ts index 56cfdda9ff11..1406a4c408e1 100644 --- a/packages/minimal/src/index.ts +++ b/packages/minimal/src/index.ts @@ -2,6 +2,7 @@ import { getCurrentHub, Hub, Scope } from '@sentry/hub'; import { Breadcrumb, CaptureContext, + CustomSamplingContext, Event, Extra, Extras, @@ -190,22 +191,25 @@ export function _callOnClient(method: string, ...args: any[]): void { } /** - * Starts a new `Transaction` and returns it. This is the entry point to manual - * tracing instrumentation. + * Starts a new `Transaction` and returns it. This is the entry point to manual tracing instrumentation. * - * A tree structure can be built by adding child spans to the transaction, and - * child spans to other spans. To start a new child span within the transaction - * or any span, call the respective `.startChild()` method. + * A tree structure can be built by adding child spans to the transaction, and child spans to other spans. To start a + * new child span within the transaction or any span, call the respective `.startChild()` method. * - * Every child span must be finished before the transaction is finished, - * otherwise the unfinished spans are discarded. + * Every child span must be finished before the transaction is finished, otherwise the unfinished spans are discarded. * - * The transaction must be finished with a call to its `.finish()` method, at - * which point the transaction with all its finished child spans will be sent to - * Sentry. + * The transaction must be finished with a call to its `.finish()` method, at which point the transaction with all its + * finished child spans will be sent to Sentry. * * @param context Properties of the new `Transaction`. + * @param customSamplingContext Information given to the transaction sampling function (along with context-dependent + * default values). See {@link Options.tracesSampler}. + * + * @returns The transaction which was just started */ -export function startTransaction(context: TransactionContext): Transaction { - return callOnHub('startTransaction', { ...context }); +export function startTransaction( + context: TransactionContext, + customSamplingContext?: CustomSamplingContext, +): Transaction { + return callOnHub('startTransaction', { ...context }, customSamplingContext); } diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index affaf24a7123..a8af9001cbcc 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -1,10 +1,16 @@ /* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { captureException, getCurrentHub, startTransaction, withScope } from '@sentry/core'; -import { Span } from '@sentry/tracing'; +import { extractTraceparentData, Span } from '@sentry/tracing'; import { Event } from '@sentry/types'; -import { forget, isPlainObject, isString, logger, normalize, stripUrlQueryAndFragment } from '@sentry/utils'; -import * as cookie from 'cookie'; +import { + extractNodeRequestData, + forget, + isPlainObject, + isString, + logger, + stripUrlQueryAndFragment, +} from '@sentry/utils'; import * as domain from 'domain'; import * as http from 'http'; import * as os from 'os'; @@ -34,26 +40,16 @@ export function tracingHandler(): ( const reqMethod = (req.method || '').toUpperCase(); const reqUrl = req.url && stripUrlQueryAndFragment(req.url); - let traceId; - let parentSpanId; - let sampled; - // If there is a trace header set, we extract the data from it (parentSpanId, traceId, and sampling decision) + let traceparentData; if (req.headers && isString(req.headers['sentry-trace'])) { - const span = Span.fromTraceparent(req.headers['sentry-trace'] as string); - if (span) { - traceId = span.traceId; - parentSpanId = span.parentSpanId; - sampled = span.sampled; - } + traceparentData = extractTraceparentData(req.headers['sentry-trace'] as string); } const transaction = startTransaction({ name: `${reqMethod} ${reqUrl}`, op: 'http.server', - parentSpanId, - sampled, - traceId, + ...traceparentData, }); // We put the transaction on the scope so users can attach children to it @@ -120,85 +116,6 @@ function extractTransaction(req: { [key: string]: any }, type: boolean | Transac } } -/** Default request keys that'll be used to extract data from the request */ -const DEFAULT_REQUEST_KEYS = ['cookies', 'data', 'headers', 'method', 'query_string', 'url']; - -/** JSDoc */ -function extractRequestData(req: { [key: string]: any }, keys: boolean | string[]): { [key: string]: string } { - const request: { [key: string]: any } = {}; - const attributes = Array.isArray(keys) ? keys : DEFAULT_REQUEST_KEYS; - - // headers: - // node, express: req.headers - // koa: req.header - const headers = (req.headers || req.header || {}) as { - host?: string; - cookie?: string; - }; - // method: - // node, express, koa: req.method - const method = req.method; - // host: - // express: req.hostname in > 4 and req.host in < 4 - // koa: req.host - // node: req.headers.host - const host = req.hostname || req.host || headers.host || ''; - // protocol: - // node: - // express, koa: req.protocol - const protocol = - req.protocol === 'https' || req.secure || ((req.socket || {}) as { encrypted?: boolean }).encrypted - ? 'https' - : 'http'; - // url (including path and query string): - // node, express: req.originalUrl - // koa: req.url - const originalUrl = (req.originalUrl || req.url) as string; - // absolute url - const absoluteUrl = `${protocol}://${host}${originalUrl}`; - - attributes.forEach(key => { - switch (key) { - case 'headers': - request.headers = headers; - break; - case 'method': - request.method = method; - break; - case 'url': - request.url = absoluteUrl; - break; - case 'cookies': - // cookies: - // node, express, koa: req.headers.cookie - request.cookies = cookie.parse(headers.cookie || ''); - break; - case 'query_string': - // query string: - // node: req.url (raw) - // express, koa: req.query - request.query_string = url.parse(originalUrl || '', false).query; - break; - case 'data': - if (method === 'GET' || method === 'HEAD') { - break; - } - // body data: - // node, express, koa: req.body - if (req.body !== undefined) { - request.data = isString(req.body) ? req.body : JSON.stringify(normalize(req.body)); - } - break; - default: - if ({}.hasOwnProperty.call(req, key)) { - request[key] = (req as { [key: string]: any })[key]; - } - } - }); - - return request; -} - /** Default user keys that'll be used to extract data from the request */ const DEFAULT_USER_KEYS = ['id', 'username', 'email']; @@ -277,9 +194,13 @@ export function parseRequest( } if (options.request) { + // if the option value is `true`, use the default set of keys by not passing anything to `extractNodeRequestData()` + const extractedRequestData = Array.isArray(options.request) + ? extractNodeRequestData(req, options.request) + : extractNodeRequestData(req); event.request = { ...event.request, - ...extractRequestData(req, options.request), + ...extractedRequestData, }; } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index b8678705fb66..6429ce3a75c9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -57,13 +57,10 @@ const INTEGRATIONS = { export { INTEGRATIONS as Integrations, Transports, Handlers }; -// We need to patch domain on the global __SENTRY__ object to make it work for node -// if we don't do this, browser bundlers will have troubles resolving require('domain') +// We need to patch domain on the global __SENTRY__ object to make it work for node in cross-platform packages like +// @sentry/hub. If we don't do this, browser bundlers will have troubles resolving `require('domain')`. const carrier = getMainCarrier(); if (carrier.__SENTRY__) { carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; - if (!carrier.__SENTRY__.extensions.domain) { - // @ts-ignore domain is missing from extensions Type - carrier.__SENTRY__.extensions.domain = domain; - } + carrier.__SENTRY__.extensions.domain = carrier.__SENTRY__.extensions.domain || domain; } diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index 8f3add8bc546..1edb81c98295 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -4,8 +4,8 @@ import { logger } from '@sentry/utils'; import { startIdleTransaction } from '../hubextensions'; import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../idletransaction'; -import { Span } from '../span'; import { SpanStatus } from '../spanstatus'; +import { extractTraceparentData } from '../utils'; import { registerBackgroundTabDetection } from './backgroundtab'; import { MetricsInstrumentation } from './metrics'; import { @@ -213,21 +213,16 @@ export class BrowserTracing implements Integration { /** * Gets transaction context from a sentry-trace meta. + * + * @returns Transaction context data from the header or undefined if there's no header or the header is malformed */ -function getHeaderContext(): Partial { +function getHeaderContext(): Partial | undefined { const header = getMetaContent('sentry-trace'); if (header) { - const span = Span.fromTraceparent(header); - if (span) { - return { - parentSpanId: span.parentSpanId, - sampled: span.sampled, - traceId: span.traceId, - }; - } + return extractTraceparentData(header); } - return {}; + return undefined; } /** Returns the value of a meta tag */ diff --git a/packages/tracing/src/hubextensions.ts b/packages/tracing/src/hubextensions.ts index 872941f7b8bc..2d0610c24a96 100644 --- a/packages/tracing/src/hubextensions.ts +++ b/packages/tracing/src/hubextensions.ts @@ -1,9 +1,18 @@ -import { getMainCarrier, Hub } from '@sentry/hub'; -import { TransactionContext } from '@sentry/types'; +import { getActiveDomain, getMainCarrier, Hub } from '@sentry/hub'; +import { CustomSamplingContext, SamplingContext, TransactionContext } from '@sentry/types'; +import { + dynamicRequire, + extractNodeRequestData, + getGlobalObject, + isInstanceOf, + isNodeEnv, + logger, +} from '@sentry/utils'; import { registerErrorInstrumentation } from './errors'; import { IdleTransaction } from './idletransaction'; import { Transaction } from './transaction'; +import { hasTracingEnabled } from './utils'; /** Returns all trace headers that are currently on the top scope. */ function traceHeaders(this: Hub): { [key: string]: string } { @@ -20,34 +29,178 @@ function traceHeaders(this: Hub): { [key: string]: string } { } /** - * Use RNG to generate sampling decision, which all child spans inherit. + * Implements sampling inheritance and falls back to user-provided static rate if no parent decision is available. + * + * @param parentSampled: The parent transaction's sampling decision, if any. + * @param givenRate: The rate to use if no parental decision is available. + * + * @returns The parent's sampling decision (if one exists), or the provided static rate */ -function sample(hub: Hub, transaction: T): T { +function _inheritOrUseGivenRate(parentSampled: boolean | undefined, givenRate: unknown): boolean | unknown { + return parentSampled !== undefined ? parentSampled : givenRate; +} + +/** + * Makes a sampling decision for the given transaction and stores it on the transaction. + * + * Called every time a transaction is created. Only transactions which emerge with a `sampled` value of `true` will be + * sent to Sentry. + * + * @param hub: The hub off of which to read config options + * @param transaction: The transaction needing a sampling decision + * @param samplingContext: Default and user-provided data which may be used to help make the decision + * + * @returns The given transaction with its `sampled` value set + */ +function sample(hub: Hub, transaction: T, samplingContext: SamplingContext): T { const client = hub.getClient(); - if (transaction.sampled === undefined) { - const sampleRate = (client && client.getOptions().tracesSampleRate) || 0; - // if true = we want to have the transaction - // if false = we don't want to have it - // Math.random (inclusive of 0, but not 1) - transaction.sampled = Math.random() < sampleRate; + const options = (client && client.getOptions()) || {}; + + // nothing to do if there's no client or if tracing is disabled + if (!client || !hasTracingEnabled(options)) { + transaction.sampled = false; + return transaction; + } + + // we would have bailed already if neither `tracesSampler` nor `tracesSampleRate` were defined, so one of these should + // work; prefer the hook if so + const sampleRate = + typeof options.tracesSampler === 'function' + ? options.tracesSampler(samplingContext) + : _inheritOrUseGivenRate(samplingContext.parentSampled, options.tracesSampleRate); + + // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The + // only valid values are booleans or numbers between 0 and 1.) + if (!isValidSampleRate(sampleRate)) { + logger.warn(`[Tracing] Discarding transaction because of invalid sample rate.`); + transaction.sampled = false; + return transaction; + } + + // if the function returned 0 (or false), or if `tracesSampleRate` is 0, it's a sign the transaction should be dropped + if (!sampleRate) { + logger.log( + `[Tracing] Discarding transaction because ${ + typeof options.tracesSampler === 'function' + ? 'tracesSampler returned 0 or false' + : 'tracesSampleRate is set to 0' + }`, + ); + transaction.sampled = false; + return transaction; } - // We only want to create a span list if we sampled the transaction - // If sampled == false, we will discard the span anyway, so we can save memory by not storing child spans - if (transaction.sampled) { - const experimentsOptions = (client && client.getOptions()._experiments) || {}; - transaction.initSpanRecorder(experimentsOptions.maxSpans as number); + // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is + // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. + transaction.sampled = Math.random() < (sampleRate as number | boolean); + + // if we're not going to keep it, we're done + if (!transaction.sampled) { + logger.log( + `[Tracing] Discarding transaction because it's not included in the random sample (sampling rate = ${Number( + sampleRate, + )})`, + ); + return transaction; } + // at this point we know we're keeping the transaction, whether because of an inherited decision or because it got + // lucky with the dice roll + const experimentsOptions = options._experiments || {}; + transaction.initSpanRecorder(experimentsOptions.maxSpans as number); + return transaction; } +/** + * Gets the correct context to pass to the tracesSampler, based on the environment (i.e., which SDK is being used) + * + * @returns The default sample context + */ +function getDefaultSamplingContext(transactionContext: TransactionContext): SamplingContext { + // promote parent sampling decision (if any) for easy access + const { parentSampled } = transactionContext; + const defaultSamplingContext: SamplingContext = { transactionContext, parentSampled }; + + if (isNodeEnv()) { + const domain = getActiveDomain(); + + if (domain) { + // for all node servers that we currently support, we store the incoming request object (which is an instance of + // http.IncomingMessage) on the domain + + // the domain members are stored as an array, so our only way to find the request is to iterate through the array + // and compare types + + const nodeHttpModule = dynamicRequire(module, 'http'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const requestType = nodeHttpModule.IncomingMessage; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const request = domain.members.find((member: any) => isInstanceOf(member, requestType)); + + if (request) { + defaultSamplingContext.request = extractNodeRequestData(request); + } + } + } + + // we must be in browser-js (or some derivative thereof) + else { + // we use `getGlobalObject()` rather than `window` since service workers also have a `location` property on `self` + const globalObject = getGlobalObject(); + + if ('location' in globalObject) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + defaultSamplingContext.location = { ...(globalObject as any).location }; + } + } + + return defaultSamplingContext; +} + +/** + * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). + */ +function isValidSampleRate(rate: unknown): boolean { + // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (isNaN(rate as any) || !(typeof rate === 'number' || typeof rate === 'boolean')) { + logger.warn( + `[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( + rate, + )} of type ${JSON.stringify(typeof rate)}.`, + ); + return false; + } + + // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false + if (rate < 0 || rate > 1) { + logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`); + return false; + } + return true; +} /** - * {@see Hub.startTransaction} + * Creates a new transaction and adds a sampling decision if it doesn't yet have one. + * + * The Hub.startTransaction method delegates to this method to do its work, passing the Hub instance in as `this`. + * Exists as a separate function so that it can be injected into the class as an "extension method." + * + * @returns The new transaction + * + * @see {@link Hub.startTransaction} */ -function startTransaction(this: Hub, context: TransactionContext): Transaction { - const transaction = new Transaction(context, this); - return sample(this, transaction); +function _startTransaction( + this: Hub, + transactionContext: TransactionContext, + customSamplingContext?: CustomSamplingContext, +): Transaction { + const transaction = new Transaction(transactionContext, this); + return sample(this, transaction, { + ...getDefaultSamplingContext(transactionContext), + ...customSamplingContext, + }); } /** @@ -55,12 +208,12 @@ function startTransaction(this: Hub, context: TransactionContext): Transaction { */ export function startIdleTransaction( hub: Hub, - context: TransactionContext, + transactionContext: TransactionContext, idleTimeout?: number, onScope?: boolean, ): IdleTransaction { - const transaction = new IdleTransaction(context, hub, idleTimeout, onScope); - return sample(hub, transaction); + const transaction = new IdleTransaction(transactionContext, hub, idleTimeout, onScope); + return sample(hub, transaction, getDefaultSamplingContext(transactionContext)); } /** @@ -71,7 +224,7 @@ export function _addTracingExtensions(): void { if (carrier.__SENTRY__) { carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; if (!carrier.__SENTRY__.extensions.startTransaction) { - carrier.__SENTRY__.extensions.startTransaction = startTransaction; + carrier.__SENTRY__.extensions.startTransaction = _startTransaction; } if (!carrier.__SENTRY__.extensions.traceHeaders) { carrier.__SENTRY__.extensions.traceHeaders = traceHeaders; diff --git a/packages/tracing/src/index.bundle.ts b/packages/tracing/src/index.bundle.ts index bbe587d18d47..c7d60c3635a3 100644 --- a/packages/tracing/src/index.bundle.ts +++ b/packages/tracing/src/index.bundle.ts @@ -55,7 +55,7 @@ import { getGlobalObject } from '@sentry/utils'; import { BrowserTracing } from './browser'; import { addExtensionMethods } from './hubextensions'; -export { Span, TRACEPARENT_REGEXP } from './span'; +export { Span } from './span'; let windowIntegrations = {}; diff --git a/packages/tracing/src/index.ts b/packages/tracing/src/index.ts index 457d94df8135..c779d122bf38 100644 --- a/packages/tracing/src/index.ts +++ b/packages/tracing/src/index.ts @@ -5,7 +5,7 @@ import * as ApmIntegrations from './integrations'; const Integrations = { ...ApmIntegrations, BrowserTracing }; export { Integrations }; -export { Span, TRACEPARENT_REGEXP } from './span'; +export { Span } from './span'; export { Transaction } from './transaction'; export { SpanStatus } from './spanstatus'; @@ -14,3 +14,5 @@ export { SpanStatus } from './spanstatus'; addExtensionMethods(); export { addExtensionMethods }; + +export * from './utils'; diff --git a/packages/tracing/src/span.ts b/packages/tracing/src/span.ts index f36dea5a04c9..0118871eabd4 100644 --- a/packages/tracing/src/span.ts +++ b/packages/tracing/src/span.ts @@ -4,14 +4,6 @@ import { dropUndefinedKeys, timestampWithMs, uuid4 } from '@sentry/utils'; import { SpanStatus } from './spanstatus'; -export const TRACEPARENT_REGEXP = new RegExp( - '^[ \\t]*' + // whitespace - '([0-9a-f]{32})?' + // trace_id - '-?([0-9a-f]{16})?' + // span_id - '-?([01])?' + // sampled - '[ \\t]*$', // whitespace -); - /** * Keeps track of finished spans for a given transaction * @internal @@ -153,32 +145,6 @@ export class Span implements SpanInterface, SpanContext { } } - /** - * Continues a trace from a string (usually the header). - * @param traceparent Traceparent string - */ - public static fromTraceparent( - traceparent: string, - spanContext?: Pick>, - ): Span | undefined { - const matches = traceparent.match(TRACEPARENT_REGEXP); - if (matches) { - let sampled: boolean | undefined; - if (matches[3] === '1') { - sampled = true; - } else if (matches[3] === '0') { - sampled = false; - } - return new Span({ - ...spanContext, - parentSpanId: matches[2], - sampled, - traceId: matches[1], - }); - } - return undefined; - } - /** * @inheritDoc * @deprecated diff --git a/packages/tracing/src/transaction.ts b/packages/tracing/src/transaction.ts index 61c5033094e6..997adb3b1ccf 100644 --- a/packages/tracing/src/transaction.ts +++ b/packages/tracing/src/transaction.ts @@ -68,6 +68,7 @@ export class Transaction extends SpanClass { this.name = ''; } + // just sets the end timestamp super.finish(endTimestamp); if (this.sampled !== true) { diff --git a/packages/tracing/src/utils.ts b/packages/tracing/src/utils.ts new file mode 100644 index 000000000000..24ac56c764b3 --- /dev/null +++ b/packages/tracing/src/utils.ts @@ -0,0 +1,43 @@ +import { Options, TraceparentData } from '@sentry/types'; + +export const TRACEPARENT_REGEXP = new RegExp( + '^[ \\t]*' + // whitespace + '([0-9a-f]{32})?' + // trace_id + '-?([0-9a-f]{16})?' + // span_id + '-?([01])?' + // sampled + '[ \\t]*$', // whitespace +); + +/** + * Determines if tracing is currently enabled. + * + * Tracing is enabled when at least one of `tracesSampleRate` and `tracesSampler` is defined in the SDK config. + */ +export function hasTracingEnabled(options: Options): boolean { + return 'tracesSampleRate' in options || 'tracesSampler' in options; +} + +/** + * Extract transaction context data from a `sentry-trace` header. + * + * @param traceparent Traceparent string + * + * @returns Object containing data from the header, or undefined if traceparent string is malformed + */ +export function extractTraceparentData(traceparent: string): TraceparentData | undefined { + const matches = traceparent.match(TRACEPARENT_REGEXP); + if (matches) { + let parentSampled: boolean | undefined; + if (matches[3] === '1') { + parentSampled = true; + } else if (matches[3] === '0') { + parentSampled = false; + } + return { + traceId: matches[1], + parentSampled, + parentSpanId: matches[2], + }; + } + return undefined; +} diff --git a/packages/tracing/test/hub.test.ts b/packages/tracing/test/hub.test.ts index 017be5a5b898..9ae9badf7956 100644 --- a/packages/tracing/test/hub.test.ts +++ b/packages/tracing/test/hub.test.ts @@ -1,55 +1,331 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { BrowserClient } from '@sentry/browser'; -import { Hub } from '@sentry/hub'; +import { getMainCarrier, Hub } from '@sentry/hub'; +import * as utilsModule from '@sentry/utils'; // for mocking +import { getGlobalObject, isNodeEnv, logger } from '@sentry/utils'; +import * as nodeHttpModule from 'http'; import { addExtensionMethods } from '../src/hubextensions'; addExtensionMethods(); describe('Hub', () => { + beforeEach(() => { + jest.spyOn(logger, 'warn'); + jest.spyOn(logger, 'log'); + jest.spyOn(utilsModule, 'isNodeEnv'); + }); + afterEach(() => { - jest.resetAllMocks(); + jest.restoreAllMocks(); jest.useRealTimers(); }); - describe('getTransaction', () => { - test('simple invoke', () => { + describe('getTransaction()', () => { + it('should find a transaction which has been set on the scope', () => { const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); - const transaction = hub.startTransaction({ name: 'foo' }); + const transaction = hub.startTransaction({ name: 'dogpark' }); hub.configureScope(scope => { scope.setSpan(transaction); }); - hub.configureScope(s => { - expect(s.getTransaction()).toBe(transaction); - }); + + expect(hub.getScope()?.getTransaction()).toBe(transaction); }); - test('not invoke', () => { + it("should not find an open transaction if it's not on the scope", () => { const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); - const transaction = hub.startTransaction({ name: 'foo' }); - hub.configureScope(s => { - expect(s.getTransaction()).toBeUndefined(); - }); - transaction.finish(); + hub.startTransaction({ name: 'dogpark' }); + + expect(hub.getScope()?.getTransaction()).toBeUndefined(); }); }); - describe('spans', () => { - describe('sampling', () => { - test('set tracesSampleRate 0 on transaction', () => { + describe('transaction sampling', () => { + describe('tracesSampleRate and tracesSampler options', () => { + it("should call tracesSampler if it's defined", () => { + const tracesSampler = jest.fn(); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(tracesSampler).toHaveBeenCalled(); + }); + + it('should prefer tracesSampler to tracesSampleRate', () => { + const tracesSampler = jest.fn(); + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1, tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(tracesSampler).toHaveBeenCalled(); + }); + + it('tolerates tracesSampler returning a boolean', () => { + const tracesSampler = jest.fn().mockReturnValue(true); + const hub = new Hub(new BrowserClient({ tracesSampler })); + const transaction = hub.startTransaction({ name: 'dogpark' }); + + expect(tracesSampler).toHaveBeenCalled(); + expect(transaction.sampled).toBe(true); + }); + }); + + describe('default sample context', () => { + it('should extract request data for default sampling context when in node', () => { + // make sure we look like we're in node + (isNodeEnv as jest.Mock).mockReturnValue(true); + + // pre-normalization request object + const mockRequestObject = ({ + headers: { ears: 'furry', nose: 'wet', tongue: 'panting', cookie: 'favorite=zukes' }, + method: 'wagging', + protocol: 'mutualsniffing', + hostname: 'the.dog.park', + originalUrl: '/by/the/trees/?chase=me&please=thankyou', + } as unknown) as nodeHttpModule.IncomingMessage; + + // The "as unknown as nodeHttpModule.IncomingMessage" casting above keeps TS happy, but doesn't actually mean that + // mockRequestObject IS an instance of our desired class. Fix that so that when we search for it by type, we + // actually find it. + Object.setPrototypeOf(mockRequestObject, nodeHttpModule.IncomingMessage.prototype); + + // in production, the domain will have at minimum the request and the response, so make a response object to prove + // that our code identifying the request in domain.members works + const mockResponseObject = new nodeHttpModule.ServerResponse(mockRequestObject); + + // normally the node request handler does this, but that's not part of this test + (getMainCarrier().__SENTRY__!.extensions as any).domain = { + active: { members: [mockRequestObject, mockResponseObject] }, + }; + + // Ideally we'd use a NodeClient here, but @sentry/tracing can't depend on @sentry/node since the reverse is + // already true (node's request handlers start their own transactions) - even as a dev dependency. Fortunately, + // we're not relying on anything other than the client having a captureEvent method, which all clients do (it's + // in the abstract base class), so a BrowserClient will do. + const tracesSampler = jest.fn(); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + // post-normalization request object + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + request: { + headers: { ears: 'furry', nose: 'wet', tongue: 'panting', cookie: 'favorite=zukes' }, + method: 'wagging', + url: 'http://the.dog.park/by/the/trees/?chase=me&please=thankyou', + cookies: { favorite: 'zukes' }, + query_string: 'chase=me&please=thankyou', + }, + }), + ); + }); + + it('should extract window.location/self.location for default sampling context when in browser/service worker', () => { + // make sure we look like we're in the browser + (isNodeEnv as jest.Mock).mockReturnValue(false); + + const dogParkLocation = { + hash: '#next-to-the-fountain', + host: 'the.dog.park', + hostname: 'the.dog.park', + href: 'mutualsniffing://the.dog.park/by/the/trees/?chase=me&please=thankyou#next-to-the-fountain', + origin: "'mutualsniffing://the.dog.park", + pathname: '/by/the/trees/', + port: '', + protocol: 'mutualsniffing:', + search: '?chase=me&please=thankyou', + }; + + getGlobalObject().location = dogParkLocation as any; + + const tracesSampler = jest.fn(); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(tracesSampler).toHaveBeenCalledWith(expect.objectContaining({ location: dogParkLocation })); + }); + + it('should add transaction context data to default sample context', () => { + const tracesSampler = jest.fn(); + const hub = new Hub(new BrowserClient({ tracesSampler })); + const transactionContext = { + name: 'dogpark', + parentSpanId: '12312012', + parentSampled: true, + }; + + hub.startTransaction(transactionContext); + + expect(tracesSampler).toHaveBeenLastCalledWith(expect.objectContaining({ transactionContext })); + }); + + it("should add parent's sampling decision to default sample context", () => { + const tracesSampler = jest.fn(); + const hub = new Hub(new BrowserClient({ tracesSampler })); + const parentSamplingDecsion = false; + + hub.startTransaction({ + name: 'dogpark', + parentSpanId: '12312012', + parentSampled: parentSamplingDecsion, + }); + + expect(tracesSampler).toHaveBeenLastCalledWith( + expect.objectContaining({ parentSampled: parentSamplingDecsion }), + ); + }); + }); + + describe('sample()', () => { + it('should not sample transactions when tracing is disabled', () => { + // neither tracesSampleRate nor tracesSampler is defined -> tracing disabled + const hub = new Hub(new BrowserClient({})); + const transaction = hub.startTransaction({ name: 'dogpark' }); + + expect(transaction.sampled).toBe(false); + }); + + it('should not sample transactions when tracesSampleRate is 0', () => { const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); - const transaction = hub.startTransaction({ name: 'foo' }); + const transaction = hub.startTransaction({ name: 'dogpark' }); + expect(transaction.sampled).toBe(false); }); - test('set tracesSampleRate 1 on transaction', () => { + + it('should sample transactions when tracesSampleRate is 1', () => { const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); - const transaction = hub.startTransaction({ name: 'foo' }); - expect(transaction.sampled).toBeTruthy(); + const transaction = hub.startTransaction({ name: 'dogpark' }); + + expect(transaction.sampled).toBe(true); }); - test('set tracesSampleRate should be propergated to children', () => { - const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); - const transaction = hub.startTransaction({ name: 'foo' }); + }); + + describe('isValidSampleRate()', () => { + it("should reject tracesSampleRates which aren't numbers or booleans", () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 'dogs!' as any })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a boolean or a number')); + }); + + it('should reject tracesSampleRates which are NaN', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 'dogs!' as any })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a boolean or a number')); + }); + + // the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1 + it('should reject tracesSampleRates less than 0', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: -26 })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1')); + }); + + // the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1 + it('should reject tracesSampleRates greater than 1', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 26 })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1')); + }); + + it("should reject tracesSampler return values which aren't numbers or booleans", () => { + const tracesSampler = jest.fn().mockReturnValue('dogs!'); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a boolean or a number')); + }); + + it('should reject tracesSampler return values which are NaN', () => { + const tracesSampler = jest.fn().mockReturnValue(NaN); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a boolean or a number')); + }); + + // the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1 + it('should reject tracesSampler return values less than 0', () => { + const tracesSampler = jest.fn().mockReturnValue(-12); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1')); + }); + + // the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1 + it('should reject tracesSampler return values greater than 1', () => { + const tracesSampler = jest.fn().mockReturnValue(31); + const hub = new Hub(new BrowserClient({ tracesSampler })); + hub.startTransaction({ name: 'dogpark' }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1')); + }); + }); + + it('should drop transactions with sampled = false', () => { + const client = new BrowserClient({ tracesSampleRate: 0 }); + jest.spyOn(client, 'captureEvent'); + + const hub = new Hub(client); + const transaction = hub.startTransaction({ name: 'dogpark' }); + + jest.spyOn(transaction, 'finish'); + transaction.finish(); + + expect(transaction.sampled).toBe(false); + expect(transaction.finish).toReturnWith(undefined); + expect(client.captureEvent).not.toBeCalled(); + }); + + describe('sampling inheritance', () => { + it('should propagate sampling decision to child spans', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: Math.random() })); + const transaction = hub.startTransaction({ name: 'dogpark' }); const child = transaction.startChild({ op: 'test' }); - expect(child.sampled).toBeFalsy(); + + expect(child.sampled).toBe(transaction.sampled); + }); + + it('should propagate sampling decision to child transactions in XHR header', () => { + // TODO fix this and write the test + }); + + it('should propagate sampling decision to child transactions in fetch header', () => { + // TODO fix this and write the test + }); + + it("should inherit parent's sampling decision when creating a new transaction if tracesSampler is undefined", () => { + // tracesSampleRate = 1 means every transaction should end up with sampled = true, so make parent's decision the + // opposite to prove that inheritance takes precedence over tracesSampleRate + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + const parentSamplingDecsion = false; + + const transaction = hub.startTransaction({ + name: 'dogpark', + parentSpanId: '12312012', + parentSampled: parentSamplingDecsion, + }); + + expect(transaction.sampled).toBe(parentSamplingDecsion); + }); + + it("should ignore parent's sampling decision when tracesSampler is defined", () => { + // this tracesSampler causes every transaction to end up with sampled = true, so make parent's decision the + // opposite to prove that tracesSampler takes precedence over inheritance + const tracesSampler = () => true; + const parentSamplingDecsion = false; + + const hub = new Hub(new BrowserClient({ tracesSampler })); + + const transaction = hub.startTransaction({ + name: 'dogpark', + parentSpanId: '12312012', + parentSampled: parentSamplingDecsion, + }); + + expect(transaction.sampled).not.toBe(parentSamplingDecsion); }); }); }); diff --git a/packages/tracing/test/span.test.ts b/packages/tracing/test/span.test.ts index 5fed6cfbf9d4..c8026974dc9d 100644 --- a/packages/tracing/test/span.test.ts +++ b/packages/tracing/test/span.test.ts @@ -100,42 +100,6 @@ describe('Span', () => { }); }); - describe('fromTraceparent', () => { - test('no sample', () => { - const from = Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb') as any; - - expect(from.parentSpanId).toEqual('bbbbbbbbbbbbbbbb'); - expect(from.traceId).toEqual('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); - expect(from.spanId).not.toEqual('bbbbbbbbbbbbbbbb'); - expect(from.sampled).toBeUndefined(); - }); - test('sample true', () => { - const from = Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-1') as any; - expect(from.sampled).toBeTruthy(); - }); - - test('sample false', () => { - const from = Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-0') as any; - expect(from.sampled).toBeFalsy(); - }); - - test('just sample rate', () => { - const from = Span.fromTraceparent('0') as any; - expect(from.traceId).toHaveLength(32); - expect(from.spanId).toHaveLength(16); - expect(from.sampled).toBeFalsy(); - - const from2 = Span.fromTraceparent('1') as any; - expect(from2.traceId).toHaveLength(32); - expect(from2.spanId).toHaveLength(16); - expect(from2.sampled).toBeTruthy(); - }); - - test('invalid', () => { - expect(Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-x')).toBeUndefined(); - }); - }); - describe('toJSON', () => { test('simple', () => { const span = JSON.parse( diff --git a/packages/tracing/test/utils.test.ts b/packages/tracing/test/utils.test.ts new file mode 100644 index 000000000000..cda8fbbb062f --- /dev/null +++ b/packages/tracing/test/utils.test.ts @@ -0,0 +1,64 @@ +import { extractTraceparentData } from '../src/utils'; + +describe('extractTraceparentData', () => { + test('no sample', () => { + const data = extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb') as any; + + expect(data).toBeDefined(); + expect(data.parentSpanId).toEqual('bbbbbbbbbbbbbbbb'); + expect(data.traceId).toEqual('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(data?.parentSampled).toBeUndefined(); + }); + + test('sample true', () => { + const data = extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-1') as any; + + expect(data).toBeDefined(); + expect(data.parentSampled).toBeTruthy(); + }); + + test('sample false', () => { + const data = extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-0') as any; + + expect(data).toBeDefined(); + expect(data.parentSampled).toBeFalsy(); + }); + + test('just sample decision - false', () => { + const data = extractTraceparentData('0') as any; + + expect(data).toBeDefined(); + expect(data.traceId).toBeUndefined(); + expect(data.spanId).toBeUndefined(); + expect(data.parentSampled).toBeFalsy(); + }); + + test('just sample decision - true', () => { + const data = extractTraceparentData('1') as any; + + expect(data).toBeDefined(); + expect(data.traceId).toBeUndefined(); + expect(data.spanId).toBeUndefined(); + expect(data.parentSampled).toBeTruthy(); + }); + + test('invalid', () => { + // trace id wrong length + expect(extractTraceparentData('a-bbbbbbbbbbbbbbbb-1')).toBeUndefined(); + + // parent span id wrong length + expect(extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-b-1')).toBeUndefined(); + + // parent sampling decision wrong length + expect(extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-11')).toBeUndefined(); + + // trace id invalid hex value + expect(extractTraceparentData('someStuffHereWhichIsNotAtAllHexy-bbbbbbbbbbbbbbbb-1')).toBeUndefined(); + + // parent span id invalid hex value + expect(extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-alsoNotSuperHexy-1')).toBeUndefined(); + + // bogus sampling decision + expect(extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-x')).toBeUndefined(); + }); +}); diff --git a/packages/types/src/hub.ts b/packages/types/src/hub.ts index 6ab719821785..29f538f5fd94 100644 --- a/packages/types/src/hub.ts +++ b/packages/types/src/hub.ts @@ -6,7 +6,7 @@ import { Integration, IntegrationClass } from './integration'; import { Scope } from './scope'; import { Severity } from './severity'; import { Span, SpanContext } from './span'; -import { Transaction, TransactionContext } from './transaction'; +import { CustomSamplingContext, Transaction, TransactionContext } from './transaction'; import { User } from './user'; /** @@ -183,21 +183,21 @@ export interface Hub { startSpan(context: SpanContext): Span; /** - * Starts a new `Transaction` and returns it. This is the entry point to manual - * tracing instrumentation. + * Starts a new `Transaction` and returns it. This is the entry point to manual tracing instrumentation. * - * A tree structure can be built by adding child spans to the transaction, and - * child spans to other spans. To start a new child span within the transaction - * or any span, call the respective `.startChild()` method. + * A tree structure can be built by adding child spans to the transaction, and child spans to other spans. To start a + * new child span within the transaction or any span, call the respective `.startChild()` method. * - * Every child span must be finished before the transaction is finished, - * otherwise the unfinished spans are discarded. + * Every child span must be finished before the transaction is finished, otherwise the unfinished spans are discarded. * - * The transaction must be finished with a call to its `.finish()` method, at - * which point the transaction with all its finished child spans will be sent to - * Sentry. + * The transaction must be finished with a call to its `.finish()` method, at which point the transaction with all its + * finished child spans will be sent to Sentry. * * @param context Properties of the new `Transaction`. + * @param customSamplingContext Information given to the transaction sampling function (along with context-dependent + * default values). See {@link Options.tracesSampler}. + * + * @returns The transaction which was just started */ - startTransaction(context: TransactionContext): Transaction; + startTransaction(context: TransactionContext, customSamplingContext?: CustomSamplingContext): Transaction; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1c6939f60969..da4fa2f6129f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -10,6 +10,7 @@ export { Hub } from './hub'; export { Integration, IntegrationClass } from './integration'; export { LogLevel } from './loglevel'; export { Mechanism } from './mechanism'; +export { ExtractedNodeRequestData, WorkerLocation } from './misc'; export { Options } from './options'; export { Package } from './package'; export { Request } from './request'; @@ -22,7 +23,13 @@ export { Span, SpanContext } from './span'; export { StackFrame } from './stackframe'; export { Stacktrace } from './stacktrace'; export { Status } from './status'; -export { Transaction, TransactionContext } from './transaction'; +export { + CustomSamplingContext, + SamplingContext, + TraceparentData, + Transaction, + TransactionContext, +} from './transaction'; export { Thread } from './thread'; export { Transport, TransportOptions, TransportClass } from './transport'; export { User } from './user'; diff --git a/packages/types/src/misc.ts b/packages/types/src/misc.ts new file mode 100644 index 000000000000..9e52b5aa6e22 --- /dev/null +++ b/packages/types/src/misc.ts @@ -0,0 +1,61 @@ +/** + * Data extracted from an incoming request to a node server + */ +export interface ExtractedNodeRequestData { + [key: string]: any; + + /** Specific headers from the request */ + headers?: { [key: string]: string }; + + /** The request's method */ + method?: string; + + /** The request's URL, including query string */ + url?: string; + + /** String representing the cookies sent along with the request */ + cookies?: { [key: string]: string }; + + /** The request's query string, without the leading '?' */ + query_string?: string; + + /** Any data sent in the request's body, as a JSON string */ + data?: string; +} + +/** + * Location object on a service worker's `self` object. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/WorkerLocation. + */ +export interface WorkerLocation { + /** The protocol scheme of the URL of the script executed in the Worker, including the final ':'. */ + readonly protocol: string; + + /** The host, that is the hostname, a ':', and the port of the URL of the script executed in the Worker. */ + readonly host: string; + + /** The domain of the URL of the script executed in the Worker. */ + readonly hostname: string; + + /** The canonical form of the origin of the specific location. */ + readonly origin: string; + + /** The port number of the URL of the script executed in the Worker. */ + readonly port: string; + + /** The path of the URL of the script executed in the Worker, beginning with a '/'. */ + readonly pathname: string; + + /** The parameters (query string) of the URL of the script executed in the Worker, beginning with a '?'. */ + readonly search: string; + + /** The fragment identifier of the URL of the script executed in the Worker, beginning with a '#'. */ + readonly hash: string; + + /** Stringifier that returns the whole URL of the script executed in the Worker. */ + readonly href: string; + + /** Synonym for `href` attribute */ + toString(): string; +} diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index 4cc187b83d73..1fe5444e953d 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -2,6 +2,7 @@ import { Breadcrumb, BreadcrumbHint } from './breadcrumb'; import { Event, EventHint } from './event'; import { Integration } from './integration'; import { LogLevel } from './loglevel'; +import { SamplingContext } from './transaction'; import { Transport, TransportClass, TransportOptions } from './transport'; /** Base configuration options for every SDK. */ @@ -78,16 +79,6 @@ export interface Options { /** A global sample rate to apply to all events (0 - 1). */ sampleRate?: number; - /** - * Sample rate to determine trace sampling. - * - * 0.0 = 0% chance of a given trace being sent (send no traces) - * 1.0 = 100% chance of a given trace being sent (send all traces) - * - * Default: 0.0 - */ - tracesSampleRate?: number; - /** Attaches stacktraces to pure capture message / log integrations */ attachStacktrace?: boolean; @@ -114,10 +105,38 @@ export interface Options { */ shutdownTimeout?: number; + /** + * Options which are in beta, or otherwise not guaranteed to be stable. + */ _experiments?: { [key: string]: any; }; + /** + * Sample rate to determine trace sampling. + * + * 0.0 = 0% chance of a given trace being sent (send no traces) 1.0 = 100% chance of a given trace being sent (send + * all traces) + * + * Tracing is enabled if either this or `tracesSampler` is defined. If both are defined, `tracesSampleRate` is + * ignored. + */ + tracesSampleRate?: number; + + /** + * Function to compute tracing sample rate dynamically and filter unwanted traces. + * + * Tracing is enabled if either this or `tracesSampleRate` is defined. If both are defined, `tracesSampleRate` is + * ignored. + * + * Will automatically be passed a context object of default and optional custom data. See + * {@link Transaction.samplingContext} and {@link Hub.startTransaction}. + * + * @returns A sample rate between 0 and 1 (0 drops the trace, 1 guarantees it will be sent). Returning `true` is + * equivalent to returning 1 and returning `false` is equivalent to returning 0. + */ + tracesSampler?(samplingContext: SamplingContext): number | boolean; + /** * A callback invoked during event submission, allowing to optionally modify * the event before it is sent to Sentry. diff --git a/packages/types/src/request.ts b/packages/types/src/request.ts index 980dd498aa73..0e0e8e172117 100644 --- a/packages/types/src/request.ts +++ b/packages/types/src/request.ts @@ -1,4 +1,6 @@ -/** JSDoc */ +/** + * Request data included in an event as sent to Sentry + */ export interface Request { url?: string; method?: string; diff --git a/packages/types/src/transaction.ts b/packages/types/src/transaction.ts index 4606763a7e22..3888bcc890e3 100644 --- a/packages/types/src/transaction.ts +++ b/packages/types/src/transaction.ts @@ -1,10 +1,15 @@ +import { ExtractedNodeRequestData, WorkerLocation } from './misc'; import { Span, SpanContext } from './span'; /** * Interface holding Transaction-specific properties */ export interface TransactionContext extends SpanContext { + /** + * Human-readable identifier for the transaction + */ name: string; + /** * If true, sets the end timestamp of the transaction to the highest timestamp of child spans, trimming * the duration of the transaction. This is useful to discard extra time in the transaction that is not @@ -12,8 +17,18 @@ export interface TransactionContext extends SpanContext { * transaction after a given "idle time" and we don't want this "idle time" to be part of the transaction. */ trimEnd?: boolean; + + /** + * If this transaction has a parent, the parent's sampling decision + */ + parentSampled?: boolean; } +/** + * Data pulled from a `sentry-trace` header + */ +export type TraceparentData = Pick; + /** * Transaction "Class", inherits Span only has `setName` */ @@ -48,3 +63,38 @@ export interface Transaction extends TransactionContext, Span { */ setName(name: string): void; } + +/** + * Context data passed by the user when starting a transaction, to be used by the tracesSampler method. + */ +export interface CustomSamplingContext { + [key: string]: any; +} + +/** + * Data passed to the `tracesSampler` function, which forms the basis for whatever decisions it might make. + * + * Adds default data to data provided by the user. See {@link Hub.startTransaction} + */ +export interface SamplingContext extends CustomSamplingContext { + /** + * Context data with which transaction being sampled was created + */ + transactionContext: TransactionContext; + + /** + * Sampling decision from the parent transaction, if any. + */ + parentSampled?: boolean; + + /** + * Object representing the URL of the current page or worker script. Passed by default in a browser or service worker + * context. + */ + location?: Location | WorkerLocation; + + /** + * Object representing the incoming request to a node server. Passed by default when using the TracingHandler. + */ + request?: ExtractedNodeRequestData; +} diff --git a/packages/utils/src/browser.ts b/packages/utils/src/browser.ts new file mode 100644 index 000000000000..e81402501740 --- /dev/null +++ b/packages/utils/src/browser.ts @@ -0,0 +1,98 @@ +import { isString } from './is'; + +/** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @returns generated DOM path + */ +export function htmlTreeAsString(elem: unknown): string { + type SimpleNode = { + parentNode: SimpleNode; + } | null; + + // try/catch both: + // - accessing event.target (see getsentry/raven-js#838, #768) + // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly + // - can throw an exception in some circumstances. + try { + let currentElem = elem as SimpleNode; + const MAX_TRAVERSE_HEIGHT = 5; + const MAX_OUTPUT_LEN = 80; + const out = []; + let height = 0; + let len = 0; + const separator = ' > '; + const sepLength = separator.length; + let nextStr; + + // eslint-disable-next-line no-plusplus + while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { + nextStr = _htmlElementAsString(currentElem); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds MAX_OUTPUT_LEN + // (ignore this limit if we are on the first iteration) + if (nextStr === 'html' || (height > 1 && len + out.length * sepLength + nextStr.length >= MAX_OUTPUT_LEN)) { + break; + } + + out.push(nextStr); + + len += nextStr.length; + currentElem = currentElem.parentNode; + } + + return out.reverse().join(separator); + } catch (_oO) { + return ''; + } +} + +/** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @returns generated DOM path + */ +function _htmlElementAsString(el: unknown): string { + const elem = el as { + tagName?: string; + id?: string; + className?: string; + getAttribute(key: string): string; + }; + + const out = []; + let className; + let classes; + let key; + let attr; + let i; + + if (!elem || !elem.tagName) { + return ''; + } + + out.push(elem.tagName.toLowerCase()); + if (elem.id) { + out.push(`#${elem.id}`); + } + + // eslint-disable-next-line prefer-const + className = elem.className; + if (className && isString(className)) { + classes = className.split(/\s+/); + for (i = 0; i < classes.length; i++) { + out.push(`.${classes[i]}`); + } + } + const allowedAttrs = ['type', 'name', 'title', 'alt']; + for (i = 0; i < allowedAttrs.length; i++) { + key = allowedAttrs[i]; + attr = elem.getAttribute(key); + if (attr) { + out.push(`[${key}="${attr}"]`); + } + } + return out.join(''); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6457c22d419b..472d54dd542a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,14 +1,17 @@ export * from './async'; +export * from './browser'; +export * from './dsn'; export * from './error'; export * from './is'; export * from './logger'; export * from './memo'; export * from './misc'; +export * from './node'; export * from './object'; export * from './path'; export * from './promisebuffer'; +export * from './stacktrace'; export * from './string'; export * from './supports'; export * from './syncpromise'; export * from './instrument'; -export * from './dsn'; diff --git a/packages/utils/src/instrument.ts b/packages/utils/src/instrument.ts index 7308d9efd892..1921d11a84e1 100644 --- a/packages/utils/src/instrument.ts +++ b/packages/utils/src/instrument.ts @@ -4,8 +4,9 @@ import { WrappedFunction } from '@sentry/types'; import { isInstanceOf, isString } from './is'; import { logger } from './logger'; -import { getFunctionName, getGlobalObject } from './misc'; +import { getGlobalObject } from './misc'; import { fill } from './object'; +import { getFunctionName } from './stacktrace'; import { supportsHistory, supportsNativeFetch } from './supports'; const global = getGlobalObject(); diff --git a/packages/utils/src/misc.ts b/packages/utils/src/misc.ts index 083b69c611e9..ecd753794263 100644 --- a/packages/utils/src/misc.ts +++ b/packages/utils/src/misc.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Event, Integration, StackFrame, WrappedFunction } from '@sentry/types'; -import { isString } from './is'; +import { dynamicRequire, isNodeEnv } from './node'; import { snipLine } from './string'; /** Internal */ @@ -21,26 +21,6 @@ interface SentryGlobal { }; } -/** - * Requires a module which is protected against bundler minification. - * - * @param request The module path to resolve - */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function dynamicRequire(mod: any, request: string): any { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return mod.require(request); -} - -/** - * Checks whether we're in the Node.js or Browser environment - * - * @returns Answer to given question - */ -export function isNodeEnv(): boolean { - return Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'; -} - const fallbackGlobalObject = {}; /** @@ -177,12 +157,14 @@ export function consoleSandbox(callback: () => any): any { return callback(); } - const originalConsole = global.console as ExtensibleConsole; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const originalConsole = (global as any).console as ExtensibleConsole; const wrappedLevels: { [key: string]: any } = {}; // Restore all wrapped console methods levels.forEach(level => { - if (level in global.console && (originalConsole[level] as WrappedFunction).__sentry_original__) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (level in (global as any).console && (originalConsole[level] as WrappedFunction).__sentry_original__) { wrappedLevels[level] = originalConsole[level] as WrappedFunction; originalConsole[level] = (originalConsole[level] as WrappedFunction).__sentry_original__; } @@ -252,103 +234,6 @@ export function getLocationHref(): string { } } -/** - * Given a child DOM element, returns a query-selector statement describing that - * and its ancestors - * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] - * @returns generated DOM path - */ -export function htmlTreeAsString(elem: unknown): string { - type SimpleNode = { - parentNode: SimpleNode; - } | null; - - // try/catch both: - // - accessing event.target (see getsentry/raven-js#838, #768) - // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly - // - can throw an exception in some circumstances. - try { - let currentElem = elem as SimpleNode; - const MAX_TRAVERSE_HEIGHT = 5; - const MAX_OUTPUT_LEN = 80; - const out = []; - let height = 0; - let len = 0; - const separator = ' > '; - const sepLength = separator.length; - let nextStr; - - // eslint-disable-next-line no-plusplus - while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { - nextStr = _htmlElementAsString(currentElem); - // bail out if - // - nextStr is the 'html' element - // - the length of the string that would be created exceeds MAX_OUTPUT_LEN - // (ignore this limit if we are on the first iteration) - if (nextStr === 'html' || (height > 1 && len + out.length * sepLength + nextStr.length >= MAX_OUTPUT_LEN)) { - break; - } - - out.push(nextStr); - - len += nextStr.length; - currentElem = currentElem.parentNode; - } - - return out.reverse().join(separator); - } catch (_oO) { - return ''; - } -} - -/** - * Returns a simple, query-selector representation of a DOM element - * e.g. [HTMLElement] => input#foo.btn[name=baz] - * @returns generated DOM path - */ -function _htmlElementAsString(el: unknown): string { - const elem = el as { - tagName?: string; - id?: string; - className?: string; - getAttribute(key: string): string; - }; - - const out = []; - let className; - let classes; - let key; - let attr; - let i; - - if (!elem || !elem.tagName) { - return ''; - } - - out.push(elem.tagName.toLowerCase()); - if (elem.id) { - out.push(`#${elem.id}`); - } - - // eslint-disable-next-line prefer-const - className = elem.className; - if (className && isString(className)) { - classes = className.split(/\s+/); - for (i = 0; i < classes.length; i++) { - out.push(`.${classes[i]}`); - } - } - const allowedAttrs = ['type', 'name', 'title', 'alt']; - for (i = 0; i < allowedAttrs.length; i++) { - key = allowedAttrs[i]; - attr = elem.getAttribute(key); - if (attr) { - out.push(`[${key}="${attr}"]`); - } - } - return out.join(''); -} - const INITIAL_TIME = Date.now(); let prevNow = 0; @@ -470,24 +355,6 @@ 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; - } -} - /** * This function adds context (pre/post/line) lines to the provided frame * diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts new file mode 100644 index 000000000000..1154568e45a7 --- /dev/null +++ b/packages/utils/src/node.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ExtractedNodeRequestData } from '@sentry/types'; + +import { isString } from './is'; +import { normalize } from './object'; + +/** + * Checks whether we're in the Node.js or Browser environment + * + * @returns Answer to given question + */ +export function isNodeEnv(): boolean { + return Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'; +} + +/** + * Requires a module which is protected against bundler minification. + * + * @param request The module path to resolve + */ +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function dynamicRequire(mod: any, request: string): any { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return mod.require(request); +} + +/** Default request keys that'll be used to extract data from the request */ +const DEFAULT_REQUEST_KEYS = ['cookies', 'data', 'headers', 'method', 'query_string', 'url']; + +/** + * Normalizes data from the request object, accounting for framework differences. + * + * @param req The request object from which to extract data + * @param keys An optional array of keys to include in the normalized data. Defaults to DEFAULT_REQUEST_KEYS if not + * provided. + * @returns An object containing normalized request data + */ +export function extractNodeRequestData( + req: { [key: string]: any }, + keys: string[] = DEFAULT_REQUEST_KEYS, +): ExtractedNodeRequestData { + // make sure we can safely use dynamicRequire below + if (!isNodeEnv()) { + throw new Error("Can't get node request data outside of a node environment"); + } + + const requestData: { [key: string]: any } = {}; + + // headers: + // node, express: req.headers + // koa: req.header + const headers = (req.headers || req.header || {}) as { + host?: string; + cookie?: string; + }; + // method: + // node, express, koa: req.method + const method = req.method; + // host: + // express: req.hostname in > 4 and req.host in < 4 + // koa: req.host + // node: req.headers.host + const host = req.hostname || req.host || headers.host || ''; + // protocol: + // node: + // express, koa: req.protocol + const protocol = + req.protocol === 'https' || req.secure || ((req.socket || {}) as { encrypted?: boolean }).encrypted + ? 'https' + : 'http'; + // url (including path and query string): + // node, express: req.originalUrl + // koa: req.url + const originalUrl = (req.originalUrl || req.url) as string; + // absolute url + const absoluteUrl = `${protocol}://${host}${originalUrl}`; + + keys.forEach(key => { + switch (key) { + case 'headers': + requestData.headers = headers; + break; + case 'method': + requestData.method = method; + break; + case 'url': + requestData.url = absoluteUrl; + break; + case 'cookies': + // cookies: + // node, express, koa: req.headers.cookie + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + requestData.cookies = dynamicRequire(module, 'cookie').parse(headers.cookie || ''); + break; + case 'query_string': + // query string: + // node: req.url (raw) + // express, koa: req.query + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + requestData.query_string = dynamicRequire(module, 'url').parse(originalUrl || '', false).query; + break; + case 'data': + if (method === 'GET' || method === 'HEAD') { + break; + } + // body data: + // node, express, koa: req.body + if (req.body !== undefined) { + requestData.data = isString(req.body) ? req.body : JSON.stringify(normalize(req.body)); + } + break; + default: + if ({}.hasOwnProperty.call(req, key)) { + requestData[key] = (req as { [key: string]: any })[key]; + } + } + }); + + return requestData; +} diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 02e5ec7bb1e4..f1b6cc88dca1 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ExtendedError, WrappedFunction } from '@sentry/types'; +import { htmlTreeAsString } from './browser'; import { isElement, isError, isEvent, isInstanceOf, isPlainObject, isPrimitive, isSyntheticEvent } from './is'; import { Memo } from './memo'; -import { getFunctionName, htmlTreeAsString } from './misc'; +import { getFunctionName } from './stacktrace'; import { truncate } from './string'; /** diff --git a/packages/utils/src/stacktrace.ts b/packages/utils/src/stacktrace.ts new file mode 100644 index 000000000000..3ee2344d2dd4 --- /dev/null +++ b/packages/utils/src/stacktrace.ts @@ -0,0 +1,17 @@ +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; + } +}