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.

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 181
*/
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 +67
// 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 430
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 3 times, most recently from 964c99f to 1f07bc8 Compare January 2, 2026 15:26
@onurtemizkan onurtemizkan force-pushed the onur/remix-server-timing-headers branch from 1f07bc8 to 67ec468 Compare January 2, 2026 15:42
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.

2 participants