+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/parent.child.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/parent.child.tsx
new file mode 100644
index 000000000000..d6f1e8e81e9b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/parent.child.tsx
@@ -0,0 +1,40 @@
+import { json, LoaderFunctionArgs } from '@remix-run/node';
+import { useLoaderData } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+// Child route loader - runs after parent loader
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ // Get current trace info for debugging
+ const span = Sentry.getActiveSpan();
+ const rootSpan = span ? Sentry.getRootSpan(span) : null;
+ const traceId = rootSpan ? Sentry.spanToJSON(rootSpan).trace_id : 'no-trace';
+
+ // Simulate some async work
+ await new Promise(resolve => setTimeout(resolve, 5));
+
+ return json({
+ childData: 'Child loader data',
+ childTraceId: traceId,
+ });
+};
+
+export default function Child() {
+ const { childData, childTraceId } = useLoaderData();
+
+ return (
+
+
Child Route
+
Child data: {childData}
+
+ Child trace ID: {childTraceId}
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/parent.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/parent.tsx
new file mode 100644
index 000000000000..adca832afc98
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/parent.tsx
@@ -0,0 +1,43 @@
+import { json, LoaderFunctionArgs } from '@remix-run/node';
+import { Outlet, useLoaderData } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+// Parent route loader - runs before child loaders
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ // Get current trace info for debugging
+ const span = Sentry.getActiveSpan();
+ const rootSpan = span ? Sentry.getRootSpan(span) : null;
+ const traceId = rootSpan ? Sentry.spanToJSON(rootSpan).trace_id : 'no-trace';
+
+ // Simulate some async work
+ await new Promise(resolve => setTimeout(resolve, 5));
+
+ return json({
+ parentData: 'Parent loader data',
+ parentTraceId: traceId,
+ });
+};
+
+export default function Parent() {
+ const { parentData, parentTraceId } = useLoaderData();
+
+ return (
+
+
Parent Route
+
Parent data: {parentData}
+
+ Parent trace ID: {parentTraceId}
+
+
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/prefetch-test.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/prefetch-test.tsx
new file mode 100644
index 000000000000..b5ef5f9b9c4e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/prefetch-test.tsx
@@ -0,0 +1,44 @@
+import { json, LoaderFunctionArgs } from '@remix-run/node';
+import { Link, useLoaderData } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ return json({ tag });
+};
+
+export default function PrefetchTest() {
+ const { tag } = useLoaderData();
+
+ return (
+
+
Prefetch Test
+
This page tests Server-Timing with prefetch behavior.
+
+
Links with different prefetch modes:
+
+
+
+ Prefetch on Intent (hover)
+
+
+
+
+ Prefetch on Render
+
+
+
+
+ No Prefetch
+
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx
new file mode 100644
index 000000000000..185dca84a51d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx
@@ -0,0 +1,20 @@
+import { LoaderFunctionArgs, redirect } from '@remix-run/node';
+import * as Sentry from '@sentry/remix';
+
+// Route that returns a redirect response
+// Tests that Server-Timing headers are present on redirect responses
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ // Redirect to user page, preserving the tag
+ const targetUrl = tag ? `/user/redirected?tag=${tag}` : '/user/redirected';
+ return redirect(targetUrl);
+};
+
+// No default export needed - loader always redirects
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/streaming-legacy.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/streaming-legacy.tsx
new file mode 100644
index 000000000000..fb7ee8844960
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/streaming-legacy.tsx
@@ -0,0 +1,51 @@
+import { defer, LoaderFunctionArgs } from '@remix-run/node';
+import { Await, useLoaderData } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+import { Suspense } from 'react';
+
+// Simulate a slow async operation
+async function getSlowData(): Promise<{ message: string; timestamp: number }> {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return {
+ message: 'Deferred data loaded (legacy)!',
+ timestamp: Date.now(),
+ };
+}
+
+// Legacy streaming using defer() - deprecated but still supported
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ // Legacy pattern: use defer() explicitly
+ return defer({
+ immediate: { greeting: 'Hello from legacy streaming route!' },
+ deferred: getSlowData(),
+ });
+};
+
+export default function StreamingLegacy() {
+ const { immediate, deferred } = useLoaderData();
+
+ return (
+
+
Legacy Streaming Response Test (defer)
+
Immediate data: {immediate.greeting}
+
+ Loading deferred data...}>
+
+ {data => (
+
+
Deferred message: {data.message}
+
Timestamp: {data.timestamp}
+
+ )}
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/streaming.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/streaming.tsx
new file mode 100644
index 000000000000..9960848ab5a8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/streaming.tsx
@@ -0,0 +1,52 @@
+import { LoaderFunctionArgs } from '@remix-run/node';
+import { Await, useLoaderData } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+import { Suspense } from 'react';
+
+// Simulate a slow async operation
+async function getSlowData(): Promise<{ message: string; timestamp: number }> {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return {
+ message: 'Deferred data loaded (Single Fetch)!',
+ timestamp: Date.now(),
+ };
+}
+
+// Modern streaming using Single Fetch pattern (v3_singleFetch)
+// Just return promises directly - turbo-stream handles streaming automatically
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ // Modern pattern: return promises directly, turbo-stream handles streaming
+ return {
+ immediate: { greeting: 'Hello from Single Fetch streaming route!' },
+ deferred: getSlowData(), // Promise is streamed automatically
+ };
+};
+
+export default function Streaming() {
+ const { immediate, deferred } = useLoaderData();
+
+ return (
+
+
Single Fetch Streaming Response Test
+
Immediate data: {immediate.greeting}
+
+ Loading deferred data...}>
+
+ {data => (
+
+
Deferred message: {data.message}
+
Timestamp: {data.timestamp}
+
+ )}
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx
new file mode 100644
index 000000000000..2e79a2bacf0a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx
@@ -0,0 +1,37 @@
+import { json, LoaderFunctionArgs } from '@remix-run/node';
+import { Link, useLoaderData } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+import { useEffect } from 'react';
+
+export const loader = async ({ params, request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const tag = url.searchParams.get('tag');
+
+ // Set the tag on the server side so it's included in the server transaction
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+
+ // Simulate some async work
+ await new Promise(resolve => setTimeout(resolve, 10));
+ return json({ userId: params.id, tag });
+};
+
+export default function User() {
+ const { userId, tag } = useLoaderData();
+
+ useEffect(() => {
+ // Also set the tag on the client side for the pageload transaction
+ if (tag) {
+ Sentry.setTag('sentry_test', tag);
+ }
+ }, [tag]);
+
+ return (
+
+
User {userId}
+
This is a parameterized route for user {userId}.
+ Back to Home
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts
new file mode 100644
index 000000000000..78ed2345c6e4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs
new file mode 100644
index 000000000000..6d211cac4592
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs
@@ -0,0 +1,8 @@
+const Sentry = require('@sentry/remix');
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json
new file mode 100644
index 000000000000..d31e86ff0cdc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "build": "remix vite:build && pnpm typecheck",
+ "dev": "remix vite:dev",
+ "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/server/index.js",
+ "typecheck": "tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm playwright test"
+ },
+ "dependencies": {
+ "@sentry/remix": "latest || *",
+ "@remix-run/css-bundle": "2.17.4",
+ "@remix-run/node": "2.17.4",
+ "@remix-run/react": "2.17.4",
+ "@remix-run/serve": "2.17.4",
+ "isbot": "^3.6.8",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@remix-run/dev": "2.17.4",
+ "@remix-run/eslint-config": "2.17.4",
+ "@types/react": "^18.2.64",
+ "@types/react-dom": "^18.2.34",
+ "@types/prop-types": "15.7.7",
+ "eslint": "^8.38.0",
+ "typescript": "^5.1.6",
+ "vite": "^5.4.11",
+ "vite-tsconfig-paths": "^4.2.1"
+ },
+ "resolutions": {
+ "@types/react": "18.2.22"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs
new file mode 100644
index 000000000000..b52ff06a5105
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs
@@ -0,0 +1,8 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: 'pnpm start',
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts
new file mode 100644
index 000000000000..dcf8c45e1d4c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs
new file mode 100644
index 000000000000..2fbbf5087be1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'remix-server-timing',
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-header.test.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-header.test.ts
new file mode 100644
index 000000000000..68d02d53a011
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-header.test.ts
@@ -0,0 +1,72 @@
+import { expect, test } from '@playwright/test';
+
+test.describe.configure({ mode: 'serial' });
+
+test('Server-Timing header contains sentry-trace on page load', async ({ page }) => {
+ // Intercept the document response (not data requests or other resources)
+ const responsePromise = page.waitForResponse(
+ response =>
+ response.url().endsWith('/') && response.status() === 200 && response.request().resourceType() === 'document',
+ );
+
+ await page.goto('/');
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+});
+
+test('Server-Timing header contains valid trace ID format', async ({ page }) => {
+ // Match only the document response for /user/123 (not .data requests)
+ const responsePromise = page.waitForResponse(
+ response =>
+ response.url().endsWith('/user/123') &&
+ response.status() === 200 &&
+ response.request().resourceType() === 'document',
+ );
+
+ await page.goto('/user/123');
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+
+ // Extract sentry-trace value from header
+ // Format: sentry-trace;desc="traceid-spanid" or sentry-trace;desc="traceid-spanid-sampled"
+ const sentryTraceMatch = serverTimingHeader.match(/sentry-trace;desc="([^"]+)"/);
+ expect(sentryTraceMatch).toBeTruthy();
+
+ const sentryTraceValue = sentryTraceMatch![1];
+
+ // Validate sentry-trace format: traceid-spanid or traceid-spanid-sampled (case insensitive)
+ // The format is: 32 hex chars, dash, 16 hex chars, optionally followed by dash and 0 or 1
+ const traceIdMatch = sentryTraceValue.match(/^([a-fA-F0-9]{32})-([a-fA-F0-9]{16})(?:-([01]))?$/);
+ expect(traceIdMatch).toBeTruthy();
+
+ // Verify the trace ID and span ID parts
+ const [, traceId, spanId] = traceIdMatch!;
+ expect(traceId).toHaveLength(32);
+ expect(spanId).toHaveLength(16);
+});
+
+test('Server-Timing header is present on parameterized routes', async ({ page }) => {
+ // Match only the document response for /user/456 (not .data requests)
+ const responsePromise = page.waitForResponse(
+ response =>
+ response.url().endsWith('/user/456') &&
+ response.status() === 200 &&
+ response.request().resourceType() === 'document',
+ );
+
+ await page.goto('/user/456');
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts
new file mode 100644
index 000000000000..b74f36c853a2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts
@@ -0,0 +1,472 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test.describe.configure({ mode: 'serial' });
+
+// =============================================================================
+// BASIC FUNCTIONALITY TESTS
+// =============================================================================
+
+test('Sends pageload transaction to Sentry', async ({ page }) => {
+ const transactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/';
+ });
+
+ await page.goto('/');
+
+ const transactionEvent = await transactionPromise;
+
+ expect(transactionEvent).toBeDefined();
+ expect(transactionEvent.contexts?.trace?.op).toBe('pageload');
+});
+
+test('Sends navigation transaction to Sentry', async ({ page }) => {
+ const transactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id';
+ });
+
+ await page.goto('/');
+
+ const linkElement = page.locator('id=navigation');
+ await linkElement.click();
+
+ const transactionEvent = await transactionPromise;
+
+ expect(transactionEvent).toBeDefined();
+ expect(transactionEvent.contexts?.trace?.op).toBe('navigation');
+});
+
+test('Test app does not render sentry-trace meta tags (precondition)', async ({ page }) => {
+ // This confirms we are testing Server-Timing propagation, not meta tag propagation
+ await page.goto('/');
+
+ const sentryTraceMetaTag = await page.$('meta[name="sentry-trace"]');
+ const baggageMetaTag = await page.$('meta[name="baggage"]');
+
+ expect(sentryTraceMetaTag).toBeNull();
+ expect(baggageMetaTag).toBeNull();
+});
+
+// =============================================================================
+// CORE TRACE PROPAGATION TEST
+// This is the main test that verifies end-to-end trace propagation works
+// =============================================================================
+
+test('Propagates trace context from Server-Timing header to client pageload', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
+ );
+
+ const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag;
+ });
+
+ await page.goto(`/?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ // Verify Server-Timing header format
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+
+ // Extract trace info from header
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ expect(sentryTraceMatch).toBeTruthy();
+ const [headerTraceId, headerSpanId, headerSampled] = sentryTraceMatch?.[1]?.split('-') || [];
+
+ expect(headerTraceId).toHaveLength(32);
+ expect(headerSpanId).toHaveLength(16);
+ expect(headerSampled).toBe('1');
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(pageloadTransaction.transaction).toBe('/');
+
+ // CRITICAL: Verify trace propagation worked
+ expect(pageloadTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId);
+ expect(pageloadTransaction.contexts?.trace?.parent_span_id).toEqual(headerSpanId);
+});
+
+// =============================================================================
+// DSC/BAGGAGE VERIFICATION
+// =============================================================================
+
+test('DSC fields in baggage are complete, valid, and consistent with sentry-trace', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
+ );
+
+ await page.goto(`/?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ // Extract trace_id from sentry-trace header
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ const headerTraceId = sentryTraceMatch?.[1]?.split('-')[0];
+
+ // Extract and decode baggage
+ const baggageMatch = serverTimingHeader?.match(/baggage;desc="([^"]+)"/);
+ expect(baggageMatch).toBeTruthy();
+
+ const decodedBaggage = decodeURIComponent(baggageMatch?.[1] || '');
+ const baggageEntries = decodedBaggage.split(',').reduce(
+ (acc, entry) => {
+ const [key, value] = entry.split('=');
+ if (key && value) {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+
+ // Verify essential DSC fields
+ expect(baggageEntries['sentry-trace_id']).toHaveLength(32);
+ expect(baggageEntries['sentry-environment']).toBe('qa');
+ expect(baggageEntries['sentry-public_key']).toBeDefined();
+
+ // Verify sampling info present
+ const hasSamplingInfo =
+ baggageEntries['sentry-sample_rate'] !== undefined || baggageEntries['sentry-sampled'] !== undefined;
+ expect(hasSamplingInfo).toBe(true);
+
+ // CRITICAL: Baggage trace_id must match sentry-trace header trace_id
+ // This is a regression test for the DSC trace_id mismatch bug
+ expect(baggageEntries['sentry-trace_id']).toEqual(headerTraceId);
+});
+
+// =============================================================================
+// RESPONSE TYPE TESTS - Where Server-Timing IS expected
+// =============================================================================
+
+test('Server-Timing header is present on parameterized routes', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes(`/user/789`) && response.url().includes(`tag=${testTag}`),
+ );
+
+ await page.goto(`/user/789?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ const [traceId, spanId] = sentryTraceMatch?.[1]?.split('-') || [];
+ expect(traceId).toHaveLength(32);
+ expect(spanId).toHaveLength(16);
+});
+
+test('Server-Timing header merges with existing Server-Timing entries', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes('/merge-test') && response.url().includes(`tag=${testTag}`),
+ );
+
+ await page.goto(`/merge-test?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+
+ // Verify original entries are preserved
+ expect(serverTimingHeader).toContain('db;dur=53.2');
+ expect(serverTimingHeader).toContain('cache');
+
+ // Verify Sentry entries are also present
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+});
+
+test('Server-Timing header is present on error responses', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes('/error-test') && response.url().includes(`tag=${testTag}`),
+ );
+
+ await page.goto(`/error-test?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ // Server-Timing header should be present on error responses
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ expect(sentryTraceMatch).toBeTruthy();
+ const [traceId, spanId] = sentryTraceMatch![1].split('-');
+ expect(traceId).toHaveLength(32);
+ expect(spanId).toHaveLength(16);
+
+ await expect(page.locator('h1')).toContainText('Error');
+});
+
+test('Single Fetch streaming (modern pattern) propagates trace context to client pageload', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response =>
+ response.url().includes('/streaming') &&
+ !response.url().includes('/streaming-legacy') &&
+ response.url().includes(`tag=${testTag}`),
+ );
+
+ const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/streaming';
+ });
+
+ await page.goto(`/streaming?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ // Verify header format
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ const [headerTraceId, headerSpanId] = sentryTraceMatch?.[1]?.split('-') || [];
+ expect(headerTraceId).toHaveLength(32);
+ expect(headerSpanId).toHaveLength(16);
+
+ // Verify deferred content renders with Single Fetch
+ await expect(page.locator('h1')).toContainText('Single Fetch Streaming');
+ await expect(page.locator('text=Deferred message:')).toBeVisible({ timeout: 5000 });
+
+ // CRITICAL: Verify trace propagation worked for streaming response
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ expect(pageloadTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId);
+ expect(pageloadTransaction.contexts?.trace?.parent_span_id).toEqual(headerSpanId);
+});
+
+test('Legacy defer() streaming propagates trace context to client pageload', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes('/streaming-legacy') && response.url().includes(`tag=${testTag}`),
+ );
+
+ const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/streaming-legacy';
+ });
+
+ await page.goto(`/streaming-legacy?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ // Verify header format
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ const [headerTraceId, headerSpanId] = sentryTraceMatch?.[1]?.split('-') || [];
+ expect(headerTraceId).toHaveLength(32);
+ expect(headerSpanId).toHaveLength(16);
+
+ // Verify deferred content renders with legacy defer()
+ await expect(page.locator('h1')).toContainText('Legacy Streaming');
+ await expect(page.locator('text=Deferred message:')).toBeVisible({ timeout: 5000 });
+
+ // CRITICAL: Verify trace propagation worked for streaming response
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ expect(pageloadTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId);
+ expect(pageloadTransaction.contexts?.trace?.parent_span_id).toEqual(headerSpanId);
+});
+
+test('Server-Timing header is present on redirect responses', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const redirectResponsePromise = page.waitForResponse(
+ response => response.url().includes('/redirect-test') && response.url().includes(`tag=${testTag}`),
+ );
+
+ page.goto(`/redirect-test?tag=${testTag}`);
+
+ const redirectResponse = await redirectResponsePromise;
+ const serverTimingHeader = redirectResponse.headers()['server-timing'];
+
+ // Server-Timing header should be present on redirect responses
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ expect(sentryTraceMatch).toBeTruthy();
+ const [traceId, spanId] = sentryTraceMatch![1].split('-');
+ expect(traceId).toHaveLength(32);
+ expect(spanId).toHaveLength(16);
+
+ await page.waitForURL(/\/user\/redirected/);
+ await expect(page.locator('h1')).toContainText('User redirected');
+});
+
+test('Server-Timing header is present on nested routes', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes('/parent/child') && response.url().includes(`tag=${testTag}`),
+ );
+
+ await page.goto(`/parent/child?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ const [traceId, spanId] = sentryTraceMatch?.[1]?.split('-') || [];
+ expect(traceId).toHaveLength(32);
+ expect(spanId).toHaveLength(16);
+
+ // Verify both parent and child rendered
+ await expect(page.locator('h1')).toContainText('Parent Route');
+ await expect(page.locator('h2')).toContainText('Child Route');
+});
+
+// =============================================================================
+// DATA FETCH TESTS - Where Server-Timing is NOT expected
+// Server-Timing is only injected for document requests, not data fetches
+// =============================================================================
+
+test('Data fetches do not include Server-Timing headers', async ({ page, context }) => {
+ // Server-Timing headers are only injected for document requests (HTML responses)
+ // because the Performance API can only read them from the initial document load.
+ // Data fetches (JSON responses) don't need Server-Timing since there's no
+ // Performance API context to read them from.
+
+ const testTag = crypto.randomUUID();
+
+ // Test 1: Resource route (JSON API)
+ const apiResponse = await page.request.get(`/api/data?tag=${testTag}`);
+ expect(apiResponse.status()).toBe(200);
+ expect(apiResponse.headers()['server-timing']).toBeUndefined();
+
+ // Test 2: Client-side navigation data fetch
+ // Single Fetch uses .data suffix, old format uses ?_data= query param
+ await page.goto(`/?tag=${testTag}`);
+ await page.locator('#navigation').waitFor({ state: 'visible' });
+
+ const navDataFetchPromise = page.waitForResponse(
+ response =>
+ response.url().includes('/user/123') && (response.url().includes('_data=') || response.url().endsWith('.data')),
+ );
+ await page.click('#navigation');
+ const navDataFetch = await navDataFetchPromise;
+ expect(navDataFetch.headers()['server-timing']).toBeUndefined();
+
+ // Test 3: Action (POST) data fetch
+ const page2 = await context.newPage();
+ await page2.goto(`/action-test?tag=${testTag}`);
+
+ const actionDataFetchPromise = page2.waitForResponse(
+ response => response.url().includes('/action-test') && response.request().method() === 'POST',
+ );
+ await page2.click('button[type="submit"]');
+ const actionDataFetch = await actionDataFetchPromise;
+ expect(actionDataFetch.headers()['server-timing']).toBeUndefined();
+ await page2.close();
+
+ // Test 4: Prefetch data fetch
+ // Single Fetch uses .data suffix, old format uses ?_data= query param
+ const page3 = await context.newPage();
+ const prefetchResponses: (string | undefined)[] = [];
+ page3.on('response', response => {
+ const url = response.url();
+ const isDataFetch = url.includes('_data=') || url.endsWith('.data');
+ if (isDataFetch && url.includes('prefetch-target')) {
+ prefetchResponses.push(response.headers()['server-timing']);
+ }
+ });
+
+ await page3.goto(`/prefetch-test?tag=${testTag}`);
+ await expect(page3.locator('h1')).toContainText('Prefetch Test');
+ await page3.waitForTimeout(500);
+
+ for (const serverTiming of prefetchResponses) {
+ expect(serverTiming).toBeUndefined();
+ }
+ await page3.close();
+});
+
+// =============================================================================
+// TRACE ISOLATION TESTS
+// =============================================================================
+
+test('Concurrent requests have isolated trace contexts', async ({ page, context }) => {
+ const testTag1 = crypto.randomUUID();
+ const testTag2 = crypto.randomUUID();
+
+ const page2 = await context.newPage();
+
+ const response1Promise = page.waitForResponse(
+ response => response.url().includes(`tag=${testTag1}`) && response.status() === 200,
+ );
+ const response2Promise = page2.waitForResponse(
+ response => response.url().includes(`tag=${testTag2}`) && response.status() === 200,
+ );
+
+ await Promise.all([page.goto(`/?tag=${testTag1}`), page2.goto(`/?tag=${testTag2}`)]);
+
+ const [response1, response2] = await Promise.all([response1Promise, response2Promise]);
+
+ const match1 = response1.headers()['server-timing']?.match(/sentry-trace;desc="([^"]+)"/);
+ const match2 = response2.headers()['server-timing']?.match(/sentry-trace;desc="([^"]+)"/);
+
+ const traceId1 = match1?.[1]?.split('-')[0];
+ const traceId2 = match2?.[1]?.split('-')[0];
+
+ expect(traceId1).toHaveLength(32);
+ expect(traceId2).toHaveLength(32);
+ expect(traceId1).not.toEqual(traceId2);
+
+ await page2.close();
+});
+
+test('Sequential pageloads get fresh trace IDs', async ({ page }) => {
+ const testTag1 = crypto.randomUUID();
+ const testTag2 = crypto.randomUUID();
+
+ // First pageload
+ const response1Promise = page.waitForResponse(
+ response => response.url().includes(`tag=${testTag1}`) && response.status() === 200,
+ );
+ await page.goto(`/?tag=${testTag1}`);
+ const response1 = await response1Promise;
+
+ // Second pageload
+ const response2Promise = page.waitForResponse(
+ response => response.url().includes(`tag=${testTag2}`) && response.status() === 200,
+ );
+ await page.goto(`/user/sequential?tag=${testTag2}`);
+ const response2 = await response2Promise;
+
+ const match1 = response1.headers()['server-timing']?.match(/sentry-trace;desc="([^"]+)"/);
+ const match2 = response2.headers()['server-timing']?.match(/sentry-trace;desc="([^"]+)"/);
+
+ const traceId1 = match1?.[1]?.split('-')[0];
+ const traceId2 = match2?.[1]?.split('-')[0];
+
+ expect(traceId1).toHaveLength(32);
+ expect(traceId2).toHaveLength(32);
+ expect(traceId1).not.toEqual(traceId2);
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json
new file mode 100644
index 000000000000..91f6b263f2c7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "globals.d.ts"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["@remix-run/node", "vite/client"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "target": "ES2022",
+ "strict": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts
new file mode 100644
index 000000000000..086b41447284
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts
@@ -0,0 +1,25 @@
+import { vitePlugin as remix } from '@remix-run/dev';
+import { sentryRemixVitePlugin } from '@sentry/remix';
+import { defineConfig } from 'vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+// Enable Single Fetch types
+declare module '@remix-run/node' {
+ interface Future {
+ v3_singleFetch: true;
+ }
+}
+
+export default defineConfig({
+ plugins: [
+ remix({
+ ignoredRouteFiles: ['**/.*'],
+ serverModuleFormat: 'cjs',
+ future: {
+ v3_singleFetch: true,
+ },
+ }),
+ sentryRemixVitePlugin(),
+ tsconfigPaths(),
+ ],
+});
diff --git a/packages/remix/src/client/performance.tsx b/packages/remix/src/client/performance.tsx
index 213f4eb43176..d713cffcdc2b 100644
--- a/packages/remix/src/client/performance.tsx
+++ b/packages/remix/src/client/performance.tsx
@@ -92,21 +92,18 @@ export function startPageloadSpan(client: Client): void {
return;
}
- // Try to parameterize the route using the route manifest
const parameterizedRoute = maybeParameterizeRemixRoute(initPathName);
const spanName = parameterizedRoute || initPathName;
const source = parameterizedRoute ? 'route' : 'url';
- const spanContext: StartSpanOptions = {
+ startBrowserTracingPageLoadSpan(client, {
name: spanName,
op: 'pageload',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.remix',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
},
- };
-
- startBrowserTracingPageLoadSpan(client, spanContext);
+ });
}
function startNavigationSpan(matches: RouteMatch[], location: ReturnType): void {
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 1533a1ca7221..ce6d3278299d 100644
--- a/packages/remix/src/server/index.ts
+++ b/packages/remix/src/server/index.ts
@@ -139,3 +139,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..a59c8a986ec0 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;
};
};
}
@@ -186,13 +227,15 @@ function makeWrappedDataFunction(
build?: ServerBuild,
): DataFunction {
return async function (this: unknown, args: DataFunctionArgs): Promise {
+ let res: Response | AppData;
+
if (instrumentTracing) {
// Update span name for Cloudflare Workers/Hydrogen environments
if (build) {
updateSpanWithRoute(args, build);
}
- return startSpan(
+ res = await startSpan(
{
op: `function.remix.${name}`,
name: id,
@@ -207,8 +250,24 @@ function makeWrappedDataFunction(
},
);
} else {
- return errorHandleDataFunction.call(this, origFn, name, args);
+ res = await errorHandleDataFunction.call(this, origFn, name, args);
+ }
+
+ // 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')) {
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan && getRootSpan(activeSpan);
+ if (rootSpan) {
+ const serverTimingHeader = generateSentryServerTimingHeader({ span: rootSpan });
+ if (serverTimingHeader) {
+ DEBUG_BUILD && debug.log('Injecting Server-Timing header for redirect response');
+ return injectServerTimingHeaderValue(res, serverTimingHeader);
+ }
+ }
}
+
+ return res;
};
}
@@ -254,14 +313,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 +338,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 +345,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 +394,45 @@ 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;
+ };
+
+ return continueTrace({ sentryTrace: sentryTrace || '', baggage: baggage || '' }, handleRequest);
});
};
}
diff --git a/packages/remix/src/server/serverTimingTracePropagation.ts b/packages/remix/src/server/serverTimingTracePropagation.ts
new file mode 100644
index 000000000000..8aebf582fcd5
--- /dev/null
+++ b/packages/remix/src/server/serverTimingTracePropagation.ts
@@ -0,0 +1,148 @@
+import type { Span } from '@sentry/core';
+import {
+ debug,
+ getActiveSpan,
+ getRootSpan,
+ getTraceData,
+ isNodeEnv,
+ spanToBaggageHeader,
+ spanToTraceHeader,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../utils/debug-build';
+
+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);
+ baggage = spanToBaggageHeader(span);
+ } 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');
+
+ // Skip injection if Sentry trace data already exists to prevent duplicates
+ if (existing?.includes('sentry-trace')) {
+ DEBUG_BUILD && debug.log('Server-Timing header already contains sentry-trace, skipping injection');
+ return response;
+ }
+
+ 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/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..feac8a82b701
--- /dev/null
+++ b/packages/remix/test/server/serverTimingTracePropagation.test.ts
@@ -0,0 +1,330 @@
+import {
+ getActiveSpan,
+ getRootSpan,
+ getTraceData,
+ isNodeEnv,
+ spanToBaggageHeader,
+ 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',
+ })),
+ spanToTraceHeader: vi.fn(() => '12345678901234567890123456789012-1234567890123456-1'),
+ spanToBaggageHeader: vi.fn(() => 'sentry-environment=production,sentry-release=1.0.0'),
+ 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(spanToBaggageHeader).mockReturnValue('sentry-environment=production,sentry-release=1.0.0');
+ 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(spanToBaggageHeader).mockReturnValue('sentry-custom=value');
+
+ const result = generateSentryServerTimingHeader({ span: customSpan });
+
+ expect(spanToTraceHeader).toHaveBeenCalledWith(customSpan);
+ expect(spanToBaggageHeader).toHaveBeenCalledWith(customSpan);
+ expect(result).toContain('sentry-trace;desc="custom-trace-id-custom-span-id-1"');
+ expect(result).toContain('baggage;desc="sentry-custom=value"');
+ });
+
+ 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(spanToBaggageHeader).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(spanToBaggageHeader).mockReturnValue(undefined);
+
+ 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('skips injection when sentry-trace already exists in header', () => {
+ const existingHeader = 'sentry-trace;desc="existing-trace-id-1234567890123456-1", baggage;desc="existing"';
+ const mockResponse = new Response('test body', {
+ status: 200,
+ headers: new Headers({ 'Server-Timing': existingHeader }),
+ });
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="new-trace"');
+
+ // Should return original response unchanged
+ expect(result).toBe(mockResponse);
+ expect(result.headers.get('Server-Timing')).toBe(existingHeader);
+ });
+
+ 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');
+ });
+ });
+});