-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(remix): Server Timing Headers Trace Propagation PoC #18653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a proof-of-concept for propagating Sentry trace context from server to client using the Server-Timing HTTP header and the browser Performance API. This provides an alternative to meta tag-based trace propagation, particularly useful for streaming SSR responses and edge runtimes.
Key changes:
- Added utilities for generating and injecting Server-Timing headers with Sentry trace data
- Implemented client-side parsing of Server-Timing headers via the Performance API
- Updated server instrumentation to capture and propagate trace context via Server-Timing headers
- Added comprehensive E2E test coverage for both Node.js and Cloudflare environments
Reviewed changes
Copilot reviewed 25 out of 27 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
packages/remix/src/server/serverTimingTracePropagation.ts |
New utility module for generating Server-Timing headers with trace context |
packages/remix/src/client/serverTimingTracePropagation.ts |
New client-side utilities for parsing trace data from Server-Timing headers |
packages/remix/src/server/instrumentServer.ts |
Updated server instrumentation to inject Server-Timing headers and refactored trace propagation logic |
packages/remix/src/client/performance.tsx |
Updated pageload span initialization to use Server-Timing trace propagation |
packages/remix/src/server/index.ts |
Exported new Server-Timing utilities for public API |
packages/remix/src/client/index.ts |
Exported new client-side Server-Timing utilities |
packages/remix/src/cloudflare/index.ts |
Exported Server-Timing utilities for Cloudflare runtime |
dev-packages/e2e-tests/test-applications/remix-server-timing/* |
New E2E test application validating Server-Timing trace propagation |
dev-packages/e2e-tests/test-applications/remix-hydrogen/* |
Updated Hydrogen test app to demonstrate Cloudflare support |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function addSentryServerTimingHeader(response: Response, options?: ServerTimingTraceOptions): Response { | ||
| const sentryTiming = generateSentryServerTimingHeader(options); | ||
|
|
||
| if (!sentryTiming) { | ||
| return response; | ||
| } | ||
|
|
||
| if (response.bodyUsed) { | ||
| DEBUG_BUILD && debug.warn('Cannot add Server-Timing header: response body already consumed'); | ||
| return response; | ||
| } | ||
|
|
||
| try { | ||
| const headers = new Headers(response.headers); | ||
| const existing = headers.get('Server-Timing'); | ||
|
|
||
| headers.set('Server-Timing', existing ? `${existing}, ${sentryTiming}` : sentryTiming); | ||
|
|
||
| return new Response(response.body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers, | ||
| }); | ||
| } catch (e) { | ||
| DEBUG_BUILD && debug.warn('Failed to add Server-Timing header to response', e); | ||
| return response; | ||
| } | ||
| } |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function addSentryServerTimingHeader duplicates the logic from injectServerTimingHeader in instrumentServer.ts (lines 77-107). Both functions perform the same operations: check for bodyUsed, create new headers, merge Server-Timing values, and create a new Response. This code duplication makes maintenance harder and increases the risk of inconsistencies between the two implementations. Consider extracting the common logic into a shared utility function that both can use.
| */ | ||
| export function getNavigationTraceContextAsync( | ||
| callback: (trace: ServerTimingTraceContext | null) => void, | ||
| maxAttempts: number = DEFAULT_RETRY_ATTEMPTS, | ||
| delayMs: number = DEFAULT_RETRY_DELAY_MS, | ||
| ): void { | ||
| if (navigationTraceCache !== undefined) { | ||
| callback(navigationTraceCache); | ||
| return; | ||
| } | ||
|
|
||
| if (!isServerTimingSupported()) { | ||
| DEBUG_BUILD && debug.log('[Server-Timing] Server-Timing API not supported'); | ||
| navigationTraceCache = null; | ||
| callback(null); | ||
| return; | ||
| } | ||
|
|
||
| let attempts = 0; | ||
|
|
||
| const tryGet = (): void => { | ||
| attempts++; | ||
| const result = tryGetNavigationTraceContext(); | ||
|
|
||
| if (result === false) { | ||
| navigationTraceCache = null; | ||
| callback(null); | ||
| return; | ||
| } | ||
|
|
||
| if (result === null) { | ||
| if (attempts < maxAttempts) { | ||
| setTimeout(tryGet, delayMs); | ||
| return; | ||
| } | ||
| DEBUG_BUILD && debug.log('[Server-Timing] Max retry attempts reached'); | ||
| navigationTraceCache = null; | ||
| callback(null); | ||
| return; | ||
| } | ||
|
|
||
| navigationTraceCache = result; | ||
| callback(result); | ||
| }; | ||
|
|
||
| tryGet(); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The async retry mechanism uses setTimeout without any cleanup mechanism if the component/page is unmounted before the retry completes. This could lead to memory leaks or attempts to call the callback after the context is no longer valid. Consider returning an abort function from getNavigationTraceContextAsync that allows the caller to cancel the pending retries, or use AbortSignal to support cancellation.
| */ | |
| export function getNavigationTraceContextAsync( | |
| callback: (trace: ServerTimingTraceContext | null) => void, | |
| maxAttempts: number = DEFAULT_RETRY_ATTEMPTS, | |
| delayMs: number = DEFAULT_RETRY_DELAY_MS, | |
| ): void { | |
| if (navigationTraceCache !== undefined) { | |
| callback(navigationTraceCache); | |
| return; | |
| } | |
| if (!isServerTimingSupported()) { | |
| DEBUG_BUILD && debug.log('[Server-Timing] Server-Timing API not supported'); | |
| navigationTraceCache = null; | |
| callback(null); | |
| return; | |
| } | |
| let attempts = 0; | |
| const tryGet = (): void => { | |
| attempts++; | |
| const result = tryGetNavigationTraceContext(); | |
| if (result === false) { | |
| navigationTraceCache = null; | |
| callback(null); | |
| return; | |
| } | |
| if (result === null) { | |
| if (attempts < maxAttempts) { | |
| setTimeout(tryGet, delayMs); | |
| return; | |
| } | |
| DEBUG_BUILD && debug.log('[Server-Timing] Max retry attempts reached'); | |
| navigationTraceCache = null; | |
| callback(null); | |
| return; | |
| } | |
| navigationTraceCache = result; | |
| callback(result); | |
| }; | |
| tryGet(); | |
| * | |
| * Returns an abort function that can be used to cancel pending retries. | |
| */ | |
| export function getNavigationTraceContextAsync( | |
| callback: (trace: ServerTimingTraceContext | null) => void, | |
| maxAttempts: number = DEFAULT_RETRY_ATTEMPTS, | |
| delayMs: number = DEFAULT_RETRY_DELAY_MS, | |
| ): () => void { | |
| let cancelled = false; | |
| let timeoutId: ReturnType<typeof setTimeout> | undefined; | |
| if (navigationTraceCache !== undefined) { | |
| callback(navigationTraceCache); | |
| return () => { | |
| // no pending retries to cancel | |
| }; | |
| } | |
| if (!isServerTimingSupported()) { | |
| DEBUG_BUILD && debug.log('[Server-Timing] Server-Timing API not supported'); | |
| navigationTraceCache = null; | |
| callback(null); | |
| return () => { | |
| // no pending retries to cancel | |
| }; | |
| } | |
| let attempts = 0; | |
| const tryGet = (): void => { | |
| if (cancelled) { | |
| return; | |
| } | |
| attempts++; | |
| const result = tryGetNavigationTraceContext(); | |
| if (result === false) { | |
| navigationTraceCache = null; | |
| timeoutId = undefined; | |
| callback(null); | |
| return; | |
| } | |
| if (result === null) { | |
| if (attempts < maxAttempts) { | |
| timeoutId = setTimeout(tryGet, delayMs); | |
| return; | |
| } | |
| DEBUG_BUILD && debug.log('[Server-Timing] Max retry attempts reached'); | |
| navigationTraceCache = null; | |
| timeoutId = undefined; | |
| callback(null); | |
| return; | |
| } | |
| navigationTraceCache = result; | |
| timeoutId = undefined; | |
| callback(result); | |
| }; | |
| tryGet(); | |
| return (): void => { | |
| cancelled = true; | |
| if (timeoutId !== undefined) { | |
| clearTimeout(timeoutId); | |
| timeoutId = undefined; | |
| } | |
| }; |
| // Without a valid span ID, generating a sentry-trace header would produce an | ||
| // invalid format like "traceid-undefined-1" which would poison downstream propagation. | ||
| if (propagationContext.traceId && spanId) { | ||
| const fallbackTrace = generateSentryTraceHeader(propagationContext.traceId, spanId, propagationContext.sampled); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback logic constructs a trace header using generateSentryTraceHeader(propagationContext.traceId, spanId, propagationContext.sampled), but the sampled field may be undefined in the propagation context. If sampled is undefined, this could result in a trace header with an undefined sampled flag, which may not match the expected format. Consider ensuring the sampled flag is properly defined or handling the undefined case explicitly.
| const fallbackTrace = generateSentryTraceHeader(propagationContext.traceId, spanId, propagationContext.sampled); | |
| const sampledFromContext = typeof propagationContext.sampled === 'boolean' | |
| ? propagationContext.sampled | |
| : (typeof traceData.sampled === 'boolean' ? traceData.sampled : undefined); | |
| const fallbackTrace = | |
| typeof sampledFromContext === 'boolean' | |
| ? generateSentryTraceHeader(propagationContext.traceId, spanId, sampledFromContext) | |
| : generateSentryTraceHeader(propagationContext.traceId, spanId); |
| test('No sentry-trace meta tag is present (testing Server-Timing-only propagation)', async ({ page }) => { | ||
| await page.goto('/'); | ||
|
|
||
| // Verify that NO sentry-trace meta tag is present | ||
| // This confirms we are testing Server-Timing propagation, not meta tag propagation | ||
| const sentryTraceMetaTag = await page.$('meta[name="sentry-trace"]'); | ||
| const baggageMetaTag = await page.$('meta[name="baggage"]'); | ||
|
|
||
| // Both should be null since we intentionally don't use meta tags in this test app | ||
| expect(sentryTraceMetaTag).toBeNull(); | ||
| expect(baggageMetaTag).toBeNull(); | ||
| }); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test description says "No sentry-trace meta tag is present (testing Server-Timing-only propagation)" but the test only verifies the absence of meta tags without actually verifying that Server-Timing propagation is working. This test should either be renamed to accurately reflect what it's testing (just verifying no meta tags exist) or it should be enhanced to actually verify that Server-Timing propagation works despite the absence of meta tags.
| // URL-encode baggage to handle special characters | ||
| metrics.push(`baggage;desc="${encodeURIComponent(baggage)}"`); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The encodeURIComponent is applied to the entire baggage value, which may already contain encoded components. If the baggage header contains pre-encoded values, this will result in double-encoding. The baggage format is a structured header that may contain multiple key-value pairs, and applying encodeURIComponent to the entire string could corrupt the structure. Consider whether the baggage needs encoding at all for the Server-Timing header, or if only specific parts should be encoded.
| // URL-encode baggage to handle special characters | |
| metrics.push(`baggage;desc="${encodeURIComponent(baggage)}"`); | |
| // Escape baggage for use inside a quoted-string without re-encoding its structured header contents | |
| const escapedBaggage = baggage.replace(/(["\\])/g, '\\$1'); | |
| metrics.push(`baggage;desc="${escapedBaggage}"`); |
| if (options?.instrumentTracing && rootSpan) { | ||
| // Update the root span with Remix-specific attributes | ||
| rootSpan.updateName(name); | ||
| rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.http.remix'); | ||
| rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); | ||
| rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server'); | ||
| rootSpan.setAttribute('http.method', request.method); | ||
|
|
||
| // Add HTTP header attributes | ||
| const headerAttributes = httpHeadersToSpanAttributes( | ||
| winterCGHeadersToDict(request.headers), | ||
| clientOptions.sendDefaultPii ?? false, | ||
| ); | ||
| for (const [key, value] of Object.entries(headerAttributes)) { | ||
| rootSpan.setAttribute(key, value); | ||
| } |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The root span attributes are being set after the request has been handled (lines 497-510), which means if the span has already ended by the time this code runs, these attributes won't be captured. The comment on line 491 acknowledges this ("The span may be null if OTel has already ended it"), but the code should either ensure the span is still active when setting attributes, or set these attributes before calling origRequestHandler. Consider moving the attribute setting to before the request is handled to ensure they're captured.
964c99f to
1f07bc8
Compare
1f07bc8 to
67ec468
Compare
Adds automatic trace propagation from server to client via the
Server-TimingHTTP header for Remix applications.This provides an alternative to meta tag injection for linking server and client traces. The server automatically injects
sentry-traceandbaggageinto theServer-Timingresponse header, and the client SDK reads it via the Performance API during pageload.Server-side:
generateSentryServerTimingHeader()- generates the header valueaddSentryServerTimingHeader(response)- adds header to a Responseentry.server.tsxusingaddSentryServerTimingHeaderClient-side:
Server-Timingfrom navigation timing entriesWorks on both Node.js and Cloudflare Workers environments.