Skip to content

Commit ec336c8

Browse files
committed
test(node): Add integration tests for continueTrace and startNewTrace
Adds node-integration-tests directly exercising the public `Sentry.continueTrace()` and `Sentry.startNewTrace()` APIs, which previously had no direct coverage in node-integration-tests. `continueTrace` is covered across four incoming-trace variants (sampled, unsampled, deferred sampling decision, and no incoming trace) and three tracing configs (no `tracesSampleRate`, `=1`, `=0`), asserting the continued trace id reaches both the error event and the transaction, that the incoming sampling decision overrides the local rate, and that outgoing propagation carries the continued trace. `startNewTrace` asserts that every root span created within a single callback shares the one new trace id (multiple `startInactiveSpan` plus a `startSpan`), that the new trace is detached from an ambient active span, and that outgoing propagation carries the new trace id — across all three tracing configs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(browser): Add continueTrace + multi-span startNewTrace coverage Adds browser-integration-tests for `Sentry.continueTrace()`, which had no direct coverage (only `dist/` bundle artifacts referenced it). Covers four incoming-trace variants (sampled, unsampled, deferred sampling decision, no incoming trace) across three tracing configs — `tracesSampleRate: 1`, `tracesSampleRate: 0`, and tracing-without-performance — asserting trace-id continuation on transactions and error events, incoming-sampling-decision precedence over the local rate, and outgoing request propagation. `startNewTrace` was already covered for the single-span case, so this adds a focused suite asserting that multiple root spans created within one `startNewTrace` callback all share the single new trace id and remain independent root spans. Also refactors the node `continueTrace`/`startNewTrace` suites to drive the config and variant matrices via `describe.each`/`test.each` instead of nested loops, and widens the runner's `withEnv` to accept `undefined` values so an unset config maps cleanly to TwP mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> adjustments and fixes
1 parent 3adbdd4 commit ec336c8

18 files changed

Lines changed: 770 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
traceLifecycle: 'static',
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [Sentry.browserTracingIntegration()],
9+
tracePropagationTargets: ['http://sentry-test-site.example'],
10+
tracesSampleRate: 1,
11+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import type { EventAndTraceHeader } from '../../../../utils/helpers';
4+
import {
5+
eventAndTraceHeaderRequestParser,
6+
getFirstSentryEnvelopeRequest,
7+
shouldSkipTracingTest,
8+
waitForErrorRequest,
9+
waitForTransactionRequest,
10+
} from '../../../../utils/helpers';
11+
12+
const SAMPLED_TRACE_ID = '12345678901234567890123456789012';
13+
const SAMPLED_SPAN_ID = '1234567890123456';
14+
const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
15+
const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
16+
17+
sentryTest(
18+
'continueTrace continues a sampled incoming trace into span and outgoing request',
19+
async ({ getLocalTestUrl, page }) => {
20+
if (shouldSkipTracingTest()) {
21+
sentryTest.skip();
22+
}
23+
24+
const url = await getLocalTestUrl({ testDir: __dirname });
25+
26+
const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**');
27+
await page.route('http://sentry-test-site.example/**', route => {
28+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
29+
});
30+
31+
// Discard the initial pageload transaction.
32+
await getFirstSentryEnvelopeRequest<EventAndTraceHeader>(page, url, eventAndTraceHeaderRequestParser);
33+
34+
const transactionPromise = waitForTransactionRequest(
35+
page,
36+
event => event.contexts?.trace?.trace_id === SAMPLED_TRACE_ID,
37+
);
38+
39+
await page.locator('#sampled').click();
40+
41+
const req = await transactionPromise;
42+
const transaction = eventAndTraceHeaderRequestParser(req);
43+
const traceContext = transaction[0].contexts?.trace;
44+
45+
expect(traceContext?.trace_id).toBe(SAMPLED_TRACE_ID);
46+
expect(traceContext?.parent_span_id).toBe(SAMPLED_SPAN_ID);
47+
expect(transaction[0].transaction).toBe('continued-sampled');
48+
49+
// The incoming positive sampling decision is honored regardless of local config.
50+
expect(transaction[1]?.sampled).toBe('true');
51+
52+
// Outgoing request carries the continued trace.
53+
const outgoingRequest = await outgoingRequestPromise;
54+
const headers = await outgoingRequest.allHeaders();
55+
expect(headers['sentry-trace']).toMatch(new RegExp(`^${SAMPLED_TRACE_ID}-[a-f0-9]{16}-1$`));
56+
expect(headers['baggage']).toContain(`sentry-trace_id=${SAMPLED_TRACE_ID}`);
57+
},
58+
);
59+
60+
sentryTest(
61+
'continueTrace continues an unsampled incoming trace without emitting a transaction',
62+
async ({ getLocalTestUrl, page }) => {
63+
if (shouldSkipTracingTest()) {
64+
sentryTest.skip();
65+
}
66+
67+
const url = await getLocalTestUrl({ testDir: __dirname });
68+
69+
const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**');
70+
await page.route('http://sentry-test-site.example/**', route => {
71+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
72+
});
73+
74+
await getFirstSentryEnvelopeRequest<EventAndTraceHeader>(page, url, eventAndTraceHeaderRequestParser);
75+
76+
// The captured error carries the continued (unsampled) trace even though no transaction is sent.
77+
const errorPromise = waitForErrorRequest(page);
78+
79+
await page.locator('#unsampled').click();
80+
81+
const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise);
82+
expect(errorEvent.contexts?.trace?.trace_id).toBe(UNSAMPLED_TRACE_ID);
83+
84+
// Outgoing request carries the continued trace with the negative sampling decision.
85+
const outgoingRequest = await outgoingRequestPromise;
86+
const headers = await outgoingRequest.allHeaders();
87+
expect(headers['sentry-trace']).toMatch(new RegExp(`^${UNSAMPLED_TRACE_ID}-[a-f0-9]{16}-0$`));
88+
},
89+
);
90+
91+
sentryTest(
92+
'continueTrace continues a deferred-sampling incoming trace and applies the local sample rate',
93+
async ({ getLocalTestUrl, page }) => {
94+
if (shouldSkipTracingTest()) {
95+
sentryTest.skip();
96+
}
97+
98+
const url = await getLocalTestUrl({ testDir: __dirname });
99+
100+
await page.route('http://sentry-test-site.example/**', route => {
101+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
102+
});
103+
104+
await getFirstSentryEnvelopeRequest<EventAndTraceHeader>(page, url, eventAndTraceHeaderRequestParser);
105+
106+
// With tracesSampleRate=1 the deferred decision resolves to sampled, so a transaction is emitted
107+
// on the continued trace id.
108+
const transactionPromise = waitForTransactionRequest(
109+
page,
110+
event => event.contexts?.trace?.trace_id === DEFERRED_TRACE_ID,
111+
);
112+
113+
await page.locator('#deferred').click();
114+
115+
const transaction = eventAndTraceHeaderRequestParser(await transactionPromise);
116+
expect(transaction[0].contexts?.trace?.trace_id).toBe(DEFERRED_TRACE_ID);
117+
expect(transaction[0].transaction).toBe('continued-deferred');
118+
},
119+
);
120+
121+
sentryTest('continueTrace with no incoming trace starts a fresh trace', async ({ getLocalTestUrl, page }) => {
122+
if (shouldSkipTracingTest()) {
123+
sentryTest.skip();
124+
}
125+
126+
const url = await getLocalTestUrl({ testDir: __dirname });
127+
128+
await page.route('http://sentry-test-site.example/**', route => {
129+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
130+
});
131+
132+
const [pageloadEvent] = await getFirstSentryEnvelopeRequest<EventAndTraceHeader>(
133+
page,
134+
url,
135+
eventAndTraceHeaderRequestParser,
136+
);
137+
const pageloadTraceId = pageloadEvent.contexts?.trace?.trace_id;
138+
139+
const transactionPromise = waitForTransactionRequest(page, event => event.transaction === 'continued-noTrace');
140+
141+
await page.locator('#noTrace').click();
142+
143+
const transaction = eventAndTraceHeaderRequestParser(await transactionPromise);
144+
const traceId = transaction[0].contexts?.trace?.trace_id;
145+
146+
expect(traceId).toMatch(/^[a-f0-9]{32}$/);
147+
expect(traceId).not.toBe(pageloadTraceId);
148+
expect(transaction[0].contexts?.trace).not.toHaveProperty('parent_span_id');
149+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const TRACES = {
2+
sampled: {
3+
sentryTrace: '12345678901234567890123456789012-1234567890123456-1',
4+
baggage:
5+
'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.42',
6+
},
7+
unsampled: {
8+
sentryTrace: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111111111-0',
9+
baggage: undefined,
10+
},
11+
// No trailing sampling flag -> deferred sampling decision.
12+
deferred: {
13+
sentryTrace: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2222222222222222',
14+
baggage: undefined,
15+
},
16+
noTrace: {
17+
sentryTrace: undefined,
18+
baggage: undefined,
19+
},
20+
};
21+
22+
function continueAndRun(variant) {
23+
const { sentryTrace, baggage } = TRACES[variant];
24+
Sentry.continueTrace({ sentryTrace, baggage }, () => {
25+
// Keep the span callback synchronous: the browser ACS is stack-based, so an `await` here would pop
26+
// the continued span/scope before `fetch` and `captureException` run. Firing `fetch` without
27+
// awaiting still attaches the propagation headers (they are read synchronously at call time).
28+
Sentry.startSpan({ op: 'ui.interaction.click', name: `continued-${variant}` }, () => {
29+
fetch('http://sentry-test-site.example');
30+
Sentry.captureException(new Error(`continued-${variant}-error`));
31+
});
32+
});
33+
}
34+
35+
for (const variant of ['sampled', 'unsampled', 'deferred', 'noTrace']) {
36+
document.getElementById(variant).addEventListener('click', () => continueAndRun(variant));
37+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="sampled">Sampled incoming trace</button>
8+
<button id="unsampled">Unsampled incoming trace</button>
9+
<button id="deferred">Deferred sampling decision</button>
10+
<button id="noTrace">No incoming trace</button>
11+
</body>
12+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
traceLifecycle: 'static',
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [Sentry.browserTracingIntegration()],
9+
tracePropagationTargets: ['http://sentry-test-site.example'],
10+
tracesSampleRate: 0,
11+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import {
4+
eventAndTraceHeaderRequestParser,
5+
shouldSkipTracingTest,
6+
waitForErrorRequest,
7+
waitForTransactionRequest,
8+
} from '../../../../utils/helpers';
9+
10+
const SAMPLED_TRACE_ID = '12345678901234567890123456789012';
11+
const SAMPLED_SPAN_ID = '1234567890123456';
12+
const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
13+
const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
14+
15+
sentryTest(
16+
'continueTrace honors a positive incoming sampling decision over tracesSampleRate=0',
17+
async ({ getLocalTestUrl, page }) => {
18+
if (shouldSkipTracingTest()) {
19+
sentryTest.skip();
20+
}
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname });
23+
24+
const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**');
25+
await page.route('http://sentry-test-site.example/**', route => {
26+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
27+
});
28+
29+
// With tracesSampleRate=0 there is no pageload transaction, so we only wait for the continued one.
30+
const transactionPromise = waitForTransactionRequest(
31+
page,
32+
event => event.contexts?.trace?.trace_id === SAMPLED_TRACE_ID,
33+
);
34+
35+
await page.goto(url);
36+
await page.locator('#sampled').click();
37+
38+
const transaction = eventAndTraceHeaderRequestParser(await transactionPromise);
39+
expect(transaction[0].contexts?.trace?.trace_id).toBe(SAMPLED_TRACE_ID);
40+
expect(transaction[0].contexts?.trace?.parent_span_id).toBe(SAMPLED_SPAN_ID);
41+
42+
const outgoingRequest = await outgoingRequestPromise;
43+
const headers = await outgoingRequest.allHeaders();
44+
expect(headers['sentry-trace']).toMatch(new RegExp(`^${SAMPLED_TRACE_ID}-[a-f0-9]{16}-1$`));
45+
},
46+
);
47+
48+
sentryTest(
49+
'continueTrace does not emit a transaction for a deferred decision with tracesSampleRate=0',
50+
async ({ getLocalTestUrl, page }) => {
51+
if (shouldSkipTracingTest()) {
52+
sentryTest.skip();
53+
}
54+
55+
const url = await getLocalTestUrl({ testDir: __dirname });
56+
57+
const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**');
58+
await page.route('http://sentry-test-site.example/**', route => {
59+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
60+
});
61+
62+
await page.goto(url);
63+
64+
// The captured error carries the continued trace even though no transaction is sent.
65+
const errorPromise = waitForErrorRequest(page);
66+
67+
await page.locator('#deferred').click();
68+
69+
const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise);
70+
expect(errorEvent.contexts?.trace?.trace_id).toBe(DEFERRED_TRACE_ID);
71+
72+
// The deferred decision resolves negatively against the local tracesSampleRate=0.
73+
const outgoingRequest = await outgoingRequestPromise;
74+
const headers = await outgoingRequest.allHeaders();
75+
expect(headers['sentry-trace']).toMatch(new RegExp(`^${DEFERRED_TRACE_ID}-[a-f0-9]{16}-0$`));
76+
},
77+
);
78+
79+
sentryTest(
80+
'continueTrace continues an unsampled incoming trace with tracesSampleRate=0',
81+
async ({ getLocalTestUrl, page }) => {
82+
if (shouldSkipTracingTest()) {
83+
sentryTest.skip();
84+
}
85+
86+
const url = await getLocalTestUrl({ testDir: __dirname });
87+
88+
const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**');
89+
await page.route('http://sentry-test-site.example/**', route => {
90+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
91+
});
92+
93+
await page.goto(url);
94+
95+
const errorPromise = waitForErrorRequest(page);
96+
97+
await page.locator('#unsampled').click();
98+
99+
const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise);
100+
expect(errorEvent.contexts?.trace?.trace_id).toBe(UNSAMPLED_TRACE_ID);
101+
102+
const outgoingRequest = await outgoingRequestPromise;
103+
const headers = await outgoingRequest.allHeaders();
104+
expect(headers['sentry-trace']).toMatch(new RegExp(`^${UNSAMPLED_TRACE_ID}-[a-f0-9]{16}-0$`));
105+
},
106+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
// In the browser, "tracing without performance" (TwP) means enabling `browserTracingIntegration`
6+
// but not setting `tracesSampleRate`.
7+
Sentry.init({
8+
traceLifecycle: 'static',
9+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
10+
integrations: [Sentry.browserTracingIntegration()],
11+
tracePropagationTargets: ['http://sentry-test-site.example'],
12+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import {
4+
eventAndTraceHeaderRequestParser,
5+
shouldSkipTracingTest,
6+
waitForErrorRequest,
7+
} from '../../../../utils/helpers';
8+
9+
const SAMPLED_TRACE_ID = '12345678901234567890123456789012';
10+
const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
11+
const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
12+
13+
// In TwP mode no transactions are emitted, so each variant is observed via the captured error event
14+
// (which carries the continued trace on its `contexts.trace`) and the outgoing request headers.
15+
const VARIANTS = [
16+
{ button: 'sampled', traceId: SAMPLED_TRACE_ID, sampledFlag: '-1' },
17+
{ button: 'unsampled', traceId: UNSAMPLED_TRACE_ID, sampledFlag: '-0' },
18+
{ button: 'deferred', traceId: DEFERRED_TRACE_ID, sampledFlag: '' },
19+
] as const;
20+
21+
for (const variant of VARIANTS) {
22+
sentryTest(
23+
`continueTrace continues the ${variant.button} incoming trace in TwP mode`,
24+
async ({ getLocalTestUrl, page }) => {
25+
if (shouldSkipTracingTest()) {
26+
sentryTest.skip();
27+
}
28+
29+
const url = await getLocalTestUrl({ testDir: __dirname });
30+
31+
const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**');
32+
await page.route('http://sentry-test-site.example/**', route => {
33+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
34+
});
35+
36+
await page.goto(url);
37+
38+
const errorPromise = waitForErrorRequest(page);
39+
40+
await page.locator(`#${variant.button}`).click();
41+
42+
const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise);
43+
expect(errorEvent.contexts?.trace?.trace_id).toBe(variant.traceId);
44+
45+
const outgoingRequest = await outgoingRequestPromise;
46+
const headers = await outgoingRequest.allHeaders();
47+
expect(headers['sentry-trace']).toMatch(new RegExp(`^${variant.traceId}-[a-f0-9]{16}${variant.sampledFlag}$`));
48+
},
49+
);
50+
}

0 commit comments

Comments
 (0)