-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix(platform): Classify traffic via middleware metric instead of trace sampling #18775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,3 @@ | ||
| import * as Sentry from '@sentry/nextjs'; | ||
|
|
||
| import { | ||
| AI_AGENT_PATTERN, | ||
| BOT_PATTERN, | ||
|
|
@@ -41,107 +39,81 @@ function getHeaderValue( | |
| const VALID_TRAFFIC_TYPES = new Set<TrafficType>(['ai_agent', 'bot', 'user', 'unknown']); | ||
|
|
||
| /** | ||
| * Gets middleware traffic classification from headers. | ||
| * Validates that traffic type is one of the expected values. | ||
| * Gets the traffic classification the middleware stamped onto the forwarded | ||
| * request via the `x-traffic-type` header (see middleware.ts). | ||
| */ | ||
| function getMiddlewareClassification(headers?: Record<string, string>): { | ||
| deviceType: string | undefined; | ||
| trafficType: TrafficType | undefined; | ||
| } { | ||
| function getForwardedTrafficType( | ||
| headers?: Record<string, string> | ||
| ): TrafficType | undefined { | ||
| const rawTrafficType = getHeaderValue(headers, 'x-traffic-type'); | ||
| return { | ||
| deviceType: getHeaderValue(headers, 'x-device-type'), | ||
| trafficType: VALID_TRAFFIC_TYPES.has(rawTrafficType as TrafficType) | ||
| ? (rawTrafficType as TrafficType) | ||
| : undefined, | ||
| }; | ||
| return VALID_TRAFFIC_TYPES.has(rawTrafficType as TrafficType) | ||
| ? (rawTrafficType as TrafficType) | ||
| : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| function isMiddlewareRootSpan(samplingContext: SamplingContext): boolean { | ||
| return ( | ||
| samplingContext.attributes?.['next.span_type'] === 'Middleware.execute' || | ||
| samplingContext.attributes?.['sentry.op'] === 'http.server.middleware' || | ||
| samplingContext.name === 'middleware' || | ||
| Boolean(samplingContext.name?.startsWith('middleware ')) | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Middleware name fallbacks never matchMedium Severity
Reviewed by Cursor Bugbot for commit b1e62a6. Configure here. |
||
| } | ||
|
|
||
| /** | ||
| * Determines trace sample rate based on traffic classification. | ||
| * Uses middleware classification headers when available, falls back to user-agent pattern matching. | ||
| * | ||
| * 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) | ||
| * - AI agents: 100% (full visibility into agentic docs consumption) | ||
| * - Bots/crawlers: 0% (filter out noise) | ||
| * - Real users: 30% | ||
| * - Unknown: 30% (tracked separately for visibility) | ||
| * | ||
| * AI agents are checked first; if something matches both AI and bot patterns, we sample it. | ||
| * Classification prefers the `x-traffic-type` header stamped by the middleware | ||
| * onto forwarded requests, falling back to user-agent pattern matching for | ||
| * requests that didn't pass through the middleware. AI agents are checked | ||
| * before bots; if something matches both patterns, we sample it. | ||
| */ | ||
| export function tracesSampler(samplingContext: SamplingContext): number { | ||
| const headers = samplingContext.normalizedRequest?.headers; | ||
|
|
||
| // Check for middleware classification headers first (most reliable) | ||
| const middlewareClassification = getMiddlewareClassification(headers); | ||
|
|
||
| // If middleware provided valid classification, use it | ||
| if (middlewareClassification.trafficType) { | ||
| const {trafficType, deviceType} = middlewareClassification; | ||
| const sampleRate = SAMPLE_RATES[trafficType]; | ||
| if (isMiddlewareRootSpan(samplingContext)) { | ||
| return 0; | ||
| } | ||
|
|
||
| Sentry.metrics.count('docs.trace.sampled', 1, { | ||
| attributes: { | ||
| sample_rate: sampleRate, | ||
| traffic_type: trafficType, | ||
| device_type: deviceType || 'unknown', | ||
| }, | ||
| }); | ||
| const headers = samplingContext.normalizedRequest?.headers; | ||
|
|
||
| return sampleRate; | ||
| // Trust the classification the middleware stamped onto the forwarded request | ||
| const forwardedType = getForwardedTrafficType(headers); | ||
| if (forwardedType) { | ||
| return SAMPLE_RATES[forwardedType]; | ||
| } | ||
|
|
||
| // Fallback to user-agent pattern matching if no middleware classification | ||
| // This handles cases where middleware didn't run (API routes, etc.) | ||
| // (e.g. requests that bypass the middleware matcher) | ||
| const userAgent = | ||
| getHeaderValue(headers, 'user-agent') ?? | ||
| (samplingContext.attributes?.['http.user_agent'] as string | undefined) ?? | ||
| (samplingContext.attributes?.['user_agent.original'] as string | undefined); | ||
|
|
||
| // No user-agent = unknown traffic, track it explicitly | ||
| if (!userAgent) { | ||
| Sentry.metrics.count('docs.trace.sampled', 1, { | ||
| attributes: { | ||
| sample_rate: SAMPLE_RATES.unknown, | ||
| traffic_type: 'unknown', | ||
| device_type: 'unknown', | ||
| }, | ||
| }); | ||
| return SAMPLE_RATES.unknown; | ||
| } | ||
|
|
||
| // Check for AI agents first | ||
| const aiAgent = matchPattern(userAgent, AI_AGENT_PATTERN); | ||
| if (aiAgent) { | ||
| Sentry.metrics.count('docs.trace.sampled', 1, { | ||
| attributes: { | ||
| sample_rate: SAMPLE_RATES.ai_agent, | ||
| traffic_type: 'ai_agent', | ||
| agent_match: aiAgent, | ||
| }, | ||
| }); | ||
| if (matchPattern(userAgent, AI_AGENT_PATTERN)) { | ||
| return SAMPLE_RATES.ai_agent; | ||
| } | ||
|
|
||
| // Check for bots/crawlers | ||
| const bot = matchPattern(userAgent, BOT_PATTERN); | ||
| if (bot) { | ||
| Sentry.metrics.count('docs.trace.sampled', 1, { | ||
| attributes: { | ||
| sample_rate: SAMPLE_RATES.bot, | ||
| traffic_type: 'bot', | ||
| bot_match: bot, | ||
| }, | ||
| }); | ||
| if (matchPattern(userAgent, BOT_PATTERN)) { | ||
| return SAMPLE_RATES.bot; | ||
| } | ||
|
|
||
| // Sample real users at default rate | ||
| Sentry.metrics.count('docs.trace.sampled', 1, { | ||
| attributes: { | ||
| sample_rate: SAMPLE_RATES.user, | ||
| traffic_type: 'user', | ||
| }, | ||
| }); | ||
| return SAMPLE_RATES.user; | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused device-type header remains
Low Severity
createClassifiedRequestHeadersstill setsx-device-typeon every forwarded request, but this commit removed the only reader intracesSampler. Device type is now only used for the localdocs.request.classifiedmetric, so the header is a dead store.Reviewed by Cursor Bugbot for commit b1e62a6. Configure here.