-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(nextjs): Make request data available to tracesSampler for edge middleware root spans
#22232
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
base: develop
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { NextResponse } from 'next/server'; | ||
|
|
||
| export const dynamic = 'force-dynamic'; | ||
|
|
||
| export function GET() { | ||
| return NextResponse.json({ name: 'Jane Doe' }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { NextResponse } from 'next/server'; | ||
|
|
||
| export const dynamic = 'force-dynamic'; | ||
|
|
||
| export function GET() { | ||
| return NextResponse.json({ name: 'John Doe' }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import type { NextRequest } from 'next/server'; | ||
|
|
||
| export async function middleware(request: NextRequest) { | ||
| // Keep this invocation in-flight for a bit so a concurrent request to the other matched endpoint genuinely | ||
| // overlaps with it — the concurrency test in tests/middleware.test.ts relies on this overlap. | ||
| if (request.nextUrl.pathname === '/api/endpoint-behind-middleware-2') { | ||
| await new Promise(resolve => setTimeout(resolve, 300)); | ||
| } | ||
|
|
||
| return NextResponse.next(); | ||
| } | ||
|
|
||
| // See "Matching Paths" below to learn more | ||
| export const config = { | ||
| matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-middleware-2'], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForTransaction } from '@sentry-internal/test-utils'; | ||
|
|
||
| // The `tracesSampler` in `sentry.edge.config.ts` only samples `Middleware.execute` spans when `normalizedRequest` | ||
| // is available at sampling time, so this test times out if the request data does not reach the sampler. | ||
| test('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => { | ||
| const middlewareTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { | ||
| return transactionEvent?.transaction === 'middleware GET'; | ||
| }); | ||
|
|
||
| const response = await request.get('/api/endpoint-behind-middleware'); | ||
| expect(await response.json()).toStrictEqual({ name: 'John Doe' }); | ||
|
|
||
| const middlewareTransaction = await middlewareTransactionPromise; | ||
|
|
||
| expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); | ||
| expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); | ||
| expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); | ||
| expect(middlewareTransaction.request?.method).toBe('GET'); | ||
| }); | ||
|
|
||
| // The `tracesSampler` additionally asserts that `normalizedRequest.url` matches the sampled span's own | ||
| // `http.target`, so a request leaking into the sampling context of a concurrent one drops that transaction | ||
| // and times this test out. | ||
| test('does not leak normalizedRequest between concurrent middleware invocations', async ({ request }) => { | ||
| const firstTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { | ||
| return ( | ||
| transactionEvent?.transaction === 'middleware GET' && | ||
| transactionEvent.contexts?.trace?.data?.['http.target'] === '/api/endpoint-behind-middleware' | ||
| ); | ||
| }); | ||
|
|
||
| const secondTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { | ||
| return ( | ||
| transactionEvent?.transaction === 'middleware GET' && | ||
| transactionEvent.contexts?.trace?.data?.['http.target'] === '/api/endpoint-behind-middleware-2' | ||
| ); | ||
| }); | ||
|
|
||
| await Promise.all([request.get('/api/endpoint-behind-middleware'), request.get('/api/endpoint-behind-middleware-2')]); | ||
|
|
||
| const [firstTransaction, secondTransaction] = await Promise.all([firstTransactionPromise, secondTransactionPromise]); | ||
|
|
||
| expect(firstTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); | ||
| expect(firstTransaction.request?.url).not.toContain('/api/endpoint-behind-middleware-2'); | ||
| expect(secondTransaction.request?.url).toContain('/api/endpoint-behind-middleware-2'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { context } from '@opentelemetry/api'; | ||
| import type { Span, SpanAttributes } from '@sentry/core'; | ||
| import { | ||
| getCapturedScopesOnSpan, | ||
| getCurrentScope, | ||
| getIsolationScope, | ||
| getRootSpan, | ||
| setCapturedScopesOnSpan, | ||
| } from '@sentry/core'; | ||
| import { getScopesFromContext } from '@sentry/opentelemetry'; | ||
| import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; | ||
|
|
||
| /** | ||
| * Forks the isolation scope for `BaseServer.handleRequest` / `Middleware.execute` root spans so that request-scoped | ||
| * data (e.g. `normalizedRequest`) stays isolated per request. | ||
| */ | ||
| export function maybeForkIsolationScopeForRootSpan(span: Span, spanAttributes: SpanAttributes | undefined): void { | ||
| const spanType = spanAttributes?.[ATTR_NEXT_SPAN_TYPE]; | ||
| if (spanType !== 'BaseServer.handleRequest' && spanType !== 'Middleware.execute') { | ||
| return; | ||
| } | ||
|
|
||
| if (span !== getRootSpan(span)) { | ||
| return; | ||
| } | ||
|
|
||
| const scopes = getCapturedScopesOnSpan(span); | ||
|
|
||
| const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); | ||
| const scope = scopes.scope || getCurrentScope(); | ||
|
|
||
| const currentScopesPointer = getScopesFromContext(context.active()); | ||
| if (currentScopesPointer) { | ||
| currentScopesPointer.isolationScope = isolationScope; | ||
| } | ||
|
|
||
| setCapturedScopesOnSpan(span, scope, isolationScope); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { | ||
| HTTP_METHOD, | ||
| HTTP_REQUEST_METHOD, | ||
| HTTP_TARGET, | ||
| HTTP_URL, | ||
| URL_FULL, | ||
| URL_PATH, | ||
| URL_QUERY, | ||
| } from '@sentry/conventions/attributes'; | ||
| import type { RequestEventData, SpanAttributes } from '@sentry/core'; | ||
|
|
||
| /** | ||
| * Builds a partial `normalizedRequest` from OTel HTTP span attributes. | ||
| * Only method, URL, and query string can be derived — headers are not available as span attributes. | ||
| */ | ||
| export function getNormalizedRequestFromAttributes(attributes: SpanAttributes): RequestEventData | undefined { | ||
| // eslint-disable-next-line typescript/no-deprecated | ||
| const method = attributes[HTTP_REQUEST_METHOD] || attributes[HTTP_METHOD]; | ||
|
|
||
| // eslint-disable-next-line typescript/no-deprecated | ||
| const url = attributes[URL_FULL] || attributes[HTTP_URL] || attributes[URL_PATH] || attributes[HTTP_TARGET]; | ||
|
|
||
| if (typeof method !== 'string' && typeof url !== 'string') { | ||
| return undefined; | ||
| } | ||
|
|
||
| const normalizedRequest: RequestEventData = {}; | ||
|
|
||
| if (typeof method === 'string') { | ||
| normalizedRequest.method = method; | ||
| } | ||
|
|
||
| if (typeof url === 'string') { | ||
| normalizedRequest.url = url; | ||
|
|
||
| const queryFromAttribute = attributes[URL_QUERY]; | ||
| if (typeof queryFromAttribute === 'string') { | ||
| normalizedRequest.query_string = queryFromAttribute; | ||
| } else { | ||
| const queryIndex = url.indexOf('?'); | ||
| if (queryIndex !== -1) { | ||
| normalizedRequest.query_string = url.slice(queryIndex + 1); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return normalizedRequest; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,8 @@ | ||
| // import/export got a false positive, and affects most of our index barrel files | ||
| // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 | ||
| /* eslint-disable import/export */ | ||
| import { context } from '@opentelemetry/api'; | ||
| import { | ||
| applySdkMetadata, | ||
| getCapturedScopesOnSpan, | ||
| getCurrentScope, | ||
| getGlobalScope, | ||
| getIsolationScope, | ||
| getRootSpan, | ||
|
|
@@ -14,17 +11,17 @@ import { | |
| SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, | ||
| setCapturedScopesOnSpan, | ||
| spanToJSON, | ||
| } from '@sentry/core'; | ||
| import { getScopesFromContext } from '@sentry/opentelemetry'; | ||
| import type { VercelEdgeOptions } from '@sentry/vercel-edge'; | ||
| import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; | ||
| import { DEBUG_BUILD } from '../common/debug-build'; | ||
| import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; | ||
| import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; | ||
| import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; | ||
| import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; | ||
| import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; | ||
| import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; | ||
| import { isBuild } from '../common/utils/isBuild'; | ||
| import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; | ||
| import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; | ||
|
|
@@ -101,6 +98,20 @@ export function init(options: VercelEdgeOptions = {}): void { | |
|
|
||
| const client = vercelEdgeInit(opts); | ||
|
|
||
| // Next.js's OTel instrumentation samples root spans before the Sentry middleware wrapper can set | ||
| // `normalizedRequest` on the isolation scope. Seed it from span attributes so `tracesSampler` has access. | ||
| client?.on('beforeSampling', ({ spanAttributes }) => { | ||
| const spanType = spanAttributes[ATTR_NEXT_SPAN_TYPE]; | ||
| if (spanType !== 'Middleware.execute' && spanType !== 'BaseServer.handleRequest') { | ||
| return; | ||
| } | ||
|
|
||
| const normalizedRequest = getNormalizedRequestFromAttributes(spanAttributes); | ||
| if (normalizedRequest) { | ||
| getIsolationScope().setSDKProcessingMetadata({ normalizedRequest }); | ||
| } | ||
|
Comment on lines
+110
to
+112
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. Bug: In serverless warm starts, stale Suggested FixThe Prompt for AI Agent |
||
| }); | ||
|
|
||
| client?.on('spanStart', span => { | ||
| const spanAttributes = spanToJSON(span).data; | ||
| const rootSpan = getRootSpan(span); | ||
|
|
@@ -120,19 +131,7 @@ export function init(options: VercelEdgeOptions = {}): void { | |
| } | ||
|
|
||
| // We want to fork the isolation scope for incoming requests | ||
| if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) { | ||
| const scopes = getCapturedScopesOnSpan(span); | ||
|
|
||
| const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); | ||
| const scope = scopes.scope || getCurrentScope(); | ||
|
|
||
| const currentScopesPointer = getScopesFromContext(context.active()); | ||
| if (currentScopesPointer) { | ||
| currentScopesPointer.isolationScope = isolationScope; | ||
| } | ||
|
|
||
| setCapturedScopesOnSpan(span, scope, isolationScope); | ||
| } | ||
| maybeForkIsolationScopeForRootSpan(span, spanAttributes); | ||
|
|
||
| if (isRootSpan) { | ||
| // todo: check if we can set request headers for edge on sdkProcessingMetadata | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.