From edae97c56d12c0700b537bba9d11796e8be77f44 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 10 Feb 2026 14:47:00 -0500 Subject: [PATCH 1/3] fix: filter out empty base server spans --- .../nextjs/src/common/utils/tracingUtils.ts | 24 +- packages/nextjs/src/edge/index.ts | 8 + packages/nextjs/src/server/index.ts | 8 + .../nextLowQualityTransactionsFilter.test.ts | 231 ++++++++++++++++++ 4 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts diff --git a/packages/nextjs/src/common/utils/tracingUtils.ts b/packages/nextjs/src/common/utils/tracingUtils.ts index efa3ac4fdbf6..6f2fe53fd6dd 100644 --- a/packages/nextjs/src/common/utils/tracingUtils.ts +++ b/packages/nextjs/src/common/utils/tracingUtils.ts @@ -1,5 +1,5 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import type { PropagationContext, Span, SpanAttributes } from '@sentry/core'; +import type { Event, PropagationContext, Span, SpanAttributes } from '@sentry/core'; import { debug, getActiveSpan, @@ -117,9 +117,29 @@ export function dropNextjsRootContext(): void { const rootSpan = getRootSpan(nextJsOwnedSpan); const rootSpanAttributes = spanToJSON(rootSpan).data; if (rootSpanAttributes?.['next.span_type']) { - getRootSpan(nextJsOwnedSpan)?.setAttribute(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + rootSpan.setAttribute(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); } + + return; } + + DEBUG_BUILD && + debug.warn( + 'dropNextjsRootContext: No active span found. The BaseServer.handleRequest transaction may not be dropped.', + ); +} + +/** + * Checks if an event is an empty `BaseServer.handleRequest` transaction. + * + * A valid `BaseServer.handleRequest` transaction should always have child spans, so an empty one is safe to drop. + */ +export function isEmptyBaseServerTrace(event: Event): boolean { + return ( + event.type === 'transaction' && + event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && + (!event.spans || event.spans.length === 0) + ); } /** diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 3dd74a03c43e..09227074fa1c 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -30,6 +30,7 @@ import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunn import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; +import { isEmptyBaseServerTrace } from '../common/utils/tracingUtils'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/vercel-edge'; @@ -178,6 +179,13 @@ export function init(options: VercelEdgeOptions = {}): void { return null; } + // Defensive check: Drop empty BaseServer.handleRequest transactions that leaked through + // when dropNextjsRootContext() failed to set the drop attribute (e.g. due to AsyncLocalStorage + // context loss). + if (isEmptyBaseServerTrace(event)) { + return null; + } + return event; } else { return event; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 9a10bf60dccb..9c7b4d8a2659 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -33,6 +33,7 @@ import { import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; +import { isEmptyBaseServerTrace } from '../common/utils/tracingUtils'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; @@ -234,6 +235,13 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } + // Defensive check: Drop empty BaseServer.handleRequest transactions that leaked through + // when dropNextjsRootContext() failed to set the drop attribute (e.g. due to AsyncLocalStorage + // context loss). + if (isEmptyBaseServerTrace(event)) { + return null; + } + // Next.js 13 sometimes names the root transactions like this containing useless tracing. if (event.transaction === 'NextServer.getRequestHandler') { return null; diff --git a/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts b/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts new file mode 100644 index 000000000000..832bb27d304f --- /dev/null +++ b/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts @@ -0,0 +1,231 @@ +import type { Event } from '@sentry/core'; +import { getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; +import * as SentryNode from '@sentry/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isEmptyBaseServerTrace } from '../../src/common/utils/tracingUtils'; +import { init } from '../../src/server'; + +// normally this is set as part of the build process, so mock it here +(GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir: string })._sentryRewriteFramesDistDir = '.next'; + +describe('NextLowQualityTransactionsFilter', () => { + afterEach(() => { + vi.clearAllMocks(); + + SentryNode.getGlobalScope().clear(); + SentryNode.getIsolationScope().clear(); + SentryNode.getCurrentScope().clear(); + SentryNode.getCurrentScope().setClient(undefined); + }); + + function getEventProcessor(): (event: Event) => Event | null { + init({}); + + const eventProcessors = getGlobalScope()['_eventProcessors']; + const processor = eventProcessors.find( + (p: { id?: string }) => p.id === 'NextLowQualityTransactionsFilter', + ); + expect(processor).toBeDefined(); + return processor as (event: Event) => Event | null; + } + + it('drops transactions with sentry.drop_transaction attribute', () => { + const processor = getEventProcessor(); + + const event: Event = { + type: 'transaction', + transaction: 'GET /api/hello', + contexts: { + trace: { + data: { + 'sentry.drop_transaction': true, + }, + }, + }, + }; + + expect(processor(event)).toBeNull(); + }); + + it('drops empty BaseServer.handleRequest transactions (defensive check for context loss)', () => { + const processor = getEventProcessor(); + + const event: Event = { + type: 'transaction', + transaction: 'GET /api/hello', + contexts: { + trace: { + trace_id: 'abc123', + span_id: 'def456', + parent_span_id: 'parent789', + data: { + 'next.span_type': 'BaseServer.handleRequest', + }, + }, + }, + spans: [], + }; + + expect(processor(event)).toBeNull(); + }); + + it('drops BaseServer.handleRequest transactions with undefined spans', () => { + const processor = getEventProcessor(); + + const event: Event = { + type: 'transaction', + transaction: 'GET /api/hello', + contexts: { + trace: { + trace_id: 'abc123', + span_id: 'def456', + data: { + 'next.span_type': 'BaseServer.handleRequest', + }, + }, + }, + // spans is undefined + }; + + expect(processor(event)).toBeNull(); + }); + + it('keeps BaseServer.handleRequest transactions with child spans', () => { + const processor = getEventProcessor(); + + const event: Event = { + type: 'transaction', + transaction: 'GET /api/hello', + contexts: { + trace: { + trace_id: 'abc123', + span_id: 'def456', + data: { + 'next.span_type': 'BaseServer.handleRequest', + }, + }, + }, + spans: [ + { + trace_id: 'abc123', + span_id: 'child1', + parent_span_id: 'def456', + start_timestamp: 1000, + timestamp: 1001, + description: 'executing api route (pages) /api/hello', + }, + ], + }; + + expect(processor(event)).toBe(event); + }); + + it('keeps non-BaseServer.handleRequest transactions even without spans', () => { + const processor = getEventProcessor(); + + const event: Event = { + type: 'transaction', + transaction: 'GET /api/hello', + contexts: { + trace: { + trace_id: 'abc123', + span_id: 'def456', + data: { + 'sentry.origin': 'auto.http.nextjs', + }, + }, + }, + spans: [], + }; + + expect(processor(event)).toBe(event); + }); + + it('passes through non-transaction events unchanged', () => { + const processor = getEventProcessor(); + + const event: Event = { + message: 'test error', + }; + + expect(processor(event)).toBe(event); + }); + + it('drops static asset transactions', () => { + const processor = getEventProcessor(); + + const event: Event = { + type: 'transaction', + transaction: 'GET /_next/static/chunks/main.js', + }; + + expect(processor(event)).toBeNull(); + }); + + it('drops /404 transactions', () => { + const processor = getEventProcessor(); + + expect( + processor({ + type: 'transaction', + transaction: '/404', + }), + ).toBeNull(); + + expect( + processor({ + type: 'transaction', + transaction: 'GET /404', + }), + ).toBeNull(); + }); +}); + +describe('isEmptyBaseServerTrace', () => { + it('returns true for empty BaseServer.handleRequest transactions', () => { + expect( + isEmptyBaseServerTrace({ + type: 'transaction', + contexts: { trace: { data: { 'next.span_type': 'BaseServer.handleRequest' } } }, + spans: [], + }), + ).toBe(true); + }); + + it('returns true when spans is undefined', () => { + expect( + isEmptyBaseServerTrace({ + type: 'transaction', + contexts: { trace: { data: { 'next.span_type': 'BaseServer.handleRequest' } } }, + }), + ).toBe(true); + }); + + it('returns false when BaseServer.handleRequest has child spans', () => { + expect( + isEmptyBaseServerTrace({ + type: 'transaction', + contexts: { trace: { data: { 'next.span_type': 'BaseServer.handleRequest' } } }, + spans: [{ span_id: 'child', trace_id: 'abc', start_timestamp: 0 }], + }), + ).toBe(false); + }); + + it('returns false for non-BaseServer.handleRequest transactions', () => { + expect( + isEmptyBaseServerTrace({ + type: 'transaction', + contexts: { trace: { data: { 'sentry.origin': 'auto.http.nextjs' } } }, + spans: [], + }), + ).toBe(false); + }); + + it('returns false for non-transaction events', () => { + expect( + isEmptyBaseServerTrace({ + message: 'test error', + }), + ).toBe(false); + }); +}); From b477b99ded3eb82a9bbfee6271e34af0987e8e43 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 10 Feb 2026 14:47:26 -0500 Subject: [PATCH 2/3] fix: types --- .../nextLowQualityTransactionsFilter.test.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts b/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts index 832bb27d304f..42dfec8088ec 100644 --- a/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts +++ b/packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts @@ -22,9 +22,7 @@ describe('NextLowQualityTransactionsFilter', () => { init({}); const eventProcessors = getGlobalScope()['_eventProcessors']; - const processor = eventProcessors.find( - (p: { id?: string }) => p.id === 'NextLowQualityTransactionsFilter', - ); + const processor = eventProcessors.find((p: { id?: string }) => p.id === 'NextLowQualityTransactionsFilter'); expect(processor).toBeDefined(); return processor as (event: Event) => Event | null; } @@ -32,17 +30,19 @@ describe('NextLowQualityTransactionsFilter', () => { it('drops transactions with sentry.drop_transaction attribute', () => { const processor = getEventProcessor(); - const event: Event = { + const event = { type: 'transaction', transaction: 'GET /api/hello', contexts: { trace: { + trace_id: 'abc123', + span_id: 'def456', data: { 'sentry.drop_transaction': true, }, }, }, - }; + } as Event; expect(processor(event)).toBeNull(); }); @@ -50,7 +50,7 @@ describe('NextLowQualityTransactionsFilter', () => { it('drops empty BaseServer.handleRequest transactions (defensive check for context loss)', () => { const processor = getEventProcessor(); - const event: Event = { + const event = { type: 'transaction', transaction: 'GET /api/hello', contexts: { @@ -64,7 +64,7 @@ describe('NextLowQualityTransactionsFilter', () => { }, }, spans: [], - }; + } as Event; expect(processor(event)).toBeNull(); }); @@ -72,7 +72,7 @@ describe('NextLowQualityTransactionsFilter', () => { it('drops BaseServer.handleRequest transactions with undefined spans', () => { const processor = getEventProcessor(); - const event: Event = { + const event = { type: 'transaction', transaction: 'GET /api/hello', contexts: { @@ -85,7 +85,7 @@ describe('NextLowQualityTransactionsFilter', () => { }, }, // spans is undefined - }; + } as Event; expect(processor(event)).toBeNull(); }); @@ -93,7 +93,7 @@ describe('NextLowQualityTransactionsFilter', () => { it('keeps BaseServer.handleRequest transactions with child spans', () => { const processor = getEventProcessor(); - const event: Event = { + const event = { type: 'transaction', transaction: 'GET /api/hello', contexts: { @@ -112,10 +112,11 @@ describe('NextLowQualityTransactionsFilter', () => { parent_span_id: 'def456', start_timestamp: 1000, timestamp: 1001, + data: {}, description: 'executing api route (pages) /api/hello', }, ], - }; + } as Event; expect(processor(event)).toBe(event); }); @@ -123,7 +124,7 @@ describe('NextLowQualityTransactionsFilter', () => { it('keeps non-BaseServer.handleRequest transactions even without spans', () => { const processor = getEventProcessor(); - const event: Event = { + const event = { type: 'transaction', transaction: 'GET /api/hello', contexts: { @@ -136,7 +137,7 @@ describe('NextLowQualityTransactionsFilter', () => { }, }, spans: [], - }; + } as Event; expect(processor(event)).toBe(event); }); @@ -186,9 +187,9 @@ describe('isEmptyBaseServerTrace', () => { expect( isEmptyBaseServerTrace({ type: 'transaction', - contexts: { trace: { data: { 'next.span_type': 'BaseServer.handleRequest' } } }, + contexts: { trace: { trace_id: 'a', span_id: 'b', data: { 'next.span_type': 'BaseServer.handleRequest' } } }, spans: [], - }), + } as Event), ).toBe(true); }); @@ -196,8 +197,8 @@ describe('isEmptyBaseServerTrace', () => { expect( isEmptyBaseServerTrace({ type: 'transaction', - contexts: { trace: { data: { 'next.span_type': 'BaseServer.handleRequest' } } }, - }), + contexts: { trace: { trace_id: 'a', span_id: 'b', data: { 'next.span_type': 'BaseServer.handleRequest' } } }, + } as Event), ).toBe(true); }); @@ -205,9 +206,9 @@ describe('isEmptyBaseServerTrace', () => { expect( isEmptyBaseServerTrace({ type: 'transaction', - contexts: { trace: { data: { 'next.span_type': 'BaseServer.handleRequest' } } }, - spans: [{ span_id: 'child', trace_id: 'abc', start_timestamp: 0 }], - }), + contexts: { trace: { trace_id: 'a', span_id: 'b', data: { 'next.span_type': 'BaseServer.handleRequest' } } }, + spans: [{ span_id: 'child', trace_id: 'a', start_timestamp: 0, data: {} }], + } as Event), ).toBe(false); }); @@ -215,9 +216,9 @@ describe('isEmptyBaseServerTrace', () => { expect( isEmptyBaseServerTrace({ type: 'transaction', - contexts: { trace: { data: { 'sentry.origin': 'auto.http.nextjs' } } }, + contexts: { trace: { trace_id: 'a', span_id: 'b', data: { 'sentry.origin': 'auto.http.nextjs' } } }, spans: [], - }), + } as Event), ).toBe(false); }); From 527719e7d541e0dd19cd49d32500e15e6b3810f6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 10 Feb 2026 15:01:14 -0500 Subject: [PATCH 3/3] chore: cleanup --- packages/nextjs/src/edge/index.ts | 10 +++------- packages/nextjs/src/server/index.ts | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 09227074fa1c..cba20396c316 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -179,17 +179,13 @@ export function init(options: VercelEdgeOptions = {}): void { return null; } - // Defensive check: Drop empty BaseServer.handleRequest transactions that leaked through - // when dropNextjsRootContext() failed to set the drop attribute (e.g. due to AsyncLocalStorage - // context loss). + // Drop noisy empty BaseServer.handleRequest transactions if (isEmptyBaseServerTrace(event)) { return null; } - - return event; - } else { - return event; } + + return event; }) satisfies EventProcessor, { id: 'NextLowQualityTransactionsFilter' }, ), diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 9c7b4d8a2659..1398ac5eddbe 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -235,9 +235,7 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } - // Defensive check: Drop empty BaseServer.handleRequest transactions that leaked through - // when dropNextjsRootContext() failed to set the drop attribute (e.g. due to AsyncLocalStorage - // context loss). + // Drop noisy empty BaseServer.handleRequest transactions if (isEmptyBaseServerTrace(event)) { return null; } @@ -257,11 +255,9 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } } - - return event; - } else { - return event; } + + return event; }) satisfies EventProcessor, { id: 'NextLowQualityTransactionsFilter' }, ),