From e92db03839118f74e7fa4977b2c651ebe21b5947 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 30 Jul 2026 11:24:52 -0400 Subject: [PATCH 1/3] fix(platform): Restore middleware spans at 1% with route detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Middleware roots now sample at a fixed 1% (~24k spans/day at current traffic) instead of 0. Because the rate is applied blind, bots are included in proportion to their share of traffic; middleware.ts stamps traffic_type onto the span so they stay filterable at query time. middleware.ts also names the span, which is what made these spans worth keeping. Next.js collapses the name to 'middleware GET'; setting sentry.source to 'custom' from inside the middleware stops the SDK reclaiming it. Spans are named by outcome (redirect / rewrite / passthrough) rather than by path — the docs site has thousands of paths plus every file under /mdx-images/, so naming by URL would blow up transaction-name cardinality. The path is kept as the url.path attribute. Note that send-time filtering is not an option here: beforeSendSpan is typed (span: SpanJSON) => SpanJSON with no null return, and with traceLifecycle 'stream' it is the only span hook, so spans cannot be dropped after creation. The blind sample rate is the only lever. docs.request.classified remains the unsampled system of record for traffic counting and is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- middleware.ts | 61 +++++++++++++++++++++++++++----- src/lib/trafficClassification.ts | 7 ++++ src/tracesSampler.ts | 14 ++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/middleware.ts b/middleware.ts index b6e065d710d86..8353898949942 100644 --- a/middleware.ts +++ b/middleware.ts @@ -43,15 +43,16 @@ export function middleware(request: NextRequest) { const classification = classifyTraffic(request); recordClassification(request, classification); - // First, handle canonical URL redirects for deprecated paths - const canonicalRedirect = handleRedirects(request); - if (canonicalRedirect) { - return applyNoindexForNonProductionDomains(request, canonicalRedirect); - } + // First handle canonical URL redirects for deprecated paths, then check for + // AI/LLM clients and redirect to markdown if appropriate. + const response = applyNoindexForNonProductionDomains( + request, + handleRedirects(request) ?? handleAIClientRedirect(request, classification) + ); + + annotateMiddlewareSpan(request, classification, response); - // Then, check for AI/LLM clients and redirect to markdown if appropriate - const response = handleAIClientRedirect(request, classification); - return applyNoindexForNonProductionDomains(request, response); + return response; } /** @@ -75,6 +76,50 @@ function applyNoindexForNonProductionDomains( type TrafficClassification = ReturnType; +type MiddlewareOutcome = 'redirect' | 'rewrite' | 'passthrough'; + +function middlewareOutcome(response: NextResponse): MiddlewareOutcome { + if (response.status >= 300 && response.status < 400) { + return 'redirect'; + } + // Set by NextResponse.rewrite(). If Next.js renames it we degrade to + // 'passthrough' rather than throwing. + return response.headers.has('x-middleware-rewrite') ? 'rewrite' : 'passthrough'; +} + +/** + * Next.js names this span `middleware GET` with no route detail, and the + * tracesSampler can't classify it. Both are fixable here, where the request is + * in hand: `sentry.source: 'custom'` stops the SDK reclaiming the name, and + * `traffic_type` keeps bots filterable at query time. + * + * Named by outcome, not path — the docs site has thousands of paths (plus every + * file under /mdx-images/), so naming by URL would blow up transaction-name + * cardinality. The path stays queryable as `url.path`. + */ +function annotateMiddlewareSpan( + request: NextRequest, + classification: TrafficClassification, + response: NextResponse +): void { + const activeSpan = Sentry.getActiveSpan(); + if (!activeSpan) { + return; + } + + const outcome = middlewareOutcome(response); + + Sentry.getRootSpan(activeSpan) + .updateName(`middleware ${request.method} ${outcome}`) + .setAttributes({ + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + 'middleware.outcome': outcome, + 'url.path': request.nextUrl.pathname, + traffic_type: classification.trafficType, + device_type: classification.deviceType, + }); +} + /** * Records the per-request traffic classification as a counter metric. * Every request the middleware sees is counted — no sampling — making this diff --git a/src/lib/trafficClassification.ts b/src/lib/trafficClassification.ts index a326fd38cc0d0..1fe4630cab404 100644 --- a/src/lib/trafficClassification.ts +++ b/src/lib/trafficClassification.ts @@ -33,6 +33,13 @@ export const SAMPLE_RATES: Record = { unknown: 0.3, // 30% - same as users, but tracked separately }; +/** + * Middleware root spans can't be classified at sampling time (see + * src/tracesSampler.ts), so this rate is applied blind to all traffic. + * ~1% of 2.4M requests/day ≈ 24k spans/day. + */ +export const MIDDLEWARE_SAMPLE_RATE = 0.01; + /** * Checks if the input matches the pattern. * Returns the matched substring (lowercase), or undefined if no match. diff --git a/src/tracesSampler.ts b/src/tracesSampler.ts index fcb77a2ac71b1..60f522a221906 100644 --- a/src/tracesSampler.ts +++ b/src/tracesSampler.ts @@ -2,6 +2,7 @@ import { AI_AGENT_PATTERN, BOT_PATTERN, matchPattern, + MIDDLEWARE_SAMPLE_RATE, SAMPLE_RATES, type TrafficType, } from './lib/trafficClassification'; @@ -53,10 +54,9 @@ function getForwardedTrafficType( /** * Middleware root spans are created by Next.js itself ('Middleware.execute') - * before any request data reaches Sentry, so they can never be classified - * here — and they carry no useful detail (the name is collapsed to - * `middleware GET`). Per-request traffic classification is recorded as the - * `docs.request.classified` metric in middleware.ts instead. + * before any request data reaches Sentry, so they can never be classified here — + * no headers, no user-agent. They get a low blind rate instead; middleware.ts + * names them and stamps `traffic_type` so bots stay filterable at query time. */ function isMiddlewareRootSpan(samplingContext: SamplingContext): boolean { return ( @@ -71,8 +71,8 @@ function isMiddlewareRootSpan(samplingContext: SamplingContext): boolean { * Determines trace sample rate based on traffic classification. * * Sample rates (from shared config): - * - Middleware root spans: 0% (unclassifiable by architecture and information-free; - * traffic counting happens in middleware.ts via the docs.request.classified metric) + * - Middleware root spans: 1% (unclassifiable by architecture, so sampled blind + * at a low rate for latency visibility; named and tagged in middleware.ts) * - AI agents: 100% (full visibility into agentic docs consumption) * - Bots/crawlers: 0% (filter out noise) * - Real users: 30% @@ -85,7 +85,7 @@ function isMiddlewareRootSpan(samplingContext: SamplingContext): boolean { */ export function tracesSampler(samplingContext: SamplingContext): number { if (isMiddlewareRootSpan(samplingContext)) { - return 0; + return MIDDLEWARE_SAMPLE_RATE; } const headers = samplingContext.normalizedRequest?.headers; From 3b6db2811ef4d61f5a020e9a8ecb31e32c3aab71 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 30 Jul 2026 11:31:39 -0400 Subject: [PATCH 2/3] ref(platform): Split span updateName and setAttributes calls No behavior change. Every span implementation reachable here returns `this` from updateName (SentrySpan, SentryNonRecordingSpan, OTel sdk-trace-base Span, OTel API NonRecordingSpan), so the chained form was safe, but core's own updateSpanName helper uses separate statements and this doesn't depend on a fluent return we don't control. Co-Authored-By: Claude Opus 5 (1M context) --- middleware.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/middleware.ts b/middleware.ts index 8353898949942..83c51e1981dfa 100644 --- a/middleware.ts +++ b/middleware.ts @@ -108,16 +108,16 @@ function annotateMiddlewareSpan( } const outcome = middlewareOutcome(response); + const rootSpan = Sentry.getRootSpan(activeSpan); - Sentry.getRootSpan(activeSpan) - .updateName(`middleware ${request.method} ${outcome}`) - .setAttributes({ - [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - 'middleware.outcome': outcome, - 'url.path': request.nextUrl.pathname, - traffic_type: classification.trafficType, - device_type: classification.deviceType, - }); + rootSpan.updateName(`middleware ${request.method} ${outcome}`); + rootSpan.setAttributes({ + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + 'middleware.outcome': outcome, + 'url.path': request.nextUrl.pathname, + traffic_type: classification.trafficType, + device_type: classification.deviceType, + }); } /** From b12b8e856f177d1915eaba130138244d63f0dd53 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 31 Jul 2026 11:41:33 -0400 Subject: [PATCH 3/3] ref(platform): Annotate middleware span with attributes instead of renaming it The outcome-based span name never survived. The SDK's enhanceMiddlewareRootSpan rewrites the name of every Middleware.execute span to `middleware {METHOD}` on the send path, reading Next.js' next.span_name attribute and ignoring sentry.source, so the rename here was silently discarded. Drop it and keep the redirect/rewrite/passthrough breakdown as the middleware.outcome attribute, which is queryable and keeps name cardinality flat. Also match both middleware span ops in the sampler: http.server.middleware in v10, middleware in v11. next.span_type stays the load-bearing check either way. Co-Authored-By: Claude Opus 5 (1M context) --- middleware.ts | 23 ++++++++++++----------- src/tracesSampler.ts | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/middleware.ts b/middleware.ts index 83c51e1981dfa..5543e9a0947d8 100644 --- a/middleware.ts +++ b/middleware.ts @@ -88,14 +88,18 @@ function middlewareOutcome(response: NextResponse): MiddlewareOutcome { } /** - * Next.js names this span `middleware GET` with no route detail, and the - * tracesSampler can't classify it. Both are fixable here, where the request is - * in hand: `sentry.source: 'custom'` stops the SDK reclaiming the name, and - * `traffic_type` keeps bots filterable at query time. + * The tracesSampler can't classify this span (no request data at sampling time), + * so the detail it needs to stay useful is attached here instead, where the + * request is in hand. `traffic_type` keeps bots filterable at query time and + * `middleware.outcome` gives the redirect/rewrite/passthrough breakdown. * - * Named by outcome, not path — the docs site has thousands of paths (plus every - * file under /mdx-images/), so naming by URL would blow up transaction-name - * cardinality. The path stays queryable as `url.path`. + * Attributes only — deliberately no `updateName`. The SDK's + * `enhanceMiddlewareRootSpan` rewrites the name of every `Middleware.execute` + * span to `middleware {METHOD}` on the send path, reading Next.js' + * `next.span_name` attribute and ignoring `sentry.source`, so any name set here + * is silently discarded. Outcome and path live as attributes rather than in the + * transaction name — which also keeps name cardinality flat, since the docs + * site has thousands of paths plus every file under /mdx-images/. */ function annotateMiddlewareSpan( request: NextRequest, @@ -107,13 +111,10 @@ function annotateMiddlewareSpan( return; } - const outcome = middlewareOutcome(response); const rootSpan = Sentry.getRootSpan(activeSpan); - rootSpan.updateName(`middleware ${request.method} ${outcome}`); rootSpan.setAttributes({ - [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - 'middleware.outcome': outcome, + 'middleware.outcome': middlewareOutcome(response), 'url.path': request.nextUrl.pathname, traffic_type: classification.trafficType, device_type: classification.deviceType, diff --git a/src/tracesSampler.ts b/src/tracesSampler.ts index 60f522a221906..f06123be6a544 100644 --- a/src/tracesSampler.ts +++ b/src/tracesSampler.ts @@ -52,16 +52,27 @@ function getForwardedTrafficType( : undefined; } +/** + * Ops the SDK has used for the Next.js middleware root span. `http.server.middleware` + * is the v10 op; v11 renames it to the `@sentry/conventions` `middleware`. Both are + * matched so the detection survives the upgrade. + */ +const MIDDLEWARE_SPAN_OPS = new Set(['http.server.middleware', 'middleware']); + /** * Middleware root spans are created by Next.js itself ('Middleware.execute') * before any request data reaches Sentry, so they can never be classified here — * no headers, no user-agent. They get a low blind rate instead; middleware.ts - * names them and stamps `traffic_type` so bots stay filterable at query time. + * stamps `traffic_type` on them so bots stay filterable at query time. + * + * `next.span_type` is the load-bearing check — it's set by Next.js at span + * creation, so it's the one attribute reliably present this early. The op and + * name checks are fallbacks for runtimes that get there another way. */ function isMiddlewareRootSpan(samplingContext: SamplingContext): boolean { return ( samplingContext.attributes?.['next.span_type'] === 'Middleware.execute' || - samplingContext.attributes?.['sentry.op'] === 'http.server.middleware' || + MIDDLEWARE_SPAN_OPS.has(samplingContext.attributes?.['sentry.op'] as string) || samplingContext.name === 'middleware' || Boolean(samplingContext.name?.startsWith('middleware ')) );