diff --git a/middleware.ts b/middleware.ts index b6e065d710d86..5543e9a0947d8 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,51 @@ 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'; +} + +/** + * 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. + * + * 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, + classification: TrafficClassification, + response: NextResponse +): void { + const activeSpan = Sentry.getActiveSpan(); + if (!activeSpan) { + return; + } + + const rootSpan = Sentry.getRootSpan(activeSpan); + + rootSpan.setAttributes({ + 'middleware.outcome': middlewareOutcome(response), + '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..f06123be6a544 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'; @@ -51,17 +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 — 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 + * 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 ')) ); @@ -71,8 +82,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 +96,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;