Skip to content

Commit 1e0894c

Browse files
authored
feat(browser): Add option to sample linked traces consistently (#16037)
Implement consistent sampling across traces in browser SDKs. Concrete changes: - Add `consistentTraceSampling` option - Emit `beforeSampling` hook before sampling and creating `SentrySpan`'s in the coreSDK (this hook is already called in Otel-based spans). - Allow `beforeSampling` hook subscribers to alter span `attributes` and `parentSampled` flag - Subscribe to `beforeSampling` hook when `consistentTraceSampling` is enabled and `linkPreviousTrace` is not disabled - modify attributes and `parentSampled` flag based on the sampling decision, sample rate and sample rand of the previous trace - this will ensure that the newly started trace is sampled postitively/negatively depending on the decision of the previous trace - we also directly modify the propagation context to ensure that the previous trace's sampling decision, rate and rand are propagated correctly to downstream services and in the `trace` envelope header. This is necessary to ensure correct span metrics extrapolation. - sampling decisions from `<meta>` tags still have precedence for the first pageload over the previous trace's sampling decision. This is necessary because we need to prioritize inter-trace completeness over intra-trace completeness - explicit sampling decisions from `tracesSampler` still have precedence over the previous trace's sampling decision - added a bunch of browser integration tests describing the behaviour in various edge cases and sampling configurations
1 parent 2e41f5e commit 1e0894c

File tree

47 files changed

+1532
-166
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1532
-166
lines changed

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ module.exports = [
4747
path: 'packages/browser/build/npm/esm/index.js',
4848
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
4949
gzip: true,
50-
limit: '76 KB',
50+
limit: '77 KB',
5151
},
5252
{
5353
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
@@ -219,7 +219,7 @@ module.exports = [
219219
import: createImport('init'),
220220
ignore: ['$app/stores'],
221221
gzip: true,
222-
limit: '38.5 KB',
222+
limit: '39 KB',
223223
},
224224
// Node SDK (ESM)
225225
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration({
9+
linkPreviousTrace: 'in-memory',
10+
consistentTraceSampling: true,
11+
}),
12+
],
13+
tracePropagationTargets: ['someurl.com'],
14+
tracesSampler: ctx => {
15+
if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
16+
return 1;
17+
}
18+
return ctx.inheritOrSampleWith(0);
19+
},
20+
debug: true,
21+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const btn1 = document.getElementById('btn1');
2+
3+
const btn2 = document.getElementById('btn2');
4+
5+
btn1.addEventListener('click', () => {
6+
Sentry.startNewTrace(() => {
7+
Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
8+
});
9+
});
10+
11+
btn2.addEventListener('click', () => {
12+
Sentry.startNewTrace(() => {
13+
Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
14+
await fetch('https://someUrl.com');
15+
});
16+
});
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<button id="btn1">
7+
</button>
8+
<button id="btn2">
9+
</button>
10+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { expect } from '@playwright/test';
2+
import {
3+
extractTraceparentData,
4+
parseBaggageHeader,
5+
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
6+
SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE,
7+
} from '@sentry/core';
8+
import { sentryTest } from '../../../../../../utils/fixtures';
9+
import {
10+
eventAndTraceHeaderRequestParser,
11+
shouldSkipTracingTest,
12+
waitForTracingHeadersOnUrl,
13+
waitForTransactionRequest,
14+
} from '../../../../../../utils/helpers';
15+
16+
sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
17+
sentryTest('Continues sampling decision from initial pageload', async ({ getLocalTestUrl, page }) => {
18+
if (shouldSkipTracingTest()) {
19+
sentryTest.skip();
20+
}
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname });
23+
24+
const { pageloadTraceContext, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
25+
const pageloadRequestPromise = waitForTransactionRequest(page, evt => {
26+
return evt.contexts?.trace?.op === 'pageload';
27+
});
28+
await page.goto(url);
29+
30+
const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
31+
const pageloadSampleRand = Number(res[1]?.sample_rand);
32+
const pageloadTraceContext = res[0].contexts?.trace;
33+
34+
expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
35+
expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
36+
37+
return { pageloadTraceContext: res[0].contexts?.trace, pageloadSampleRand };
38+
});
39+
40+
const customTraceContext = await sentryTest.step('Custom trace', async () => {
41+
const customTrace1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
42+
await page.locator('#btn1').click();
43+
const [customTrace1Event, customTraceTraceHeader] = eventAndTraceHeaderRequestParser(
44+
await customTrace1RequestPromise,
45+
);
46+
47+
const customTraceContext = customTrace1Event.contexts?.trace;
48+
49+
expect(customTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
50+
// although we "continue the trace" from pageload, this is actually a root span,
51+
// so there must not be a parent span id
52+
expect(customTraceContext?.parent_span_id).toBeUndefined();
53+
54+
expect(pageloadSampleRand).toEqual(Number(customTraceTraceHeader?.sample_rand));
55+
56+
return customTraceContext;
57+
});
58+
59+
await sentryTest.step('Navigation', async () => {
60+
const navigation1RequestPromise = waitForTransactionRequest(
61+
page,
62+
evt => evt.contexts?.trace?.op === 'navigation',
63+
);
64+
await page.goto(`${url}#foo`);
65+
const [navigationEvent, navigationTraceHeader] = eventAndTraceHeaderRequestParser(
66+
await navigation1RequestPromise,
67+
);
68+
const navTraceContext = navigationEvent.contexts?.trace;
69+
70+
expect(navTraceContext?.trace_id).not.toEqual(customTraceContext?.trace_id);
71+
expect(navTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
72+
73+
expect(navTraceContext?.links).toEqual([
74+
{
75+
trace_id: customTraceContext?.trace_id,
76+
span_id: customTraceContext?.span_id,
77+
sampled: true,
78+
attributes: {
79+
[SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
80+
},
81+
},
82+
]);
83+
expect(navTraceContext?.parent_span_id).toBeUndefined();
84+
85+
expect(pageloadSampleRand).toEqual(Number(navigationTraceHeader?.sample_rand));
86+
});
87+
});
88+
89+
sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => {
90+
if (shouldSkipTracingTest()) {
91+
sentryTest.skip();
92+
}
93+
94+
const url = await getLocalTestUrl({ testDir: __dirname });
95+
96+
const { pageloadTraceContext, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
97+
const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
98+
await page.goto(url);
99+
100+
const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
101+
const pageloadSampleRand = Number(res[1]?.sample_rand);
102+
103+
expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
104+
105+
const pageloadTraceContext = res[0].contexts?.trace;
106+
107+
expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
108+
109+
return { pageloadTraceContext: pageloadTraceContext, pageloadSampleRand };
110+
});
111+
112+
await sentryTest.step('Make fetch request', async () => {
113+
const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
114+
const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com');
115+
116+
await page.locator('#btn2').click();
117+
118+
const { baggage, sentryTrace } = await tracingHeadersPromise;
119+
120+
const [fetchTraceEvent, fetchTraceTraceHeader] = eventAndTraceHeaderRequestParser(await fetchTracePromise);
121+
122+
const fetchTraceSampleRand = Number(fetchTraceTraceHeader?.sample_rand);
123+
const fetchTraceTraceContext = fetchTraceEvent.contexts?.trace;
124+
const httpClientSpan = fetchTraceEvent.spans?.find(span => span.op === 'http.client');
125+
126+
expect(fetchTraceSampleRand).toEqual(pageloadSampleRand);
127+
128+
expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toEqual(
129+
pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE],
130+
);
131+
expect(fetchTraceTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
132+
133+
expect(sentryTrace).toBeDefined();
134+
expect(baggage).toBeDefined();
135+
136+
expect(extractTraceparentData(sentryTrace)).toEqual({
137+
traceId: fetchTraceTraceContext?.trace_id,
138+
parentSpanId: httpClientSpan?.span_id,
139+
parentSampled: true,
140+
});
141+
142+
expect(parseBaggageHeader(baggage)).toEqual({
143+
'sentry-environment': 'production',
144+
'sentry-public_key': 'public',
145+
'sentry-sample_rand': `${pageloadSampleRand}`,
146+
'sentry-sample_rate': '1',
147+
'sentry-sampled': 'true',
148+
'sentry-trace_id': fetchTraceTraceContext?.trace_id,
149+
'sentry-transaction': 'custom root span 2',
150+
});
151+
});
152+
});
153+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration({
9+
linkPreviousTrace: 'in-memory',
10+
consistentTraceSampling: true,
11+
}),
12+
],
13+
tracePropagationTargets: ['someurl.com'],
14+
tracesSampleRate: 1,
15+
debug: true,
16+
sendClientReports: true,
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const btn1 = document.getElementById('btn1');
2+
3+
const btn2 = document.getElementById('btn2');
4+
5+
btn1.addEventListener('click', () => {
6+
Sentry.startNewTrace(() => {
7+
Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
8+
});
9+
});
10+
11+
btn2.addEventListener('click', () => {
12+
Sentry.startNewTrace(() => {
13+
Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
14+
await fetch('https://someUrl.com');
15+
});
16+
});
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-0" />
6+
<meta
7+
name="baggage"
8+
content="sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=0.2,sentry-sampled=false,sentry-transaction=my-transaction,sentry-public_key=public,sentry-release=1.0.0,sentry-environment=prod,sentry-sample_rand=0.9"
9+
/>
10+
</head>
11+
<button id="btn1">Custom Trace</button>
12+
<button id="btn2">fetch request</button>
13+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { expect } from '@playwright/test';
2+
import type { ClientReport } from '@sentry/core';
3+
import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
4+
import { sentryTest } from '../../../../../../utils/fixtures';
5+
import {
6+
envelopeRequestParser,
7+
getMultipleSentryEnvelopeRequests,
8+
hidePage,
9+
shouldSkipTracingTest,
10+
waitForClientReportRequest,
11+
waitForTracingHeadersOnUrl,
12+
} from '../../../../../../utils/helpers';
13+
14+
const metaTagSampleRand = 0.9;
15+
const metaTagSampleRate = 0.2;
16+
const metaTagTraceId = '12345678901234567890123456789012';
17+
18+
sentryTest.describe('When `consistentTraceSampling` is `true` and page contains <meta> tags', () => {
19+
sentryTest(
20+
'Continues negative sampling decision from meta tag across all traces and downstream propagations',
21+
async ({ getLocalTestUrl, page }) => {
22+
if (shouldSkipTracingTest()) {
23+
sentryTest.skip();
24+
}
25+
26+
const url = await getLocalTestUrl({ testDir: __dirname });
27+
28+
let txnsReceived = 0;
29+
// @ts-expect-error - no need to return something valid here
30+
getMultipleSentryEnvelopeRequests<Event>(page, 1, { envelopeType: 'transaction' }, () => {
31+
++txnsReceived;
32+
return {};
33+
});
34+
35+
const clientReportPromise = waitForClientReportRequest(page);
36+
37+
await sentryTest.step('Initial pageload', async () => {
38+
await page.goto(url);
39+
expect(txnsReceived).toEqual(0);
40+
});
41+
42+
await sentryTest.step('Custom instrumented button click', async () => {
43+
await page.locator('#btn1').click();
44+
expect(txnsReceived).toEqual(0);
45+
});
46+
47+
await sentryTest.step('Navigation', async () => {
48+
await page.goto(`${url}#foo`);
49+
expect(txnsReceived).toEqual(0);
50+
});
51+
52+
await sentryTest.step('Make fetch request', async () => {
53+
const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com');
54+
55+
await page.locator('#btn2').click();
56+
const { baggage, sentryTrace } = await tracingHeadersPromise;
57+
58+
expect(sentryTrace).toBeDefined();
59+
expect(baggage).toBeDefined();
60+
61+
expect(extractTraceparentData(sentryTrace)).toEqual({
62+
traceId: expect.not.stringContaining(metaTagTraceId),
63+
parentSpanId: expect.stringMatching(/^[0-9a-f]{16}$/),
64+
parentSampled: false,
65+
});
66+
67+
expect(parseBaggageHeader(baggage)).toEqual({
68+
'sentry-environment': 'production',
69+
'sentry-public_key': 'public',
70+
'sentry-sample_rand': `${metaTagSampleRand}`,
71+
'sentry-sample_rate': `${metaTagSampleRate}`,
72+
'sentry-sampled': 'false',
73+
'sentry-trace_id': expect.not.stringContaining(metaTagTraceId),
74+
'sentry-transaction': 'custom root span 2',
75+
});
76+
});
77+
78+
await sentryTest.step('Client report', async () => {
79+
await hidePage(page);
80+
const clientReport = envelopeRequestParser<ClientReport>(await clientReportPromise);
81+
expect(clientReport).toEqual({
82+
timestamp: expect.any(Number),
83+
discarded_events: [
84+
{
85+
category: 'transaction',
86+
quantity: 4,
87+
reason: 'sample_rate',
88+
},
89+
],
90+
});
91+
});
92+
93+
await sentryTest.step('Wait for transactions to be discarded', async () => {
94+
// give it a little longer just in case a txn is pending to be sent
95+
await page.waitForTimeout(1000);
96+
expect(txnsReceived).toEqual(0);
97+
});
98+
},
99+
);
100+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration({
9+
linkPreviousTrace: 'session-storage',
10+
consistentTraceSampling: true,
11+
}),
12+
],
13+
tracePropagationTargets: ['someurl.com'],
14+
tracesSampler: ({ inheritOrSampleWith }) => {
15+
return inheritOrSampleWith(0);
16+
},
17+
debug: true,
18+
sendClientReports: true,
19+
});

0 commit comments

Comments
 (0)