Skip to content

Commit 003357d

Browse files
committed
Improve dynamic sampling context and baggage consistency
1 parent e6fc3c0 commit 003357d

File tree

2 files changed

+148
-8
lines changed

2 files changed

+148
-8
lines changed

dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,16 @@ test('Propagates trace context from Server-Timing header to client pageload (wit
5555
expect(serverTimingHeader).toContain('sentry-trace');
5656
expect(serverTimingHeader).toContain('baggage');
5757

58-
// Extract the trace ID from the Server-Timing header
58+
// Extract the trace ID and span ID from the Server-Timing header
5959
const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
6060
expect(sentryTraceMatch).toBeTruthy();
6161

6262
const serverTimingTraceValue = sentryTraceMatch?.[1];
63-
const serverTimingTraceId = serverTimingTraceValue?.split('-')[0];
63+
const [serverTimingTraceId, serverTimingSpanId] = serverTimingTraceValue?.split('-') || [];
6464
expect(serverTimingTraceId).toBeDefined();
6565
expect(serverTimingTraceId).toHaveLength(32);
66+
expect(serverTimingSpanId).toBeDefined();
67+
expect(serverTimingSpanId).toHaveLength(16);
6668

6769
const pageloadTransaction = await pageLoadTransactionPromise;
6870

@@ -76,8 +78,9 @@ test('Propagates trace context from Server-Timing header to client pageload (wit
7678
// This proves that the client correctly read and used the Server-Timing header for trace propagation
7779
expect(pageLoadTraceId).toEqual(serverTimingTraceId);
7880

79-
// Verify that the pageload has a parent span (indicating it's continuing a trace, not starting a new one)
80-
expect(pageLoadParentSpanId).toBeDefined();
81+
// Verify that the pageload's parent_span_id matches the span_id from Server-Timing
82+
// This proves the client is continuing from the exact server span, not just using the trace_id
83+
expect(pageLoadParentSpanId).toEqual(serverTimingSpanId);
8184
});
8285

8386
test('Propagates trace context via Server-Timing for parameterized routes', async ({ page }) => {
@@ -124,7 +127,7 @@ test('Propagates trace context via Server-Timing for parameterized routes', asyn
124127
expect(pageLoadTraceId).toEqual(serverTimingTraceId);
125128
});
126129

127-
test('No sentry-trace meta tag is present (testing Server-Timing-only propagation)', async ({ page }) => {
130+
test('Test app does not render sentry-trace meta tags (precondition for Server-Timing tests)', async ({ page }) => {
128131
await page.goto('/');
129132

130133
// Verify that NO sentry-trace meta tag is present
@@ -136,3 +139,115 @@ test('No sentry-trace meta tag is present (testing Server-Timing-only propagatio
136139
expect(sentryTraceMetaTag).toBeNull();
137140
expect(baggageMetaTag).toBeNull();
138141
});
142+
143+
test('Propagates baggage/DSC from server to client via Server-Timing header', async ({ page }) => {
144+
const testTag = crypto.randomUUID();
145+
146+
const responsePromise = page.waitForResponse(
147+
response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
148+
);
149+
150+
const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
151+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag;
152+
});
153+
154+
await page.goto(`/?tag=${testTag}`);
155+
156+
const response = await responsePromise;
157+
const serverTimingHeader = response.headers()['server-timing'];
158+
159+
// Extract sentry-trace from Server-Timing header
160+
const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
161+
expect(sentryTraceMatch).toBeTruthy();
162+
const sentryTraceValue = sentryTraceMatch?.[1];
163+
const [headerTraceId] = sentryTraceValue?.split('-') || [];
164+
165+
// Extract baggage from Server-Timing header
166+
const baggageMatch = serverTimingHeader?.match(/baggage;desc="([^"]+)"/);
167+
expect(baggageMatch).toBeTruthy();
168+
169+
// Baggage is URL-encoded in Server-Timing header
170+
const encodedBaggage = baggageMatch?.[1];
171+
const decodedBaggage = decodeURIComponent(encodedBaggage || '');
172+
173+
// Parse baggage string into key-value pairs
174+
const baggageEntries = decodedBaggage.split(',').reduce(
175+
(acc, entry) => {
176+
const [key, value] = entry.split('=');
177+
if (key && value) {
178+
acc[key] = value;
179+
}
180+
return acc;
181+
},
182+
{} as Record<string, string>,
183+
);
184+
185+
// Verify essential DSC fields are present in baggage
186+
expect(baggageEntries['sentry-environment']).toBeDefined();
187+
expect(baggageEntries['sentry-trace_id']).toBeDefined();
188+
expect(baggageEntries['sentry-public_key']).toBeDefined();
189+
190+
// CRITICAL: The trace_id in baggage MUST match the trace_id in sentry-trace header
191+
// Both are generated from the same span in generateSentryServerTimingHeader()
192+
// If they differ, there's a bug in DSC/trace context handling
193+
expect(baggageEntries['sentry-trace_id']).toEqual(headerTraceId);
194+
195+
const pageloadTransaction = await pageLoadTransactionPromise;
196+
197+
// Verify the client transaction uses the trace_id from sentry-trace header
198+
expect(pageloadTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId);
199+
});
200+
201+
test('Client pageload continues server trace with correct parent span ID', async ({ page }) => {
202+
// This test verifies the complete trace chain:
203+
// 1. Server generates Server-Timing header with trace_id and span_id
204+
// 2. Client receives and parses the header
205+
// 3. Client pageload transaction uses the same trace_id
206+
// 4. Client pageload's parent_span_id matches the server's span_id
207+
//
208+
// Note: We verify trace continuity via the Server-Timing header rather than
209+
// waiting for the server transaction, as the header is the propagation mechanism.
210+
211+
const testTag = crypto.randomUUID();
212+
213+
const responsePromise = page.waitForResponse(
214+
response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
215+
);
216+
217+
const clientTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
218+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag;
219+
});
220+
221+
await page.goto(`/?tag=${testTag}`);
222+
223+
const response = await responsePromise;
224+
const serverTimingHeader = response.headers()['server-timing'];
225+
226+
// Extract trace info from Server-Timing header (this represents the server span)
227+
const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
228+
expect(sentryTraceMatch).toBeTruthy();
229+
const serverTimingTraceValue = sentryTraceMatch?.[1];
230+
const [serverTraceId, serverSpanId, sampled] = serverTimingTraceValue?.split('-') || [];
231+
232+
// Verify server trace info is valid
233+
expect(serverTraceId).toBeDefined();
234+
expect(serverTraceId).toHaveLength(32);
235+
expect(serverSpanId).toBeDefined();
236+
expect(serverSpanId).toHaveLength(16);
237+
expect(sampled).toBe('1'); // Should be sampled
238+
239+
const clientTransaction = await clientTransactionPromise;
240+
241+
// Verify client transaction
242+
expect(clientTransaction).toBeDefined();
243+
const clientTraceId = clientTransaction.contexts?.trace?.trace_id;
244+
const clientParentSpanId = clientTransaction.contexts?.trace?.parent_span_id;
245+
246+
// CRITICAL: Client trace_id must match server trace_id
247+
// This proves trace propagation worked
248+
expect(clientTraceId).toEqual(serverTraceId);
249+
250+
// CRITICAL: Client's parent_span_id must match the server's span_id
251+
// This proves the client is continuing from the exact server span that sent the header
252+
expect(clientParentSpanId).toEqual(serverSpanId);
253+
});

packages/remix/src/server/serverTimingTracePropagation.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import type { Span } from '@sentry/core';
2-
import { debug, getActiveSpan, getRootSpan, getTraceData, isNodeEnv, spanToTraceHeader } from '@sentry/core';
2+
import {
3+
debug,
4+
getActiveSpan,
5+
getDynamicSamplingContextFromSpan,
6+
getRootSpan,
7+
getTraceData,
8+
isNodeEnv,
9+
spanToTraceHeader,
10+
} from '@sentry/core';
311
import { DEBUG_BUILD } from '../utils/debug-build';
412

13+
// Sentry baggage key prefix
14+
const SENTRY_BAGGAGE_KEY_PREFIX = 'sentry-';
15+
516
export interface ServerTimingTraceOptions {
617
/** Include baggage in Server-Timing header. @default true */
718
includeBaggage?: boolean;
@@ -46,8 +57,22 @@ export function generateSentryServerTimingHeader(options: ServerTimingTraceOptio
4657

4758
if (span) {
4859
sentryTrace = spanToTraceHeader(span);
49-
const traceData = getTraceData({ span });
50-
baggage = traceData.baggage;
60+
const spanTraceId = span.spanContext().traceId;
61+
62+
// Get DSC from span and ensure trace_id consistency
63+
const dsc = getDynamicSamplingContextFromSpan(span);
64+
65+
// Build baggage string, ensuring trace_id matches the span's trace_id
66+
// The DSC may have a different trace_id if it was frozen from an earlier context
67+
const baggageEntries: string[] = [];
68+
for (const [key, value] of Object.entries(dsc)) {
69+
if (value) {
70+
// Override trace_id to match the span's trace_id for consistency
71+
const actualValue = key === 'trace_id' ? spanTraceId : value;
72+
baggageEntries.push(`${SENTRY_BAGGAGE_KEY_PREFIX}${key}=${actualValue}`);
73+
}
74+
}
75+
baggage = baggageEntries.length > 0 ? baggageEntries.join(',') : undefined;
5176
} else {
5277
const traceData = getTraceData();
5378
sentryTrace = traceData['sentry-trace'];

0 commit comments

Comments
 (0)