Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -75,6 +76,51 @@ function applyNoindexForNonProductionDomains(

type TrafficClassification = ReturnType<typeof classifyTraffic>;

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
Expand Down
7 changes: 7 additions & 0 deletions src/lib/trafficClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export const SAMPLE_RATES: Record<TrafficType, number> = {
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.
Expand Down
27 changes: 19 additions & 8 deletions src/tracesSampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AI_AGENT_PATTERN,
BOT_PATTERN,
matchPattern,
MIDDLEWARE_SAMPLE_RATE,
SAMPLE_RATES,
type TrafficType,
} from './lib/trafficClassification';
Expand Down Expand Up @@ -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 '))
);
Expand All @@ -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%
Expand All @@ -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;
Expand Down
Loading