): void {
@@ -201,6 +239,9 @@ export function withSentry, R extends React.Co
}
if (_instrumentNavigation && matches?.length) {
+ // Reset navigation state when starting a new navigation
+ resetNavigationState();
+
if (activeRootSpan) {
activeRootSpan.end();
}
diff --git a/packages/remix/src/client/serverTimingTracePropagation.ts b/packages/remix/src/client/serverTimingTracePropagation.ts
new file mode 100644
index 000000000000..bd32e24b143c
--- /dev/null
+++ b/packages/remix/src/client/serverTimingTracePropagation.ts
@@ -0,0 +1,256 @@
+import { debug, extractTraceparentData } from '@sentry/core';
+import { WINDOW } from '@sentry/react';
+import { DEBUG_BUILD } from '../utils/debug-build';
+
+export interface ServerTimingTraceContext {
+ sentryTrace: string;
+ baggage: string;
+}
+
+type NavigationTraceResult =
+ | { status: 'pending' }
+ | { status: 'unavailable' }
+ | { status: 'available'; data: ServerTimingTraceContext };
+
+/**
+ * Cache for navigation trace context.
+ * - undefined: Not yet attempted to retrieve
+ * - null: Attempted but unavailable (no Server-Timing data or API not supported)
+ * - ServerTimingTraceContext: Successfully retrieved trace context
+ */
+let navigationTraceCache: ServerTimingTraceContext | null | undefined;
+
+const MAX_RETRY_ATTEMPTS = 40;
+const RETRY_INTERVAL_MS = 50;
+
+/**
+ * Check if Server-Timing API is supported in the current browser.
+ */
+export function isServerTimingSupported(): boolean {
+ if (typeof WINDOW === 'undefined' || !WINDOW.performance) {
+ return false;
+ }
+
+ try {
+ const navEntries = WINDOW.performance.getEntriesByType?.('navigation');
+ if (!navEntries || navEntries.length === 0) {
+ return false;
+ }
+
+ const firstEntry = navEntries[0];
+ if (!firstEntry) {
+ return false;
+ }
+
+ return 'serverTiming' in firstEntry;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Parses Server-Timing header entries to extract Sentry trace context.
+ * Expects entries with names 'sentry-trace' and 'baggage'.
+ * Baggage is URL-decoded as it's encoded in the Server-Timing header.
+ */
+function parseServerTimingTrace(serverTiming: readonly PerformanceServerTiming[]): ServerTimingTraceContext | null {
+ let sentryTrace = '';
+ let baggage = '';
+
+ for (const entry of serverTiming) {
+ if (entry.name === 'sentry-trace') {
+ sentryTrace = entry.description;
+ } else if (entry.name === 'baggage') {
+ // Baggage is escaped for quoted-string context (backslash-escaped quotes and backslashes)
+ baggage = entry.description.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
+ }
+ }
+
+ if (!sentryTrace) {
+ return null;
+ }
+
+ const traceparentData = extractTraceparentData(sentryTrace);
+ if (!traceparentData?.traceId || !traceparentData?.parentSpanId) {
+ DEBUG_BUILD && debug.warn('Invalid sentry-trace format:', sentryTrace);
+ return null;
+ }
+
+ return { sentryTrace, baggage };
+}
+
+/**
+ * Attempts to retrieve trace context from the navigation performance entry.
+ *
+ * @returns
+ * - `{ status: 'available', data }` - Trace context successfully retrieved
+ * - `{ status: 'pending' }` - Headers not yet processed (responseStart === 0), retry recommended
+ * - `{ status: 'unavailable' }` - No Server-Timing data available, don't retry
+ */
+function tryGetNavigationTraceContext(): NavigationTraceResult {
+ try {
+ const navEntries = WINDOW.performance.getEntriesByType('navigation');
+
+ if (!navEntries || navEntries.length === 0) {
+ return { status: 'unavailable' };
+ }
+
+ const navEntry = navEntries[0] as PerformanceNavigationTiming;
+
+ // responseStart === 0 means headers haven't been processed yet
+ if (navEntry.responseStart === 0) {
+ return { status: 'pending' };
+ }
+
+ const serverTiming = navEntry.serverTiming;
+
+ if (!serverTiming || serverTiming.length === 0) {
+ return { status: 'unavailable' };
+ }
+
+ const result = parseServerTimingTrace(serverTiming);
+
+ return result ? { status: 'available', data: result } : { status: 'unavailable' };
+ } catch {
+ return { status: 'unavailable' };
+ }
+}
+
+/**
+ * Get trace context from Server-Timing header synchronously. Results are cached.
+ */
+export function getNavigationTraceContext(): ServerTimingTraceContext | null {
+ if (navigationTraceCache !== undefined) {
+ return navigationTraceCache;
+ }
+
+ if (!isServerTimingSupported()) {
+ DEBUG_BUILD && debug.log('Server-Timing API not supported');
+ navigationTraceCache = null;
+ return null;
+ }
+
+ const result = tryGetNavigationTraceContext();
+
+ switch (result.status) {
+ case 'unavailable':
+ navigationTraceCache = null;
+ return null;
+ case 'pending':
+ return null;
+ case 'available':
+ navigationTraceCache = result.data;
+ return result.data;
+ }
+}
+
+/**
+ * Get trace context from Server-Timing header with retry mechanism for early SDK initialization.
+ * Returns a cleanup function to cancel pending retries.
+ */
+export function getNavigationTraceContextAsync(
+ callback: (trace: ServerTimingTraceContext | null) => void,
+ maxAttempts: number = MAX_RETRY_ATTEMPTS,
+ delayMs: number = RETRY_INTERVAL_MS,
+): () => void {
+ const state = { cancelled: false };
+
+ if (navigationTraceCache !== undefined) {
+ callback(navigationTraceCache);
+ return () => {
+ state.cancelled = true;
+ };
+ }
+
+ if (!isServerTimingSupported()) {
+ DEBUG_BUILD && debug.log('Server-Timing API not supported');
+ navigationTraceCache = null;
+ callback(null);
+ return () => {
+ state.cancelled = true;
+ };
+ }
+
+ let attempts = 0;
+
+ const tryGet = (): void => {
+ if (state.cancelled) {
+ return;
+ }
+
+ attempts++;
+ const result = tryGetNavigationTraceContext();
+
+ switch (result.status) {
+ case 'unavailable':
+ if (!state.cancelled) {
+ navigationTraceCache = null;
+ callback(null);
+ }
+ return;
+ case 'pending':
+ if (attempts < maxAttempts) {
+ setTimeout(tryGet, delayMs);
+ return;
+ }
+ DEBUG_BUILD && debug.warn('Max retry attempts reached, trace context unavailable');
+ if (!state.cancelled) {
+ navigationTraceCache = null;
+ callback(null);
+ }
+ return;
+ case 'available':
+ if (!state.cancelled) {
+ navigationTraceCache = result.data;
+ callback(result.data);
+ }
+ }
+ };
+
+ tryGet();
+
+ return () => {
+ state.cancelled = true;
+ };
+}
+
+/**
+ * Get trace context from meta tags as a fallback for browsers without Server-Timing support.
+ */
+export function getMetaTagTraceContext(): ServerTimingTraceContext | null {
+ if (typeof WINDOW === 'undefined' || !WINDOW.document) {
+ return null;
+ }
+
+ try {
+ const sentryTraceMeta = WINDOW.document.querySelector('meta[name="sentry-trace"]');
+ const baggageMeta = WINDOW.document.querySelector('meta[name="baggage"]');
+
+ const sentryTrace = sentryTraceMeta?.content;
+
+ if (!sentryTrace) {
+ return null;
+ }
+
+ const traceparentData = extractTraceparentData(sentryTrace);
+ if (!traceparentData?.traceId || !traceparentData?.parentSpanId) {
+ DEBUG_BUILD && debug.warn('Invalid sentry-trace format in meta tag:', sentryTrace);
+ return null;
+ }
+
+ return {
+ sentryTrace,
+ baggage: baggageMeta?.content || '',
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Resets the navigation trace cache for fresh retrieval.
+ * @internal
+ */
+export function clearNavigationTraceCache(): void {
+ navigationTraceCache = undefined;
+}
diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts
index 9b78855ae2d3..5ef14ecf7343 100644
--- a/packages/remix/src/cloudflare/index.ts
+++ b/packages/remix/src/cloudflare/index.ts
@@ -13,6 +13,12 @@ export { captureRemixErrorBoundaryError } from '../client/errors';
export { withSentry } from '../client/performance';
export { ErrorBoundary, browserTracingIntegration } from '../client';
export { makeWrappedCreateRequestHandler, sentryHandleError };
+export {
+ generateSentryServerTimingHeader,
+ mergeSentryServerTimingHeader,
+ addSentryServerTimingHeader,
+} from '../server/serverTimingTracePropagation';
+export type { ServerTimingTraceOptions } from '../server/serverTimingTracePropagation';
/**
* Instruments a Remix build to capture errors and performance data.
diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts
index 181c9fd36d16..923ab42c7350 100644
--- a/packages/remix/src/server/index.ts
+++ b/packages/remix/src/server/index.ts
@@ -135,3 +135,9 @@ export * from '@sentry/node';
export { init, getRemixDefaultIntegrations } from './sdk';
export { captureRemixServerException } from './errors';
export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer';
+export {
+ generateSentryServerTimingHeader,
+ mergeSentryServerTimingHeader,
+ addSentryServerTimingHeader,
+} from './serverTimingTracePropagation';
+export type { ServerTimingTraceOptions } from './serverTimingTracePropagation';
diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts
index d8864d254a99..4734930a2e38 100644
--- a/packages/remix/src/server/instrumentServer.ts
+++ b/packages/remix/src/server/instrumentServer.ts
@@ -17,8 +17,10 @@ import {
continueTrace,
debug,
fill,
+ generateSentryTraceHeader,
getActiveSpan,
getClient,
+ getCurrentScope,
getRootSpan,
getTraceData,
hasSpansEnabled,
@@ -39,6 +41,11 @@ import { DEBUG_BUILD } from '../utils/debug-build';
import { createRoutes, getTransactionName } from '../utils/utils';
import { extractData, isResponse, json } from '../utils/vendor/response';
import { captureRemixServerException, errorHandleDataFunction } from './errors';
+import {
+ generateSentryServerTimingHeader,
+ injectServerTimingHeaderValue,
+ isCloudflareEnv,
+} from './serverTimingTracePropagation';
type AppData = unknown;
type RemixRequest = Parameters[0];
@@ -95,22 +102,45 @@ export function wrapHandleErrorWithSentry(
};
}
-function isCloudflareEnv(): boolean {
- // eslint-disable-next-line no-restricted-globals
- return navigator?.userAgent?.includes('Cloudflare');
-}
-
+/**
+ * Get trace context for meta tag injection. Returns empty object when Server-Timing
+ * headers will be used (active span in Node.js/Cloudflare), as Server-Timing takes
+ * priority over meta tags for trace propagation.
+ */
function getTraceAndBaggage(): {
sentryTrace?: string;
sentryBaggage?: string;
} {
+ // Server-Timing headers take priority over meta tags.
+ // When in Node.js or Cloudflare environments with an active span,
+ // Server-Timing headers will be injected, so skip meta tag data.
if (isNodeEnv() || isCloudflareEnv()) {
+ const activeSpan = getActiveSpan();
+ if (activeSpan) {
+ // Active span exists - Server-Timing header will be injected by makeWrappedDocumentRequestFunction.
+ // Return empty to avoid duplicate trace context in meta tags.
+ DEBUG_BUILD && debug.log('Skipping meta tag injection - Server-Timing header will be used');
+ return {};
+ }
+
+ // No active span - fall back to meta tags via propagation context
+ const scope = getCurrentScope();
+ const propagationContext = scope.getPropagationContext();
const traceData = getTraceData();
+ const spanId = propagationContext.propagationSpanId ?? propagationContext.parentSpanId;
- return {
- sentryTrace: traceData['sentry-trace'],
- sentryBaggage: traceData.baggage,
- };
+ 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');
+
+ return {
+ sentryTrace: fallbackTrace,
+ sentryBaggage: traceData.baggage,
+ };
+ }
+
+ DEBUG_BUILD && debug.log('No valid trace context available');
+ return {};
}
return {};
@@ -119,13 +149,18 @@ function getTraceAndBaggage(): {
function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) {
return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction {
return async function (this: unknown, request: Request, ...args: unknown[]): Promise {
- if (instrumentTracing) {
- const activeSpan = getActiveSpan();
- const rootSpan = activeSpan && getRootSpan(activeSpan);
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan && getRootSpan(activeSpan);
+ // Capture trace data now before span ends
+ const serverTimingHeader = rootSpan ? generateSentryServerTimingHeader({ span: rootSpan }) : null;
+
+ let response: Response;
+
+ if (instrumentTracing) {
const name = rootSpan ? spanToJSON(rootSpan).description : undefined;
- return startSpan(
+ response = await startSpan(
{
// If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow
// So we don't need to care too much about the fallback name, it's just for typing purposes....
@@ -143,8 +178,14 @@ function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) {
},
);
} else {
- return origDocumentRequestFunction.call(this, request, ...args);
+ response = await origDocumentRequestFunction.call(this, request, ...args);
}
+
+ if (serverTimingHeader && response instanceof Response) {
+ return injectServerTimingHeaderValue(response, serverTimingHeader);
+ }
+
+ return response;
};
};
}
@@ -254,14 +295,12 @@ function makeWrappedRootLoader(instrumentTracing?: boolean, build?: ServerBuild)
const data = await extractData(res);
if (typeof data === 'object') {
- return json(
- { ...data, ...traceAndBaggage },
- {
- headers: res.headers,
- statusText: res.statusText,
- status: res.status,
- },
- );
+ const merged = { ...data, ...traceAndBaggage };
+ return json(merged, {
+ headers: res.headers,
+ statusText: res.statusText,
+ status: res.status,
+ });
} else {
DEBUG_BUILD && debug.warn('Skipping injection of trace and baggage as the response body is not an object');
return res;
@@ -281,10 +320,6 @@ function wrapRequestHandler ServerBuild | Promise
instrumentTracing?: boolean;
},
): RequestHandler {
- let resolvedBuild: ServerBuild | { build: ServerBuild };
- let name: string;
- let source: TransactionSource;
-
return async function (this: unknown, request: RemixRequest, loadContext?: AppLoadContext): Promise {
const upperCaseMethod = request.method.toUpperCase();
// We don't want to wrap OPTIONS and HEAD requests
@@ -292,6 +327,11 @@ function wrapRequestHandler ServerBuild | Promise
return origRequestHandler.call(this, request, loadContext);
}
+ // These variables are declared inside the async function to avoid race conditions
+ // across concurrent requests. Each request gets its own instance.
+ let resolvedBuild: ServerBuild | { build: ServerBuild };
+ let name: string;
+ let source: TransactionSource;
let resolvedRoutes: AgnosticRouteObject[] | undefined;
if (options?.instrumentTracing) {
@@ -336,50 +376,51 @@ function wrapRequestHandler ServerBuild | Promise
isolationScope.setSDKProcessingMetadata({ normalizedRequest });
+ const sentryTrace = request.headers.get('sentry-trace');
+ const baggage = request.headers.get('baggage');
+
if (!clientOptions || !hasSpansEnabled(clientOptions)) {
- return origRequestHandler.call(this, request, loadContext);
+ return (await origRequestHandler.call(this, request, loadContext)) as Response;
}
- return continueTrace(
- {
- sentryTrace: request.headers.get('sentry-trace') || '',
- baggage: request.headers.get('baggage') || '',
- },
- async () => {
- if (options?.instrumentTracing) {
- const parentSpan = getActiveSpan();
- const rootSpan = parentSpan && getRootSpan(parentSpan);
- rootSpan?.updateName(name);
-
- return startSpan(
- {
- name,
- attributes: {
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix',
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
- method: request.method,
- ...httpHeadersToSpanAttributes(
- winterCGHeadersToDict(request.headers),
- clientOptions.sendDefaultPii ?? false,
- ),
- },
- },
- async span => {
- const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
-
- if (isResponse(res)) {
- setHttpStatus(span, res.status);
- }
-
- return res;
- },
- );
+ // We update the existing http.server span (created by OTel) with Remix-specific
+ // attributes rather than creating a nested child span. This ensures proper trace
+ // hierarchy where the http.server span is the parent of loader/action spans.
+ const handleRequest = async (): Promise => {
+ const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
+
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
+
+ if (options?.instrumentTracing && rootSpan) {
+ 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);
+
+ const headerAttributes = httpHeadersToSpanAttributes(
+ winterCGHeadersToDict(request.headers),
+ clientOptions.sendDefaultPii ?? false,
+ );
+ for (const [key, value] of Object.entries(headerAttributes)) {
+ rootSpan.setAttribute(key, value);
}
- return (await origRequestHandler.call(this, request, loadContext)) as Response;
- },
- );
+ if (isResponse(res)) {
+ setHttpStatus(rootSpan, res.status);
+ }
+ }
+
+ return res;
+ };
+
+ // Only continue trace if there's an incoming sentry-trace header.
+ // Otherwise, start a fresh trace in the isolation scope.
+ if (sentryTrace) {
+ return continueTrace({ sentryTrace, baggage: baggage || '' }, handleRequest);
+ }
+
+ return handleRequest();
});
};
}
diff --git a/packages/remix/src/server/serverTimingTracePropagation.ts b/packages/remix/src/server/serverTimingTracePropagation.ts
new file mode 100644
index 000000000000..393a5dddd816
--- /dev/null
+++ b/packages/remix/src/server/serverTimingTracePropagation.ts
@@ -0,0 +1,158 @@
+import type { Span } from '@sentry/core';
+import {
+ debug,
+ getActiveSpan,
+ getDynamicSamplingContextFromSpan,
+ getRootSpan,
+ getTraceData,
+ isNodeEnv,
+ spanToTraceHeader,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../utils/debug-build';
+
+// Sentry baggage key prefix
+const SENTRY_BAGGAGE_KEY_PREFIX = 'sentry-';
+
+export interface ServerTimingTraceOptions {
+ /** Include baggage in Server-Timing header. @default true */
+ includeBaggage?: boolean;
+ /** Explicitly pass a span to use for trace data. */
+ span?: Span;
+}
+
+const DEFAULT_OPTIONS: Required> = {
+ includeBaggage: true,
+};
+
+/**
+ * Check if running in Cloudflare Workers environment.
+ */
+export function isCloudflareEnv(): boolean {
+ // eslint-disable-next-line no-restricted-globals
+ return typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Cloudflare');
+}
+
+/**
+ * Generate a Server-Timing header value containing Sentry trace context.
+ * Called automatically by instrumented `handleDocumentRequest`.
+ */
+export function generateSentryServerTimingHeader(options: ServerTimingTraceOptions = {}): string | null {
+ // Only generate on server environments
+ if (!isNodeEnv() && !isCloudflareEnv()) {
+ return null;
+ }
+
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+
+ let span = opts.span;
+ if (!span) {
+ const activeSpan = getActiveSpan();
+ if (activeSpan) {
+ span = getRootSpan(activeSpan);
+ }
+ }
+
+ let sentryTrace: string | undefined;
+ let baggage: string | undefined;
+
+ if (span) {
+ sentryTrace = spanToTraceHeader(span);
+ const spanTraceId = span.spanContext().traceId;
+
+ // Get DSC from span and ensure trace_id consistency
+ const dsc = getDynamicSamplingContextFromSpan(span);
+
+ const baggageEntries: string[] = [];
+ for (const [key, value] of Object.entries(dsc)) {
+ if (value) {
+ // Override trace_id to match the span's trace_id for consistency
+ const actualValue = key === 'trace_id' ? spanTraceId : value;
+ baggageEntries.push(`${SENTRY_BAGGAGE_KEY_PREFIX}${key}=${actualValue}`);
+ }
+ }
+ baggage = baggageEntries.length > 0 ? baggageEntries.join(',') : undefined;
+ } else {
+ const traceData = getTraceData();
+ sentryTrace = traceData['sentry-trace'];
+ baggage = traceData.baggage;
+ }
+
+ if (!sentryTrace) {
+ return null;
+ }
+
+ const metrics: string[] = [];
+
+ metrics.push(`sentry-trace;desc="${sentryTrace}"`);
+
+ if (opts.includeBaggage && baggage) {
+ // Escape special characters for use inside a quoted-string (RFC 7230)
+ // We escape backslashes and double quotes to prevent injection
+ const escapedBaggage = baggage.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ metrics.push(`baggage;desc="${escapedBaggage}"`);
+ }
+
+ return metrics.join(', ');
+}
+
+/**
+ * Merge Sentry trace context with an existing Server-Timing header value.
+ */
+export function mergeSentryServerTimingHeader(
+ existingHeader: string | null | undefined,
+ options?: ServerTimingTraceOptions,
+): string {
+ const sentryTiming = generateSentryServerTimingHeader(options);
+
+ if (!sentryTiming) {
+ return existingHeader || '';
+ }
+
+ if (!existingHeader) {
+ return sentryTiming;
+ }
+
+ return `${existingHeader}, ${sentryTiming}`;
+}
+
+/**
+ * Inject a precomputed Server-Timing header value into a Response.
+ * Returns a new Response with the header added.
+ * @internal
+ */
+export function injectServerTimingHeaderValue(response: Response, serverTimingValue: string): 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}, ${serverTimingValue}` : serverTimingValue);
+
+ 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;
+ }
+}
+
+/**
+ * Add Sentry trace context to a Response via the Server-Timing header.
+ * Returns a new Response with the header added (original is not modified).
+ */
+export function addSentryServerTimingHeader(response: Response, options?: ServerTimingTraceOptions): Response {
+ const sentryTiming = generateSentryServerTimingHeader(options);
+
+ if (!sentryTiming) {
+ return response;
+ }
+
+ return injectServerTimingHeaderValue(response, sentryTiming);
+}
diff --git a/packages/remix/test/client/serverTimingTracePropagation.test.ts b/packages/remix/test/client/serverTimingTracePropagation.test.ts
new file mode 100644
index 000000000000..b8ef3482ea40
--- /dev/null
+++ b/packages/remix/test/client/serverTimingTracePropagation.test.ts
@@ -0,0 +1,553 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ clearNavigationTraceCache,
+ getMetaTagTraceContext,
+ getNavigationTraceContext,
+ getNavigationTraceContextAsync,
+ isServerTimingSupported,
+} from '../../src/client/serverTimingTracePropagation';
+
+// Mock @sentry/react WINDOW
+const mockPerformance = {
+ getEntriesByType: vi.fn(),
+};
+
+const mockDocument = {
+ querySelector: vi.fn(),
+};
+
+vi.mock('@sentry/react', () => ({
+ WINDOW: {
+ get performance() {
+ return mockPerformance;
+ },
+ get document() {
+ return mockDocument;
+ },
+ location: { pathname: '/' },
+ },
+}));
+
+// Mock @sentry/core
+vi.mock('@sentry/core', () => ({
+ debug: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ },
+ extractTraceparentData: vi.fn((sentryTrace: string) => {
+ const parts = sentryTrace.split('-');
+ if (parts.length >= 2 && parts[0].length === 32 && parts[1].length === 16) {
+ return {
+ traceId: parts[0],
+ parentSpanId: parts[1],
+ sampled: parts[2] === '1' ? true : parts[2] === '0' ? false : undefined,
+ };
+ }
+ return undefined;
+ }),
+}));
+
+describe('serverTimingTracePropagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ clearNavigationTraceCache();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ clearNavigationTraceCache();
+ });
+
+ describe('isServerTimingSupported', () => {
+ it('returns false when performance API is not available', () => {
+ mockPerformance.getEntriesByType = undefined as unknown as typeof mockPerformance.getEntriesByType;
+
+ expect(isServerTimingSupported()).toBe(false);
+ });
+
+ it('returns false when navigation entries are empty', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([]);
+
+ expect(isServerTimingSupported()).toBe(false);
+ });
+
+ it('returns false when navigation entry does not have serverTiming property', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([{ responseStart: 100 }]);
+
+ expect(isServerTimingSupported()).toBe(false);
+ });
+
+ it('returns true when serverTiming property is available', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [],
+ },
+ ]);
+
+ expect(isServerTimingSupported()).toBe(true);
+ });
+
+ it('handles exceptions gracefully', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockImplementation(() => {
+ throw new Error('API not supported');
+ });
+
+ expect(isServerTimingSupported()).toBe(false);
+ });
+ });
+
+ describe('getNavigationTraceContext', () => {
+ it('returns null when Server-Timing API is not supported', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([]);
+
+ expect(getNavigationTraceContext()).toBeNull();
+ });
+
+ it('returns null when no navigation entries exist', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [],
+ },
+ ]);
+
+ expect(getNavigationTraceContext()).toBeNull();
+ });
+
+ it('returns null when serverTiming has no sentry-trace entry', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'other', description: 'value' }],
+ },
+ ]);
+
+ expect(getNavigationTraceContext()).toBeNull();
+ });
+
+ it('returns trace context when valid sentry-trace and baggage are present', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+ const baggage = 'sentry-trace_id=123,sentry-environment=production';
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [
+ { name: 'sentry-trace', description: sentryTrace },
+ // Baggage is escaped for quoted-string context (not URL-encoded)
+ { name: 'baggage', description: baggage },
+ ],
+ },
+ ]);
+
+ const result = getNavigationTraceContext();
+
+ expect(result).toEqual({
+ sentryTrace,
+ baggage,
+ });
+ });
+
+ it('returns trace context with empty baggage when only sentry-trace is present', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: sentryTrace }],
+ },
+ ]);
+
+ const result = getNavigationTraceContext();
+
+ expect(result).toEqual({
+ sentryTrace,
+ baggage: '',
+ });
+ });
+
+ it('caches the result after first successful retrieval', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: sentryTrace }],
+ },
+ ]);
+
+ const result1 = getNavigationTraceContext();
+ const result2 = getNavigationTraceContext();
+
+ expect(result1).toBe(result2);
+ // First call: isServerTimingSupported + tryGetNavigationTraceContext
+ // Second call: returns from cache
+ expect(mockPerformance.getEntriesByType).toHaveBeenCalledTimes(2);
+ });
+
+ it('returns null for pending state (responseStart === 0)', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 0,
+ serverTiming: [{ name: 'sentry-trace', description: 'valid-trace' }],
+ },
+ ]);
+
+ const result = getNavigationTraceContext();
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null and caches for unavailable state', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [],
+ },
+ ]);
+
+ const result1 = getNavigationTraceContext();
+ const result2 = getNavigationTraceContext();
+
+ expect(result1).toBeNull();
+ expect(result2).toBeNull();
+ });
+
+ it('handles invalid sentry-trace format', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: 'invalid-format' }],
+ },
+ ]);
+
+ const result = getNavigationTraceContext();
+
+ expect(result).toBeNull();
+ });
+
+ it('unescapes backslash-escaped baggage', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+ const baggage = 'sentry-environment=production,sentry-release=1.0.0';
+ // Simulate escaped baggage (backslash-escaped quotes and backslashes)
+ const escapedBaggage = baggage.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [
+ { name: 'sentry-trace', description: sentryTrace },
+ { name: 'baggage', description: escapedBaggage },
+ ],
+ },
+ ]);
+
+ const result = getNavigationTraceContext();
+
+ expect(result?.baggage).toBe(baggage);
+ });
+
+ it('handles baggage with special characters', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+ // Baggage with escaped quotes (simulating what server sends)
+ const escapedBaggage = 'key=value\\"with\\"quotes';
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [
+ { name: 'sentry-trace', description: sentryTrace },
+ { name: 'baggage', description: escapedBaggage },
+ ],
+ },
+ ]);
+
+ const result = getNavigationTraceContext();
+
+ // Should unescape the backslash-escaped quotes
+ expect(result?.baggage).toBe('key=value"with"quotes');
+ });
+ });
+
+ describe('getNavigationTraceContextAsync', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('calls callback immediately when cache is populated', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: sentryTrace }],
+ },
+ ]);
+
+ // Populate cache
+ getNavigationTraceContext();
+
+ const callback = vi.fn();
+ getNavigationTraceContextAsync(callback);
+
+ expect(callback).toHaveBeenCalledWith({ sentryTrace, baggage: '' });
+ });
+
+ it('calls callback with null when Server-Timing is not supported', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([]);
+
+ const callback = vi.fn();
+ getNavigationTraceContextAsync(callback);
+
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+
+ it('retries when status is pending and eventually succeeds', async () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+
+ let callCount = 0;
+ mockPerformance.getEntriesByType = vi.fn().mockImplementation(() => {
+ callCount++;
+ // Calls 1-3: pending (includes isServerTimingSupported check + 2 tryGet attempts)
+ // Call 4+: available
+ if (callCount <= 3) {
+ return [{ responseStart: 0, serverTiming: [] }];
+ }
+ return [
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: sentryTrace }],
+ },
+ ];
+ });
+
+ const callback = vi.fn();
+ getNavigationTraceContextAsync(callback, 5, 50);
+
+ // First call: isServerTimingSupported (callCount=1)
+ // Second call: tryGet #1 (callCount=2), pending, schedules retry
+ expect(callback).not.toHaveBeenCalled();
+
+ // After first 50ms: tryGet #2 (callCount=3), still pending, schedules retry
+ await vi.advanceTimersByTimeAsync(50);
+ expect(callback).not.toHaveBeenCalled();
+
+ // After second 50ms: tryGet #3 (callCount=4), now available
+ await vi.advanceTimersByTimeAsync(50);
+ expect(callback).toHaveBeenCalledWith({ sentryTrace, baggage: '' });
+ });
+
+ it('calls callback with null after max attempts', async () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([{ responseStart: 0, serverTiming: [] }]);
+
+ const callback = vi.fn();
+ getNavigationTraceContextAsync(callback, 3, 50);
+
+ expect(callback).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(50);
+ expect(callback).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(50);
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+
+ it('stops retrying when cancelled', async () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([{ responseStart: 0, serverTiming: [] }]);
+
+ const callback = vi.fn();
+ const cancel = getNavigationTraceContextAsync(callback, 10, 50);
+
+ await vi.advanceTimersByTimeAsync(50);
+ expect(callback).not.toHaveBeenCalled();
+
+ cancel();
+
+ await vi.advanceTimersByTimeAsync(500);
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('returns cleanup function that can be called immediately', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: '12345678901234567890123456789012-1234567890123456-1' }],
+ },
+ ]);
+
+ const callback = vi.fn();
+ const cancel = getNavigationTraceContextAsync(callback);
+
+ expect(typeof cancel).toBe('function');
+ cancel();
+ });
+
+ it('calls callback with null for unavailable state', () => {
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [],
+ },
+ ]);
+
+ const callback = vi.fn();
+ getNavigationTraceContextAsync(callback);
+
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('getMetaTagTraceContext', () => {
+ it('returns null when document is not available', () => {
+ const originalDocument = mockDocument.querySelector;
+ (mockDocument as { querySelector: unknown }).querySelector = undefined;
+
+ expect(getMetaTagTraceContext()).toBeNull();
+
+ mockDocument.querySelector = originalDocument;
+ });
+
+ it('returns null when sentry-trace meta tag is not present', () => {
+ mockDocument.querySelector = vi.fn().mockReturnValue(null);
+
+ expect(getMetaTagTraceContext()).toBeNull();
+ });
+
+ it('returns trace context from meta tags', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+ const baggage = 'sentry-environment=production';
+
+ mockDocument.querySelector = vi.fn().mockImplementation((selector: string) => {
+ if (selector === 'meta[name="sentry-trace"]') {
+ return { content: sentryTrace };
+ }
+ if (selector === 'meta[name="baggage"]') {
+ return { content: baggage };
+ }
+ return null;
+ });
+
+ const result = getMetaTagTraceContext();
+
+ expect(result).toEqual({
+ sentryTrace,
+ baggage,
+ });
+ });
+
+ it('returns trace context with empty baggage when baggage meta tag is not present', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+
+ mockDocument.querySelector = vi.fn().mockImplementation((selector: string) => {
+ if (selector === 'meta[name="sentry-trace"]') {
+ return { content: sentryTrace };
+ }
+ return null;
+ });
+
+ const result = getMetaTagTraceContext();
+
+ expect(result).toEqual({
+ sentryTrace,
+ baggage: '',
+ });
+ });
+
+ it('returns null for invalid sentry-trace format in meta tag', () => {
+ mockDocument.querySelector = vi.fn().mockImplementation((selector: string) => {
+ if (selector === 'meta[name="sentry-trace"]') {
+ return { content: 'invalid-format' };
+ }
+ return null;
+ });
+
+ const result = getMetaTagTraceContext();
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when sentry-trace meta tag has empty content', () => {
+ mockDocument.querySelector = vi.fn().mockImplementation((selector: string) => {
+ if (selector === 'meta[name="sentry-trace"]') {
+ return { content: '' };
+ }
+ return null;
+ });
+
+ const result = getMetaTagTraceContext();
+
+ expect(result).toBeNull();
+ });
+
+ it('handles exceptions gracefully', () => {
+ mockDocument.querySelector = vi.fn().mockImplementation(() => {
+ throw new Error('DOM error');
+ });
+
+ const result = getMetaTagTraceContext();
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('clearNavigationTraceCache', () => {
+ it('clears the cache allowing fresh retrieval', () => {
+ const traceId = '12345678901234567890123456789012';
+ const spanId = '1234567890123456';
+ const sentryTrace = `${traceId}-${spanId}-1`;
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: sentryTrace }],
+ },
+ ]);
+
+ // First retrieval
+ const result1 = getNavigationTraceContext();
+ expect(result1).not.toBeNull();
+
+ // Clear cache
+ clearNavigationTraceCache();
+
+ // Now change the mock to return different data
+ const newTraceId = '98765432109876543210987654321098';
+ const newSpanId = '9876543210987654';
+ const newSentryTrace = `${newTraceId}-${newSpanId}-0`;
+
+ mockPerformance.getEntriesByType = vi.fn().mockReturnValue([
+ {
+ responseStart: 100,
+ serverTiming: [{ name: 'sentry-trace', description: newSentryTrace }],
+ },
+ ]);
+
+ // Second retrieval should get new data
+ const result2 = getNavigationTraceContext();
+ expect(result2?.sentryTrace).toBe(newSentryTrace);
+ });
+ });
+});
diff --git a/packages/remix/test/integration/app/root.tsx b/packages/remix/test/integration/app/root.tsx
index c1d0bf218baa..fc9762bee192 100644
--- a/packages/remix/test/integration/app/root.tsx
+++ b/packages/remix/test/integration/app/root.tsx
@@ -15,12 +15,12 @@ export const ErrorBoundary: ErrorBoundaryComponent = () => {
);
};
-export const meta: MetaFunction = ({ data }) => [
+// With Server-Timing headers as the primary trace propagation method,
+// meta tags for sentry-trace and baggage are no longer needed.
+export const meta: MetaFunction = () => [
{ charset: 'utf-8' },
{ title: 'New Remix App' },
{ name: 'viewport', content: 'width=device-width,initial-scale=1' },
- { name: 'sentry-trace', content: data.sentryTrace },
- { name: 'baggage', content: data.sentryBaggage },
];
export const loader: LoaderFunction = async ({ request }) => {
diff --git a/packages/remix/test/integration/test/client/meta-tags.test.ts b/packages/remix/test/integration/test/client/meta-tags.test.ts
index 94a5ecfa1bd4..a23dd8bbe9fb 100644
--- a/packages/remix/test/integration/test/client/meta-tags.test.ts
+++ b/packages/remix/test/integration/test/client/meta-tags.test.ts
@@ -2,65 +2,51 @@ import { expect, test } from '@playwright/test';
import type { Event } from '@sentry/core';
import { getFirstSentryEnvelopeRequest } from './utils/helpers';
-test('should inject `sentry-trace` and `baggage` meta tags inside the root page.', async ({ page }) => {
+// With Server-Timing headers as the primary trace propagation method,
+// meta tags are no longer injected in Node.js/Cloudflare environments.
+
+test('should NOT inject `sentry-trace` and `baggage` meta tags inside the root page (Server-Timing is used instead)', async ({
+ page,
+}) => {
await page.goto('/');
const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
- const sentryTraceContent = await sentryTraceTag?.getAttribute('content');
-
- expect(sentryTraceContent).toEqual(expect.any(String));
-
const sentryBaggageTag = await page.$('meta[name="baggage"]');
- const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
- expect(sentryBaggageContent).toEqual(expect.any(String));
+ // Meta tags should not be present - Server-Timing headers are used instead
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags inside a parameterized route.', async ({ page }) => {
+test('should NOT inject `sentry-trace` and `baggage` meta tags inside a parameterized route (Server-Timing is used instead)', async ({
+ page,
+}) => {
await page.goto('/loader-json-response/0');
const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
- const sentryTraceContent = await sentryTraceTag?.getAttribute('content');
-
- expect(sentryTraceContent).toEqual(expect.any(String));
-
const sentryBaggageTag = await page.$('meta[name="baggage"]');
- const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
- expect(sentryBaggageContent).toEqual(expect.any(String));
+ // Meta tags should not be present - Server-Timing headers are used instead
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should send transactions with corresponding `sentry-trace` and `baggage` inside root page', async ({
- page,
- browserName,
-}) => {
+test('should send pageload transaction with valid trace context from Server-Timing (root page)', async ({ page }) => {
const envelope = await getFirstSentryEnvelopeRequest(page, '/');
- const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
- const sentryTraceContent = await sentryTraceTag?.getAttribute('content');
- const sentryBaggageTag = await page.$('meta[name="baggage"]');
- const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
-
- expect(sentryTraceContent).toContain(
- `${envelope.contexts?.trace?.trace_id}-${envelope.contexts?.trace?.parent_span_id}-`,
- );
-
- expect(sentryBaggageContent).toContain(envelope.contexts?.trace?.trace_id);
+ // Verify trace propagation worked - transaction should have valid trace context
+ expect(envelope.contexts?.trace?.trace_id).toHaveLength(32);
+ expect(envelope.contexts?.trace?.parent_span_id).toHaveLength(16);
+ expect(envelope.contexts?.trace?.op).toBe('pageload');
});
-test('should send transactions with corresponding `sentry-trace` and `baggage` inside a parameterized route', async ({
+test('should send pageload transaction with valid trace context from Server-Timing (parameterized route)', async ({
page,
}) => {
const envelope = await getFirstSentryEnvelopeRequest(page, '/loader-json-response/0');
- const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
- const sentryTraceContent = await sentryTraceTag?.getAttribute('content');
- const sentryBaggageTag = await page.$('meta[name="baggage"]');
- const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
-
- expect(sentryTraceContent).toContain(
- `${envelope.contexts?.trace?.trace_id}-${envelope.contexts?.trace?.parent_span_id}-`,
- );
-
- expect(sentryBaggageContent).toContain(envelope.contexts?.trace?.trace_id);
+ // Verify trace propagation worked - transaction should have valid trace context
+ expect(envelope.contexts?.trace?.trace_id).toHaveLength(32);
+ expect(envelope.contexts?.trace?.parent_span_id).toHaveLength(16);
+ expect(envelope.contexts?.trace?.op).toBe('pageload');
});
diff --git a/packages/remix/test/integration/test/client/root-loader.test.ts b/packages/remix/test/integration/test/client/root-loader.test.ts
index eb26654cf85a..a25e547a53a2 100644
--- a/packages/remix/test/integration/test/client/root-loader.test.ts
+++ b/packages/remix/test/integration/test/client/root-loader.test.ts
@@ -1,106 +1,124 @@
-import { type Page, expect, test } from '@playwright/test';
+import { expect, test } from '@playwright/test';
-async function extractTraceAndBaggageFromMeta(
- page: Page,
-): Promise<{ sentryTrace?: string | null; sentryBaggage?: string | null }> {
- const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
- const sentryTraceContent = await sentryTraceTag?.getAttribute('content');
-
- const sentryBaggageTag = await page.$('meta[name="baggage"]');
- const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
-
- return { sentryTrace: sentryTraceContent, sentryBaggage: sentryBaggageContent };
-}
+// With Server-Timing headers as the primary trace propagation method,
+// meta tags are no longer injected in Node.js/Cloudflare environments.
+// These tests verify that meta tags are NOT present for various loader types.
-test('should inject `sentry-trace` and `baggage` meta tags with empty loader', async ({ page }) => {
+test('should NOT inject meta tags with empty loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=empty');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ // Meta tags should not be present - Server-Timing headers are used instead
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with plain object loader', async ({ page }) => {
+test('should NOT inject meta tags with plain object loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=plain');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with JSON response loader', async ({ page }) => {
+test('should NOT inject meta tags with JSON response loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=json');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with deferred response loader', async ({ page }) => {
+test('should NOT inject meta tags with deferred response loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=defer');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with null loader', async ({ page }) => {
+test('should NOT inject meta tags with null loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=null');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with undefined loader', async ({ page }) => {
+test('should NOT inject meta tags with undefined loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=undefined');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with throw redirect loader', async ({ page }) => {
+test('should NOT inject meta tags with throw redirect loader (Server-Timing is used instead)', async ({ page }) => {
await page.goto('/?type=throwRedirect');
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
// We should be successfully redirected to the path.
expect(page.url()).toEqual(expect.stringContaining('/?type=plain'));
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ // Meta tags should not be present after redirect either
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should inject `sentry-trace` and `baggage` meta tags with return redirect loader', async ({ page, baseURL }) => {
+test('should NOT inject meta tags with return redirect loader (Server-Timing is used instead)', async ({
+ page,
+ baseURL,
+}) => {
await page.goto(`${baseURL}/?type=returnRedirect`);
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
// We should be successfully redirected to the path.
expect(page.url()).toEqual(expect.stringContaining('/?type=plain'));
- expect(sentryTrace).toMatch(/.+/);
- expect(sentryBaggage).toMatch(/.+/);
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
+
+ // Meta tags should not be present after redirect either
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should return redirect to an external path with no baggage and trace injected.', async ({ page, baseURL }) => {
+test('should return redirect to an external path with no baggage and trace meta tags.', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/?type=returnRedirectToExternal`);
expect(page.url()).toEqual(expect.stringContaining('docs.sentry.io'));
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
+ // External page won't have our meta tags
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
- expect(sentryTrace).toBeUndefined();
- expect(sentryBaggage).toBeUndefined();
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
-test('should throw redirect to an external path with no baggage and trace injected.', async ({ page, baseURL }) => {
+test('should throw redirect to an external path with no baggage and trace meta tags.', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/?type=throwRedirectToExternal`);
// We should be successfully redirected to the external path.
expect(page.url()).toEqual(expect.stringContaining('docs.sentry.io'));
- const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
+ // External page won't have our meta tags
+ const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
+ const sentryBaggageTag = await page.$('meta[name="baggage"]');
- expect(sentryTrace).toBeUndefined();
- expect(sentryBaggage).toBeUndefined();
+ expect(sentryTraceTag).toBeNull();
+ expect(sentryBaggageTag).toBeNull();
});
diff --git a/packages/remix/test/server/serverTimingTracePropagation.test.ts b/packages/remix/test/server/serverTimingTracePropagation.test.ts
new file mode 100644
index 000000000000..9646fcea2b68
--- /dev/null
+++ b/packages/remix/test/server/serverTimingTracePropagation.test.ts
@@ -0,0 +1,320 @@
+import {
+ getActiveSpan,
+ getDynamicSamplingContextFromSpan,
+ getRootSpan,
+ getTraceData,
+ isNodeEnv,
+ spanToTraceHeader,
+} from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ addSentryServerTimingHeader,
+ generateSentryServerTimingHeader,
+ injectServerTimingHeaderValue,
+ isCloudflareEnv,
+ mergeSentryServerTimingHeader,
+} from '../../src/server/serverTimingTracePropagation';
+
+// Mock @sentry/core - vi.mock is hoisted automatically
+const mockSpan = {
+ spanId: 'test-span-id',
+ spanContext: () => ({ traceId: '12345678901234567890123456789012' }),
+};
+const mockRootSpan = {
+ spanId: 'root-span-id',
+ spanContext: () => ({ traceId: '12345678901234567890123456789012' }),
+};
+
+vi.mock('@sentry/core', () => ({
+ debug: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ },
+ getActiveSpan: vi.fn(),
+ getRootSpan: vi.fn(() => mockRootSpan),
+ getTraceData: vi.fn(() => ({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: 'sentry-environment=production,sentry-release=1.0.0',
+ })),
+ getDynamicSamplingContextFromSpan: vi.fn(() => ({
+ trace_id: '12345678901234567890123456789012',
+ environment: 'production',
+ release: '1.0.0',
+ })),
+ spanToTraceHeader: vi.fn(() => '12345678901234567890123456789012-1234567890123456-1'),
+ isNodeEnv: vi.fn(() => true),
+}));
+
+describe('serverTimingTracePropagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(isNodeEnv).mockReturnValue(true);
+ vi.mocked(getActiveSpan).mockReturnValue(mockSpan);
+ vi.mocked(getRootSpan).mockReturnValue(mockRootSpan);
+ vi.mocked(spanToTraceHeader).mockReturnValue('12345678901234567890123456789012-1234567890123456-1');
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: 'sentry-environment=production,sentry-release=1.0.0',
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ // Clean up navigator mock
+ vi.unstubAllGlobals();
+ });
+
+ describe('isCloudflareEnv', () => {
+ it('returns false when navigator is not available', () => {
+ expect(isCloudflareEnv()).toBe(false);
+ });
+
+ it('returns false when navigator.userAgent does not include Cloudflare', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Node.js' });
+
+ expect(isCloudflareEnv()).toBe(false);
+ });
+
+ it('returns true when navigator.userAgent includes Cloudflare', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' });
+
+ expect(isCloudflareEnv()).toBe(true);
+ });
+ });
+
+ describe('generateSentryServerTimingHeader', () => {
+ it('returns null when not in Node.js or Cloudflare environment', () => {
+ vi.mocked(isNodeEnv).mockReturnValue(false);
+
+ expect(generateSentryServerTimingHeader()).toBeNull();
+ });
+
+ it('returns null when no span and no trace data is available', () => {
+ vi.mocked(getActiveSpan).mockReturnValue(undefined);
+ vi.mocked(getTraceData).mockReturnValue({});
+
+ expect(generateSentryServerTimingHeader()).toBeNull();
+ });
+
+ it('generates header with sentry-trace and baggage from active span', () => {
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toContain('sentry-trace;desc="12345678901234567890123456789012-1234567890123456-1"');
+ expect(result).toContain('baggage;desc=');
+ // Baggage is escaped for quoted-string context (not URL-encoded)
+ expect(result).toContain('sentry-environment=production,sentry-release=1.0.0');
+ });
+
+ it('generates header without baggage when includeBaggage is false', () => {
+ const result = generateSentryServerTimingHeader({ includeBaggage: false });
+
+ expect(result).toContain('sentry-trace;desc="12345678901234567890123456789012-1234567890123456-1"');
+ expect(result).not.toContain('baggage');
+ });
+
+ it('uses explicitly provided span', () => {
+ const customSpan = {
+ spanId: 'custom-span',
+ spanContext: () => ({ traceId: 'custom-trace-id' }),
+ };
+ vi.mocked(spanToTraceHeader).mockReturnValue('custom-trace-id-custom-span-id-1');
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': 'custom-trace-id-custom-span-id-1',
+ baggage: 'sentry-custom=value',
+ });
+
+ const result = generateSentryServerTimingHeader({ span: customSpan });
+
+ expect(spanToTraceHeader).toHaveBeenCalledWith(customSpan);
+ expect(result).toContain('sentry-trace;desc="custom-trace-id-custom-span-id-1"');
+ });
+
+ it('falls back to getTraceData when no span is available', () => {
+ vi.mocked(getActiveSpan).mockReturnValue(undefined);
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': 'fallback-trace-id-1234567890123456-0',
+ baggage: 'sentry-fallback=true',
+ });
+
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toContain('sentry-trace;desc="fallback-trace-id-1234567890123456-0"');
+ // Baggage is escaped for quoted-string context (not URL-encoded)
+ expect(result).toContain('sentry-fallback=true');
+ });
+
+ it('works in Cloudflare environment', () => {
+ vi.mocked(isNodeEnv).mockReturnValue(false);
+ vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' });
+
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toContain('sentry-trace');
+ });
+
+ it('returns header without baggage when baggage is empty', () => {
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: '',
+ });
+ vi.mocked(getDynamicSamplingContextFromSpan).mockReturnValue({});
+
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toBe('sentry-trace;desc="12345678901234567890123456789012-1234567890123456-1"');
+ });
+
+ it('returns header without baggage when baggage is undefined', () => {
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ });
+ vi.mocked(getDynamicSamplingContextFromSpan).mockReturnValue({});
+
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toBe('sentry-trace;desc="12345678901234567890123456789012-1234567890123456-1"');
+ });
+ });
+
+ describe('mergeSentryServerTimingHeader', () => {
+ it('returns empty string when no existing header and no sentry timing', () => {
+ vi.mocked(isNodeEnv).mockReturnValue(false);
+
+ expect(mergeSentryServerTimingHeader(null)).toBe('');
+ expect(mergeSentryServerTimingHeader(undefined)).toBe('');
+ });
+
+ it('returns sentry timing when no existing header', () => {
+ const result = mergeSentryServerTimingHeader(null);
+
+ expect(result).toContain('sentry-trace');
+ });
+
+ it('returns existing header when sentry timing cannot be generated', () => {
+ vi.mocked(isNodeEnv).mockReturnValue(false);
+
+ expect(mergeSentryServerTimingHeader('cache;dur=100')).toBe('cache;dur=100');
+ });
+
+ it('merges existing header with sentry timing', () => {
+ const result = mergeSentryServerTimingHeader('cache;dur=100');
+
+ expect(result).toContain('cache;dur=100');
+ expect(result).toContain('sentry-trace');
+ expect(result).toContain(', ');
+ });
+ });
+
+ describe('injectServerTimingHeaderValue', () => {
+ it('returns original response when body is already used', () => {
+ const mockResponse = {
+ bodyUsed: true,
+ headers: new Headers(),
+ } as Response;
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ expect(result).toBe(mockResponse);
+ });
+
+ it('adds Server-Timing header to response without existing header', () => {
+ const mockResponse = new Response('test body', {
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers(),
+ });
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ expect(result.headers.get('Server-Timing')).toBe('sentry-trace;desc="test"');
+ expect(result.status).toBe(200);
+ expect(result.statusText).toBe('OK');
+ });
+
+ it('merges with existing Server-Timing header', () => {
+ const mockResponse = new Response('test body', {
+ status: 200,
+ headers: new Headers({ 'Server-Timing': 'cache;dur=100' }),
+ });
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ expect(result.headers.get('Server-Timing')).toBe('cache;dur=100, sentry-trace;desc="test"');
+ });
+
+ it('preserves response body', async () => {
+ const mockResponse = new Response('test body content', {
+ status: 200,
+ });
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ const body = await result.text();
+ expect(body).toBe('test body content');
+ });
+
+ it('returns original response on error', () => {
+ // Create a response that will throw when cloned
+ const mockResponse = {
+ bodyUsed: false,
+ headers: {
+ get: () => {
+ throw new Error('Header error');
+ },
+ },
+ body: null,
+ status: 200,
+ statusText: 'OK',
+ } as unknown as Response;
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ expect(result).toBe(mockResponse);
+ });
+ });
+
+ describe('addSentryServerTimingHeader', () => {
+ it('returns original response when sentry timing cannot be generated', () => {
+ vi.mocked(isNodeEnv).mockReturnValue(false);
+
+ const mockResponse = new Response('test');
+ const result = addSentryServerTimingHeader(mockResponse);
+
+ expect(result).toBe(mockResponse);
+ });
+
+ it('adds Server-Timing header with trace data', () => {
+ const mockResponse = new Response('test');
+
+ const result = addSentryServerTimingHeader(mockResponse);
+
+ expect(result.headers.get('Server-Timing')).toContain('sentry-trace');
+ });
+
+ it('respects options passed to generateSentryServerTimingHeader', () => {
+ const mockResponse = new Response('test');
+
+ const result = addSentryServerTimingHeader(mockResponse, { includeBaggage: false });
+
+ expect(result.headers.get('Server-Timing')).not.toContain('baggage');
+ });
+
+ it('uses provided span option', () => {
+ const customSpan = {
+ spanId: 'custom',
+ spanContext: () => ({ traceId: 'custom-trace-id' }),
+ };
+ vi.mocked(spanToTraceHeader).mockReturnValue('custom-trace-header');
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': 'custom-trace-header',
+ baggage: 'custom-baggage',
+ });
+
+ const mockResponse = new Response('test');
+ const result = addSentryServerTimingHeader(mockResponse, { span: customSpan });
+
+ expect(spanToTraceHeader).toHaveBeenCalledWith(customSpan);
+ expect(result.headers.get('Server-Timing')).toContain('custom-trace-header');
+ });
+ });
+});