From 50108647e36ce5eb31fe90c9f487acf8c64762c2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 22 Sep 2026 13:44:48 +0200 Subject: [PATCH] fix(nestjs): Follow the `function` conventions for `@OnEvent` span names The `@OnEvent` handler span was named `event ` and set no `code.function.name`, so the `event ` prefix was derivable from nothing and Relay could infer no description at all for it. Both the name and the description conventions for the `function` op are built from `code.function.name`. Name the span after the event the handler listens to and set that as `code.function.name`. The event is the closest thing to an identity the SDK has for an `@OnEvent` handler, and it is what made the old name useful. The prefixed name moves to `sentry.description`, matching what the bullmq helper next to it already does. Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATION.md | 1 + .../tests/events.test.ts | 10 +++--- packages/nestjs/src/integrations/helpers.ts | 13 ++++++- .../orchestrion-subscriber.test.ts | 34 ++++++++++++++++--- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index cf5e833d40e3..0bea28105dad 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1037,6 +1037,7 @@ The following span names were adjusted: | `function` (SvelteKit) | The route the wrapped function ran for, or the raw URL path if the SDK couldn't resolve one | `/users/[id]`, `/users/123`, `GET /api/users/[id]` | The name of the wrapped function | `load`, `GET` | | `function` (Ember route hooks) | The full route name | `slow-loading-route.index` | The hook the span wraps, matching its `code.function.name`. The route moves to `sentry.description` | `beforeModel`, `model`, `setupController` | | `function` (React Router route hooks) | The route the hook ran for, the raw URL path if React Router matched no pattern, or the fetcher key | `/users/:id`, `/users/123`, `Fetcher fetcher-1` | The hook the span wraps, matching its `code.function.name`. The previous name moves to `sentry.description` | `loader`, `action`, `clientLoader`, `fetcher` | +| `function` (NestJS `@OnEvent` handlers) | The event the handler listens to, prefixed with `event ` | `event user.created` | The event the handler listens to, which is also its `code.function.name` | `user.created` | | `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | | `function.aws` | The Lambda function name | `my-function` | Unchanged, except that the SDK now falls back to `Serverless function execution` if it cannot resolve the function name | `my-function`, `Serverless function execution` | | `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` | diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index a541ce9ffc1c..796203640063 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -10,7 +10,7 @@ test('Event emitter', async () => { }); const successEventSpanPromise = waitForStreamedSpan( APP_NAME, - span => span.is_segment && span.name === 'event myEvent.pass', + span => span.is_segment && span.name === 'myEvent.pass', ); const eventsUrl = `http://localhost:3050/events/emit`; @@ -36,7 +36,7 @@ test('Event emitter', async () => { // A segment span also carries the scope contexts and the SDK's integration list, which vary by // machine, so only the event-handler attributes are pinned here. expect(successEventSpan).toEqual({ - name: 'event myEvent.pass', + name: 'myEvent.pass', span_id: expect.stringMatching(/^[a-f0-9]{16}$/), trace_id: expect.stringMatching(/^[a-f0-9]{32}$/), parent_span_id: expect.stringMatching(/^[a-f0-9]{16}$/), @@ -49,7 +49,9 @@ test('Event emitter', async () => { 'sentry.origin': { type: 'string', value: 'auto.event.nestjs' }, 'sentry.segment.name.source': { type: 'string', value: 'custom' }, 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, - 'sentry.segment.name': { type: 'string', value: 'event myEvent.pass' }, + 'sentry.segment.name': { type: 'string', value: 'myEvent.pass' }, + 'sentry.description': { type: 'string', value: 'event myEvent.pass' }, + 'code.function.name': { type: 'string', value: 'myEvent.pass' }, 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, 'sentry.environment': { type: 'string', value: 'qa' }, }), @@ -100,7 +102,7 @@ test('Multiple OnEvent decorators', async () => { const rootSpan = await rootSpanPromise; const findHandlerSpans = () => - streamedSpans.filter(span => span.is_segment && span.name === 'event multiple.first|multiple.second'); + streamedSpans.filter(span => span.is_segment && span.name === 'multiple.first|multiple.second'); await expect.poll(() => findHandlerSpans().length).toBe(2); // Streamed spans carry no scope tags, so the app reports its isolation scope as an attribute. diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index cfbdc1b2dd61..8759a78b31ee 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -1,7 +1,9 @@ import { + CODE_FUNCTION_NAME, MESSAGING_DESTINATION_NAME, MESSAGING_OPERATION_TYPE, MESSAGING_SYSTEM, + SENTRY_DESCRIPTION, SENTRY_OP, } from '@sentry/conventions/attributes'; import { FUNCTION, MIDDLEWARE, QUEUE_PROCESS } from '@sentry/conventions/op'; @@ -111,11 +113,20 @@ export function getEventSpanOptions(event: string): { attributes: Record; forceTransaction: boolean; } { + const client = getClient(); + const isStreamed = !!client && hasSpanStreamingEnabled(client); + const description = `event ${event}`; + return { - name: `event ${event}`, + // With span streaming, a `function` span is named after what it wraps. An `@OnEvent` handler is + // identified by the event it listens to, so that doubles as its `code.function.name`. + name: isStreamed ? event : description, attributes: { [SENTRY_OP]: FUNCTION, + [CODE_FUNCTION_NAME]: event, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs', + // Relay infers a `function` span's description from `code.function.name` alone, which drops the prefix. + ...(isStreamed && { [SENTRY_DESCRIPTION]: description }), }, // oxlint-disable-next-line typescript/no-deprecated forceTransaction: true, diff --git a/packages/nestjs/test/integrations/orchestrion-subscriber.test.ts b/packages/nestjs/test/integrations/orchestrion-subscriber.test.ts index 5b7d79fb53a8..04d6d5598c63 100644 --- a/packages/nestjs/test/integrations/orchestrion-subscriber.test.ts +++ b/packages/nestjs/test/integrations/orchestrion-subscriber.test.ts @@ -876,11 +876,37 @@ describe('NestJS orchestrion subscriber: schedule / event / bullmq', () => { await (descriptor.value as AnyFn)(); const json = spanToJSON(spanInside!); - expect(json.name).toBe('event user.created'); + expect(json.name).toBe('user.created'); expect(json.attributes['sentry.op']).toBe('function'); + expect(json.attributes['code.function.name']).toBe('user.created'); + expect(json.attributes['sentry.description']).toBe('event user.created'); expect(json.attributes['sentry.origin']).toBe('auto.event.nestjs'); }); + it('event @OnEvent: keeps the transaction-mode span name when span streaming is off', async () => { + installTestAsyncContextStrategy(); + initTestClient('static'); + subscribeToNestChannels(); + + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_ONEVENT, ['user.created'], (_t, _k, d) => d); + + let spanInside: Span | undefined; + const descriptor: PropertyDescriptor = { + value: async function onUserCreated(): Promise { + spanInside = getActiveSpan(); + return 'ok'; + }, + configurable: true, + }; + wrappedDecorator({}, 'onUserCreated', descriptor); + + await (descriptor.value as AnyFn)(); + + const json = spanToJSON(spanInside!); + expect(json.name).toBe('event user.created'); + expect(json.attributes['sentry.description']).toBeUndefined(); + }); + it('bullmq @Processor: patches `process` into a queue.process transaction (string queue name)', async () => { installTestAsyncContextStrategy(); initTestClient(); @@ -1014,12 +1040,12 @@ describe('NestJS orchestrion subscriber: schedule / event / bullmq', () => { }); it.each([ - { label: 'symbol', event: Symbol('user.created'), expected: 'event Symbol(user.created)' }, - { label: 'string array', event: ['user.created', 'user.updated'], expected: 'event user.created,user.updated' }, + { label: 'symbol', event: Symbol('user.created'), expected: 'Symbol(user.created)' }, + { label: 'string array', event: ['user.created', 'user.updated'], expected: 'user.created,user.updated' }, { label: 'mixed array', event: [Symbol('user.created'), 'user.updated'], - expected: 'event Symbol(user.created),user.updated', + expected: 'Symbol(user.created),user.updated', }, ])('event @OnEvent: names the transaction from a $label event', async ({ event, expected }) => { installTestAsyncContextStrategy();