Skip to content
28 changes: 18 additions & 10 deletions packages/tracing/src/browser/browsertracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,14 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
markBackgroundTransactions: boolean;

/**
* beforeNavigate is called before a pageload/navigation transaction is created and allows for users
* to set custom transaction context. Default behavior is to return `window.location.pathname`.
* beforeNavigate is called before a pageload/navigation transaction is created and allows users to modify transaction
* context data, or drop the transaction entirely (by setting `sampled = false` in the context).
*
* If undefined is returned, a pageload/navigation transaction will not be created.
* Note: For legacy reasons, transactions can also be dropped by returning `undefined`.
*
* @param context: The context data which will be passed to `startTransaction` by default
*
* @returns A (potentially) modified context object, with `sampled = false` if the transaction should be dropped.
*/
beforeNavigate?(context: TransactionContext): TransactionContext | undefined;

Expand Down Expand Up @@ -187,22 +191,26 @@ export class BrowserTracing implements Integration {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { beforeNavigate, idleTimeout, maxTransactionDuration } = this.options;

// if beforeNavigate returns undefined, we should not start a transaction.
const parentContextFromHeader = context.op === 'pageload' ? getHeaderContext() : undefined;

const expandedContext = {
...context,
...getHeaderContext(),
...parentContextFromHeader,
trimEnd: true,
};
const modifiedContext = typeof beforeNavigate === 'function' ? beforeNavigate(expandedContext) : expandedContext;

if (modifiedContext === undefined) {
logger.log(`[Tracing] Did not create ${context.op} idleTransaction due to beforeNavigate`);
return undefined;
// For backwards compatibility reasons, beforeNavigate can return undefined to "drop" the transaction (prevent it
// from being sent to Sentry).
const finalContext = modifiedContext === undefined ? { ...expandedContext, sampled: false } : modifiedContext;

if (finalContext.sampled === false) {
logger.log(`[Tracing] Will not send ${finalContext.op} transaction because of beforeNavigate.`);
}

const hub = this._getCurrentHub();
const idleTransaction = startIdleTransaction(hub, modifiedContext, idleTimeout, true);
logger.log(`[Tracing] Starting ${modifiedContext.op} transaction on scope`);
const idleTransaction = startIdleTransaction(hub, finalContext, idleTimeout, true);
logger.log(`[Tracing] Starting ${finalContext.op} transaction on scope`);
idleTransaction.registerBeforeFinishCallback((transaction, endTimestamp) => {
this._metrics.addPerformanceEntries(transaction);
adjustTransactionDuration(secToMs(maxTransactionDuration), transaction, endTimestamp);
Expand Down
28 changes: 17 additions & 11 deletions packages/tracing/src/browser/request.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { getCurrentHub } from '@sentry/hub';
import { addInstrumentationHandler, isInstanceOf, isMatchingPattern } from '@sentry/utils';

import { Span } from '../span';
import { getActiveTransaction } from '../utils';
import { getActiveTransaction, hasTracingEnabled } from '../utils';

export const DEFAULT_TRACING_ORIGINS = ['localhost', /^\//];

Expand Down Expand Up @@ -142,7 +143,13 @@ export function fetchCallback(
shouldCreateSpan: (url: string) => boolean,
spans: Record<string, Span>,
): void {
if (!handlerData.fetchData || !shouldCreateSpan(handlerData.fetchData.url)) {
const currentClientOptions = getCurrentHub()
.getClient()
?.getOptions();
if (
!(currentClientOptions && hasTracingEnabled(currentClientOptions)) ||
!(handlerData.fetchData && shouldCreateSpan(handlerData.fetchData.url))
) {
return;
}

Expand Down Expand Up @@ -209,19 +216,18 @@ export function xhrCallback(
shouldCreateSpan: (url: string) => boolean,
spans: Record<string, Span>,
): void {
if (!handlerData || !handlerData.xhr || !handlerData.xhr.__sentry_xhr__) {
const currentClientOptions = getCurrentHub()
.getClient()
?.getOptions();
if (
!(currentClientOptions && hasTracingEnabled(currentClientOptions)) ||
!(handlerData.xhr && handlerData.xhr.__sentry_xhr__ && shouldCreateSpan(handlerData.xhr.__sentry_xhr__.url)) ||
handlerData.xhr.__sentry_own_request__
) {
return;
}

const xhr = handlerData.xhr.__sentry_xhr__;
if (!shouldCreateSpan(xhr.url)) {
return;
}

// We only capture complete, non-sentry requests
if (handlerData.xhr.__sentry_own_request__) {
return;
}

// check first if the request has finished and is tracked by an existing span which should now end
if (handlerData.endTimestamp && handlerData.xhr.__sentry_xhr_span_id__) {
Expand Down
5 changes: 5 additions & 0 deletions packages/tracing/src/hubextensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ function sample<T extends Transaction>(hub: Hub, transaction: T, samplingContext
return transaction;
}

// if the user has forced a sampling decision by passing a `sampled` value in their transaction context, go with that
if (transaction.sampled !== undefined) {
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 =
Expand Down
68 changes: 66 additions & 2 deletions packages/tracing/test/browser/browsertracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BrowserTracing,
BrowserTracingOptions,
DEFAULT_MAX_TRANSACTION_DURATION_SECONDS,
getHeaderContext,
getMetaContent,
} from '../../src/browser/browsertracing';
import { defaultRequestInstrumentionOptions } from '../../src/browser/request';
Expand Down Expand Up @@ -177,14 +178,15 @@ describe('BrowserTracing', () => {
expect(mockBeforeNavigation).toHaveBeenCalledTimes(1);
});

it('does not create a transaction if it returns undefined', () => {
// TODO add this back in once getTransaction() returns sampled = false transactions, too
it.skip('creates a transaction with sampled = false if it returns undefined', () => {
const mockBeforeNavigation = jest.fn().mockReturnValue(undefined);
createBrowserTracing(true, {
beforeNavigate: mockBeforeNavigation,
routingInstrumentation: customRoutingInstrumentation,
});
const transaction = getActiveTransaction(hub) as IdleTransaction;
expect(transaction).not.toBeDefined();
expect(transaction.sampled).toBe(false);

expect(mockBeforeNavigation).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -379,5 +381,67 @@ describe('BrowserTracing', () => {
expect(metaTagValue).toBe(content);
});
});

describe('getHeaderContext', () => {
it('correctly parses a valid sentry-trace meta header', () => {
document.head.innerHTML = `<meta name="sentry-trace" content="12312012123120121231201212312012-1121201211212012-0">`;

const headerContext = getHeaderContext();

expect(headerContext).toBeDefined();
expect(headerContext!.traceId).toEqual('12312012123120121231201212312012');
expect(headerContext!.parentSpanId).toEqual('1121201211212012');
expect(headerContext!.parentSampled).toEqual(false);
});

it('returns undefined if the header is malformed', () => {
document.head.innerHTML = `<meta name="sentry-trace" content="12312012-112120121-0">`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L:

💡 If we see a meta tag that is half-way there, <meta name="sentry-trace" content="some-invalid-data"> we should log an error / send a usage error to Sentry.

Such invalid tags are most likely coming from a wrong setup in the backend.


const headerContext = getHeaderContext();

expect(headerContext).toBeUndefined();
});

it("returns undefined if the header isn't there", () => {
document.head.innerHTML = `<meta name="dogs" content="12312012123120121231201212312012-1121201211212012-0">`;

const headerContext = getHeaderContext();

expect(headerContext).toBeUndefined();
});
});

describe('using the data', () => {
// TODO add this back in once getTransaction() returns sampled = false transactions, too
it.skip('uses the data for pageload transactions', () => {
// make sampled false here, so we can see that it's being used rather than the tracesSampleRate-dictated one
document.head.innerHTML = `<meta name="sentry-trace" content="12312012123120121231201212312012-1121201211212012-0">`;

// pageload transactions are created as part of the BrowserTracing integration's initialization
createBrowserTracing(true);
const transaction = getActiveTransaction(hub) as IdleTransaction;

expect(transaction).toBeDefined();
expect(transaction.op).toBe('pageload');
expect(transaction.traceId).toEqual('12312012123120121231201212312012');
expect(transaction.parentSpanId).toEqual('1121201211212012');
expect(transaction.sampled).toBe(false);
});

it('ignores the data for navigation transactions', () => {
mockChangeHistory = () => undefined;
document.head.innerHTML = `<meta name="sentry-trace" content="12312012123120121231201212312012-1121201211212012-0">`;

createBrowserTracing(true);

mockChangeHistory({ to: 'here', from: 'there' });
const transaction = getActiveTransaction(hub) as IdleTransaction;

expect(transaction).toBeDefined();
expect(transaction.op).toBe('navigation');
expect(transaction.traceId).not.toEqual('12312012123120121231201212312012');
expect(transaction.parentSpanId).toBeUndefined();
});
});
});
});
41 changes: 41 additions & 0 deletions packages/tracing/test/browser/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ beforeAll(() => {
global.Request = {};
});

const hasTracingEnabled = jest.spyOn(tracingUtils, 'hasTracingEnabled');
const addInstrumentationHandler = jest.spyOn(utils, 'addInstrumentationHandler');
const setRequestHeader = jest.fn();

Expand Down Expand Up @@ -108,6 +109,30 @@ describe('callbacks', () => {
expect(spans).toEqual({});
});

it('does not add fetch request spans if tracing is disabled', () => {
hasTracingEnabled.mockReturnValueOnce(false);
const spans = {};

fetchCallback(fetchHandlerData, alwaysCreateSpan, spans);
expect(spans).toEqual({});
});

it('does not add fetch request headers if tracing is disabled', () => {
hasTracingEnabled.mockReturnValueOnce(false);

// make a local copy so the global one doesn't get mutated
const handlerData: FetchData = {
args: ['http://dogs.are.great/', {}],
fetchData: { url: 'http://dogs.are.great/', method: 'GET' },
startTimestamp: 1353501072000,
};

fetchCallback(handlerData, alwaysCreateSpan, {});

const headers = (handlerData.args[1].headers as Record<string, string>) || {};
expect(headers['sentry-trace']).not.toBeDefined();
});

it('creates and finishes fetch span on active transaction', () => {
const spans = {};

Expand Down Expand Up @@ -174,6 +199,22 @@ describe('callbacks', () => {
expect(spans).toEqual({});
});

it('does not add xhr request spans if tracing is disabled', () => {
hasTracingEnabled.mockReturnValueOnce(false);
const spans = {};

xhrCallback(xhrHandlerData, alwaysCreateSpan, spans);
expect(spans).toEqual({});
});

it('does not add xhr request headers if tracing is disabled', () => {
hasTracingEnabled.mockReturnValueOnce(false);

xhrCallback(xhrHandlerData, alwaysCreateSpan, {});

expect(setRequestHeader).not.toHaveBeenCalled();
});

it('adds sentry-trace header to XHR requests', () => {
xhrCallback(xhrHandlerData, alwaysCreateSpan, {});

Expand Down
Loading