Skip to content
Closed
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
24 changes: 22 additions & 2 deletions packages/nextjs/src/common/utils/tracingUtils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.',
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message hard-codes BaseServer.handleRequest, but dropNextjsRootContext() is a generic helper used from pages-router instrumentation and may be called in other tracing contexts. Consider rewording to a more general message (e.g. "Next.js root transaction may not be dropped") to avoid misleading debug output.

Suggested change
'dropNextjsRootContext: No active span found. The BaseServer.handleRequest transaction may not be dropped.',
'dropNextjsRootContext: No active span found. A Next.js root transaction may not be dropped.',

Copilot uses AI. Check for mistakes.
);
}

/**
* 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)
);
}

/**
Expand Down
10 changes: 7 additions & 3 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -178,10 +179,13 @@ export function init(options: VercelEdgeOptions = {}): void {
return null;
}

return event;
} else {
return event;
// Drop noisy empty BaseServer.handleRequest transactions
if (isEmptyBaseServerTrace(event)) {
return null;
}
}

return event;
}) satisfies EventProcessor,
{ id: 'NextLowQualityTransactionsFilter' },
),
Expand Down
12 changes: 8 additions & 4 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -234,6 +235,11 @@ export function init(options: NodeOptions): NodeClient | undefined {
return null;
}

// Drop noisy empty BaseServer.handleRequest transactions
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;
Expand All @@ -249,11 +255,9 @@ export function init(options: NodeOptions): NodeClient | undefined {
return null;
}
}

return event;
} else {
return event;
}

return event;
}) satisfies EventProcessor,
{ id: 'NextLowQualityTransactionsFilter' },
),
Expand Down
232 changes: 232 additions & 0 deletions packages/nextjs/test/server/nextLowQualityTransactionsFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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);
});
Comment on lines +12 to +19
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup in afterEach calls Scope.clear(), but clear() does not remove registered event processors. Since init({}) adds NextLowQualityTransactionsFilter to the global scope each time, processors can accumulate across tests and potentially affect other test files running in the same worker. Consider also clearing getGlobalScope().getScopeData().eventProcessors (e.g., set its length to 0) during teardown, or avoid re-calling init() per test.

Copilot uses AI. Check for mistakes.

function getEventProcessor(): (event: Event) => Event | null {
init({});

const eventProcessors = getGlobalScope()['_eventProcessors'];
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test reaches into the private _eventProcessors field on the scope. Since Scope.getScopeData() is public and already exposes eventProcessors, prefer getGlobalScope().getScopeData().eventProcessors to avoid coupling the test to internal property names.

Suggested change
const eventProcessors = getGlobalScope()['_eventProcessors'];
const eventProcessors = getGlobalScope().getScopeData().eventProcessors;

Copilot uses AI. Check for mistakes.
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 = {
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();
});

it('drops empty BaseServer.handleRequest transactions (defensive check for context loss)', () => {
const processor = getEventProcessor();

const 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: [],
} as Event;

expect(processor(event)).toBeNull();
});

it('drops BaseServer.handleRequest transactions with undefined spans', () => {
const processor = getEventProcessor();

const event = {
type: 'transaction',
transaction: 'GET /api/hello',
contexts: {
trace: {
trace_id: 'abc123',
span_id: 'def456',
data: {
'next.span_type': 'BaseServer.handleRequest',
},
},
},
// spans is undefined
} as Event;

expect(processor(event)).toBeNull();
});

it('keeps BaseServer.handleRequest transactions with child spans', () => {
const processor = getEventProcessor();

const 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,
data: {},
description: 'executing api route (pages) /api/hello',
},
],
} as Event;

expect(processor(event)).toBe(event);
});

it('keeps non-BaseServer.handleRequest transactions even without spans', () => {
const processor = getEventProcessor();

const event = {
type: 'transaction',
transaction: 'GET /api/hello',
contexts: {
trace: {
trace_id: 'abc123',
span_id: 'def456',
data: {
'sentry.origin': 'auto.http.nextjs',
},
},
},
spans: [],
} as Event;

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: { trace_id: 'a', span_id: 'b', data: { 'next.span_type': 'BaseServer.handleRequest' } } },
spans: [],
} as Event),
).toBe(true);
});

it('returns true when spans is undefined', () => {
expect(
isEmptyBaseServerTrace({
type: 'transaction',
contexts: { trace: { trace_id: 'a', span_id: 'b', data: { 'next.span_type': 'BaseServer.handleRequest' } } },
} as Event),
).toBe(true);
});

it('returns false when BaseServer.handleRequest has child spans', () => {
expect(
isEmptyBaseServerTrace({
type: 'transaction',
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);
});

it('returns false for non-BaseServer.handleRequest transactions', () => {
expect(
isEmptyBaseServerTrace({
type: 'transaction',
contexts: { trace: { trace_id: 'a', span_id: 'b', data: { 'sentry.origin': 'auto.http.nextjs' } } },
spans: [],
} as Event),
).toBe(false);
});

it('returns false for non-transaction events', () => {
expect(
isEmptyBaseServerTrace({
message: 'test error',
}),
).toBe(false);
});
});
Loading