Summary
#21833 wired the isolation scope's normalizedRequest into the tracesSampler sampling context for root spans, so a custom sampler can decide from the incoming request. Its stated motivation is exactly the use case below ("drop health-check routes").
On the Next.js edge runtime (middleware) the value never arrives. tracesSampler is called for every middleware GET root span with normalizedRequest: undefined, so the request cannot be inspected at all. The Node runtime, in the same app and with the same sampler, gets the full header bag.
The practical consequence is that middleware traces cannot be sampled by anything about the request (no URL, no route, no User-Agent), so a sampler cannot drop synthetic traffic there.
Versions
|
|
@sentry/nextjs |
10.63.0 |
@sentry/core |
10.63.0 |
@sentry/vercel-edge |
10.63.0 |
next |
16.2.10 |
| Node |
22.22.0 |
Reproduced on a real next build && next start, and observed in production on Vercel.
Evidence
I instrumented my tracesSampler to log what it is handed, then drove one request with a marked User-Agent and one without:
[SAMPLER-PROBE] {"name":"GET /health", "hasNormalizedRequest":true, "headerKeys":["host","user-agent","accept"], "ua":"Mozilla/5.0 ... my-e2e/1", "rate":0}
[SAMPLER-PROBE] {"name":"middleware GET", "hasNormalizedRequest":false, "headerKeys":[], "rate":1}
[SAMPLER-PROBE] {"name":"GET /health", "hasNormalizedRequest":true, "headerKeys":["host","user-agent","accept"], "ua":"Mozilla/5.0 ...", "rate":1}
[SAMPLER-PROBE] {"name":"middleware GET", "hasNormalizedRequest":false, "headerKeys":[], "rate":1}
hasNormalizedRequest is false for every middleware span, including the one whose request carried the marker. The header bag is empty. The Node span, one line above and in the same request, gets the full bag.
beforeSendTransaction cannot recover it either: a event.request.headers['user-agent'] check never fires on these events, which suggests the finished transaction has no request data either, not just the sampling context.
What I traced in the source (the chain looks correct, and the value still does not arrive)
@sentry/nextjs/build/cjs/common/wrapMiddlewareWithSentry.js:33 calls isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req) }), before startSpan on line 51.
winterCGRequestToRequestData (@sentry/core utils/request.js) returns { method, url, query_string, headers } with the full header dict.
@sentry/core tracing/trace.js builds the sampling context with normalizedRequest: getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest.
tracing/sampling.js calls options.tracesSampler(...) first, with no short-circuit.
- The sampler is definitely installed on edge (it is in
sentry.edge.config.ts, and it runs: the probe above is its own output).
So every link exists, and the isolation scope the wrapper writes to does not appear to be the one the sampler reads from on this runtime. I have not chased which of the two it is.
Steps to reproduce
- Next.js app with
@sentry/nextjs and a middleware.ts matching normal routes.
- In
sentry.edge.config.ts and sentry.server.config.ts, install the same sampler:
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampler: (ctx) => {
console.log(JSON.stringify({
name: ctx.name,
hasNormalizedRequest: Boolean(ctx.normalizedRequest),
headerKeys: Object.keys(ctx.normalizedRequest?.headers ?? {}),
}));
return 1;
},
});
next build && next start, then curl http://localhost:3000/any-page.
- The
middleware GET line reports hasNormalizedRequest: false with an empty headerKeys. The Node route's line reports true with the headers.
Expected
tracesSampler receives normalizedRequest for middleware root spans on the edge runtime, as it does on Node, so #21833's "decide from the incoming request" works on every runtime.
Actual
normalizedRequest is undefined for every edge (middleware) root span, so the sampler has only ctx.name to go on.
Workaround, for anyone who finds this
The only thing the sampler is given on edge is the span name, so I match on it and refuse the trace outright:
const isMiddlewareSpan = (name?: string) =>
typeof name === 'string' && (name === 'middleware' || name.startsWith('middleware '));
tracesSampler: (ctx) => (isMiddlewareSpan(ctx.name) ? 0 : /* your real policy */ 1),
This costs the middleware traces of real users too, which is only acceptable because those spans land with no url, no route and no User-Agent anyway, so they cannot be attributed to a user or an endpoint. Errors are unaffected (captureException is governed by sampleRate, not tracesSampler).
For context on why this mattered enough to chase: these unsampleable middleware spans, plus the Supabase auth call each one makes as a child of the same trace, were ~90% of the spans our CI was still sending after we thought we had silenced it.
Summary
#21833 wired the isolation scope's
normalizedRequestinto thetracesSamplersampling context for root spans, so a custom sampler can decide from the incoming request. Its stated motivation is exactly the use case below ("drop health-check routes").On the Next.js edge runtime (middleware) the value never arrives.
tracesSampleris called for everymiddleware GETroot span withnormalizedRequest: undefined, so the request cannot be inspected at all. The Node runtime, in the same app and with the same sampler, gets the full header bag.The practical consequence is that middleware traces cannot be sampled by anything about the request (no URL, no route, no User-Agent), so a sampler cannot drop synthetic traffic there.
Versions
@sentry/nextjs@sentry/core@sentry/vercel-edgenextReproduced on a real
next build && next start, and observed in production on Vercel.Evidence
I instrumented my
tracesSamplerto log what it is handed, then drove one request with a marked User-Agent and one without:hasNormalizedRequestisfalsefor every middleware span, including the one whose request carried the marker. The header bag is empty. The Node span, one line above and in the same request, gets the full bag.beforeSendTransactioncannot recover it either: aevent.request.headers['user-agent']check never fires on these events, which suggests the finished transaction has no request data either, not just the sampling context.What I traced in the source (the chain looks correct, and the value still does not arrive)
@sentry/nextjs/build/cjs/common/wrapMiddlewareWithSentry.js:33callsisolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req) }), beforestartSpanon line 51.winterCGRequestToRequestData(@sentry/coreutils/request.js) returns{ method, url, query_string, headers }with the full header dict.@sentry/coretracing/trace.jsbuilds the sampling context withnormalizedRequest: getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest.tracing/sampling.jscallsoptions.tracesSampler(...)first, with no short-circuit.sentry.edge.config.ts, and it runs: the probe above is its own output).So every link exists, and the isolation scope the wrapper writes to does not appear to be the one the sampler reads from on this runtime. I have not chased which of the two it is.
Steps to reproduce
@sentry/nextjsand amiddleware.tsmatching normal routes.sentry.edge.config.tsandsentry.server.config.ts, install the same sampler:next build && next start, thencurl http://localhost:3000/any-page.middleware GETline reportshasNormalizedRequest: falsewith an emptyheaderKeys. The Node route's line reportstruewith the headers.Expected
tracesSamplerreceivesnormalizedRequestfor middleware root spans on the edge runtime, as it does on Node, so #21833's "decide from the incoming request" works on every runtime.Actual
normalizedRequestisundefinedfor every edge (middleware) root span, so the sampler has onlyctx.nameto go on.Workaround, for anyone who finds this
The only thing the sampler is given on edge is the span name, so I match on it and refuse the trace outright:
This costs the middleware traces of real users too, which is only acceptable because those spans land with no url, no route and no User-Agent anyway, so they cannot be attributed to a user or an endpoint. Errors are unaffected (
captureExceptionis governed bysampleRate, nottracesSampler).For context on why this mattered enough to chase: these unsampleable middleware spans, plus the Supabase auth call each one makes as a child of the same trace, were ~90% of the spans our CI was still sending after we thought we had silenced it.