From 01acae535bdd3b96c49bb58b781e9d42e65f4011 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 14 Oct 2026 19:18:42 +0200 Subject: [PATCH 1/5] test(nextjs): Expect one http.server segment per request behind the proxy Requests behind the proxy produce an extra route-less `GET` segment with op `http.server` next to the route segment. This test shows it for an API route and a page. Refs #25370 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../nextjs-16/app/page-behind-proxy/page.tsx | 5 ++++ .../test-applications/nextjs-16/proxy.ts | 2 +- .../nextjs-16/tests/middleware.test.ts | 25 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/page-behind-proxy/page.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/page-behind-proxy/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/page-behind-proxy/page.tsx new file mode 100644 index 000000000000..7d2ee050d65f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/page-behind-proxy/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Page behind proxy

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts index 2daf05c0c50d..dd42ea499092 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts @@ -33,5 +33,5 @@ export async function proxy(request: NextRequest) { // See "Matching Paths" below to learn more export const config = { - matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware', '/page-behind-proxy'], }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index b4063c5be7dd..959008f877a7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -48,6 +48,31 @@ test('Should create a span for middleware', async ({ request }) => { expect(routeSpan.attributes['isolation_scope.has_proxy_marker']).toEqual({ value: false, type: 'boolean' }); }); +for (const path of ['/api/endpoint-behind-middleware', '/page-behind-proxy']) { + test(`Should create one http.server segment for a request to ${path} behind the proxy`, async ({ request }) => { + test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span'); + + // Next.js runs the proxy in a `BaseServer.handleRequest` span of its own, before the one that renders the route. + // That first span has neither a route nor the middleware span as a child, so it must not become a second + // `http.server` segment. + const spansPromise = collectStreamedSpans( + 'nextjs-16', + spans => + spans.some(span => span.is_segment && span.name === 'middleware GET') && + spans.some(span => span.is_segment && span.name === `GET ${path}`), + ); + + const response = await request.get(path); + expect(response.status()).toBe(200); + + const segmentSpans = (await spansPromise).filter(span => span.is_segment); + expect(segmentSpans.map(span => span.name).sort()).toEqual([`GET ${path}`, 'middleware GET']); + expect(segmentSpans.filter(span => getSpanOp(span) === 'http.server').map(span => span.name)).toEqual([ + `GET ${path}`, + ]); + }); +} + // `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so middleware runs inside the request's // `http.server` segment and its span is a child of it rather than a segment of its own. test('Should create a span for middleware inside the request segment of the Worker', async ({ request }) => { From 1d02ca7a054520150200bd9b71326bb4984fd7cb Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 14 Oct 2026 19:18:42 +0200 Subject: [PATCH 2/5] fix(nextjs): Drop the request span that only runs the proxy Since Next.js 16.3 the proxy span is no longer a child of the `BaseServer.handleRequest` span that runs it, so that span became an empty `http.server` segment next to the one of the route. Drop it at span start in `beforeSampling`, based on the `middlewareInvoke` flag Next.js sets on the request. Its attributes at start are the same as the route's. For this, core now keeps the incoming request on the request's isolation scope and enforces the `beforeSampling` decision again, which it had stopped doing when `SentrySampler` was removed. Fixes #25370 Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/index.ts | 4 ++ .../integrations/http/server-subscription.ts | 2 + packages/core/src/tracing/trace.ts | 35 ++++++----- .../core/src/utils/incomingRequestOnScope.ts | 21 +++++++ packages/core/test/lib/tracing/trace.test.ts | 20 ++++++ packages/nextjs/src/server/serverSpanHooks.ts | 37 +++++++++++ .../test/server/serverSpanHooks.test.ts | 62 ++++++++++++++++++- 7 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/utils/incomingRequestOnScope.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..214f7fd83860 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,6 +123,10 @@ export { INTERNAL_setSegmentNameSourceIfSegment, } from './utils/spanUtils'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; +export { + _getIncomingRequestForScope as _INTERNAL_getIncomingRequestForScope, + _setIncomingRequestForScope as _INTERNAL_setIncomingRequestForScope, +} from './utils/incomingRequestOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; export { getTraceData } from './utils/traceData'; diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index 8427c699e031..015fbd7f2a2c 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -34,6 +34,7 @@ import { import { patchRequestToCaptureBody } from './patch-request-to-capture-body'; import { getUrlFragment, getUrlQuery, parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url'; import { recordRequestSession } from './record-request-session'; +import { _setIncomingRequestForScope } from '../../utils/incomingRequestOnScope'; import { generateSpanId, generateTraceId } from '../../utils/propagationContext'; import { continueTrace, startSpanManual } from '../../tracing/trace'; import { getSpanStatusFromHttpCode, SPAN_STATUS_ERROR } from '../../tracing'; @@ -153,6 +154,7 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht // Update the isolation scope, isolate this request isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); + _setIncomingRequestForScope(isolationScope, request); // attempt to update the scope's `transactionName` based on the request // URL. Ideally, framework instrumentations coming after the diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index cb6f9f853184..4463febccec0 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -490,8 +490,10 @@ function _startRootSpan( const mutableSpanSamplingData = { spanAttributes: { ...spanArguments.attributes }, spanName: name, parentSampled }; - // we don't care about the decision for the moment; this is just a placeholder - client?.emit('beforeSampling', mutableSpanSamplingData, { decision: false }); + const samplingDecision = { decision: true }; + client?.emit('beforeSampling', mutableSpanSamplingData, samplingDecision); + // Like suppressed tracing, a span dropped by a hook is SDK-internal and records no client outcome + const _isDroppedBeforeSampling = !samplingDecision.decision; // If hook consumers override the parentSampled flag, we will use that value instead of the actual one const finalParentSampled = mutableSpanSamplingData.parentSampled ?? parentSampled; @@ -500,19 +502,20 @@ function _startRootSpan( const currentPropagationContext = scope.getPropagationContext(); const _isTracingSuppressed = isTracingSuppressed(scope); - const [sampled, sampleRate, localSampleRateWasApplied, dropReason] = _isTracingSuppressed - ? [false] - : sampleSpan( - options, - { - name, - parentSampled: finalParentSampled, - attributes: finalAttributes, - normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest, - parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate), - }, - currentPropagationContext.sampleRand, - ); + const [sampled, sampleRate, localSampleRateWasApplied, dropReason] = + _isTracingSuppressed || _isDroppedBeforeSampling + ? [false] + : sampleSpan( + options, + { + name, + parentSampled: finalParentSampled, + attributes: finalAttributes, + normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest, + parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate), + }, + currentPropagationContext.sampleRand, + ); const rootSpan = new SentrySpan({ ...spanArguments, @@ -525,7 +528,7 @@ function _startRootSpan( sampled, }); - if (!sampled && client && !_isTracingSuppressed) { + if (!sampled && client && !_isTracingSuppressed && !_isDroppedBeforeSampling) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); const outcomeReason = dropReason || 'sample_rate'; // A standalone span is sent on its own and never becomes a transaction. diff --git a/packages/core/src/utils/incomingRequestOnScope.ts b/packages/core/src/utils/incomingRequestOnScope.ts new file mode 100644 index 000000000000..a1140110f413 --- /dev/null +++ b/packages/core/src/utils/incomingRequestOnScope.ts @@ -0,0 +1,21 @@ +import type { Scope } from '../scope'; +import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef'; + +const SCOPE_INCOMING_REQUEST_FIELD = 'incomingRequest'; + +/** + * Set the incoming request (e.g. Node's `IncomingMessage`) that a request's isolation scope belongs to. + * NOTE: This should NOT be used directly, but is only used internally by the server instrumentation. + */ +export function _setIncomingRequestForScope(scope: Scope, request: object): void { + // Use WeakRef so a scope that outlives the request (e.g. captured on a span) does not keep it alive + scope.refs[SCOPE_INCOMING_REQUEST_FIELD] = makeWeakRef(request); +} + +/** + * Get the incoming request that a request's isolation scope belongs to. + * NOTE: This should NOT be used directly, but is only used internally by framework SDKs. + */ +export function _getIncomingRequestForScope(scope: Scope): object | undefined { + return derefWeakRef(scope.refs[SCOPE_INCOMING_REQUEST_FIELD] as MaybeWeakRef | undefined); +} diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index f59946d50c10..63508bd9593a 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2788,6 +2788,26 @@ describe('ignoreSpans (core path, streaming)', () => { expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'sample_rate', 'span'); }); + it('does not record a root span that a beforeSampling hook drops, nor an outcome for it', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + client.on('beforeSampling', (_samplingData, samplingDecision) => { + samplingDecision.decision = false; + }); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'GET /foo' }, span => { + expect(spanIsSampled(span)).toBe(false); + }); + + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + }); + it('records sample_rate outcomes for the transaction and all of its spans on static path', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 0, diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index 3d21bb094eae..f265ab220548 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -11,10 +11,13 @@ import { import { MIDDLEWARE } from '@sentry/conventions/op'; import type { Client, Options, Span } from '@sentry/core'; import { + _INTERNAL_getIncomingRequestForScope, getIsolationScope, getRootSpan, + GLOBAL_OBJ, hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, + parseSemver, spanToJSON, } from '@sentry/core'; import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; @@ -36,6 +39,11 @@ import { } from './vercelQueuesMonitoring'; const NEXTJS_ERROR_PAGE_ROUTES = ['/500', '/_error']; +const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta'); + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryNextJsVersion?: string; +}; export const NEXTJS_SERVER_IGNORE_SPANS: NonNullable = [ // Static assets (matches `_next/static` anywhere in the name to handle custom basePath) @@ -75,6 +83,10 @@ export function addNextjsServerSpanHooks(client: Client): void { ) { samplingDecision.decision = false; } + + if (isProxyInvocationSpan(spanAttributes)) { + samplingDecision.decision = false; + } }); client.on('spanStart', span => handleOnSpanStart(span, client)); @@ -103,6 +115,31 @@ export function addNextjsServerSpanHooks(client: Client): void { type SpanAttributes = ReturnType['attributes']; +/** + * Whether a span is the `BaseServer.handleRequest` span that Next.js starts only to run the proxy (middleware). + * + * Next.js handles a request behind a proxy with two `handleRequest` calls: one that only runs the proxy, and one that + * renders the route. Since Next.js 16.3 (vercel/next.js#95306), the proxy span is not a child of the first span anymore, + * so that span would end up as an empty `http.server` segment next to the one of the route. On older versions it holds + * the proxy span and becomes the `middleware` segment, so it must be kept there. + */ +function isProxyInvocationSpan(spanAttributes: Record): boolean { + if (spanAttributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest') { + return false; + } + + const { major, minor } = parseSemver(globalWithInjectedValues._sentryNextJsVersion ?? ''); + if (major === undefined || minor === undefined || major < 16 || (major === 16 && minor < 3)) { + return false; + } + + // Next.js sets this flag on the request before the call that only runs the proxy, and resets it before rendering + const request = _INTERNAL_getIncomingRequestForScope(getIsolationScope()) as + | { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } } + | undefined; + return request?.[NEXT_REQUEST_META]?.middlewareInvoke === true; +} + /** * Hoists the parameterized route that a Next.js span carries in `next.route` up to the root span, as its name and * `http.route`. diff --git a/packages/nextjs/test/server/serverSpanHooks.test.ts b/packages/nextjs/test/server/serverSpanHooks.test.ts index aef1a76dd6d2..b4fdfb201bb7 100644 --- a/packages/nextjs/test/server/serverSpanHooks.test.ts +++ b/packages/nextjs/test/server/serverSpanHooks.test.ts @@ -7,10 +7,17 @@ import { } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; import type { Client } from '@sentry/core'; -import { addChildSpanToSpan, SentrySpan, spanToStaticSpanJSON } from '@sentry/core'; -import { describe, expect, it } from 'vitest'; +import { + _INTERNAL_setIncomingRequestForScope, + addChildSpanToSpan, + GLOBAL_OBJ, + SentrySpan, + spanToStaticSpanJSON, + withIsolationScope, +} from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; -import { handleOnSpanStart } from '../../src/server/serverSpanHooks'; +import { addNextjsServerSpanHooks, handleOnSpanStart } from '../../src/server/serverSpanHooks'; const client = { getOptions: () => ({}) } as unknown as Client; @@ -171,3 +178,52 @@ describe('handleOnSpanStart', () => { expect(spanToStaticSpanJSON(rootSpan).description).toBe('GET /api/endpoint'); }); }); + +describe('addNextjsServerSpanHooks beforeSampling', () => { + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion?: string }; + + afterEach(() => { + delete globalWithInjectedValues._sentryNextJsVersion; + }); + + function getSamplingDecision(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean { + globalWithInjectedValues._sentryNextJsVersion = nextVersion; + + let beforeSampling: ((data: unknown, decision: { decision: boolean }) => void) | undefined; + addNextjsServerSpanHooks({ + on: (hook: string, callback: typeof beforeSampling) => { + if (hook === 'beforeSampling') { + beforeSampling = callback; + } + }, + getOptions: () => ({}), + } as unknown as Client); + + const request = { [Symbol.for('NextInternalRequestMeta')]: { middlewareInvoke } }; + const samplingDecision = { decision: true }; + withIsolationScope(isolationScope => { + _INTERNAL_setIncomingRequestForScope(isolationScope, request); + beforeSampling?.( + { spanAttributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' }, spanName: 'GET' }, + samplingDecision, + ); + }); + return samplingDecision.decision; + } + + it('drops the request span that Next.js only starts to run the proxy', () => { + expect(getSamplingDecision('16.3.0', true)).toBe(false); + }); + + it('keeps the request span that renders the route', () => { + expect(getSamplingDecision('16.3.0', false)).toBe(true); + }); + + it('keeps the request span that runs the proxy before Next.js 16.3, since the proxy span is its child there', () => { + expect(getSamplingDecision('16.2.4', true)).toBe(true); + }); + + it('keeps the request span that runs the proxy if the Next.js version is unknown', () => { + expect(getSamplingDecision(undefined, true)).toBe(true); + }); +}); From 0453bca1b7f1d80b85c691a86280e202e1d5e7e2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 16 Oct 2026 10:42:52 +0200 Subject: [PATCH 3/5] ref(nextjs): Drop the proxy-only request span with ignoreSpans Instead of enforcing the `beforeSampling` decision in core, add a `beforeSpanStart` hook that runs right before `ignoreSpans` is checked and can change the name and attributes of a span. The Next.js SDK uses it to mark the request span that only runs the proxy with `sentry.drop_transaction`, which the existing Next.js `ignoreSpans` pattern drops. Refs #25370 Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/client.ts | 13 ++++ packages/core/src/tracing/trace.ts | 41 ++++++------ packages/core/test/lib/tracing/trace.test.ts | 65 +++++++++++++------ packages/nextjs/src/server/serverSpanHooks.ts | 11 ++-- .../test/server/serverSpanHooks.test.ts | 43 ++++++------ 5 files changed, 110 insertions(+), 63 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..a012cd20aaba 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -677,6 +677,16 @@ export abstract class Client { callback: (spanScope: { scope: Scope; parentSpan: Span | undefined }) => void, ): () => void; + /** + * Register a callback right before a span is created, before `ignoreSpans` is checked. Listeners can change the + * name and the attributes of the span by mutating the passed `spanStartData` object. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on( + hook: 'beforeSpanStart', + callback: (spanStartData: { name: string | undefined; attributes: SpanAttributes }) => void, + ): () => void; + /** * Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision` * property that can be used to make a sampling decision that will be enforced, before any span sampling runs. @@ -1038,6 +1048,9 @@ export abstract class Client { /** A hook that is called right before a span is created; listeners mutate the passed object. */ public emit(hook: 'prepareSpanScope', spanScope: { scope: Scope; parentSpan: Span | undefined }): void; + /** A hook that is called right before a span is created, before `ignoreSpans` is checked. */ + public emit(hook: 'beforeSpanStart', spanStartData: { name: string | undefined; attributes: SpanAttributes }): void; + /** A hook that is called every time before a span is sampled. */ public emit( hook: 'beforeSampling', diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 4463febccec0..6ef61fe099f8 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -362,6 +362,12 @@ function createChildOrRootSpan({ } const client = getClient(); + // Copy the attributes, so listeners do not mutate an object the caller passed in + const spanStartData = { name: spanArguments.name, attributes: { ...spanArguments.attributes } }; + client?.emit('beforeSpanStart', spanStartData); + spanArguments.name = spanStartData.name; + spanArguments.attributes = spanStartData.attributes; + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { if (!isTracingSuppressed(scope)) { // if tracing is actively suppressed (Sentry.suppressTracing(...)), @@ -490,10 +496,8 @@ function _startRootSpan( const mutableSpanSamplingData = { spanAttributes: { ...spanArguments.attributes }, spanName: name, parentSampled }; - const samplingDecision = { decision: true }; - client?.emit('beforeSampling', mutableSpanSamplingData, samplingDecision); - // Like suppressed tracing, a span dropped by a hook is SDK-internal and records no client outcome - const _isDroppedBeforeSampling = !samplingDecision.decision; + // we don't care about the decision for the moment; this is just a placeholder + client?.emit('beforeSampling', mutableSpanSamplingData, { decision: false }); // If hook consumers override the parentSampled flag, we will use that value instead of the actual one const finalParentSampled = mutableSpanSamplingData.parentSampled ?? parentSampled; @@ -502,20 +506,19 @@ function _startRootSpan( const currentPropagationContext = scope.getPropagationContext(); const _isTracingSuppressed = isTracingSuppressed(scope); - const [sampled, sampleRate, localSampleRateWasApplied, dropReason] = - _isTracingSuppressed || _isDroppedBeforeSampling - ? [false] - : sampleSpan( - options, - { - name, - parentSampled: finalParentSampled, - attributes: finalAttributes, - normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest, - parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate), - }, - currentPropagationContext.sampleRand, - ); + const [sampled, sampleRate, localSampleRateWasApplied, dropReason] = _isTracingSuppressed + ? [false] + : sampleSpan( + options, + { + name, + parentSampled: finalParentSampled, + attributes: finalAttributes, + normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest, + parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate), + }, + currentPropagationContext.sampleRand, + ); const rootSpan = new SentrySpan({ ...spanArguments, @@ -528,7 +531,7 @@ function _startRootSpan( sampled, }); - if (!sampled && client && !_isTracingSuppressed && !_isDroppedBeforeSampling) { + if (!sampled && client && !_isTracingSuppressed) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); const outcomeReason = dropReason || 'sample_rate'; // A standalone span is sent on its own and never becomes a transaction. diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 63508bd9593a..055e8dbb969a 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2717,6 +2717,51 @@ describe('ignoreSpans (core path, streaming)', () => { }); }); + it('ignores a span that a beforeSpanStart listener marks for ignoreSpans', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: [{ attributes: { 'test.ignore': true } }], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + client.on('beforeSpanStart', ({ name, attributes }) => { + if (name === 'GET') { + attributes['test.ignore'] = true; + } + }); + + startSpan({ name: 'GET' }, span => { + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + }); + startSpan({ name: 'GET /users' }, span => { + expect(span).toBeInstanceOf(SentrySpan); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('applies the changes of beforeSpanStart listeners without mutating the attributes passed in', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + client.on('beforeSpanStart', spanStartData => { + spanStartData.name = 'renamed'; + spanStartData.attributes['test.added'] = 'yes'; + }); + + const attributes = { 'test.original': 'yes' }; + const span = startInactiveSpan({ name: 'original', attributes }); + + expect(spanToJSON(span).name).toBe('renamed'); + expect(spanToJSON(span).attributes).toMatchObject({ 'test.original': 'yes', 'test.added': 'yes' }); + expect(attributes).toEqual({ 'test.original': 'yes' }); + }); + it('returns SentryNonRecordingSpan for startInactiveSpan matching ignoreSpans', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1, @@ -2788,26 +2833,6 @@ describe('ignoreSpans (core path, streaming)', () => { expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'sample_rate', 'span'); }); - it('does not record a root span that a beforeSampling hook drops, nor an outcome for it', () => { - const options = getDefaultTestClientOptions({ - tracesSampleRate: 1, - traceLifecycle: 'stream', - }); - client = new TestClient(options); - setCurrentClient(client); - client.init(); - client.on('beforeSampling', (_samplingData, samplingDecision) => { - samplingDecision.decision = false; - }); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - - startSpan({ name: 'GET /foo' }, span => { - expect(spanIsSampled(span)).toBe(false); - }); - - expect(spyOnDroppedEvent).not.toHaveBeenCalled(); - }); - it('records sample_rate outcomes for the transaction and all of its spans on static path', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 0, diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index f265ab220548..c7633ea0c7b8 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -83,9 +83,11 @@ export function addNextjsServerSpanHooks(client: Client): void { ) { samplingDecision.decision = false; } + }); - if (isProxyInvocationSpan(spanAttributes)) { - samplingDecision.decision = false; + client.on('beforeSpanStart', ({ attributes }) => { + if (isProxyInvocationSpan(attributes)) { + attributes[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION] = true; } }); @@ -134,9 +136,8 @@ function isProxyInvocationSpan(spanAttributes: Record): boolean } // Next.js sets this flag on the request before the call that only runs the proxy, and resets it before rendering - const request = _INTERNAL_getIncomingRequestForScope(getIsolationScope()) as - | { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } } - | undefined; + const request: { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } } | undefined = + _INTERNAL_getIncomingRequestForScope(getIsolationScope()); return request?.[NEXT_REQUEST_META]?.middlewareInvoke === true; } diff --git a/packages/nextjs/test/server/serverSpanHooks.test.ts b/packages/nextjs/test/server/serverSpanHooks.test.ts index b4fdfb201bb7..df22fe163921 100644 --- a/packages/nextjs/test/server/serverSpanHooks.test.ts +++ b/packages/nextjs/test/server/serverSpanHooks.test.ts @@ -6,18 +6,23 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; -import type { Client } from '@sentry/core'; +import type { Client, SpanAttributes } from '@sentry/core'; import { _INTERNAL_setIncomingRequestForScope, addChildSpanToSpan, GLOBAL_OBJ, SentrySpan, + shouldIgnoreSpan, spanToStaticSpanJSON, withIsolationScope, } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; -import { addNextjsServerSpanHooks, handleOnSpanStart } from '../../src/server/serverSpanHooks'; +import { + addNextjsServerSpanHooks, + handleOnSpanStart, + NEXTJS_SERVER_IGNORE_SPANS, +} from '../../src/server/serverSpanHooks'; const client = { getOptions: () => ({}) } as unknown as Client; @@ -179,51 +184,51 @@ describe('handleOnSpanStart', () => { }); }); -describe('addNextjsServerSpanHooks beforeSampling', () => { +describe('addNextjsServerSpanHooks beforeSpanStart', () => { const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion?: string }; afterEach(() => { delete globalWithInjectedValues._sentryNextJsVersion; }); - function getSamplingDecision(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean { + function isRequestSpanIgnored(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean { globalWithInjectedValues._sentryNextJsVersion = nextVersion; - let beforeSampling: ((data: unknown, decision: { decision: boolean }) => void) | undefined; + let beforeSpanStart: ((data: { name: string | undefined; attributes: SpanAttributes }) => void) | undefined; addNextjsServerSpanHooks({ - on: (hook: string, callback: typeof beforeSampling) => { - if (hook === 'beforeSampling') { - beforeSampling = callback; + on: (hook: string, callback: typeof beforeSpanStart) => { + if (hook === 'beforeSpanStart') { + beforeSpanStart = callback; } }, getOptions: () => ({}), } as unknown as Client); const request = { [Symbol.for('NextInternalRequestMeta')]: { middlewareInvoke } }; - const samplingDecision = { decision: true }; + const spanStartData = { name: 'GET', attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } }; withIsolationScope(isolationScope => { _INTERNAL_setIncomingRequestForScope(isolationScope, request); - beforeSampling?.( - { spanAttributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' }, spanName: 'GET' }, - samplingDecision, - ); + beforeSpanStart?.(spanStartData); }); - return samplingDecision.decision; + return shouldIgnoreSpan( + { description: spanStartData.name, attributes: spanStartData.attributes }, + NEXTJS_SERVER_IGNORE_SPANS, + ); } - it('drops the request span that Next.js only starts to run the proxy', () => { - expect(getSamplingDecision('16.3.0', true)).toBe(false); + it('ignores the request span that Next.js only starts to run the proxy', () => { + expect(isRequestSpanIgnored('16.3.0', true)).toBe(true); }); it('keeps the request span that renders the route', () => { - expect(getSamplingDecision('16.3.0', false)).toBe(true); + expect(isRequestSpanIgnored('16.3.0', false)).toBe(false); }); it('keeps the request span that runs the proxy before Next.js 16.3, since the proxy span is its child there', () => { - expect(getSamplingDecision('16.2.4', true)).toBe(true); + expect(isRequestSpanIgnored('16.2.4', true)).toBe(false); }); it('keeps the request span that runs the proxy if the Next.js version is unknown', () => { - expect(getSamplingDecision(undefined, true)).toBe(true); + expect(isRequestSpanIgnored(undefined, true)).toBe(false); }); }); From 1930c84fdfda3322819108b5e0d67f3123297c0b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 16 Oct 2026 18:06:23 +0200 Subject: [PATCH 4/5] test(nextjs): Use the op constant and separate tests for the proxy segment Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test-applications/nextjs-16/package.json | 1 + .../nextjs-16/tests/middleware.test.ts | 53 ++++++++++--------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 0415c053a37e..b90612c6b6c1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@playwright/test": "~1.63.0", + "@sentry/conventions": "0.27.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@types/node": "^20", "@types/pg": "^8.11.10", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 959008f877a7..f90e8383d856 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from '@playwright/test'; +import { type APIRequestContext, expect, test } from '@playwright/test'; +import { HTTP_SERVER } from '@sentry/conventions/op'; import { collectStreamedSpans, collectStreamedSpansUntilSegment, @@ -48,31 +49,35 @@ test('Should create a span for middleware', async ({ request }) => { expect(routeSpan.attributes['isolation_scope.has_proxy_marker']).toEqual({ value: false, type: 'boolean' }); }); -for (const path of ['/api/endpoint-behind-middleware', '/page-behind-proxy']) { - test(`Should create one http.server segment for a request to ${path} behind the proxy`, async ({ request }) => { - test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span'); - - // Next.js runs the proxy in a `BaseServer.handleRequest` span of its own, before the one that renders the route. - // That first span has neither a route nor the middleware span as a child, so it must not become a second - // `http.server` segment. - const spansPromise = collectStreamedSpans( - 'nextjs-16', - spans => - spans.some(span => span.is_segment && span.name === 'middleware GET') && - spans.some(span => span.is_segment && span.name === `GET ${path}`), - ); - - const response = await request.get(path); - expect(response.status()).toBe(200); - - const segmentSpans = (await spansPromise).filter(span => span.is_segment); - expect(segmentSpans.map(span => span.name).sort()).toEqual([`GET ${path}`, 'middleware GET']); - expect(segmentSpans.filter(span => getSpanOp(span) === 'http.server').map(span => span.name)).toEqual([ - `GET ${path}`, - ]); - }); +// Next.js runs the proxy in a `BaseServer.handleRequest` span of its own, before the one that renders the route. That +// first span has neither a route nor the middleware span as a child, so it must not become a second `http.server` +// segment. +async function expectOneHttpServerSegment(request: APIRequestContext, path: string): Promise { + const spansPromise = collectStreamedSpans( + 'nextjs-16', + spans => + spans.some(span => span.is_segment && span.name === 'middleware GET') && + spans.some(span => span.is_segment && span.name === `GET ${path}`), + ); + + const response = await request.get(path); + expect(response.status()).toBe(200); + + const segmentSpans = (await spansPromise).filter(span => span.is_segment); + expect(segmentSpans.map(span => span.name).sort()).toEqual([`GET ${path}`, 'middleware GET']); + expect(segmentSpans.filter(span => getSpanOp(span) === HTTP_SERVER).map(span => span.name)).toEqual([`GET ${path}`]); } +test('Should create one http.server segment for a route handler behind the proxy', async ({ request }) => { + test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span'); + await expectOneHttpServerSegment(request, '/api/endpoint-behind-middleware'); +}); + +test('Should create one http.server segment for a page behind the proxy', async ({ request }) => { + test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span'); + await expectOneHttpServerSegment(request, '/page-behind-proxy'); +}); + // `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so middleware runs inside the request's // `http.server` segment and its span is a child of it rather than a segment of its own. test('Should create a span for middleware inside the request segment of the Worker', async ({ request }) => { From 1a8666de453b8e45e97f3e8fb2ea73b068645dd0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 16 Oct 2026 18:06:23 +0200 Subject: [PATCH 5/5] ref(nextjs): Look up the request through the httpServerRequest hook Instead of keeping the incoming request on a hidden field of the isolation scope in core, the Next.js SDK keeps it in its own map, filled from the `httpServerRequest` hook that the Node.js SDK emits for every incoming request. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/index.ts | 4 ---- .../integrations/http/server-subscription.ts | 2 -- .../core/src/utils/incomingRequestOnScope.ts | 21 ------------------- packages/nextjs/src/server/serverSpanHooks.ts | 15 ++++++++++--- .../test/server/serverSpanHooks.test.ts | 17 +++++++-------- 5 files changed, 19 insertions(+), 40 deletions(-) delete mode 100644 packages/core/src/utils/incomingRequestOnScope.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 214f7fd83860..aae98061c25d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,10 +123,6 @@ export { INTERNAL_setSegmentNameSourceIfSegment, } from './utils/spanUtils'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; -export { - _getIncomingRequestForScope as _INTERNAL_getIncomingRequestForScope, - _setIncomingRequestForScope as _INTERNAL_setIncomingRequestForScope, -} from './utils/incomingRequestOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; export { getTraceData } from './utils/traceData'; diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index 015fbd7f2a2c..8427c699e031 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -34,7 +34,6 @@ import { import { patchRequestToCaptureBody } from './patch-request-to-capture-body'; import { getUrlFragment, getUrlQuery, parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url'; import { recordRequestSession } from './record-request-session'; -import { _setIncomingRequestForScope } from '../../utils/incomingRequestOnScope'; import { generateSpanId, generateTraceId } from '../../utils/propagationContext'; import { continueTrace, startSpanManual } from '../../tracing/trace'; import { getSpanStatusFromHttpCode, SPAN_STATUS_ERROR } from '../../tracing'; @@ -154,7 +153,6 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht // Update the isolation scope, isolate this request isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); - _setIncomingRequestForScope(isolationScope, request); // attempt to update the scope's `transactionName` based on the request // URL. Ideally, framework instrumentations coming after the diff --git a/packages/core/src/utils/incomingRequestOnScope.ts b/packages/core/src/utils/incomingRequestOnScope.ts deleted file mode 100644 index a1140110f413..000000000000 --- a/packages/core/src/utils/incomingRequestOnScope.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Scope } from '../scope'; -import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef'; - -const SCOPE_INCOMING_REQUEST_FIELD = 'incomingRequest'; - -/** - * Set the incoming request (e.g. Node's `IncomingMessage`) that a request's isolation scope belongs to. - * NOTE: This should NOT be used directly, but is only used internally by the server instrumentation. - */ -export function _setIncomingRequestForScope(scope: Scope, request: object): void { - // Use WeakRef so a scope that outlives the request (e.g. captured on a span) does not keep it alive - scope.refs[SCOPE_INCOMING_REQUEST_FIELD] = makeWeakRef(request); -} - -/** - * Get the incoming request that a request's isolation scope belongs to. - * NOTE: This should NOT be used directly, but is only used internally by framework SDKs. - */ -export function _getIncomingRequestForScope(scope: Scope): object | undefined { - return derefWeakRef(scope.refs[SCOPE_INCOMING_REQUEST_FIELD] as MaybeWeakRef | undefined); -} diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index c7633ea0c7b8..244aadf6ee9f 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -9,9 +9,8 @@ import { URL_QUERY, } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; -import type { Client, Options, Span } from '@sentry/core'; +import type { Client, Options, Scope, Span } from '@sentry/core'; import { - _INTERNAL_getIncomingRequestForScope, getIsolationScope, getRootSpan, GLOBAL_OBJ, @@ -41,6 +40,10 @@ import { const NEXTJS_ERROR_PAGE_ROUTES = ['/500', '/_error']; const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta'); +// Spans of Next.js start without access to the request they belong to, so the request is looked up through the +// isolation scope of the request +const requestsByIsolationScope = new WeakMap(); + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion?: string; }; @@ -85,6 +88,12 @@ export function addNextjsServerSpanHooks(client: Client): void { } }); + client.on('httpServerRequest', request => { + if (typeof request === 'object' && request) { + requestsByIsolationScope.set(getIsolationScope(), request); + } + }); + client.on('beforeSpanStart', ({ attributes }) => { if (isProxyInvocationSpan(attributes)) { attributes[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION] = true; @@ -137,7 +146,7 @@ function isProxyInvocationSpan(spanAttributes: Record): boolean // Next.js sets this flag on the request before the call that only runs the proxy, and resets it before rendering const request: { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } } | undefined = - _INTERNAL_getIncomingRequestForScope(getIsolationScope()); + requestsByIsolationScope.get(getIsolationScope()); return request?.[NEXT_REQUEST_META]?.middlewareInvoke === true; } diff --git a/packages/nextjs/test/server/serverSpanHooks.test.ts b/packages/nextjs/test/server/serverSpanHooks.test.ts index df22fe163921..f2b237dca092 100644 --- a/packages/nextjs/test/server/serverSpanHooks.test.ts +++ b/packages/nextjs/test/server/serverSpanHooks.test.ts @@ -6,9 +6,8 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; -import type { Client, SpanAttributes } from '@sentry/core'; +import type { Client } from '@sentry/core'; import { - _INTERNAL_setIncomingRequestForScope, addChildSpanToSpan, GLOBAL_OBJ, SentrySpan, @@ -194,21 +193,19 @@ describe('addNextjsServerSpanHooks beforeSpanStart', () => { function isRequestSpanIgnored(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean { globalWithInjectedValues._sentryNextJsVersion = nextVersion; - let beforeSpanStart: ((data: { name: string | undefined; attributes: SpanAttributes }) => void) | undefined; + const hooks: Record void> = {}; addNextjsServerSpanHooks({ - on: (hook: string, callback: typeof beforeSpanStart) => { - if (hook === 'beforeSpanStart') { - beforeSpanStart = callback; - } + on: (hook: string, callback: (...args: unknown[]) => void) => { + hooks[hook] = callback; }, getOptions: () => ({}), } as unknown as Client); const request = { [Symbol.for('NextInternalRequestMeta')]: { middlewareInvoke } }; const spanStartData = { name: 'GET', attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } }; - withIsolationScope(isolationScope => { - _INTERNAL_setIncomingRequestForScope(isolationScope, request); - beforeSpanStart?.(spanStartData); + withIsolationScope(() => { + hooks['httpServerRequest']?.(request, {}, {}); + hooks['beforeSpanStart']?.(spanStartData); }); return shouldIgnoreSpan( { description: spanStartData.name, attributes: spanStartData.attributes },