1- import { describe , expect , it } from 'vitest' ;
1+ import type * as SentryCore from '@sentry/core' ;
2+ import { beforeEach , describe , expect , it , vi } from 'vitest' ;
3+
4+ // Trace data as the SDK would produce it for the active request; empty when nothing is active.
5+ const getTraceData = vi . hoisted ( ( ) => vi . fn < ( ) => { 'sentry-trace' ?: string ; baggage ?: string } > ( ( ) => ( { } ) ) ) ;
6+
7+ vi . mock ( '@sentry/core' , async importOriginal => ( {
8+ ...( await importOriginal < typeof SentryCore > ( ) ) ,
9+ getTraceData,
10+ } ) ) ;
211
312import { sentryRemixMiddleware } from '../../src/v3/server/middleware' ;
413import type { MatcherLike , RequestContextLike } from '../../src/v3/types' ;
@@ -16,6 +25,146 @@ function contextFor(url: string, headers: Record<string, string> = {}): RequestC
1625}
1726
1827describe ( 'sentryRemixMiddleware' , ( ) => {
28+ beforeEach ( ( ) => {
29+ getTraceData . mockReturnValue ( { } ) ;
30+ } ) ;
31+
32+ it ( 'propagates the trace to HTML responses through Server-Timing, ahead of the route' , async ( ) => {
33+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' , baggage : 'sentry-trace_id=abc' } ) ;
34+ const middleware = sentryRemixMiddleware ( matcherFor ( '/users/:id' ) ) ;
35+
36+ const response = await middleware (
37+ contextFor ( 'http://x/users/1' , { accept : 'text/html' } ) ,
38+ async ( ) => new Response ( '' ) ,
39+ ) ;
40+
41+ expect ( response . headers . get ( 'server-timing' ) ) . toBe (
42+ 'sentry-trace;desc="abc-def-1", baggage;desc="sentry-trace_id=abc", sentry-route;desc="/users/:id"' ,
43+ ) ;
44+ } ) ;
45+
46+ it ( 'propagates the trace even when no route matched' , async ( ) => {
47+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
48+ const middleware = sentryRemixMiddleware ( matcherFor ( undefined ) ) ;
49+
50+ const response = await middleware (
51+ contextFor ( 'http://x/nope' , { accept : 'text/html' } ) ,
52+ async ( ) => new Response ( '' ) ,
53+ ) ;
54+
55+ expect ( response . headers . get ( 'server-timing' ) ) . toBe ( 'sentry-trace;desc="abc-def-1"' ) ;
56+ } ) ;
57+
58+ it . each ( [
59+ [ 'public' , 'public, max-age=60' ] ,
60+ [ 's-maxage' , 's-maxage=300' ] ,
61+ [ 'max-age' , 'max-age=60' ] ,
62+ ] ) ( 'leaves the trace off a response a shared cache may store (%s), keeping the route' , async ( _why , cacheControl ) => {
63+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' , baggage : 'sentry-trace_id=abc' } ) ;
64+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
65+
66+ const response = await middleware (
67+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
68+ async ( ) => new Response ( '' , { headers : { 'cache-control' : cacheControl } } ) ,
69+ ) ;
70+
71+ expect ( response . headers . get ( 'server-timing' ) ) . toBe ( 'sentry-route;desc="/"' ) ;
72+ } ) ;
73+
74+ it . each ( [
75+ [ 'stale-while-revalidate after a zero lifetime' , { 'cache-control' : 'max-age=0, stale-while-revalidate=60' } ] ,
76+ [ 'stale-if-error after a zero shared lifetime' , { 'cache-control' : 's-maxage=0, stale-if-error=600' } ] ,
77+ [
78+ 'CDN-Cache-Control over a private Cache-Control' ,
79+ { 'cache-control' : 'private' , 'cdn-cache-control' : 'max-age=3600' } ,
80+ ] ,
81+ [
82+ 'Vercel-CDN-Cache-Control over no-store' ,
83+ { 'cache-control' : 'no-store' , 'vercel-cdn-cache-control' : 's-maxage=3600' } ,
84+ ] ,
85+ [ 'Cloudflare-CDN-Cache-Control' , { 'cloudflare-cdn-cache-control' : 'public, max-age=60' } ] ,
86+ [ 'Surrogate-Control' , { 'surrogate-control' : 'max-age=3600' } ] ,
87+ ] ) ( 'leaves the trace off a response a CDN or stale-serving cache may reuse (%s)' , async ( _why , headers ) => {
88+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
89+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
90+
91+ const response = await middleware (
92+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
93+ async ( ) => new Response ( '' , { headers } ) ,
94+ ) ;
95+
96+ expect ( response . headers . get ( 'server-timing' ) ) . toBe ( 'sentry-route;desc="/"' ) ;
97+ } ) ;
98+
99+ it ( 'propagates the trace when the CDN field forbids caching and nothing else allows it' , async ( ) => {
100+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
101+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
102+
103+ const response = await middleware (
104+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
105+ async ( ) => new Response ( '' , { headers : { 'cdn-cache-control' : 'max-age=0' } } ) ,
106+ ) ;
107+
108+ expect ( response . headers . get ( 'server-timing' ) ) . toContain ( 'sentry-trace;desc="abc-def-1"' ) ;
109+ } ) ;
110+
111+ it ( 'leaves the trace off a response with a future Expires and no Cache-Control' , async ( ) => {
112+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
113+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
114+
115+ const response = await middleware (
116+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
117+ async ( ) => new Response ( '' , { headers : { expires : new Date ( Date . now ( ) + 60_000 ) . toUTCString ( ) } } ) ,
118+ ) ;
119+
120+ expect ( response . headers . get ( 'server-timing' ) ) . toBe ( 'sentry-route;desc="/"' ) ;
121+ } ) ;
122+
123+ it ( 'propagates the trace when max-age=0 overrides a future Expires' , async ( ) => {
124+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
125+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
126+
127+ const response = await middleware (
128+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
129+ async ( ) =>
130+ new Response ( '' , {
131+ headers : { 'cache-control' : 'max-age=0' , expires : new Date ( Date . now ( ) + 60_000 ) . toUTCString ( ) } ,
132+ } ) ,
133+ ) ;
134+
135+ expect ( response . headers . get ( 'server-timing' ) ) . toContain ( 'sentry-trace;desc="abc-def-1"' ) ;
136+ } ) ;
137+
138+ it ( 'propagates the trace when Expires is in the past' , async ( ) => {
139+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
140+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
141+
142+ const response = await middleware (
143+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
144+ async ( ) => new Response ( '' , { headers : { expires : 'Thu, 01 Jan 1970 00:00:00 GMT' } } ) ,
145+ ) ;
146+
147+ expect ( response . headers . get ( 'server-timing' ) ) . toContain ( 'sentry-trace;desc="abc-def-1"' ) ;
148+ } ) ;
149+
150+ it . each ( [
151+ [ 'private' , 'private, max-age=60' ] ,
152+ [ 'no-store' , 'no-store' ] ,
153+ [ 'max-age=0' , 'max-age=0, must-revalidate' ] ,
154+ [ 's-maxage=0' , 'max-age=3600, s-maxage=0' ] ,
155+ [ 'no cache header' , undefined ] ,
156+ ] ) ( 'propagates the trace on a response only this user can get back (%s)' , async ( _why , cacheControl ) => {
157+ getTraceData . mockReturnValue ( { 'sentry-trace' : 'abc-def-1' } ) ;
158+ const middleware = sentryRemixMiddleware ( matcherFor ( '/' ) ) ;
159+
160+ const response = await middleware (
161+ contextFor ( 'http://x/' , { accept : 'text/html' } ) ,
162+ async ( ) => new Response ( '' , { headers : cacheControl ? { 'cache-control' : cacheControl } : { } } ) ,
163+ ) ;
164+
165+ expect ( response . headers . get ( 'server-timing' ) ) . toContain ( 'sentry-trace;desc="abc-def-1"' ) ;
166+ } ) ;
167+
19168 it ( 'reports the route on HTML responses through Server-Timing' , async ( ) => {
20169 const middleware = sentryRemixMiddleware ( matcherFor ( '/users/:id' ) ) ;
21170
@@ -60,7 +209,7 @@ describe('sentryRemixMiddleware', () => {
60209 expect ( response . headers . has ( 'server-timing' ) ) . toBe ( false ) ;
61210 } ) ;
62211
63- it ( 'adds nothing when no route matched' , async ( ) => {
212+ it ( 'adds nothing when no route matched and no trace is active ' , async ( ) => {
64213 const middleware = sentryRemixMiddleware ( matcherFor ( undefined ) ) ;
65214
66215 const response = await middleware (
0 commit comments