From de96822d881f4651a8b99316d1520a1b2ff50d52 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 7 Oct 2026 17:01:28 +0200 Subject: [PATCH 1/5] feat(remix): Propagate the server trace to Remix 3 page loads Page loads started a new trace. The middleware now adds `sentry-trace` and `baggage` entries to the `Server-Timing` header of HTML responses, ahead of the route entry, the way the Remix 2 SDK and Nitro do. The browser SDK already reads them off the navigation timing entry for page loads, so no client change. Co-Authored-By: Claude Fable 5.1 --- .../remix-v3/tests/client.test.ts | 19 ++++++++ packages/remix/src/v3/server/middleware.ts | 38 +++++++++++----- packages/remix/test/v3/middleware.test.ts | 45 +++++++++++++++++-- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts index 70f77fb7627f..393a3e8e3195 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -15,6 +15,25 @@ test('sends a pageload span', async ({ page }) => { expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route'); }); +test('continues the server trace on a page load', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && span.is_segment === true && span.attributes?.['http.route']?.value === '/', + ); + const pageloadPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'pageload' && span.is_segment === true, + ); + + await page.goto('/'); + + const [serverSpan, pageload] = await Promise.all([serverSpanPromise, pageloadPromise]); + // The document's response carried the trace in `Server-Timing`, which the browser SDK picks up. + expect(pageload.trace_id).toBe(serverSpan.trace_id); + expect(pageload.parent_span_id).toBe(serverSpan.span_id); +}); + test('parameterizes a page load on a route with params', async ({ page }) => { const spanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment === true); diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 7ae9dc989362..72003a65525f 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -4,6 +4,7 @@ import { getIsolationScope, getRootSpan, getSpanStatusFromHttpCode, + getTraceData, INTERNAL_setSegmentNameSourceIfSegment, type Scope, updateSpanName, @@ -44,9 +45,7 @@ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { } setResponseStatus(response); - if (route) { - addRouteHeader(response, context.request, route); - } + addServerTimingHeaders(response, context.request, route); return response; }; @@ -70,18 +69,35 @@ function applyRoute(isolationScope: Scope, route: string, method: string): void } /** - * Tells the browser SDK which route served a document, so it can name its page load and navigation - * spans after the pattern. The browser has no route table of its own. Only HTML responses carry it, - * which is what document loads and the runtime's frame fetches ask for. + * What the browser SDK needs from the server on a document: the trace to continue, so a page load + * joins the request's trace, and the route that served it, so spans are named after the pattern. + * The browser reads both off the navigation timing entry. Only HTML responses carry them, which is + * what document loads and the runtime's frame fetches ask for. */ -function addRouteHeader(response: Response, request: Request, route: string): void { +function addServerTimingHeaders(response: Response, request: Request, route: string | undefined): void { if (!request.headers.get('accept')?.includes('text/html')) { return; } - try { - response.headers.append('Server-Timing', formatRouteTiming(route)); - } catch { - // Immutable headers, e.g. a response passed through from `fetch()`. + + const entries: string[] = []; + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + entries.push(`sentry-trace;desc="${traceData['sentry-trace']}"`); + } + if (traceData.baggage) { + entries.push(`baggage;desc="${traceData.baggage}"`); + } + if (route) { + entries.push(formatRouteTiming(route)); + } + + for (const entry of entries) { + try { + response.headers.append('Server-Timing', entry); + } catch { + // Immutable headers, e.g. a response passed through from `fetch()`. + return; + } } } diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 88c8f1fc42db..c57f94d1c524 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import type * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; +// Trace data as the SDK would produce it for the active request; empty when nothing is active. +let traceData: { 'sentry-trace'?: string; baggage?: string } = {}; + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getTraceData: () => traceData, +})); + +const { sentryRemixMiddleware } = await import('../../src/v3/server/middleware'); import type { MatcherLike, RequestContextLike } from '../../src/v3/types'; /** A matcher that resolves every URL to the one pattern, or to nothing. */ @@ -16,6 +25,36 @@ function contextFor(url: string, headers: Record = {}): RequestC } describe('sentryRemixMiddleware', () => { + beforeEach(() => { + traceData = {}; + }); + + it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { + traceData = { 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }; + const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); + + const response = await middleware( + contextFor('http://x/users/1', { accept: 'text/html' }), + async () => new Response(''), + ); + + expect(response.headers.get('server-timing')).toBe( + 'sentry-trace;desc="abc-def-1", baggage;desc="sentry-trace_id=abc", sentry-route;desc="/users/:id"', + ); + }); + + it('propagates the trace even when no route matched', async () => { + traceData = { 'sentry-trace': 'abc-def-1' }; + const middleware = sentryRemixMiddleware(matcherFor(undefined)); + + const response = await middleware( + contextFor('http://x/nope', { accept: 'text/html' }), + async () => new Response(''), + ); + + expect(response.headers.get('server-timing')).toBe('sentry-trace;desc="abc-def-1"'); + }); + it('reports the route on HTML responses through Server-Timing', async () => { const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); @@ -60,7 +99,7 @@ describe('sentryRemixMiddleware', () => { expect(response.headers.has('server-timing')).toBe(false); }); - it('adds nothing when no route matched', async () => { + it('adds nothing when no route matched and no trace is active', async () => { const middleware = sentryRemixMiddleware(matcherFor(undefined)); const response = await middleware( From 9145130639e899903d94513e7526dbf56de68a36 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 10:02:39 +0200 Subject: [PATCH 2/5] feat(remix): Leave the trace off cacheable Remix 3 responses A shared cache would hand one request's trace to every later page load of the cached document. A response with `public`, `s-maxage` or a positive `max-age` and no `private`/`no-store` carries the route entry only, which is the same for every request to it. Co-Authored-By: Claude Fable 5.1 --- packages/remix/src/v3/server/middleware.ts | 31 ++++++++++++++++---- packages/remix/test/v3/middleware.test.ts | 34 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 72003a65525f..2bcec61a44c3 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -80,12 +80,16 @@ function addServerTimingHeaders(response: Response, request: Request, route: str } const entries: string[] = []; - const traceData = getTraceData(); - if (traceData['sentry-trace']) { - entries.push(`sentry-trace;desc="${traceData['sentry-trace']}"`); - } - if (traceData.baggage) { - entries.push(`baggage;desc="${traceData.baggage}"`); + // A shared cache would hand this request's trace to every later page load, so a cacheable response + // carries the route only. The route is the same for every request to it. + if (!isSharedCacheable(response)) { + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + entries.push(`sentry-trace;desc="${traceData['sentry-trace']}"`); + } + if (traceData.baggage) { + entries.push(`baggage;desc="${traceData.baggage}"`); + } } if (route) { entries.push(formatRouteTiming(route)); @@ -101,6 +105,21 @@ function addServerTimingHeaders(response: Response, request: Request, route: str } } +/** Whether a cache in front of the app may store this response and serve it to other users. */ +function isSharedCacheable(response: Response): boolean { + const cacheControl = response.headers.get('cache-control')?.toLowerCase() ?? ''; + if (/\b(?:no-store|private)\b/.test(cacheControl)) { + return false; + } + // `s-maxage` overrides `max-age` for shared caches. A zero lifetime means the cache revalidates + // every time, so it never serves this response to anyone else. + const sharedMaxAge = cacheControl.match(/\bs-maxage\s*=\s*(\d+)/)?.[1]; + if (sharedMaxAge !== undefined) { + return Number(sharedMaxAge) > 0; + } + return /\b(?:public|max-age\s*=\s*[1-9])/.test(cacheControl); +} + function setResponseStatus(response: Response): void { const activeSpan = getActiveSpan(); if (!activeSpan || typeof response?.status !== 'number') { diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index c57f94d1c524..6b78a737f203 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -55,6 +55,40 @@ describe('sentryRemixMiddleware', () => { expect(response.headers.get('server-timing')).toBe('sentry-trace;desc="abc-def-1"'); }); + it.each([ + ['public', 'public, max-age=60'], + ['s-maxage', 's-maxage=300'], + ['max-age', 'max-age=60'], + ])('leaves the trace off a response a shared cache may store (%s), keeping the route', async (_why, cacheControl) => { + traceData = { 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }; + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => new Response('', { headers: { 'cache-control': cacheControl } }), + ); + + expect(response.headers.get('server-timing')).toBe('sentry-route;desc="/"'); + }); + + it.each([ + ['private', 'private, max-age=60'], + ['no-store', 'no-store'], + ['max-age=0', 'max-age=0, must-revalidate'], + ['s-maxage=0', 'max-age=3600, s-maxage=0'], + ['no cache header', undefined], + ])('propagates the trace on a response only this user can get back (%s)', async (_why, cacheControl) => { + traceData = { 'sentry-trace': 'abc-def-1' }; + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => new Response('', { headers: cacheControl ? { 'cache-control': cacheControl } : {} }), + ); + + expect(response.headers.get('server-timing')).toContain('sentry-trace;desc="abc-def-1"'); + }); + it('reports the route on HTML responses through Server-Timing', async () => { const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); From 948ac4d12c2507094f90b101efe5f3909365798e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 11:34:07 +0200 Subject: [PATCH 3/5] ref(remix): Mock trace data with a hoisted mock fn in the middleware test Co-Authored-By: Claude Fable 5.1 --- packages/remix/test/v3/middleware.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 6b78a737f203..766a41708cf7 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -2,14 +2,14 @@ import type * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. -let traceData: { 'sentry-trace'?: string; baggage?: string } = {}; +const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({}))); vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), - getTraceData: () => traceData, + getTraceData, })); -const { sentryRemixMiddleware } = await import('../../src/v3/server/middleware'); +import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; import type { MatcherLike, RequestContextLike } from '../../src/v3/types'; /** A matcher that resolves every URL to the one pattern, or to nothing. */ @@ -26,11 +26,11 @@ function contextFor(url: string, headers: Record = {}): RequestC describe('sentryRemixMiddleware', () => { beforeEach(() => { - traceData = {}; + getTraceData.mockReturnValue({}); }); it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { - traceData = { 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }; + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }); const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); const response = await middleware( @@ -44,7 +44,7 @@ describe('sentryRemixMiddleware', () => { }); it('propagates the trace even when no route matched', async () => { - traceData = { 'sentry-trace': 'abc-def-1' }; + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); const middleware = sentryRemixMiddleware(matcherFor(undefined)); const response = await middleware( @@ -60,7 +60,7 @@ describe('sentryRemixMiddleware', () => { ['s-maxage', 's-maxage=300'], ['max-age', 'max-age=60'], ])('leaves the trace off a response a shared cache may store (%s), keeping the route', async (_why, cacheControl) => { - traceData = { 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }; + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }); const middleware = sentryRemixMiddleware(matcherFor('/')); const response = await middleware( @@ -78,7 +78,7 @@ describe('sentryRemixMiddleware', () => { ['s-maxage=0', 'max-age=3600, s-maxage=0'], ['no cache header', undefined], ])('propagates the trace on a response only this user can get back (%s)', async (_why, cacheControl) => { - traceData = { 'sentry-trace': 'abc-def-1' }; + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); const middleware = sentryRemixMiddleware(matcherFor('/')); const response = await middleware( From 3639a3f0a76feee8cc13b40b5060c9a58c20beb8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 13:00:58 +0200 Subject: [PATCH 4/5] fix(remix): Treat a future Expires as shared-cacheable Without a lifetime in `Cache-Control`, a cache falls back to `Expires`, so a response with only a future `Expires` could still hand its trace to later page loads. Co-Authored-By: Claude Fable 5.1 --- packages/remix/src/v3/server/middleware.ts | 12 ++++++- packages/remix/test/v3/middleware.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 2bcec61a44c3..35f8cb0fbad9 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -117,7 +117,17 @@ function isSharedCacheable(response: Response): boolean { if (sharedMaxAge !== undefined) { return Number(sharedMaxAge) > 0; } - return /\b(?:public|max-age\s*=\s*[1-9])/.test(cacheControl); + if (/\bpublic\b/.test(cacheControl)) { + return true; + } + // `max-age` takes precedence over `Expires`, a zero one included. + const maxAge = cacheControl.match(/\bmax-age\s*=\s*(\d+)/)?.[1]; + if (maxAge !== undefined) { + return Number(maxAge) > 0; + } + // Without a lifetime in `Cache-Control`, a cache falls back to `Expires`. + const expires = response.headers.get('expires'); + return expires !== null && Date.parse(expires) > Date.now(); } function setResponseStatus(response: Response): void { diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 766a41708cf7..8621cd8697cd 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -71,6 +71,45 @@ describe('sentryRemixMiddleware', () => { expect(response.headers.get('server-timing')).toBe('sentry-route;desc="/"'); }); + it('leaves the trace off a response with a future Expires and no Cache-Control', async () => { + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => new Response('', { headers: { expires: new Date(Date.now() + 60_000).toUTCString() } }), + ); + + expect(response.headers.get('server-timing')).toBe('sentry-route;desc="/"'); + }); + + it('propagates the trace when max-age=0 overrides a future Expires', async () => { + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => + new Response('', { + headers: { 'cache-control': 'max-age=0', expires: new Date(Date.now() + 60_000).toUTCString() }, + }), + ); + + expect(response.headers.get('server-timing')).toContain('sentry-trace;desc="abc-def-1"'); + }); + + it('propagates the trace when Expires is in the past', async () => { + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => new Response('', { headers: { expires: 'Thu, 01 Jan 1970 00:00:00 GMT' } }), + ); + + expect(response.headers.get('server-timing')).toContain('sentry-trace;desc="abc-def-1"'); + }); + it.each([ ['private', 'private, max-age=60'], ['no-store', 'no-store'], From 645c9dd838c11c9a811938865488944fbf015195 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 13:17:56 +0200 Subject: [PATCH 5/5] fix(remix): Honor stale directives and CDN cache fields in the cache guard A zero lifetime with `stale-while-revalidate` or `stale-if-error` is still served stale by a shared cache. A CDN reads `CDN-Cache-Control`, its vendor variants or `Surrogate-Control` instead of `Cache-Control`, so a document can be cacheable at the CDN while private to the browser. Any source that lets a shared cache reuse the response keeps the trace off it. Co-Authored-By: Claude Fable 5.1 --- packages/remix/src/v3/server/middleware.ts | 51 +++++++++++++++++----- packages/remix/test/v3/middleware.test.ts | 37 ++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 35f8cb0fbad9..f2ce59c48f01 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -105,29 +105,60 @@ function addServerTimingHeaders(response: Response, request: Request, route: str } } -/** Whether a cache in front of the app may store this response and serve it to other users. */ +// Cache-control fields a CDN reads instead of `Cache-Control`. +const CDN_CACHE_CONTROL_HEADERS = [ + 'cdn-cache-control', + 'cloudflare-cdn-cache-control', + 'vercel-cdn-cache-control', + 'surrogate-control', +]; + +/** + * Whether a cache in front of the app may store this response and serve it to other users. + * + * A CDN that reads a targeted field ignores `Cache-Control`; a cache without targeted support reads + * `Cache-Control`. Any of them storing the response is enough. + */ function isSharedCacheable(response: Response): boolean { - const cacheControl = response.headers.get('cache-control')?.toLowerCase() ?? ''; - if (/\b(?:no-store|private)\b/.test(cacheControl)) { + for (const name of CDN_CACHE_CONTROL_HEADERS) { + const value = response.headers.get(name); + if (value !== null && sharedCachingVerdict(value.toLowerCase()) === true) { + return true; + } + } + + const verdict = sharedCachingVerdict(response.headers.get('cache-control')?.toLowerCase() ?? ''); + if (verdict !== undefined) { + return verdict; + } + // Without a lifetime in `Cache-Control`, a cache falls back to `Expires`. + const expires = response.headers.get('expires'); + return expires !== null && Date.parse(expires) > Date.now(); +} + +/** Whether these directives let a shared cache reuse the response, or `undefined` when they say nothing. */ +function sharedCachingVerdict(directives: string): boolean | undefined { + if (/\b(?:no-store|private)\b/.test(directives)) { return false; } + // A stale copy is served even after a zero lifetime. + if (/\b(?:stale-while-revalidate|stale-if-error)\s*=\s*[1-9]/.test(directives)) { + return true; + } // `s-maxage` overrides `max-age` for shared caches. A zero lifetime means the cache revalidates // every time, so it never serves this response to anyone else. - const sharedMaxAge = cacheControl.match(/\bs-maxage\s*=\s*(\d+)/)?.[1]; + const sharedMaxAge = directives.match(/\bs-maxage\s*=\s*(\d+)/)?.[1]; if (sharedMaxAge !== undefined) { return Number(sharedMaxAge) > 0; } - if (/\bpublic\b/.test(cacheControl)) { + if (/\bpublic\b/.test(directives)) { return true; } - // `max-age` takes precedence over `Expires`, a zero one included. - const maxAge = cacheControl.match(/\bmax-age\s*=\s*(\d+)/)?.[1]; + const maxAge = directives.match(/\bmax-age\s*=\s*(\d+)/)?.[1]; if (maxAge !== undefined) { return Number(maxAge) > 0; } - // Without a lifetime in `Cache-Control`, a cache falls back to `Expires`. - const expires = response.headers.get('expires'); - return expires !== null && Date.parse(expires) > Date.now(); + return undefined; } function setResponseStatus(response: Response): void { diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 8621cd8697cd..3bc7031300bf 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -71,6 +71,43 @@ describe('sentryRemixMiddleware', () => { expect(response.headers.get('server-timing')).toBe('sentry-route;desc="/"'); }); + it.each([ + ['stale-while-revalidate after a zero lifetime', { 'cache-control': 'max-age=0, stale-while-revalidate=60' }], + ['stale-if-error after a zero shared lifetime', { 'cache-control': 's-maxage=0, stale-if-error=600' }], + [ + 'CDN-Cache-Control over a private Cache-Control', + { 'cache-control': 'private', 'cdn-cache-control': 'max-age=3600' }, + ], + [ + 'Vercel-CDN-Cache-Control over no-store', + { 'cache-control': 'no-store', 'vercel-cdn-cache-control': 's-maxage=3600' }, + ], + ['Cloudflare-CDN-Cache-Control', { 'cloudflare-cdn-cache-control': 'public, max-age=60' }], + ['Surrogate-Control', { 'surrogate-control': 'max-age=3600' }], + ])('leaves the trace off a response a CDN or stale-serving cache may reuse (%s)', async (_why, headers) => { + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => new Response('', { headers }), + ); + + expect(response.headers.get('server-timing')).toBe('sentry-route;desc="/"'); + }); + + it('propagates the trace when the CDN field forbids caching and nothing else allows it', async () => { + getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); + const middleware = sentryRemixMiddleware(matcherFor('/')); + + const response = await middleware( + contextFor('http://x/', { accept: 'text/html' }), + async () => new Response('', { headers: { 'cdn-cache-control': 'max-age=0' } }), + ); + + expect(response.headers.get('server-timing')).toContain('sentry-trace;desc="abc-def-1"'); + }); + it('leaves the trace off a response with a future Expires and no Cache-Control', async () => { getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1' }); const middleware = sentryRemixMiddleware(matcherFor('/'));