Skip to content

Commit d9201af

Browse files
committed
feat(browser): Add option to sample linked traces consistently
1 parent 67f4919 commit d9201af

File tree

8 files changed

+264
-29
lines changed

8 files changed

+264
-29
lines changed

dev-packages/browser-integration-tests/utils/helpers.ts

+27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
/* eslint-disable max-lines */
12
import type { Page, Request } from '@playwright/test';
23
import { parseEnvelope } from '@sentry/core';
34
import type {
5+
ClientReport,
46
Envelope,
57
EnvelopeItem,
68
EnvelopeItemType,
@@ -254,6 +256,31 @@ export function waitForTransactionRequest(
254256
});
255257
}
256258

259+
export function waitForClientReportRequest(page: Page, callback?: (report: ClientReport) => boolean): Promise<Request> {
260+
return page.waitForRequest(req => {
261+
const postData = req.postData();
262+
if (!postData) {
263+
return false;
264+
}
265+
266+
try {
267+
const maybeReport = envelopeRequestParser<Partial<ClientReport>>(req);
268+
269+
if (typeof maybeReport.discarded_events !== 'object') {
270+
return false;
271+
}
272+
273+
if (callback) {
274+
return callback(maybeReport as ClientReport);
275+
}
276+
277+
return true;
278+
} catch {
279+
return false;
280+
}
281+
});
282+
}
283+
257284
export async function waitForSession(page: Page): Promise<SessionContext> {
258285
const req = await page.waitForRequest(req => {
259286
const postData = req.postData();

packages/browser/src/tracing/browserTracingIntegration.ts

+75-7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
GLOBAL_OBJ,
1515
SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
1616
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
17+
SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
1718
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1819
TRACING_DEFAULTS,
1920
addNonEnumerableProperty,
@@ -36,10 +37,10 @@ import { DEBUG_BUILD } from '../debug-build';
3637
import { WINDOW } from '../helpers';
3738
import { registerBackgroundTabDetection } from './backgroundtab';
3839
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
39-
import type { PreviousTraceInfo } from './previousTrace';
4040
import {
4141
addPreviousTraceSpanLink,
4242
getPreviousTraceFromSessionStorage,
43+
spanContextSampled,
4344
storePreviousTraceInSessionStorage,
4445
} from './previousTrace';
4546

@@ -172,6 +173,23 @@ export interface BrowserTracingOptions {
172173
*/
173174
linkPreviousTrace: 'in-memory' | 'session-storage' | 'off';
174175

176+
/**
177+
* If true, Sentry will consistently sample subsequent traces based on the
178+
* sampling decision of the initial trace. For example, if the initial page
179+
* load trace was sampled positively, all subsequent traces (e.g. navigations)
180+
* are also sampled positively. In case the initial trace was sampled negatively,
181+
* all subsequent traces are also sampled negatively.
182+
*
183+
* This option lets you get consistent, linked traces within a user journey
184+
* while maintaining an overall quota based on your trace sampling settings.
185+
*
186+
* This option is only effective if {@link BrowserTracingOptions.linkPreviousTrace}
187+
* is enabled (i.e. not set to `'off'`).
188+
*
189+
* @default `false` - this is an opt-in feature.
190+
*/
191+
sampleLinkedTracesConsistently: boolean;
192+
175193
/**
176194
* _experiments allows the user to send options to define how this integration works.
177195
*
@@ -206,6 +224,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
206224
enableLongAnimationFrame: true,
207225
enableInp: true,
208226
linkPreviousTrace: 'in-memory',
227+
sampleLinkedTracesConsistently: false,
209228
_experiments: {},
210229
...defaultRequestInstrumentationOptions,
211230
};
@@ -246,6 +265,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
246265
instrumentPageLoad,
247266
instrumentNavigation,
248267
linkPreviousTrace,
268+
sampleLinkedTracesConsistently,
249269
} = {
250270
...DEFAULT_BROWSER_TRACING_OPTIONS,
251271
..._options,
@@ -322,6 +342,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
322342
});
323343
},
324344
});
345+
325346
setActiveIdleSpan(client, idleSpan);
326347

327348
function emitFinish(): void {
@@ -389,20 +410,67 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
389410
});
390411

391412
if (linkPreviousTrace !== 'off') {
392-
let inMemoryPreviousTraceInfo: PreviousTraceInfo | undefined = undefined;
413+
const useSessionStorage = linkPreviousTrace === 'session-storage';
414+
415+
let inMemoryPreviousTraceInfo = useSessionStorage ? getPreviousTraceFromSessionStorage() : undefined;
393416

394417
client.on('spanStart', span => {
395418
if (getRootSpan(span) !== span) {
396419
return;
397420
}
398421

399-
if (linkPreviousTrace === 'session-storage') {
400-
const updatedPreviousTraceInfo = addPreviousTraceSpanLink(getPreviousTraceFromSessionStorage(), span);
401-
storePreviousTraceInSessionStorage(updatedPreviousTraceInfo);
402-
} else {
403-
inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span);
422+
const scope = getCurrentScope();
423+
const oldPropagationContext = scope.getPropagationContext();
424+
inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span, oldPropagationContext);
425+
426+
if (useSessionStorage) {
427+
storePreviousTraceInSessionStorage(inMemoryPreviousTraceInfo);
404428
}
405429
});
430+
431+
if (sampleLinkedTracesConsistently) {
432+
/*
433+
This is a massive hack I'm really not proud of:
434+
435+
When users opt into `sampleLinkedTracesConsistently`, we need to make sure that we "propagate"
436+
the previous trace's sample rate and rand to the current trace. This is necessary because otherwise, span
437+
metric extrapolation is off, as we'd be propagating a too high sample rate for the subsequent traces.
438+
439+
So therefore, we pretend that the previous trace was the parent trace of the newly started trace. To do that,
440+
we mutate the propagation context of the current trace and set the sample rate and sample rand of the previous trace.
441+
Timing-wise, it is fine because it happens before we even sample the root span.
442+
443+
@see https://github.com/getsentry/sentry-javascript/issues/15754
444+
*/
445+
client.on('beforeSampling', mutableSamplingContextData => {
446+
if (!inMemoryPreviousTraceInfo) {
447+
return;
448+
}
449+
450+
const scope = getCurrentScope();
451+
const currentPropagationContext = scope.getPropagationContext();
452+
453+
scope.setPropagationContext({
454+
...currentPropagationContext,
455+
dsc: {
456+
...currentPropagationContext.dsc,
457+
// The fallback to 0 should never happen; this is rather to satisfy the types
458+
sample_rate: String(inMemoryPreviousTraceInfo.sampleRate ?? 0),
459+
sampled: String(spanContextSampled(inMemoryPreviousTraceInfo.spanContext)),
460+
},
461+
sampleRand: inMemoryPreviousTraceInfo.sampleRand,
462+
});
463+
464+
mutableSamplingContextData.parentSampled = spanContextSampled(inMemoryPreviousTraceInfo.spanContext);
465+
mutableSamplingContextData.parentSampleRate = inMemoryPreviousTraceInfo.sampleRate;
466+
467+
mutableSamplingContextData.spanAttributes = {
468+
...mutableSamplingContextData.spanAttributes,
469+
// record an attribute that this span was "force-sampled", so that we can later check on this.
470+
[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]: inMemoryPreviousTraceInfo.sampleRate,
471+
};
472+
});
473+
}
406474
}
407475

408476
if (WINDOW.location) {

packages/browser/src/tracing/previousTrace.ts

+46-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import type { Span } from '@sentry/core';
2-
import { logger, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON, type SpanContextData } from '@sentry/core';
1+
import type { PropagationContext, Span } from '@sentry/core';
2+
import {
3+
logger,
4+
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
5+
SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE,
6+
spanToJSON,
7+
type SpanContextData,
8+
} from '@sentry/core';
39
import { WINDOW } from '../exports';
410
import { DEBUG_BUILD } from '../debug-build';
511

@@ -13,6 +19,16 @@ export interface PreviousTraceInfo {
1319
* Timestamp in seconds when the previous trace was started
1420
*/
1521
startTimestamp: number;
22+
23+
/**
24+
* sample rate of the previous trace
25+
*/
26+
sampleRate: number;
27+
28+
/**
29+
* The sample rand of the previous trace
30+
*/
31+
sampleRand: number;
1632
}
1733

1834
// 1h in seconds
@@ -33,14 +49,29 @@ export const PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE = 'sentry.previous_trace';
3349
export function addPreviousTraceSpanLink(
3450
previousTraceInfo: PreviousTraceInfo | undefined,
3551
span: Span,
52+
oldPropagationContext: PropagationContext,
3653
): PreviousTraceInfo {
3754
const spanJson = spanToJSON(span);
3855

56+
function getSampleRate(): number {
57+
try {
58+
return (
59+
Number(oldPropagationContext.dsc?.sample_rate) ?? Number(spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE])
60+
);
61+
} catch {
62+
return 0;
63+
}
64+
}
65+
66+
const updatedPreviousTraceInfo = {
67+
spanContext: span.spanContext(),
68+
startTimestamp: spanJson.start_timestamp,
69+
sampleRate: getSampleRate(),
70+
sampleRand: oldPropagationContext.sampleRand,
71+
};
72+
3973
if (!previousTraceInfo) {
40-
return {
41-
spanContext: span.spanContext(),
42-
startTimestamp: spanJson.start_timestamp,
43-
};
74+
return updatedPreviousTraceInfo;
4475
}
4576

4677
const previousTraceSpanCtx = previousTraceInfo.spanContext;
@@ -80,15 +111,12 @@ export function addPreviousTraceSpanLink(
80111
span.setAttribute(
81112
PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE,
82113
`${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${
83-
previousTraceSpanCtx.traceFlags === 0x1 ? 1 : 0
114+
spanContextSampled(previousTraceSpanCtx) ? 1 : 0
84115
}`,
85116
);
86117
}
87118

88-
return {
89-
spanContext: span.spanContext(),
90-
startTimestamp: spanToJSON(span).start_timestamp,
91-
};
119+
return updatedPreviousTraceInfo;
92120
}
93121

94122
/**
@@ -115,3 +143,10 @@ export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefi
115143
return undefined;
116144
}
117145
}
146+
147+
/**
148+
* see {@link import('@sentry/core').spanIsSampled}
149+
*/
150+
export const spanContextSampled = (ctx: SpanContextData): boolean => {
151+
return ctx.traceFlags === 0x1;
152+
};

0 commit comments

Comments
 (0)