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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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/nextjs10.63.0
@sentry/core10.63.0
@sentry/vercel-edge10.63.0
next16.2.10
Node22.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) }), beforestartSpan on line 51.
  • winterCGRequestToRequestData (@sentry/coreutils/request.js) returns { method, url, query_string, headers } with the full header dict.
  • @sentry/coretracing/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.tsandsentry.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??{}),}));return1;},});
  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:

constisMiddlewareSpan=(name?: string)=>typeofname==='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

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions