Skip to content

Next.js middleware: normalizedRequest never reaches tracesSampler on the edge runtime, so #21833 has no effect there #22200

Description

@gerokeller

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

  1. Next.js app with @sentry/nextjs and a middleware.ts matching normal routes.
  2. 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;
  },
});
  1. next build && next start, then curl http://localhost:3000/any-page.
  2. 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.

Metadata

Metadata

Assignees

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions