Skip to content

Commit bb6c1c8

Browse files
committed
feat(browser): Add option to sample linked traces consistently
1 parent be737e4 commit bb6c1c8

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
*
@@ -213,6 +231,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
213231
enableLongAnimationFrame: true,
214232
enableInp: true,
215233
linkPreviousTrace: 'in-memory',
234+
sampleLinkedTracesConsistently: false,
216235
_experiments: {},
217236
...defaultRequestInstrumentationOptions,
218237
};
@@ -253,6 +272,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
253272
instrumentPageLoad,
254273
instrumentNavigation,
255274
linkPreviousTrace,
275+
sampleLinkedTracesConsistently,
256276
onRequestSpanStart,
257277
} = {
258278
...DEFAULT_BROWSER_TRACING_OPTIONS,
@@ -330,6 +350,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
330350
});
331351
},
332352
});
353+
333354
setActiveIdleSpan(client, idleSpan);
334355

335356
function emitFinish(): void {
@@ -397,20 +418,67 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
397418
});
398419

399420
if (linkPreviousTrace !== 'off') {
400-
let inMemoryPreviousTraceInfo: PreviousTraceInfo | undefined = undefined;
421+
const useSessionStorage = linkPreviousTrace === 'session-storage';
422+
423+
let inMemoryPreviousTraceInfo = useSessionStorage ? getPreviousTraceFromSessionStorage() : undefined;
401424

402425
client.on('spanStart', span => {
403426
if (getRootSpan(span) !== span) {
404427
return;
405428
}
406429

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

416484
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)