Skip to content

Commit 3bf00a6

Browse files
isaacsclaude
andcommitted
feat(core): emit low cardinality request handler span names (#23533)
Name `handler` spans after the route they serve when span streaming is enabled, or `Request handler` if no route set. Static mode left as is. Drop Fastify and Hapi method, as the template dictates. NestJS and Elysia resolve no route when the span starts. The NestJS callback name stays on `nestjs.callback`. Elysia callback name is added to `code.function.name`, so no information is lost by removing it from the span name. Also: correct `REQUEST_HANDLER_SPAN_NAME_FALLBACK`: the conventions spell the fallback `Request handler`, and its `@see` link pointed at the resource section. closes #23533 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 63119eb commit 3bf00a6

15 files changed

Lines changed: 330 additions & 27 deletions

File tree

MIGRATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ The following span names were adjusted:
627627
| ------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
628628
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
629629
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
630+
| `handler` | Framework-specific, often carrying the request method (`GET /users/:id`, `route-handler`, `getUser`) | The span's `http.route`, or `Request handler` if the SDK has none |
630631
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
631632
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |
632633

@@ -642,6 +643,8 @@ For the same reason, `useOperationNameForRootSpan` no longer renames the enclosi
642643

643644
Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.
644645

646+
Only the Express, Fastify and Hapi integrations resolve a route template for `handler` spans. NestJS and Elysia have none when the span starts, so their request handler spans are named `Request handler`. The handler function name stays on an attribute: `nestjs.callback` for NestJS, and `code.function.name` for Elysia, which now sets it on every lifecycle handler span.
647+
645648
Child spans of a service or root span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references.
646649

647650
`ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
traceLifecycle: 'stream',
10+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
2+
import Fastify from 'fastify';
3+
4+
const app = Fastify();
5+
6+
app.get(
7+
'/test-transaction/:id',
8+
{
9+
preHandler: function routePreHandler(_request, _reply, done) {
10+
done();
11+
},
12+
},
13+
async () => {
14+
return {};
15+
},
16+
);
17+
18+
const run = async () => {
19+
await app.listen({ port: 0, host: 'localhost' });
20+
sendPortToRunner(app.server.address().port);
21+
};
22+
23+
run();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
3+
4+
describe('fastify auto-instrumentation (streamed)', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
10+
test('names request handler spans after their route', async () => {
11+
const runner = createRunner()
12+
.expect({
13+
span: container => {
14+
const handlerSpans = container.items.filter(item => item.attributes['sentry.op']?.value === 'handler');
15+
16+
expect(handlerSpans).not.toHaveLength(0);
17+
for (const span of handlerSpans) {
18+
expect(span.name).toBe('/test-transaction/:id');
19+
}
20+
},
21+
})
22+
.start();
23+
24+
await runner.makeRequest('get', '/test-transaction/123');
25+
26+
await runner.completed();
27+
});
28+
});
29+
});

packages/core/src/integrations/express/patch-layer.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { DEBUG_BUILD } from '../../debug-build';
3333
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
3434
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
3535
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
36-
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
36+
import { REQUEST_HANDLER_SPAN_NAME_FALLBACK, ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
3737
import { startSpanManual } from '../../tracing/trace';
3838
import { debug } from '../../utils/debug-logger';
3939
import type { SpanAttributes } from '../../types/span';
@@ -168,10 +168,17 @@ export function patchLayer(
168168
}
169169

170170
const client = getClient();
171-
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
172-
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);
171+
// With span streaming, span names have to be low cardinality, so router and request
172+
// handler spans are named after their route.
173+
const isStreamedSpan = !!client && hasSpanStreamingEnabled(client);
174+
const isStreamedRouterSpan = isStreamedSpan && type === ExpressLayerType_ROUTER;
175+
const isStreamedRequestHandlerSpan = isStreamedSpan && type === ExpressLayerType_REQUEST_HANDLER;
173176

174-
const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;
177+
const spanName = isStreamedRouterSpan
178+
? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK
179+
: isStreamedRequestHandlerSpan
180+
? actualMatchedRoute || REQUEST_HANDLER_SPAN_NAME_FALLBACK
181+
: name;
175182

176183
return startSpanManual({ name: spanName, attributes }, span => {
177184
let spanHasEnded = false;

packages/core/src/tracing/spans/spanNames.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@ export const ROUTER_SPAN_NAME_FALLBACK = 'Router';
7676

7777
/**
7878
* Fallback name for request handler spans when no better-suited span name is available.
79-
* @see https://getsentry.github.io/sentry-conventions/names/#resource-resources
79+
* @see https://getsentry.github.io/sentry-conventions/names/#web_server-request-handler
8080
*/
81-
export const REQUEST_HANDLER_SPAN_NAME_FALLBACK = 'Request Handler';
81+
export const REQUEST_HANDLER_SPAN_NAME_FALLBACK = 'Request handler';

packages/core/test/lib/integrations/express/patch-layer.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,79 @@ describe('patchLayer', () => {
542542
]);
543543
});
544544

545+
it('names request handler spans after their route when span streaming is enabled', () => {
546+
spanStreamingEnabled = true;
547+
const options: ExpressPatchLayerOptions = {};
548+
const req = Object.assign(new EventEmitter(), {
549+
originalUrl: '/a/b/c',
550+
}) as unknown as ExpressRequest;
551+
552+
const layer = {
553+
name: 'handle',
554+
handle: vi.fn(),
555+
} as unknown as ExpressLayer;
556+
557+
const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;
558+
559+
storeLayer(req, '/a');
560+
storeLayer(req, '/b');
561+
562+
patchLayer(() => options, layer, '/c');
563+
layer.handle(req, res);
564+
565+
checkSpans([
566+
{
567+
status: { code: 0, message: 'OK' },
568+
data: {
569+
'express.name': '/a/b/c',
570+
'express.type': 'request_handler',
571+
'http.route': '/a/b/c',
572+
'sentry.op': 'handler',
573+
'sentry.origin': 'auto.http.express',
574+
},
575+
description: '/a/b/c',
576+
},
577+
]);
578+
res.emit('finish');
579+
checkSpans([]);
580+
});
581+
582+
it('falls back to a static request handler span name when the route is unknown', () => {
583+
spanStreamingEnabled = true;
584+
const options: ExpressPatchLayerOptions = {};
585+
const req = Object.assign(new EventEmitter(), {
586+
originalUrl: '/abcdef',
587+
}) as unknown as ExpressRequest;
588+
589+
const layer = {
590+
name: 'handle',
591+
handle: vi.fn(),
592+
} as unknown as ExpressLayer;
593+
594+
const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;
595+
596+
storeLayer(req, '/a');
597+
storeLayer(req, '/b');
598+
599+
patchLayer(() => options, layer, '/c');
600+
layer.handle(req, res);
601+
602+
checkSpans([
603+
{
604+
status: { code: 0, message: 'OK' },
605+
data: {
606+
'express.name': '/a/b/c',
607+
'express.type': 'request_handler',
608+
'sentry.op': 'handler',
609+
'sentry.origin': 'auto.http.express',
610+
},
611+
description: 'Request handler',
612+
},
613+
]);
614+
res.emit('finish');
615+
checkSpans([]);
616+
});
617+
545618
it('handles case when route does not match url', () => {
546619
const onRouteResolved = vi.fn();
547620
const options: ExpressPatchLayerOptions = { onRouteResolved };

packages/elysia/src/withElysia.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
1+
import {
2+
SENTRY_SEGMENT_NAME_SOURCE,
3+
CODE_FUNCTION_NAME,
4+
HTTP_ROUTE,
5+
URL_FULL,
6+
URL_PATH,
7+
} from '@sentry/conventions/attributes';
28
import { MIDDLEWARE } from '@sentry/conventions/op';
39
import type { Span } from '@sentry/core';
410
import {
511
captureException,
612
continueTrace,
713
getActiveSpan,
14+
getClient,
815
getIsolationScope,
916
getRootSpan,
1017
getTraceData,
18+
hasSpanStreamingEnabled,
19+
REQUEST_HANDLER_SPAN_NAME_FALLBACK,
1120
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1221
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1322
setHttpStatus,
@@ -115,8 +124,13 @@ function instrumentLifecyclePhase(phaseName: string, listener: TraceListener, ro
115124
}
116125

117126
void listener(process => {
127+
const client = getClient();
128+
// With span streaming, span names have to be low cardinality. Elysia resolves the
129+
// route only after the handler ran, so request handler spans use the static fallback.
130+
const isStreamedRequestHandlerSpan = op === 'handler' && !!client && hasSpanStreamingEnabled(client);
131+
118132
const phaseSpan = startInactiveSpan({
119-
name: phaseName,
133+
name: isStreamedRequestHandlerSpan ? REQUEST_HANDLER_SPAN_NAME_FALLBACK : phaseName,
120134
parentSpan: rootSpan,
121135
attributes: {
122136
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
@@ -130,11 +144,12 @@ function instrumentLifecyclePhase(phaseName: string, listener: TraceListener, ro
130144
void process.onEvent(child => {
131145
const handlerName = child.name || 'anonymous';
132146
const childSpan = startInactiveSpan({
133-
name: handlerName,
147+
name: isStreamedRequestHandlerSpan ? REQUEST_HANDLER_SPAN_NAME_FALLBACK : handlerName,
134148
parentSpan: phaseSpan,
135149
attributes: {
136150
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
137151
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN,
152+
[CODE_FUNCTION_NAME]: handlerName,
138153
},
139154
});
140155

packages/elysia/test/withElysia.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
55
// Capture handlers registered by withElysia
66
let onAfterHandleHandler: (context: unknown) => void;
77
let onErrorHandler: (context: unknown) => void;
8+
let traceHandler: (lifecycle: unknown) => void;
89

910
function createMockApp() {
1011
const app: Record<string, unknown> = {};
1112
app.use = vi.fn().mockReturnValue(app);
1213
app.wrap = vi.fn().mockReturnValue(app);
13-
app.trace = vi.fn().mockReturnValue(app);
14+
app.trace = vi.fn((_opts: unknown, handler: (lifecycle: unknown) => void) => {
15+
traceHandler = handler;
16+
return app;
17+
});
1418
app.onRequest = vi.fn(() => app);
1519
app.onAfterHandle = vi.fn((_opts: unknown, handler: (context: unknown) => void) => {
1620
onAfterHandleHandler = handler;
@@ -30,9 +34,16 @@ const mockGetIsolationScope = vi.fn(() => ({
3034
setSDKProcessingMetadata: vi.fn(),
3135
setTransactionName: vi.fn(),
3236
}));
37+
let traceLifecycle: 'static' | 'stream' = 'stream';
3338
const mockGetClient = vi.fn(() => ({
3439
on: vi.fn(),
40+
getOptions: () => ({ traceLifecycle }),
3541
}));
42+
const startedSpans: { name: string; attributes?: Record<string, unknown> }[] = [];
43+
const mockStartInactiveSpan = vi.fn((options: { name: string; attributes?: Record<string, unknown> }) => {
44+
startedSpans.push({ name: options.name, attributes: options.attributes });
45+
return { end: vi.fn() };
46+
});
3647
const mockRootSpan = {
3748
setAttribute: vi.fn(),
3849
setAttributes: vi.fn(),
@@ -56,6 +67,8 @@ vi.mock('@sentry/core', async importActual => {
5667
getClient: () => mockGetClient(),
5768
getRootSpan: () => mockGetRootSpan(),
5869
getTraceData: () => mockGetTraceData(),
70+
startInactiveSpan: (options: { name: string; attributes?: Record<string, unknown> }) =>
71+
mockStartInactiveSpan(options),
5972
};
6073
});
6174

@@ -65,6 +78,8 @@ const { withElysia } = await import('../src/withElysia');
6578
describe('withElysia', () => {
6679
beforeEach(() => {
6780
mockApp = createMockApp();
81+
startedSpans.length = 0;
82+
traceLifecycle = 'stream';
6883
});
6984

7085
afterEach(() => {
@@ -185,6 +200,58 @@ describe('withElysia', () => {
185200
});
186201
});
187202

203+
describe('request handler span names', () => {
204+
/** Drive the registered trace handler through a single `Handle` phase. */
205+
function runHandlePhase(handlerNames: string[]): void {
206+
traceHandler({
207+
context: { request: new Request('http://localhost/test') },
208+
onHandle: (callback: (process: unknown) => void) => {
209+
callback({
210+
total: handlerNames.length,
211+
onEvent: (onChild: (child: unknown) => void) => {
212+
for (const name of handlerNames) {
213+
onChild({ name, onStop: () => {} });
214+
}
215+
},
216+
onStop: () => {},
217+
});
218+
},
219+
});
220+
}
221+
222+
it('uses the low cardinality fallback when span streaming is enabled', () => {
223+
// @ts-expect-error - mock app
224+
withElysia(mockApp);
225+
runHandlePhase(['getUser']);
226+
227+
expect(startedSpans.map(span => span.name)).toEqual(['Request handler', 'Request handler']);
228+
});
229+
230+
it('keeps the phase and handler names in static mode', () => {
231+
traceLifecycle = 'static';
232+
// @ts-expect-error - mock app
233+
withElysia(mockApp);
234+
runHandlePhase(['getUser']);
235+
236+
expect(startedSpans.map(span => span.name)).toEqual(['Handle', 'getUser']);
237+
});
238+
239+
it('records the handler name on the child span in both trace lifecycles', () => {
240+
// @ts-expect-error - mock app
241+
withElysia(mockApp);
242+
runHandlePhase(['getUser']);
243+
244+
traceLifecycle = 'static';
245+
// @ts-expect-error - mock app
246+
withElysia(createMockApp());
247+
runHandlePhase(['getUser']);
248+
249+
const childSpans = startedSpans.filter(span => span.attributes?.['code.function.name']);
250+
expect(childSpans).toHaveLength(2);
251+
expect(childSpans.every(span => span.attributes?.['code.function.name'] === 'getUser')).toBe(true);
252+
});
253+
});
254+
188255
describe('custom shouldHandleError', () => {
189256
it('uses custom shouldHandleError when provided', () => {
190257
const customShouldHandle = vi.fn(() => false);

packages/nestjs/src/integrations/wrap-route.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { HTTP_METHOD, HTTP_ROUTE, SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes';
22
import { FUNCTION } from '@sentry/conventions/op';
33
import type { SpanAttributes } from '@sentry/core';
4-
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, filterCollectedUrl } from '@sentry/core';
4+
import {
5+
getClient,
6+
hasSpanStreamingEnabled,
7+
REQUEST_HANDLER_SPAN_NAME_FALLBACK,
8+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
9+
startSpan,
10+
filterCollectedUrl,
11+
} from '@sentry/core';
512
import type { AnyFn } from './helpers';
613
import { copyReflectMetadata, HTTP_ORIGIN, isWrapped, markWrapped } from './helpers';
714
import { AttributeNames, NestType } from './enums';
@@ -62,7 +69,15 @@ export function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn
6269
[AttributeNames.VERSION]: moduleVersion || undefined,
6370
};
6471
const wrapped = function (this: unknown, ...args: unknown[]): unknown {
65-
return startSpan({ name: spanName, attributes }, () => callback.apply(this, args));
72+
const client = getClient();
73+
// With span streaming, span names have to be low cardinality. NestJS resolves no
74+
// route for the handler span, so it uses the static fallback. The callback name
75+
// stays on the `nestjs.callback` attribute.
76+
const isStreamedSpan = !!client && hasSpanStreamingEnabled(client);
77+
78+
return startSpan({ name: isStreamedSpan ? REQUEST_HANDLER_SPAN_NAME_FALLBACK : spanName, attributes }, () =>
79+
callback.apply(this, args),
80+
);
6681
};
6782
if (callback.name) {
6883
Object.defineProperty(wrapped, 'name', { value: callback.name });

0 commit comments

Comments
 (0)