Skip to content

Commit a49a615

Browse files
Lms24claude
andcommitted
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) <noreply@anthropic.com>
1 parent bcb8427 commit a49a615

5 files changed

Lines changed: 110 additions & 63 deletions

File tree

‎packages/core/src/client.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
677677
callback: (spanScope: { scope: Scope; parentSpan: Span | undefined }) => void,
678678
): () => void;
679679

680+
/**
681+
* Register a callback right before a span is created, before `ignoreSpans` is checked. Listeners can change the
682+
* name and the attributes of the span by mutating the passed `spanStartData` object.
683+
* @returns {() => void} A function that, when executed, removes the registered callback.
684+
*/
685+
public on(
686+
hook: 'beforeSpanStart',
687+
callback: (spanStartData: { name: string | undefined; attributes: SpanAttributes }) => void,
688+
): () => void;
689+
680690
/**
681691
* Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision`
682692
* 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<O extends ClientOptions = ClientOptions> {
10381048
/** A hook that is called right before a span is created; listeners mutate the passed object. */
10391049
public emit(hook: 'prepareSpanScope', spanScope: { scope: Scope; parentSpan: Span | undefined }): void;
10401050

1051+
/** A hook that is called right before a span is created, before `ignoreSpans` is checked. */
1052+
public emit(hook: 'beforeSpanStart', spanStartData: { name: string | undefined; attributes: SpanAttributes }): void;
1053+
10411054
/** A hook that is called every time before a span is sampled. */
10421055
public emit(
10431056
hook: 'beforeSampling',

‎packages/core/src/tracing/trace.ts‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,12 @@ function createChildOrRootSpan({
362362
}
363363

364364
const client = getClient();
365+
// Copy the attributes, so listeners do not mutate an object the caller passed in
366+
const spanStartData = { name: spanArguments.name, attributes: { ...spanArguments.attributes } };
367+
client?.emit('beforeSpanStart', spanStartData);
368+
spanArguments.name = spanStartData.name;
369+
spanArguments.attributes = spanStartData.attributes;
370+
365371
if (_shouldIgnoreStreamedSpan(client, spanArguments)) {
366372
if (!isTracingSuppressed(scope)) {
367373
// if tracing is actively suppressed (Sentry.suppressTracing(...)),
@@ -490,10 +496,8 @@ function _startRootSpan(
490496

491497
const mutableSpanSamplingData = { spanAttributes: { ...spanArguments.attributes }, spanName: name, parentSampled };
492498

493-
const samplingDecision = { decision: true };
494-
client?.emit('beforeSampling', mutableSpanSamplingData, samplingDecision);
495-
// Like suppressed tracing, a span dropped by a hook is SDK-internal and records no client outcome
496-
const _isDroppedBeforeSampling = !samplingDecision.decision;
499+
// we don't care about the decision for the moment; this is just a placeholder
500+
client?.emit('beforeSampling', mutableSpanSamplingData, { decision: false });
497501

498502
// If hook consumers override the parentSampled flag, we will use that value instead of the actual one
499503
const finalParentSampled = mutableSpanSamplingData.parentSampled ?? parentSampled;
@@ -502,20 +506,19 @@ function _startRootSpan(
502506
const currentPropagationContext = scope.getPropagationContext();
503507
const _isTracingSuppressed = isTracingSuppressed(scope);
504508

505-
const [sampled, sampleRate, localSampleRateWasApplied, dropReason] =
506-
_isTracingSuppressed || _isDroppedBeforeSampling
507-
? [false]
508-
: sampleSpan(
509-
options,
510-
{
511-
name,
512-
parentSampled: finalParentSampled,
513-
attributes: finalAttributes,
514-
normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest,
515-
parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate),
516-
},
517-
currentPropagationContext.sampleRand,
518-
);
509+
const [sampled, sampleRate, localSampleRateWasApplied, dropReason] = _isTracingSuppressed
510+
? [false]
511+
: sampleSpan(
512+
options,
513+
{
514+
name,
515+
parentSampled: finalParentSampled,
516+
attributes: finalAttributes,
517+
normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest,
518+
parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate),
519+
},
520+
currentPropagationContext.sampleRand,
521+
);
519522

520523
const rootSpan = new SentrySpan({
521524
...spanArguments,
@@ -528,7 +531,7 @@ function _startRootSpan(
528531
sampled,
529532
});
530533

531-
if (!sampled && client && !_isTracingSuppressed && !_isDroppedBeforeSampling) {
534+
if (!sampled && client && !_isTracingSuppressed) {
532535
DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.');
533536
const outcomeReason = dropReason || 'sample_rate';
534537
// A standalone span is sent on its own and never becomes a transaction.

‎packages/core/test/lib/tracing/trace.test.ts‎

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,6 +2717,51 @@ describe('ignoreSpans (core path, streaming)', () => {
27172717
});
27182718
});
27192719

2720+
it('ignores a span that a beforeSpanStart listener marks for ignoreSpans', () => {
2721+
const options = getDefaultTestClientOptions({
2722+
tracesSampleRate: 1,
2723+
traceLifecycle: 'stream',
2724+
ignoreSpans: [{ attributes: { 'test.ignore': true } }],
2725+
});
2726+
client = new TestClient(options);
2727+
setCurrentClient(client);
2728+
client.init();
2729+
const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent');
2730+
client.on('beforeSpanStart', ({ name, attributes }) => {
2731+
if (name === 'GET') {
2732+
attributes['test.ignore'] = true;
2733+
}
2734+
});
2735+
2736+
startSpan({ name: 'GET' }, span => {
2737+
expect(span).toBeInstanceOf(SentryNonRecordingSpan);
2738+
});
2739+
startSpan({ name: 'GET /users' }, span => {
2740+
expect(span).toBeInstanceOf(SentrySpan);
2741+
});
2742+
2743+
expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1);
2744+
expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span');
2745+
});
2746+
2747+
it('applies the changes of beforeSpanStart listeners without mutating the attributes passed in', () => {
2748+
const options = getDefaultTestClientOptions({ tracesSampleRate: 1 });
2749+
client = new TestClient(options);
2750+
setCurrentClient(client);
2751+
client.init();
2752+
client.on('beforeSpanStart', spanStartData => {
2753+
spanStartData.name = 'renamed';
2754+
spanStartData.attributes['test.added'] = 'yes';
2755+
});
2756+
2757+
const attributes = { 'test.original': 'yes' };
2758+
const span = startInactiveSpan({ name: 'original', attributes });
2759+
2760+
expect(spanToJSON(span).name).toBe('renamed');
2761+
expect(spanToJSON(span).attributes).toMatchObject({ 'test.original': 'yes', 'test.added': 'yes' });
2762+
expect(attributes).toEqual({ 'test.original': 'yes' });
2763+
});
2764+
27202765
it('returns SentryNonRecordingSpan for startInactiveSpan matching ignoreSpans', () => {
27212766
const options = getDefaultTestClientOptions({
27222767
tracesSampleRate: 1,
@@ -2788,26 +2833,6 @@ describe('ignoreSpans (core path, streaming)', () => {
27882833
expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'sample_rate', 'span');
27892834
});
27902835

2791-
it('does not record a root span that a beforeSampling hook drops, nor an outcome for it', () => {
2792-
const options = getDefaultTestClientOptions({
2793-
tracesSampleRate: 1,
2794-
traceLifecycle: 'stream',
2795-
});
2796-
client = new TestClient(options);
2797-
setCurrentClient(client);
2798-
client.init();
2799-
client.on('beforeSampling', (_samplingData, samplingDecision) => {
2800-
samplingDecision.decision = false;
2801-
});
2802-
const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent');
2803-
2804-
startSpan({ name: 'GET /foo' }, span => {
2805-
expect(spanIsSampled(span)).toBe(false);
2806-
});
2807-
2808-
expect(spyOnDroppedEvent).not.toHaveBeenCalled();
2809-
});
2810-
28112836
it('records sample_rate outcomes for the transaction and all of its spans on static path', () => {
28122837
const options = getDefaultTestClientOptions({
28132838
tracesSampleRate: 0,

‎packages/nextjs/src/server/serverSpanHooks.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,11 @@ export function addNextjsServerSpanHooks(client: Client): void {
8383
) {
8484
samplingDecision.decision = false;
8585
}
86+
});
8687

87-
if (isProxyInvocationSpan(spanAttributes)) {
88-
samplingDecision.decision = false;
88+
client.on('beforeSpanStart', ({ attributes }) => {
89+
if (isProxyInvocationSpan(attributes)) {
90+
attributes[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION] = true;
8991
}
9092
});
9193

@@ -134,9 +136,8 @@ function isProxyInvocationSpan(spanAttributes: Record<string, unknown>): boolean
134136
}
135137

136138
// Next.js sets this flag on the request before the call that only runs the proxy, and resets it before rendering
137-
const request = _INTERNAL_getIncomingRequestForScope(getIsolationScope()) as
138-
| { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } }
139-
| undefined;
139+
const request: { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } } | undefined =
140+
_INTERNAL_getIncomingRequestForScope(getIsolationScope());
140141
return request?.[NEXT_REQUEST_META]?.middlewareInvoke === true;
141142
}
142143

‎packages/nextjs/test/server/serverSpanHooks.test.ts‎

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@ import {
66
SENTRY_SEGMENT_NAME_SOURCE,
77
} from '@sentry/conventions/attributes';
88
import { MIDDLEWARE } from '@sentry/conventions/op';
9-
import type { Client } from '@sentry/core';
9+
import type { Client, SpanAttributes } from '@sentry/core';
1010
import {
1111
_INTERNAL_setIncomingRequestForScope,
1212
addChildSpanToSpan,
1313
GLOBAL_OBJ,
1414
SentrySpan,
15+
shouldIgnoreSpan,
1516
spanToStaticSpanJSON,
1617
withIsolationScope,
1718
} from '@sentry/core';
1819
import { afterEach, describe, expect, it } from 'vitest';
1920
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes';
20-
import { addNextjsServerSpanHooks, handleOnSpanStart } from '../../src/server/serverSpanHooks';
21+
import {
22+
addNextjsServerSpanHooks,
23+
handleOnSpanStart,
24+
NEXTJS_SERVER_IGNORE_SPANS,
25+
} from '../../src/server/serverSpanHooks';
2126

2227
const client = { getOptions: () => ({}) } as unknown as Client;
2328

@@ -179,51 +184,51 @@ describe('handleOnSpanStart', () => {
179184
});
180185
});
181186

182-
describe('addNextjsServerSpanHooks beforeSampling', () => {
187+
describe('addNextjsServerSpanHooks beforeSpanStart', () => {
183188
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion?: string };
184189

185190
afterEach(() => {
186191
delete globalWithInjectedValues._sentryNextJsVersion;
187192
});
188193

189-
function getSamplingDecision(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean {
194+
function isRequestSpanIgnored(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean {
190195
globalWithInjectedValues._sentryNextJsVersion = nextVersion;
191196

192-
let beforeSampling: ((data: unknown, decision: { decision: boolean }) => void) | undefined;
197+
let beforeSpanStart: ((data: { name: string | undefined; attributes: SpanAttributes }) => void) | undefined;
193198
addNextjsServerSpanHooks({
194-
on: (hook: string, callback: typeof beforeSampling) => {
195-
if (hook === 'beforeSampling') {
196-
beforeSampling = callback;
199+
on: (hook: string, callback: typeof beforeSpanStart) => {
200+
if (hook === 'beforeSpanStart') {
201+
beforeSpanStart = callback;
197202
}
198203
},
199204
getOptions: () => ({}),
200205
} as unknown as Client);
201206

202207
const request = { [Symbol.for('NextInternalRequestMeta')]: { middlewareInvoke } };
203-
const samplingDecision = { decision: true };
208+
const spanStartData = { name: 'GET', attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } };
204209
withIsolationScope(isolationScope => {
205210
_INTERNAL_setIncomingRequestForScope(isolationScope, request);
206-
beforeSampling?.(
207-
{ spanAttributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' }, spanName: 'GET' },
208-
samplingDecision,
209-
);
211+
beforeSpanStart?.(spanStartData);
210212
});
211-
return samplingDecision.decision;
213+
return shouldIgnoreSpan(
214+
{ description: spanStartData.name, attributes: spanStartData.attributes },
215+
NEXTJS_SERVER_IGNORE_SPANS,
216+
);
212217
}
213218

214-
it('drops the request span that Next.js only starts to run the proxy', () => {
215-
expect(getSamplingDecision('16.3.0', true)).toBe(false);
219+
it('ignores the request span that Next.js only starts to run the proxy', () => {
220+
expect(isRequestSpanIgnored('16.3.0', true)).toBe(true);
216221
});
217222

218223
it('keeps the request span that renders the route', () => {
219-
expect(getSamplingDecision('16.3.0', false)).toBe(true);
224+
expect(isRequestSpanIgnored('16.3.0', false)).toBe(false);
220225
});
221226

222227
it('keeps the request span that runs the proxy before Next.js 16.3, since the proxy span is its child there', () => {
223-
expect(getSamplingDecision('16.2.4', true)).toBe(true);
228+
expect(isRequestSpanIgnored('16.2.4', true)).toBe(false);
224229
});
225230

226231
it('keeps the request span that runs the proxy if the Next.js version is unknown', () => {
227-
expect(getSamplingDecision(undefined, true)).toBe(true);
232+
expect(isRequestSpanIgnored(undefined, true)).toBe(false);
228233
});
229234
});

0 commit comments

Comments
 (0)