Skip to content

Conversation

@onurtemizkan
Copy link
Collaborator

@onurtemizkan onurtemizkan commented Jan 1, 2026

Adds automatic trace propagation from server to client via the Server-Timing HTTP header for Remix applications.

This provides an alternative to meta tag injection for linking server and client traces. The server automatically injects sentry-trace and baggage into the Server-Timing response header, and the client SDK reads it via the Performance API during pageload.

Server-side:

  • generateSentryServerTimingHeader() - generates the header value
  • addSentryServerTimingHeader(response) - adds header to a Response
  • Automatic injection in document request handler
  • Cloudflare apps require manual response wrapping in entry.server.tsx using addSentryServerTimingHeader

Client-side:

  • Reads Server-Timing from navigation timing entries
  • Falls back to meta tags if Server-Timing unavailable
  • Async retry mechanism for slow header processing

Works on both Node.js and Cloudflare Workers environments.

Closes #18696 (added automatically)

Copy link
Contributor

Copilot AI left a 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.

Comment on lines 94 to 124
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;
}
}
Copy link

Copilot AI Jan 1, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 144 to 210
*/
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();
Copy link

Copilot AI Jan 1, 2026

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.

Suggested change
*/
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;
}
};

Copilot uses AI. Check for mistakes.
// 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);
Copy link

Copilot AI Jan 1, 2026

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 127 to 138
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();
});
Copy link

Copilot AI Jan 1, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 63 to 92
// URL-encode baggage to handle special characters
metrics.push(`baggage;desc="${encodeURIComponent(baggage)}"`);
Copy link

Copilot AI Jan 1, 2026

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.

Suggested change
// 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}"`);

Copilot uses AI. Check for mistakes.
Comment on lines 495 to 425
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);
}
Copy link

Copilot AI Jan 1, 2026

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.

Copilot uses AI. Check for mistakes.
@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch 4 times, most recently from 1f07bc8 to 67ec468 Compare January 2, 2026 15:42
@onurtemizkan onurtemizkan marked this pull request as ready for review January 5, 2026 12:55
@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch from 3e06c9b to 48e03dd Compare January 5, 2026 12:55
@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch from d882ca3 to 982c420 Compare January 5, 2026 15:31
@github-actions
Copy link
Contributor

github-actions bot commented Jan 7, 2026

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 9,565 - 8,944 +7%
GET With Sentry 1,766 18% 1,786 -1%
GET With Sentry (error only) 6,255 65% 6,223 +1%
POST Baseline 1,216 - 1,206 +1%
POST With Sentry 604 50% 597 +1%
POST With Sentry (error only) 1,084 89% 1,064 +2%
MYSQL Baseline 3,395 - 3,304 +3%
MYSQL With Sentry 488 14% 513 -5%
MYSQL With Sentry (error only) 2,781 82% 2,667 +4%

View base workflow run

@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch from 1b1481d to 8a43e90 Compare January 8, 2026 09:25
@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch from fa61b3c to 7518ec6 Compare January 23, 2026 14:35
Copy link
Member

@Lms24 Lms24 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onurtemizkan sorry for the delay on this! I merged in #18673 to get support for server-timing trace continuation on the client side in the general browser SDK. Could you update the PR to use this instead of the remix-specific approach? The server side looks fine to me, though I'll give this a more proper look once the PR is updated.

@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch from 7518ec6 to 1f7c85c Compare February 2, 2026 13:59
@github-actions
Copy link
Contributor

github-actions bot commented Feb 2, 2026

Codecov Results 📊


Generated by Codecov Action

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.


// Inject Server-Timing header for redirect responses.
// Redirects bypass makeWrappedDocumentRequestFunction, so we inject here using the active OTel span.
if (isResponse(res) && isRedirectResponse(res) && !res.headers.has('Server-Timing')) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redirect Server-Timing header merge behavior inconsistent with documents

Low Severity

The redirect response path includes a !res.headers.has('Server-Timing') check that prevents Sentry trace data from being added when an existing Server-Timing header is present. This is inconsistent with the document response path in makeWrappedDocumentRequestFunction, which calls injectServerTimingHeaderValue unconditionally and correctly merges headers. If a redirect response has an existing Server-Timing header (from user code or middleware), Sentry's trace context won't be merged, potentially breaking trace propagation for that redirect.

Fix in Cursor Fix in Web

Comment on lines +132 to +134
if (propagationContext.traceId && spanId) {
const fallbackTrace = generateSentryTraceHeader(propagationContext.traceId, spanId, propagationContext.sampled);
DEBUG_BUILD && debug.log('Using meta tags fallback - no active span for Server-Timing');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: In getTraceAndBaggage, if no active span or incoming trace exists, spanId is undefined, causing a check to fail and preventing trace propagation meta tags from being generated.
Severity: HIGH

Suggested Fix

Modify getTraceAndBaggage to align with the behavior of scopeToTraceHeader. Instead of checking if spanId is defined, pass the potentially undefined spanId to generateSentryTraceHeader and allow it to generate a new ID if one is not available. This ensures trace propagation meta tags are always generated.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/remix/src/server/instrumentServer.ts#L132-L134

Potential issue: In the `getTraceAndBaggage` function, when there is no active span and
no incoming `sentry-trace` header (e.g., a fresh request), both
`propagationContext.parentSpanId` and `propagationContext.propagationSpanId` will be
`undefined`. This results in the local `spanId` variable also being `undefined`.
Consequently, the conditional check `if (propagationContext.traceId && spanId)` fails,
causing the function to return an empty object. This prevents the generation of
necessary Sentry meta tags for trace propagation, effectively breaking the trace
connection between the server and the client in this scenario.

Did we get this right? 👍 / 👎 to inform future reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(remix): Server Timing Headers Trace Propagation PoC

3 participants