diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts index d85d9d82747d..3d4f0466917b 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts @@ -44,30 +44,30 @@ test('Sends a navigation transaction with parameterized route to Sentry', async expect(transactionEvent.transaction).toBeTruthy(); }); -test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => { + const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200); + await page.goto('/'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); -test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => { + const responsePromise = page.waitForResponse( + response => response.url().includes('/user/123') && response.status() === 200, + ); + await page.goto('/user/123'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts index 4213aae3e3de..63e80a72d082 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts @@ -54,6 +54,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page expect(httpServerSpanId).toBeDefined(); expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); + expect(pageLoadParentSpanId).toEqual(httpServerSpanId); expect(pageLoadSpanId).not.toEqual(httpServerSpanId); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts index 68237301b635..9809df29e480 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts @@ -28,30 +28,224 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { expect(transactionEvent).toBeDefined(); }); -test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => { + const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200); + await page.goto('/'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); -test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => { + const responsePromise = page.waitForResponse( + response => response.url().includes('/user/123') && response.status() === 200, + ); + await page.goto('/user/123'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; + + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); +}); + +// ============================================================================= +// META TAG FALLBACK TESTS +// Testing fallback for browsers without Server-Timing support (e.g., Safari < 16.4) +// +// These tests simulate a scenario where: +// 1. The server injects trace context via meta tags (like older Remix setups or non-Node environments) +// 2. The browser doesn't support the Server-Timing API (Safari < 16.4) +// +// We achieve this by: +// 1. Intercepting responses and injecting meta tags with trace data from Server-Timing header +// 2. Disabling the Server-Timing API via page.addInitScript() +// ============================================================================= + +test.describe('Meta tag fallback for browsers without Server-Timing support', () => { + test.use({ + // Emulate Safari 15.6.1 which doesn't support Server-Timing on PerformanceNavigationTiming + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15', }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', + + test('Server-Timing and meta tag fallback provide consistent trace context', async ({ page }) => { + // This test verifies that when we inject meta tags with trace context from Server-Timing, + // both sources contain consistent trace context that can be used for trace propagation. + // + // The test simulates a scenario where: + // 1. Server sends trace context via Server-Timing header + // 2. We also inject meta tags with the same trace context (as a fallback would) + // 3. Both should contain the same trace ID and span ID + + let capturedSentryTrace: string | null = null; + + // Intercept responses to inject meta tags (simulating a server that uses meta tags as fallback) + await page.route('**/*', async route => { + const response = await route.fetch(); + const contentType = response.headers()['content-type'] || ''; + + // Only modify HTML responses + if (contentType.includes('text/html')) { + const serverTimingHeader = response.headers()['server-timing']; + let body = await response.text(); + + if (serverTimingHeader) { + // Parse sentry-trace from Server-Timing header + const sentryTraceMatch = serverTimingHeader.match(/sentry-trace;desc="([^"]+)"/); + const baggageMatch = serverTimingHeader.match(/baggage;desc="([^"]+)"/); + + if (sentryTraceMatch?.[1]) { + const sentryTrace = sentryTraceMatch[1]; + capturedSentryTrace = sentryTrace; + // Unescape baggage (it's escaped for quoted-string context) + const baggage = baggageMatch?.[1] ? baggageMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : ''; + + // Inject meta tags right after
+ const metaTags = ``; + body = body.replace(/]*>/, match => match + metaTags); + } + } + + await route.fulfill({ + response, + body, + headers: { + ...response.headers(), + 'content-length': String(Buffer.byteLength(body)), + }, + }); + } else { + await route.continue(); + } + }); + + 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; + + // Verify Server-Timing header contains trace data + const serverTimingHeader = response.headers()['server-timing']; + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + + // Verify we captured the trace from the header + expect(capturedSentryTrace).toBeTruthy(); + + // Verify the sentry-trace format: traceId-spanId-sampled + // Using non-null assertion since we just verified it's truthy above + const parts = capturedSentryTrace!.split('-'); + expect(parts).toHaveLength(3); + expect(parts[0]).toHaveLength(32); // traceId + expect(parts[1]).toHaveLength(16); // spanId + expect(['0', '1']).toContain(parts[2]); // sampled flag }); - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + test('Meta tag trace data matches server trace context', async ({ page }) => { + // Same setup as above - disable Server-Timing API + await page.addInitScript(() => { + const originalGetEntriesByType = Performance.prototype.getEntriesByType; + Performance.prototype.getEntriesByType = function (type: string) { + const entries = originalGetEntriesByType.call(this, type); + if (type === 'navigation') { + return entries.map((entry: PerformanceEntry) => { + return new Proxy(entry, { + has(target, prop) { + if (prop === 'serverTiming') return false; + return prop in target; + }, + get(target, prop, receiver) { + if (prop === 'serverTiming') return undefined; + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + } + return entries; + }; + }); + + // Intercept responses to inject meta tags (simulating a server that uses meta tags) + await page.route('**/*', async route => { + const response = await route.fetch(); + const contentType = response.headers()['content-type'] || ''; + + // Only modify HTML responses + if (contentType.includes('text/html')) { + const serverTimingHeader = response.headers()['server-timing']; + let body = await response.text(); + + if (serverTimingHeader) { + // Parse sentry-trace from Server-Timing header + const sentryTraceMatch = serverTimingHeader.match(/sentry-trace;desc="([^"]+)"/); + const baggageMatch = serverTimingHeader.match(/baggage;desc="([^"]+)"/); + + if (sentryTraceMatch?.[1]) { + const sentryTrace = sentryTraceMatch[1]; + // Unescape baggage (it's escaped for quoted-string context) + const baggage = baggageMatch?.[1] ? baggageMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : ''; + + // Inject meta tags right after + const metaTags = ``; + body = body.replace(/]*>/, match => match + metaTags); + } + } + + await route.fulfill({ + response, + body, + headers: { + ...response.headers(), + 'content-length': String(Buffer.byteLength(body)), + }, + }); + } else { + await route.continue(); + } + }); + + 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; + + // Server-Timing header should still be present (server doesn't know client capability) + const serverTimingHeader = response.headers()['server-timing']; + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + + // Extract trace ID from Server-Timing header + const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/); + const [headerTraceId, headerSpanId] = sentryTraceMatch?.[1]?.split('-') || []; + + // Extract trace ID from meta tag (which we injected) + // We use [content] selector to get the meta tag with content (the one we injected) + const metaTraceContent = await page.locator('meta[name="sentry-trace"][content]').getAttribute('content'); + const [metaTraceId, metaSpanId] = metaTraceContent?.split('-') || []; + + // Both should have the same trace context (from the same server request) + expect(headerTraceId).toHaveLength(32); + expect(metaTraceId).toHaveLength(32); + expect(headerTraceId).toEqual(metaTraceId); + expect(headerSpanId).toEqual(metaSpanId); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index ddb866e5dbaa..ea728b31ab50 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -96,7 +96,7 @@ test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => { expect(httpServerSpanId).toBeDefined(); expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); + expect(pageLoadParentSpanId).toEqual(httpServerSpanId); expect(pageLoadSpanId).not.toEqual(httpServerSpanId); }); @@ -139,6 +139,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page expect(loaderParentSpanId).toEqual(httpServerSpanId); expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); + expect(pageLoadParentSpanId).toEqual(httpServerSpanId); expect(pageLoadSpanId).not.toEqual(httpServerSpanId); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts index cf5098686759..2ae118c25523 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/client-transactions.test.ts @@ -28,30 +28,30 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { expect(transactionEvent).toBeDefined(); }); -test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => { + const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200); + await page.goto('/'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); -test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => { + const responsePromise = page.waitForResponse( + response => response.url().includes('/user/123') && response.status() === 200, + ); + await page.goto('/user/123'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts index e624c578ce19..3bc716a4517e 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/tests/server-transactions.test.ts @@ -53,6 +53,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page expect(httpServerSpanId).toBeDefined(); expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); + expect(pageLoadParentSpanId).toEqual(httpServerSpanId); expect(pageLoadSpanId).not.toEqual(httpServerSpanId); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts index 3619368f81bb..f9f8b3998e6c 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/client-transactions.test.ts @@ -28,30 +28,30 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { expect(transactionEvent).toBeDefined(); }); -test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => { + const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200); + await page.goto('/'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); -test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { +test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => { + const responsePromise = page.waitForResponse( + response => response.url().includes('/user/123') && response.status() === 200, + ); + await page.goto('/user/123'); - const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { - state: 'attached', - }); - const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { - state: 'attached', - }); + const response = await responsePromise; + const serverTimingHeader = response.headers()['server-timing']; - expect(sentryTraceMetaTag).toBeTruthy(); - expect(baggageMetaTag).toBeTruthy(); + expect(serverTimingHeader).toBeDefined(); + expect(serverTimingHeader).toContain('sentry-trace'); + expect(serverTimingHeader).toContain('baggage'); }); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts index 75d8fa0d2b9e..479ea3899424 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts @@ -53,6 +53,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page expect(httpServerSpanId).toBeDefined(); expect(pageLoadTraceId).toEqual(httpServerTraceId); - expect(pageLoadParentSpanId).toEqual(loaderSpanId); + expect(pageLoadParentSpanId).toEqual(httpServerSpanId); expect(pageLoadSpanId).not.toEqual(httpServerSpanId); }); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx index afae990db239..be8c6b8702c7 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx @@ -1,4 +1,5 @@ import { RemixServer } from '@remix-run/react'; +import { addSentryServerTimingHeader } from '@sentry/remix/cloudflare'; import { createContentSecurityPolicy } from '@shopify/hydrogen'; import type { EntryContext } from '@shopify/remix-oxygen'; import isbot from 'isbot'; @@ -43,8 +44,11 @@ export default async function handleRequest( // This is required for Sentry's profiling integration responseHeaders.set('Document-Policy', 'js-profiling'); - return new Response(body, { + const response = new Response(body, { headers: responseHeaders, status: responseStatusCode, }); + + // Add Server-Timing header with Sentry trace context for client-side trace propagation + return addSentryServerTimingHeader(response); } diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts new file mode 100644 index 000000000000..dd5239a697f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/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 (Cloudflare)', 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 (Cloudflare)', 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 (Cloudflare)', 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/.eslintrc.js b/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js new file mode 100644 index 000000000000..126b07218de8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js @@ -0,0 +1,7 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], + rules: { + 'import/no-unresolved': 'off', + }, +}; diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore b/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore new file mode 100644 index 000000000000..a735ebed5b56 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore @@ -0,0 +1,3 @@ +node_modules +build +.env diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc b/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx new file mode 100644 index 000000000000..85c29d310c1a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx @@ -0,0 +1,44 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` + * For more information, see https://remix.run/file-conventions/entry.client + */ + +// Extend the Window interface to include ENV +declare global { + interface Window { + ENV: { + SENTRY_DSN: string; + [key: string]: unknown; + }; + } +} + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { StrictMode, startTransition, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: window.ENV.SENTRY_DSN, + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); + +startTransition(() => { + hydrateRoot( + document, +This test app validates that trace context is propagated via Server-Timing header.
+This route tests Server-Timing headers on POST (action) requests.
+ + + + {actionData && ( +Response: {actionData.message}
+Success: {actionData.success ? 'Yes' : 'No'}
+This should not render - the loader throws an error.
+An error was thrown in the loader.
+This route sets an existing Server-Timing header to test merging.
++ Expected: db;dur=53.2, cache;desc="Cache Read";dur=1.5, sentry-trace;desc="...", + baggage;desc="..." +
+Child data: {childData}
+
+ Child trace ID: {childTraceId}
+
Parent data: {parentData}
+
+ Parent trace ID: {parentTraceId}
+
This page tests Server-Timing with prefetch behavior.
+ +Immediate data: {immediate.greeting}
+ +Deferred message: {data.message}
+Timestamp: {data.timestamp}
+Immediate data: {immediate.greeting}
+ +Deferred message: {data.message}
+Timestamp: {data.timestamp}
+This is a parameterized route for user {userId}.
+ Back to Home +, 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