From 03c4d9588cceff0221ec1d125418c7f87e346af1 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 9 Jul 2026 10:48:36 -0700 Subject: [PATCH] feat(nestjs): add orchestrion diagnostics-channel instrumentation Add the NestJS channel subscriber in `@sentry/nestjs`, plus its code-transform config and channel names in `@sentry/server-utils`. `nestIntegration` now uses the diagnostics-channel path when orchestrion is injected and the vendored OTel path otherwise. Only the span origin differs between the two implementations. Fix: #20947 Fix: #20904 Fix: #20905 Fix: #20906 Fix: #20907 Fix: JS-2519 Fix: JS-2477 Fix: JS-2478 Fix: JS-2479 Fix: JS-2480 --- .../nestjs-orchestrion/.gitignore | 56 ++ .../nestjs-orchestrion/README.md | 31 + .../nestjs-orchestrion/nest-cli.json | 8 + .../nestjs-orchestrion/package.json | 36 + .../nestjs-orchestrion/playwright.config.mjs | 7 + .../nestjs-orchestrion/src/app.controller.ts | 74 ++ .../nestjs-orchestrion/src/app.module.ts | 31 + .../nestjs-orchestrion/src/app.service.ts | 17 + .../nestjs-orchestrion/src/events.service.ts | 12 + .../src/example.exception.ts | 5 + .../nestjs-orchestrion/src/example.filter.ts | 12 + .../nestjs-orchestrion/src/example.guard.ts | 12 + .../src/example.interceptor.ts | 19 + .../src/example.middleware.ts | 13 + .../nestjs-orchestrion/src/instrument.ts | 17 + .../nestjs-orchestrion/src/main.ts | 16 + .../src/schedule.service.ts | 33 + .../nestjs-orchestrion/start-event-proxy.mjs | 6 + .../nestjs-orchestrion/tests/events.test.ts | 17 + .../nestjs-orchestrion/tests/schedule.test.ts | 40 + .../tests/transactions.test.ts | 112 +++ .../nestjs-orchestrion/tsconfig.build.json | 4 + .../nestjs-orchestrion/tsconfig.json | 22 + packages/nestjs/package.json | 3 +- packages/nestjs/src/debug-build.ts | 8 + packages/nestjs/src/integrations/helpers.ts | 14 +- packages/nestjs/src/integrations/nest.ts | 10 +- .../integrations/orchestrion-subscriber.ts | 216 +++++ .../test/integrations/span-origin.test.ts | 63 ++ .../nestjs/test/orchestrion/nestjs.test.ts | 883 ++++++++++++++++++ ...erimentalUseDiagnosticsChannelInjection.ts | 6 +- .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/index.ts | 9 + .../src/orchestrion/config/nestjs.ts | 143 +++ .../server-utils/src/orchestrion/index.ts | 7 + 35 files changed, 1957 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json create mode 100644 packages/nestjs/src/debug-build.ts create mode 100644 packages/nestjs/src/integrations/orchestrion-subscriber.ts create mode 100644 packages/nestjs/test/integrations/span-origin.test.ts create mode 100644 packages/nestjs/test/orchestrion/nestjs.test.ts create mode 100644 packages/server-utils/src/orchestrion/config/nestjs.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md new file mode 100644 index 000000000000..5e35268cd1fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md @@ -0,0 +1,31 @@ +# nestjs-orchestrion + +E2E test app for the **orchestrion** (diagnostics-channel +injection) NestJS instrumentation. It is a normal +`@sentry/nestjs` app whose only difference from `nestjs-basic` is +that `src/instrument.ts` calls +`Sentry.experimentalUseDiagnosticsChannelInjection()` before +`Sentry.init()`. That swaps the OTel `Nest` integration for the +orchestrion subscriber (`@sentry/server-utils/orchestrion`) and +injects the diagnostics channels into `@nestjs/*` at load time. + +The tests assert the **same** span tree the OTel path produces +(`nestjs-basic`), so this app is the opt-in side of an A/B +against that baseline: + +- `transactions.test.ts`: `app_creation`, `request_context`, + `handler`, and the + `middleware.nestjs[.guard|.pipe|.interceptor|.exception_filter]` + spans. +- `schedule.test.ts`: `@Cron`/`@Interval`/`@Timeout` error + mechanisms. +- `events.test.ts`: the `@OnEvent` `event.nestjs` transaction. + +The spans that reassign the value a decorator factory / route +handler returns (`request_context`, +`@Cron`/`@Interval`/`@Timeout`, `@OnEvent`) rely on the +transformer returning the (mutated) `ctx.result`. That is the +default for `Sync`/`Async` transforms as of +`@apm-js-collab/code-transformer` `0.16.0` (no `mutableResult` +opt-in), which `@sentry/node`/`@sentry/server-utils` depend on, +so this app runs against the published transformer. diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json new file mode 100644 index 000000000000..f7d11be2e9ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json @@ -0,0 +1,36 @@ +{ + "name": "nestjs-orchestrion", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:prod": "node dist/main", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^4.1.0", + "@sentry/nestjs": "file:../../packed/sentry-nestjs-packed.tgz", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/express": "^5.0.0", + "@types/node": "^18.19.1", + "typescript": "~5.5.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts new file mode 100644 index 000000000000..169a4aa03313 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Param, ParseIntPipe, UseGuards, UseInterceptors } from '@nestjs/common'; +import { AppService } from './app.service'; +import { ExampleException } from './example.exception'; +import { ExampleGuard } from './example.guard'; +import { ExampleInterceptor } from './example.interceptor'; +import { ScheduleService } from './schedule.service'; + +@Controller() +export class AppController { + public constructor( + private readonly appService: AppService, + private readonly scheduleService: ScheduleService, + ) {} + + @Get('test-transaction') + public testTransaction(): unknown { + return this.appService.testSpan(); + } + + @Get('test-middleware') + public testMiddleware(): unknown { + return this.appService.testSpan(); + } + + @Get('test-guard') + @UseGuards(ExampleGuard) + public testGuard(): unknown { + return {}; + } + + @Get('test-interceptor') + @UseInterceptors(ExampleInterceptor) + public testInterceptor(): unknown { + return this.appService.testSpan(); + } + + @Get('test-pipe/:id') + public testPipe(@Param('id', ParseIntPipe) id: number): unknown { + return { value: id }; + } + + @Get('test-exception') + public testException(): never { + throw new ExampleException(); + } + + @Get('test-event') + public testEvent(): unknown { + this.appService.emitEvent(); + return { message: 'emitted' }; + } + + // Triggers the `@Timeout`-decorated handler directly (its real delay is long + // so it never fires on its own during the test). + @Get('trigger-timeout-error') + public triggerTimeoutError(): unknown { + try { + this.scheduleService.handleTimeoutError(); + } catch { + // Swallow, the error is captured by the schedule instrumentation; the + // route itself should still succeed. + } + return { message: 'triggered' }; + } + + // Stop the auto-firing scheduled jobs so they don't keep throwing after the + // assertions have run. + @Get('kill-schedules') + public killSchedules(): unknown { + this.scheduleService.killCron('test-cron-error'); + this.scheduleService.killInterval('test-interval-error'); + return { message: 'killed' }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts new file mode 100644 index 000000000000..d29a0cc68d72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts @@ -0,0 +1,31 @@ +import { MiddlewareConsumer, Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { EventsService } from './events.service'; +import { ExampleExceptionFilter } from './example.filter'; +import { ExampleMiddleware } from './example.middleware'; +import { ScheduleService } from './schedule.service'; + +@Module({ + imports: [EventEmitterModule.forRoot(), ScheduleModule.forRoot()], + controllers: [AppController], + providers: [ + AppService, + EventsService, + ScheduleService, + // Global exception filter + // exercises the `@Catch` (exception_filter) instrumentation. + { + provide: APP_FILTER, + useClass: ExampleExceptionFilter, + }, + ], +}) +export class AppModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(ExampleMiddleware).forRoutes('test-middleware'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts new file mode 100644 index 000000000000..faafa5d28ddd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class AppService { + public constructor(private readonly eventEmitter: EventEmitter2) {} + + public testSpan(): void { + // A child span, to verify request handling nests under the nestjs spans. + Sentry.startSpan({ name: 'test-controller-span' }, () => undefined); + } + + public emitEvent(): void { + this.eventEmitter.emit('test.event', { hello: 'world' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts new file mode 100644 index 000000000000..596de32724af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class EventsService { + // `@OnEvent` opens an `event.nestjs` transaction per handled event. + @OnEvent('test.event') + public handleTestEvent(): void { + Sentry.startSpan({ name: 'test-event-child-span' }, () => undefined); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts new file mode 100644 index 000000000000..36b7444fead6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts @@ -0,0 +1,5 @@ +export class ExampleException extends Error { + public constructor() { + super('Example exception handled by the example filter'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts new file mode 100644 index 000000000000..1af3d1f28769 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts @@ -0,0 +1,12 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { Response } from 'express'; +import { ExampleException } from './example.exception'; + +// `@Catch` exercises the exception_filter instrumentation. +@Catch(ExampleException) +export class ExampleExceptionFilter implements ExceptionFilter { + public catch(_exception: ExampleException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + response.status(400).json({ message: 'handled by example filter' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts new file mode 100644 index 000000000000..a9069f4e6f9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts @@ -0,0 +1,12 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ExampleGuard implements CanActivate { + public canActivate(_context: ExecutionContext): boolean { + // Child span + // should nest under the guard span (middleware.nestjs / .guard). + Sentry.startSpan({ name: 'test-guard-span' }, () => undefined); + return true; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts new file mode 100644 index 000000000000..670ae0e0d3df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts @@ -0,0 +1,19 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { tap } from 'rxjs'; + +@Injectable() +export class ExampleInterceptor implements NestInterceptor { + public intercept(_context: ExecutionContext, next: CallHandler): ReturnType { + // Runs before `next.handle()` + // nests under the interceptor "before" span. + Sentry.startSpan({ name: 'test-interceptor-span-before' }, () => undefined); + return next.handle().pipe( + tap(() => { + // Runs after the route + // nests under the "Interceptors - After Route" span. + Sentry.startSpan({ name: 'test-interceptor-span-after' }, () => undefined); + }), + ); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts new file mode 100644 index 000000000000..c04904ef62ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts @@ -0,0 +1,13 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { NextFunction, Request, Response } from 'express'; + +@Injectable() +export class ExampleMiddleware implements NestMiddleware { + public use(_req: Request, _res: Response, next: NextFunction): void { + // Child span + // should nest under the middleware span. + Sentry.startSpan({ name: 'test-middleware-span' }, () => undefined); + next(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts new file mode 100644 index 000000000000..4c784cacce09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nestjs'; + +// Opt into diagnostics-channel injection BEFORE `Sentry.init()`. This swaps +// the OTel `Nest` instrumentation for the orchestrion (diagnostics-channel) +// one and synchronously installs the module hooks that inject the channels +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1, + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts new file mode 100644 index 000000000000..b7a2a41921cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts @@ -0,0 +1,16 @@ +// Import this first. It opts into diagnostics-channel injection and installs +// the module hooks before any `@nestjs/*` module is loaded below. +import './instrument'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule); + await app.listen(PORT); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts new file mode 100644 index 000000000000..a0efa7ef33cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, Interval, SchedulerRegistry, Timeout } from '@nestjs/schedule'; + +// Scheduled-handler instrumentation captures errors (no span) under +// `auto.function.nestjs.{cron,interval,timeout}`. +@Injectable() +export class ScheduleService { + public constructor(private readonly schedulerRegistry: SchedulerRegistry) {} + + @Cron('*/5 * * * * *', { name: 'test-cron-error' }) + public handleCronError(): void { + throw new Error('Test error from cron'); + } + + @Interval('test-interval-error', 2000) + public handleIntervalError(): void { + throw new Error('Test error from interval'); + } + + // Long delay so it never fires on its own; the test triggers it via HTTP. + @Timeout('test-timeout-error', 600000) + public handleTimeoutError(): void { + throw new Error('Test error from timeout'); + } + + public killCron(name: string): void { + this.schedulerRegistry.deleteCronJob(name); + } + + public killInterval(name: string): void { + this.schedulerRegistry.deleteInterval(name); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs new file mode 100644 index 000000000000..ba90624b2481 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs-orchestrion', +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts new file mode 100644 index 000000000000..6796c439506a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// `@OnEvent` opens an `event.nestjs` transaction per handled event. +test('@OnEvent opens an event.nestjs transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return transactionEvent?.transaction === 'event test.event'; + }); + + await fetch(`${baseURL}/test-event`); + const transactionEvent = await transactionPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('event.nestjs'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.event.orchestrion.nestjs'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts new file mode 100644 index 000000000000..0029cc0fea43 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// `@Cron`/`@Interval` auto-fire (every few seconds) and throw; the schedule +// instrumentation captures the error (no span) with the per-decorator mechanism. +test('@Cron error is captured with the cron mechanism', async () => { + const error = await waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from cron'; + }); + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.cron', handled: false }), + ); +}); + +test('@Interval error is captured with the interval mechanism', async () => { + const error = await waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from interval'; + }); + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.interval', handled: false }), + ); +}); + +// `@Timeout`'s real delay is long, so the route triggers the handler directly. +test('@Timeout error is captured with the timeout mechanism', async ({ baseURL }) => { + const errorPromise = waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from timeout'; + }); + + await fetch(`${baseURL}/trigger-timeout-error`); + const error = await errorPromise; + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.timeout', handled: false }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts new file mode 100644 index 000000000000..33bc16601a02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// Find a child span by op + origin within a transaction event. +function findSpan( + transactionEvent: Awaited>, + op: string, + origin: string, +): { description?: string; op?: string; origin?: string; data?: Record } | undefined { + return (transactionEvent.spans ?? []).find(span => span.op === op && span.origin === origin); +} + +test('app_creation: emits a "Create Nest App" transaction at startup', async () => { + // Emitted once at startup (NestFactory.create), before any request, so look + // back through buffered envelopes rather than waiting for a new transaction. + const envelopeItem = await waitForEnvelopeItem( + PROXY, + item => item[0].type === 'transaction' && (item[1] as { transaction?: string }).transaction === 'Create Nest App', + 0, + ); + + const transaction = envelopeItem[1] as { + contexts: { trace: { op?: string; origin?: string; data?: Record } }; + }; + + expect(transaction.contexts.trace.op).toBe('app_creation.nestjs'); + expect(transaction.contexts.trace.origin).toBe('auto.http.orchestrion.nestjs'); + expect(transaction.contexts.trace.data).toEqual( + expect.objectContaining({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.module': 'AppModule', + }), + ); +}); + +test('request_context + handler: a route transaction nests the nestjs spans', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + const transactionEvent = await transactionPromise; + + // request_context span, identified by its controller/callback attributes. + // Its description isn't asserted: the span carries `http.*` attributes, so + // the OTel span-name inference rewrites it to `GET /test-transaction` + const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.orchestrion.nestjs'); + expect(requestContext).toBeDefined(); + expect(requestContext?.data).toMatchObject({ + 'nestjs.type': 'request_context', + 'nestjs.controller': 'AppController', + 'nestjs.callback': 'testTransaction', + }); + + // request_handler span: wraps the controller method itself. + const handler = (transactionEvent.spans ?? []).find( + span => span.op === 'handler.nestjs' && span.description === 'testTransaction', + ); + expect(handler).toBeDefined(); +}); + +// op + origin produced by `@Injectable`/`@Catch` instrumentation, +// per component type. +const MIDDLEWARE_CASES = [ + { route: 'test-middleware', origin: 'auto.middleware.orchestrion.nestjs', description: 'ExampleMiddleware' }, + { route: 'test-guard', origin: 'auto.middleware.orchestrion.nestjs.guard', description: 'ExampleGuard' }, + { route: 'test-pipe/123', origin: 'auto.middleware.orchestrion.nestjs.pipe', description: 'ParseIntPipe' }, + { + route: 'test-interceptor', + origin: 'auto.middleware.orchestrion.nestjs.interceptor', + description: 'ExampleInterceptor', + }, +] as const; + +for (const { route, origin, description } of MIDDLEWARE_CASES) { + test(`middleware span: ${origin} (${description})`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === `GET /${route.replace('/123', '/:id')}` + ); + }); + + await fetch(`${baseURL}/${route}`); + const transactionEvent = await transactionPromise; + + const span = findSpan(transactionEvent, 'middleware.nestjs', origin); + expect(span, `expected a ${origin} span`).toBeDefined(); + expect(span?.description).toBe(description); + }); +} + +test('exception_filter span: a @Catch filter opens a middleware.nestjs span', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-exception' + ); + }); + + await fetch(`${baseURL}/test-exception`); + const transactionEvent = await transactionPromise; + + const span = findSpan(transactionEvent, 'middleware.nestjs', 'auto.middleware.orchestrion.nestjs.exception_filter'); + expect(span).toBeDefined(); + expect(span?.description).toBe('ExampleExceptionFilter'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json new file mode 100644 index 000000000000..f189b152abb2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "Node16", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "moduleResolution": "Node16" + } +} diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index ad637e515e34..3b9c6434f630 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -48,7 +48,8 @@ "@opentelemetry/instrumentation": "^0.220.0", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.65.0", - "@sentry/node": "10.65.0" + "@sentry/node": "10.65.0", + "@sentry/server-utils": "10.65.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nestjs/src/debug-build.ts b/packages/nestjs/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/nestjs/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index ab4b0084dbf6..a83362099ce3 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan, } from '@sentry/core'; +import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types'; /** A function of unknown signature, matching the methods/handlers we wrap. */ @@ -38,26 +39,31 @@ export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' return false; } +// The instrumentation path is reflected in the span origin: orchestrion-created +// spans carry an `orchestrion` segment so they're distinguishable from OTel. +// Everything else about the span is identical. + /** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */ function middlewareOrigin(componentType?: string): string { - return componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs'; + const base = isOrchestrionInjected() ? 'auto.middleware.orchestrion.nestjs' : 'auto.middleware.nestjs'; + return componentType ? `${base}.${componentType}` : base; } /** * Origin for the app-creation / request-context / request-handler HTTP spans. */ export function httpOrigin(): string { - return 'auto.http.otel.nestjs'; + return isOrchestrionInjected() ? 'auto.http.orchestrion.nestjs' : 'auto.http.otel.nestjs'; } /** Origin for `@OnEvent` spans. */ function eventOrigin(): string { - return 'auto.event.nestjs'; + return isOrchestrionInjected() ? 'auto.event.orchestrion.nestjs' : 'auto.event.nestjs'; } /** Origin for BullMQ `@Processor` `process` spans. */ function bullmqOrigin(): string { - return 'auto.queue.nestjs.bullmq'; + return isOrchestrionInjected() ? 'auto.queue.orchestrion.nestjs.bullmq' : 'auto.queue.nestjs.bullmq'; } /** diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts index b18791a27ba5..9abae800e244 100644 --- a/packages/nestjs/src/integrations/nest.ts +++ b/packages/nestjs/src/integrations/nest.ts @@ -1,6 +1,8 @@ import { NestInstrumentation as NestInstrumentationCore } from './vendored/instrumentation'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node'; +import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; +import { subscribeToNestChannels } from './orchestrion-subscriber'; import { SentryNestBullMQInstrumentation } from './sentry-nest-bullmq-instrumentation'; import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation'; import { SentryNestInstrumentation } from './sentry-nest-instrumentation'; @@ -41,12 +43,18 @@ export const instrumentNest = Object.assign( /** * Integration capturing tracing data for NestJS. + * Only the span origin differs between otel and orchestrion implementations. + * See the shared `./wrap-*` helpers */ export const nestIntegration = defineIntegration(() => { return { name: INTEGRATION_NAME, setupOnce() { - instrumentNest(); + if (isOrchestrionInjected()) { + subscribeToNestChannels(); + } else { + instrumentNest(); + } }, }; }); diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts new file mode 100644 index 000000000000..874363952ee9 --- /dev/null +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -0,0 +1,216 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core'; +import { bindTracingChannelToSpan } from '@sentry/server-utils'; +import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion'; +import { DEBUG_BUILD } from '../debug-build'; +import type { AnyFn } from './helpers'; +import { isWrapped, markWrapped } from './helpers'; +import type { CatchTarget, InjectableTarget } from './types'; +import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; +import { + extractQueueName, + MECHANISM_CRON, + MECHANISM_INTERVAL, + MECHANISM_TIMEOUT, + patchMethodDescriptor, + patchProcessorTarget, + wrapEventHandler, + wrapScheduleHandler, +} from './wrap-handlers'; +import { getAppCreationSpanOptions, wrapRequestContextHandler, wrapRouteHandler } from './wrap-route'; + +const NOOP = (): void => {}; + +/** + * The orchestrion tracing-channel context. `arguments` is the live call args + * array; `result` is the return value, which an `end` handler may reassign to + * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). + */ +interface ChannelContext { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +/** + * Subscribe to a decorator channel (`Injectable`/`Catch`). + * + * The orchestrion transform targets the decorator's inner arrow, so `start` + * receives the decorated class as `arguments[0]`. There is no span around the + * decorator itself; `patch` installs the prototype-method proxies that open + * spans later. + */ +function subscribeDecoratorChannel(channelName: string, patch: (target: T) => void): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start(data) { + const target = data.arguments?.[0] as T | undefined; + if (target) { + patch(target); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +/** + * Wrap the method decorator the factory returns so it replaces + * `descriptor.value` with a wrapped handler before delegating to the + * original decorator. + */ +function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + patchMethodDescriptor( + args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined, + args[1] as string | symbol | undefined, + args[2] as PropertyDescriptor | undefined, + wrapHandler, + ); + return original.apply(this, args); + }; +} + +/** + * Wrap the class decorator `@Processor` returns so it patches + * `target.prototype.process` before delegating. + */ +function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + patchProcessorTarget(args[0] as { __SENTRY_INTERNAL__?: boolean; prototype?: { process?: AnyFn } }, queueName); + return original.apply(this, args); + }; +} + +/** + * Subscribe to a decorator-factory channel. `end` reassigns `data.result` (the + * decorator the factory returns) with a wrapped version -> `traceSync` returns + * whatever `end` leaves there. `wrap` receives the original decorator and the + * channel context (for the factory's args, e.g. the BullMQ queue name). + */ +function subscribeFactoryDecorator(channelName: string, wrap: (decorator: AnyFn, data: ChannelContext) => AnyFn): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start: NOOP, + end(data) { + const decorator = data.result; + if (typeof decorator === 'function' && !isWrapped(decorator as AnyFn)) { + const wrapped = wrap(decorator as AnyFn, data); + markWrapped(wrapped); + data.result = wrapped; + } + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +/** + * Subscribe to the diagnostics_channels the orchestrion code transform + * injects into `@nestjs/*` modules. + * + * Opens the same spans as the OTel `Nest` instrumentation, only with + * different origin. + * + * Called from `nestIntegration`'s `setupOnce` when orchestrion is active + * (`isOrchestrionInjected()`); requires the runtime hook or bundler plugin. + */ +export function subscribeToNestChannels(): void { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && debug.log('[orchestrion:nestjs] subscribing to @nestjs channels'); + + // App-creation span: `bindTracingChannelToSpan` opens the span on + // `start`, makes it the active context for the bootstrap, and ends it + // on `asyncEnd` (or `end` if `create` throws synchronously). + // + // `captureError: false`: a failed bootstrap surfaces to the caller. + // We just annotate the span. + // + // `bindTracingChannelToSpan` uses `bindStore`, which needs the + // async-context binding registered after integration `setupOnce`; defer + // until it's available. Only this bind is deferred (it fires at + // `NestFactory.create`, so a retry tick is fine); the plain `.subscribe` + // calls below stay synchronous because the decorator channels fire at + // module-load time, which a deferred subscription could miss. + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); + }, + { captureError: false }, + ); + }); + + // request_context + request_handler. `RouterExecutionContext.create` + // runs once per route at setup: it receives `(instance, callback, ...)` + // and RETURNS the per-request handler. `start` wraps the callback arg + // (-> handler span per call) and `end` reassigns `data.result` to + // replace the returned handler (-> request_context span per request). + const routerMeta = new WeakMap(); + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT).subscribe({ + start(data) { + const instance = data.arguments?.[0] as { constructor?: { name?: string } } | undefined; + const callback = data.arguments?.[1]; + routerMeta.set(data, { + instanceName: instance?.constructor?.name || 'UnnamedInstance', + callbackName: typeof callback === 'function' ? callback.name : '', + moduleVersion: data.moduleVersion, + }); + if (typeof callback === 'function') { + data.arguments[1] = wrapRouteHandler(callback as AnyFn, data.moduleVersion); + } + }, + end(data) { + const handler = data.result; + const meta = routerMeta.get(data); + if (typeof handler === 'function' && meta && !isWrapped(handler as AnyFn)) { + data.result = wrapRequestContextHandler( + handler as AnyFn, + meta.instanceName, + meta.callbackName, + meta.moduleVersion, + ); + } + routerMeta.delete(data); + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error(data) { + routerMeta.delete(data); + }, + }); + + // @Injectable (middleware/guard/pipe/interceptor) and @Catch + // (exception filter): both decorators share the + // `(target) => {...}` inner-arrow shape. + const seenInterceptorContexts = new WeakSet(); + subscribeDecoratorChannel(CHANNELS.NESTJS_INJECTABLE, target => + patchInjectableTarget(target, seenInterceptorContexts), + ); + subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); + + // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq). + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_TIMEOUT, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_TIMEOUT)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_ONEVENT, (decorator, data) => + makeMethodDecorator(decorator, handler => wrapEventHandler(handler, data.arguments?.[0])), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_PROCESSOR, (decorator, data) => + makeProcessorDecorator(decorator, extractQueueName(data.arguments?.[0])), + ); +} diff --git a/packages/nestjs/test/integrations/span-origin.test.ts b/packages/nestjs/test/integrations/span-origin.test.ts new file mode 100644 index 000000000000..5e7ab09ecf92 --- /dev/null +++ b/packages/nestjs/test/integrations/span-origin.test.ts @@ -0,0 +1,63 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getBullMQProcessSpanOptions, + getEventSpanOptions, + getMiddlewareSpanOptions, + httpOrigin, +} from '../../src/integrations/helpers'; + +type Marker = { runtime?: boolean; bundler?: boolean } | undefined; + +function setMarker(marker: Marker): void { + (globalThis as { __SENTRY_ORCHESTRION__?: Marker }).__SENTRY_ORCHESTRION__ = marker; +} + +function middlewareOrigin(componentType?: string): unknown { + return getMiddlewareSpanOptions({ name: 'X' }, undefined, componentType).attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; +} + +function eventOrigin(): unknown { + return getEventSpanOptions('x').attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; +} + +function bullmqOrigin(): unknown { + return getBullMQProcessSpanOptions('q').attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; +} + +// `nestIntegration` takes the channel path exactly when `isOrchestrionInjected()` +// is true (the `@nestjs/*` transform is always in the static config, so the +// global flag is sufficient), and the OTel path otherwise. The span origin +// follows the same flag. +describe('NestJS span origin selection', () => { + afterEach(() => { + delete (globalThis as { __SENTRY_ORCHESTRION__?: Marker }).__SENTRY_ORCHESTRION__; + }); + + it('emits orchestrion origins when injected via the runtime hook', () => { + setMarker({ runtime: true }); + + expect(httpOrigin()).toBe('auto.http.orchestrion.nestjs'); + expect(middlewareOrigin()).toBe('auto.middleware.orchestrion.nestjs'); + expect(middlewareOrigin('guard')).toBe('auto.middleware.orchestrion.nestjs.guard'); + expect(eventOrigin()).toBe('auto.event.orchestrion.nestjs'); + expect(bullmqOrigin()).toBe('auto.queue.orchestrion.nestjs.bullmq'); + }); + + it('emits orchestrion origins when injected via a bundler plugin', () => { + setMarker({ bundler: true }); + + expect(httpOrigin()).toBe('auto.http.orchestrion.nestjs'); + expect(middlewareOrigin('interceptor')).toBe('auto.middleware.orchestrion.nestjs.interceptor'); + }); + + it('emits OTel origins when orchestrion is not injected', () => { + setMarker(undefined); + + expect(httpOrigin()).toBe('auto.http.otel.nestjs'); + expect(middlewareOrigin()).toBe('auto.middleware.nestjs'); + expect(middlewareOrigin('pipe')).toBe('auto.middleware.nestjs.pipe'); + expect(eventOrigin()).toBe('auto.event.nestjs'); + expect(bullmqOrigin()).toBe('auto.queue.nestjs.bullmq'); + }); +}); diff --git a/packages/nestjs/test/orchestrion/nestjs.test.ts b/packages/nestjs/test/orchestrion/nestjs.test.ts new file mode 100644 index 000000000000..ec8cdb8f6a07 --- /dev/null +++ b/packages/nestjs/test/orchestrion/nestjs.test.ts @@ -0,0 +1,883 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + getIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion'; +import { subscribeToNestChannels } from '../../src/integrations/orchestrion-subscriber'; + +// The subscriber only ever runs when orchestrion has instrumented `@nestjs/*`. +// `isOrchestrionInjected()` selects the `orchestrion` span origins the assertions +// below expect, so the marker must be set. +beforeEach(() => { + (globalThis as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__ = { runtime: true }; +}); +afterEach(() => { + delete (globalThis as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__; +}); + +// Mirrors harness in `tracing-channel.test.ts`: `bindTracingChannelToSpan` +// only creates/ends spans when an async-context binding is available, so the +// strategy below must be installed for the subscriber to do anything. +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + //@ts-expect-error - just a mock for the test, this is fine + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +interface NestFactoryCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +describe('NestJS orchestrion subscriber: app_creation', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + // Grab the bound span off the channel payload so we can assert on it + // after the operation settles. subscriber stamps it at `start` on + // `data._sentrySpan` + function captureSpan(): { getSpan: () => Span | undefined } { + let span: Span | undefined; + const grab = (data: NestFactoryCreateData): void => { + span ??= (data as { _sentrySpan?: Span })._sentrySpan; + }; + // The raw node `tracingChannel` type wants all five handlers; only + // `end`/`asyncEnd` carry the bound span by the time it settles. + tracingChannel(CHANNELS.NESTJS_APP_CREATION).subscribe({ + start: () => undefined, + asyncStart: () => undefined, + asyncEnd: grab, + end: grab, + error: () => undefined, + }); + return { getSpan: () => span }; + } + + it('opens a "Create Nest App" span with the OTel-compatible op/origin/attributes', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + const { getSpan } = captureSpan(); + const channel = tracingChannel(CHANNELS.NESTJS_APP_CREATION); + + class AppModule {} + await channel.tracePromise(async () => ({ app: true }), { arguments: [AppModule], moduleVersion: '10.4.1' }); + + const span = getSpan(); + expect(span).toBeDefined(); + const json = spanToJSON(span!); + expect(json.description).toBe('Create Nest App'); + expect(json.op).toBe('app_creation.nestjs'); + expect(json.origin).toBe('auto.http.orchestrion.nestjs'); + expect(json.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.version': '10.4.1', + 'nestjs.module': 'AppModule', + }); + // Span was ended on `asyncEnd`. + expect(json.timestamp).toBeDefined(); + }); + + it('omits optional attributes when version/module are absent', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + const { getSpan } = captureSpan(); + const channel = tracingChannel(CHANNELS.NESTJS_APP_CREATION); + + await channel.tracePromise(async () => ({ app: true }), { arguments: [] }); + + const json = spanToJSON(getSpan()!); + expect(json.data['nestjs.version']).toBeUndefined(); + expect(json.data['nestjs.module']).toBeUndefined(); + expect(json.data['nestjs.type']).toBe('app_creation'); + }); +}); + +type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +interface RouterCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +describe('NestJS orchestrion subscriber: request_context / request_handler', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + // Drives `RouterExecutionContext.create` over the channel: the subscriber's + // `start` wraps the callback arg, its `end` reassigns the returned handler on + // `data.result`. `makeHandler` stands in for the real `create` body. Returns + // the effective return (the substituted `data.result`) and the + // wrapped callback (`data.arguments[1]`). + function driveCreate( + instance: object, + callback: AnyFn, + moduleVersion: string | undefined, + makeHandler: (data: RouterCreateData) => AnyFn, + ): { effectiveHandler: AnyFn; wrappedCallback: AnyFn } { + const channel = tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT); + const data: RouterCreateData = { arguments: [instance, callback], moduleVersion }; + channel.traceSync(() => makeHandler(data), data); + return { effectiveHandler: data.result as AnyFn, wrappedCallback: data.arguments[1] as AnyFn }; + } + + it('opens a request_context span (named Controller.method) with OTel-compatible attributes', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + class CatsController {} + const instance = new CatsController(); + function getCats(): string { + return 'cats'; + } + + let contextSpanJson: ReturnType | undefined; + const { effectiveHandler } = driveCreate(instance, getCats, '10.4.1', () => { + // The per-request handler `create` returns. Capture the active span here: + // when invoked it runs inside the request_context span. + return function perRequest(): unknown { + contextSpanJson = spanToJSON(getActiveSpan()!); + return 'ok'; + }; + }); + + effectiveHandler.call(undefined, { + method: 'GET', + originalUrl: '/cats?q=1', + url: '/cats?q=1', + route: { path: '/cats' }, + }); + + expect(contextSpanJson).toBeDefined(); + expect(contextSpanJson!.description).toBe('CatsController.getCats'); + expect(contextSpanJson!.op).toBe('request_context.nestjs'); + expect(contextSpanJson!.origin).toBe('auto.http.orchestrion.nestjs'); + expect(contextSpanJson!.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'request_context', + 'nestjs.controller': 'CatsController', + 'nestjs.callback': 'getCats', + 'nestjs.version': '10.4.1', + 'http.route': '/cats', + 'http.method': 'GET', + 'http.url': '/cats?q=1', + }); + }); + + it('wraps the callback arg into a request_handler span, preserving its name', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + class CatsController {} + const instance = new CatsController(); + let handlerSpanJson: ReturnType | undefined; + function getCats(): string { + handlerSpanJson = spanToJSON(getActiveSpan()!); + return 'cats'; + } + + const { wrappedCallback } = driveCreate(instance, getCats, '10.4.1', () => () => undefined); + + // `create`'s callback arg was replaced with a wrapper that preserves `.name`. + expect(wrappedCallback).not.toBe(getCats); + expect(wrappedCallback.name).toBe('getCats'); + + wrappedCallback.call(instance); + + expect(handlerSpanJson).toBeDefined(); + expect(handlerSpanJson!.description).toBe('getCats'); + expect(handlerSpanJson!.op).toBe('handler.nestjs'); + expect(handlerSpanJson!.origin).toBe('auto.http.orchestrion.nestjs'); + expect(handlerSpanJson!.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'handler', + 'nestjs.callback': 'getCats', + 'nestjs.version': '10.4.1', + }); + }); + + it('nests the request_handler span under the request_context span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + class CatsController {} + const instance = new CatsController(); + let contextSpanId: string | undefined; + let handlerParentSpanId: string | undefined; + function getCats(): string { + handlerParentSpanId = spanToJSON(getActiveSpan()!).parent_span_id; + return 'cats'; + } + + // The per-request handler calls the (wrapped) callback, like the real one. + const { effectiveHandler } = driveCreate(instance, getCats, undefined, data => { + return function perRequest(this: unknown): unknown { + contextSpanId = getActiveSpan()!.spanContext().spanId; + return (data.arguments[1] as AnyFn).call(instance); + }; + }); + + effectiveHandler.call(undefined, { method: 'GET', route: { path: '/cats' } }); + + expect(contextSpanId).toBeDefined(); + expect(handlerParentSpanId).toBe(contextSpanId); + }); +}); + +describe('NestJS orchestrion subscriber: @Injectable (middleware/guard/pipe/interceptor)', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + // Fire the @Injectable channel against `target` (as if its decorator arrow + // ran), so the subscriber's `start` patches `target.prototype`. + function applyInjectable(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_INJECTABLE).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('middleware: opens a span on `use`, ended when `next()` is called', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType; + class LoggerMiddleware { + public use(_req: unknown, _res: unknown, next: () => void): void { + spanInside = getActiveSpan(); + next(); + } + } + applyInjectable(LoggerMiddleware); + + const next = vi.fn(); + new LoggerMiddleware().use({ url: '/' }, {}, next); + + expect(next).toHaveBeenCalledTimes(1); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('LoggerMiddleware'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs'); + // startSpanManual span ends when the proxied `next` is called. + expect(json.timestamp).toBeDefined(); + }); + + it('guard: wraps `canActivate` in a span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType; + class AuthGuard { + public canActivate(_ctx: unknown): boolean { + spanInside = getActiveSpan(); + return true; + } + } + applyInjectable(AuthGuard); + + expect(new AuthGuard().canActivate({ ctx: true })).toBe(true); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('AuthGuard'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.guard'); + }); + + it('pipe: wraps `transform` in a span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType; + class ParseIntPipe { + public transform(value: string, _metadata: unknown): number { + spanInside = getActiveSpan(); + return Number.parseInt(value, 10); + } + } + applyInjectable(ParseIntPipe); + + expect(new ParseIntPipe().transform('42', { type: 'param' })).toBe(42); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('ParseIntPipe'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.pipe'); + }); + + it('interceptor: opens a before-span (ended at next.handle) and instruments the returned observable', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + // Minimal rxjs-like observable whose subscription records teardown fns. + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class LoggingInterceptor { + public intercept(_context: unknown, next: { handle: () => unknown }): unknown { + beforeSpan = getActiveSpan(); + return next.handle(); + } + } + applyInjectable(LoggingInterceptor); + + const next = { handle: () => observable }; + const returned = new LoggingInterceptor().intercept({}, next) as typeof observable; + + // Passthrough: the same observable is returned (with `subscribe` proxied). + expect(returned).toBe(observable); + + const beforeJson = spanToJSON(beforeSpan!); + expect(beforeJson.description).toBe('LoggingInterceptor'); + expect(beforeJson.op).toBe('middleware.nestjs'); + expect(beforeJson.origin).toBe('auto.middleware.orchestrion.nestjs.interceptor'); + // before-span ends when `next.handle()` is called. + expect(beforeJson.timestamp).toBeDefined(); + + // The returned observable was instrumented: subscribing registers an + // after-span teardown (proving the after-span was created). + returned.subscribe(); + expect(teardowns).toHaveLength(1); + expect(() => teardowns.forEach(fn => fn())).not.toThrow(); + }); + + it('async interceptor that awaits before next.handle(): still instruments the after-span', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class AsyncInterceptor { + // Awaits *before* calling `next.handle()`, so `intercept` returns a + // pending Promise while the after-span does not yet exist. + public async intercept(_context: unknown, next: { handle: () => unknown }): Promise { + beforeSpan = getActiveSpan(); + await Promise.resolve(); + return next.handle(); + } + } + applyInjectable(AsyncInterceptor); + + const next = { handle: () => observable }; + const returned = (await new AsyncInterceptor().intercept({}, next)) as typeof observable; + + expect(returned).toBe(observable); + // before-span ended (when `next.handle()` ran, post-await) + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // after-span was created AND the observable instrumented despite the await + returned.subscribe(); + expect(teardowns).toHaveLength(1); + expect(() => teardowns.forEach(fn => fn())).not.toThrow(); + }); + + it('async interceptor that never calls next.handle(): ends the before-span, no after-span', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class ShortCircuitInterceptor { + public async intercept(_context: unknown, _next: { handle: () => unknown }): Promise { + beforeSpan = getActiveSpan(); + await Promise.resolve(); + return observable; // short-circuits without calling `next.handle()` + } + } + applyInjectable(ShortCircuitInterceptor); + + const next = { handle: vi.fn() }; + const returned = (await new ShortCircuitInterceptor().intercept({}, next)) as typeof observable; + + expect(returned).toBe(observable); + expect(next.handle).not.toHaveBeenCalled(); + // before-span is closed even though `next.handle()` (which normally ends it) never ran + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // no after-span, so the observable is left un-instrumented + returned.subscribe(); + expect(teardowns).toHaveLength(0); + }); + + it('sync interceptor that short-circuits without next.handle(): ends the before-span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class CachingInterceptor { + // Synchronously returns an Observable without calling `next.handle()` + // (a cache/validation short-circuit). + public intercept(_context: unknown, _next: { handle: () => unknown }): unknown { + beforeSpan = getActiveSpan(); + return observable; + } + } + applyInjectable(CachingInterceptor); + + const next = { handle: vi.fn() }; + const returned = new CachingInterceptor().intercept({}, next) as typeof observable; + + expect(returned).toBe(observable); + expect(next.handle).not.toHaveBeenCalled(); + // before-span is closed even though `next.handle()` (which normally ends it) never ran + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // no after-span, so the observable is left un-instrumented + returned.subscribe(); + expect(teardowns).toHaveLength(0); + }); + + it('skips targets flagged __SENTRY_INTERNAL__', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + class InternalGuard { + public canActivate(_ctx: unknown): boolean { + return true; + } + } + (InternalGuard as unknown as { __SENTRY_INTERNAL__?: boolean }).__SENTRY_INTERNAL__ = true; + const original = InternalGuard.prototype.canActivate; + applyInjectable(InternalGuard); + + // Not patched: the prototype method is untouched. + expect(InternalGuard.prototype.canActivate).toBe(original); + }); +}); + +describe('NestJS orchestrion subscriber: @Catch (exception filter)', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + function applyCatch(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_CATCH).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('wraps `catch` in an exception_filter span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + applyCatch(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + it('does not open a span when exception or host is absent', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType = undefined; + class HttpExceptionFilter { + public catch(_exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return 'ok'; + } + } + applyCatch(HttpExceptionFilter); + + // Missing host -> guard short-circuits, no span opened. + new HttpExceptionFilter().catch('boom', undefined); + expect(spanInside).toBeUndefined(); + }); + + // A class can be decorated with both `@Injectable` and `@Catch` (an exception + // filter that uses DI). Which channel fires first depends on decorator + // stacking order (decorators apply inner-first): `@Catch` over `@Injectable` + // fires @Injectable first; `@Injectable` over `@Catch` fires @Catch first. + // Because the two passes use separate patched-flags, both must wrap their own + // methods regardless of which channel fires first. + function fireInjectable(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_INJECTABLE).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('still wraps `catch` when the @Injectable channel fired first (dual @Injectable @Catch filter)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + fireInjectable(HttpExceptionFilter); + applyCatch(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + it('still wraps `catch` when the @Catch channel fired first (dual @Injectable @Catch filter)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + applyCatch(HttpExceptionFilter); + fireInjectable(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + // A (contrived) class that is BOTH a guard (`canActivate`) and an exception + // filter (`catch`) proves the two passes are independent: neither ordering may + // let one pass's patched-flag block the other. Both spans must appear either way. + for (const order of ['injectable-first', 'catch-first'] as const) { + it(`wraps BOTH canActivate and catch when the ${order} channel fired first`, () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let guardSpan: ReturnType; + let filterSpan: ReturnType; + class GuardAndFilter { + public canActivate(_ctx: unknown): boolean { + guardSpan = getActiveSpan(); + return true; + } + public catch(exception: unknown, _host: unknown): string { + filterSpan = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + + if (order === 'injectable-first') { + fireInjectable(GuardAndFilter); + applyCatch(GuardAndFilter); + } else { + applyCatch(GuardAndFilter); + fireInjectable(GuardAndFilter); + } + + expect(new GuardAndFilter().canActivate({ ctx: true })).toBe(true); + expect(new GuardAndFilter().catch('boom', { switchToHttp: () => ({}) })).toBe('handled:boom'); + + expect(spanToJSON(guardSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.guard'); + expect(spanToJSON(filterSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + } +}); + +describe('NestJS orchestrion subscriber: schedule / event / bullmq', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + vi.restoreAllMocks(); + }); + + // Drive a decorator-factory channel: node's traceSync sets `data.result` to + // the factory's return (our `originalDecorator`), then the subscriber's `end` + // reassigns `data.result`. Returns the effective (wrapped) decorator. + function driveFactory(channelName: string, factoryArgs: unknown[], originalDecorator: AnyFn): AnyFn { + const data: { arguments: unknown[]; result?: unknown } = { arguments: factoryArgs }; + tracingChannel<{ arguments: unknown[]; result?: unknown }>(channelName).traceSync(() => originalDecorator, data); + return data.result as AnyFn; + } + + it('schedule @Cron: wraps the handler with isolation scope + error capture, preserving name', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + const captureSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + let originalCalled = false; + const original: AnyFn = (_t, _k, descriptor) => { + originalCalled = true; + return descriptor; + }; + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_SCHEDULE_CRON, ['*/5 * * * *'], original); + + const handler = function doCron(): void { + throw new Error('cron boom'); + }; + const descriptor: PropertyDescriptor = { value: handler, configurable: true }; + wrappedDecorator({}, 'doCron', descriptor); + + expect(originalCalled).toBe(true); + expect(descriptor.value).not.toBe(handler); + expect((descriptor.value as AnyFn).name).toBe('doCron'); + + expect(() => (descriptor.value as AnyFn)()).toThrow('cron boom'); + expect(captureSpy).toHaveBeenCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'auto.function.nestjs.cron' }, + }); + }); + + it('schedule @Interval: captures async (rejected) errors with the interval mechanism', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + const captureSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_SCHEDULE_INTERVAL, [1000], (_t, _k, d) => d); + const descriptor: PropertyDescriptor = { + value: async function doInterval(): Promise { + throw new Error('interval boom'); + }, + configurable: true, + }; + wrappedDecorator({}, 'doInterval', descriptor); + + await expect((descriptor.value as AnyFn)()).rejects.toThrow('interval boom'); + expect(captureSpy).toHaveBeenCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'auto.function.nestjs.interval' }, + }); + }); + + it('event @OnEvent: opens an event.nestjs transaction named from the event', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.description).toBe('event user.created'); + expect(json.op).toBe('event.nestjs'); + expect(json.origin).toBe('auto.event.orchestrion.nestjs'); + }); + + it('bullmq @Processor: patches `process` into a queue.process transaction (string queue name)', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + let originalCalled = false; + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_PROCESSOR, ['emails'], () => { + originalCalled = true; + }); + + let spanInside: Span | undefined; + class EmailProcessor { + public async process(_job: unknown): Promise { + spanInside = getActiveSpan(); + return 'done'; + } + } + const originalProcess = EmailProcessor.prototype.process; + wrappedDecorator(EmailProcessor); + + expect(originalCalled).toBe(true); + expect(EmailProcessor.prototype.process).not.toBe(originalProcess); + + await new EmailProcessor().process({}); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('emails process'); + expect(json.op).toBe('queue.process'); + expect(json.origin).toBe('auto.queue.orchestrion.nestjs.bullmq'); + expect(json.data).toMatchObject({ + 'messaging.system': 'bullmq', + 'messaging.destination.name': 'emails', + }); + }); + + it('bullmq @Processor: derives the queue name from an options object', () => { + installTestAsyncContextStrategy(); + initTestClient(); + subscribeToNestChannels(); + + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_PROCESSOR, [{ name: 'reports' }], () => undefined); + + let spanInside: Span | undefined; + class ReportsProcessor { + public async process(): Promise { + spanInside = getActiveSpan(); + } + } + wrappedDecorator(ReportsProcessor); + return new ReportsProcessor().process().then(() => { + expect(spanToJSON(spanInside!).description).toBe('reports process'); + }); + }); +}); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index e7c9cbcd0fd8..1fce9b35372d 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -53,7 +53,11 @@ export function experimentalUseDiagnosticsChannelInjection( options?: RegisterDiagnosticsChannelInjectionOptions, ): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - // The registry integrations 1:1 replace the OTel integration of the same name. + // These channel integrations 1:1 replace the OTel integration of the + // same name. Framework SDKs that own their own channel listener + // (e.g. `@sentry/nestjs`'s `Nest`) are NOT here. They pick the + // channel-vs-OTel path themselves at integration `setupOnce`, so + // there's nothing for the central swap to do. const integrations = Object.values(channelIntegrations).map(createIntegration => createIntegration()); const replacedOtelIntegrationNames = integrations.map(i => i.name); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 3d56b43b29d8..693dca01dd30 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -13,6 +13,7 @@ import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; import { graphqlChannels } from './config/graphql'; import { kafkajsChannels } from './config/kafkajs'; +import { nestjsChannels } from './config/nestjs'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -43,6 +44,7 @@ export const CHANNELS = { ...expressChannels, ...graphqlChannels, ...kafkajsChannels, + ...nestjsChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index b0e4d3e8b6d1..f9f43bf9f78c 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -15,7 +15,15 @@ import { redisConfig } from './redis'; import { expressConfig } from './express'; import { graphqlConfig } from './graphql'; import { kafkajsConfig } from './kafkajs'; +import { nestjsConfig } from './nestjs'; +/** + * The orchestrion code-transform configs. Every instrumentable library is here + * so the transform is all-or-nothing: whenever orchestrion is enabled, all of + * these are injected. The channel LISTENERS may live elsewhere (e.g. the NestJS + * one lives in `@sentry/nestjs`), but the config that decides what gets + * transformed is centralized here. + */ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, ...lruMemoizerConfig, @@ -32,6 +40,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...expressConfig, ...graphqlConfig, ...kafkajsConfig, + ...nestjsConfig, ]; /** diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts new file mode 100644 index 000000000000..70525177e4d3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -0,0 +1,143 @@ +import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * Wrap an instrumentation that targets nodes via a raw esquery selector + * (`NstQuery`) rather than the structured `functionQuery`. Needed when the + * target can't be named, e.g. the anonymous arrow a decorator factory returns + * The transformer supports `astQuery` at runtime (it takes precedence over + * `functionQuery`, which then only supplies `kind`), but it isn't in the + * published `InstrumentationConfig` type. Hence the cast. + */ +function astQueryInstrumentation(config: { + channelName: string; + module: InstrumentationConfig['module']; + astQuery: string; + functionQuery: { kind: FunctionKind }; +}): InstrumentationConfig { + return config as unknown as InstrumentationConfig; +} + +/** + * NestJS is different from the other instrumentations here. It's a standalone + * metaframework SDK (`@sentry/nestjs`), so its channel LISTENER lives in that + * package rather than in `server-utils`. Only the code-transform config (and + * the channel names below) live here, so `@nestjs/*` is transformed whenever + * orchestrion is enabled, same as every other library. + * + * `@sentry/nestjs` imports {@link nestjsChannels} to subscribe, and picks the + * channel-vs-OTel path based on the global injection flag. + */ +export const nestjsConfig = [ + { + channelName: 'nestFactoryCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'nest-factory.js' }, + functionQuery: { className: 'NestFactoryStatic', methodName: 'create', kind: 'Async' }, + }, + { + channelName: 'routerExecutionContextCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, + functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync' }, + }, + astQueryInstrumentation({ + // `@nestjs/common/decorators/core/injectable.decorator.js`: + // `function Injectable(options) { return (target) => { ... }; }` + // The inner decorator arrow is anonymous + returned, so only a raw + // `astQuery` can target it. The subscriber's `start` receives the + // decorated class as `arguments[0]` and patches its prototype + // use/canActivate/transform/intercept methods, reproducing the + // vendored `SentryNestInstrumentation` middleware/guard/pipe/interceptor + // spans. No span on the decorator itself, so `kind: 'Sync'`. + channelName: 'injectableDecorator', + module: { + name: '@nestjs/common', + versionRange: '>=8.0.0 <12', + filePath: 'decorators/core/injectable.decorator.js', + }, + astQuery: 'FunctionDeclaration[id.name="Injectable"] ReturnStatement > ArrowFunctionExpression', + functionQuery: { kind: 'Sync' }, + }), + astQueryInstrumentation({ + // `@nestjs/common/decorators/core/catch.decorator.js`: + // `function Catch(...exceptions) { return (target) => { ... }; }` + // Same anonymous-returned-arrow shape as `Injectable`. The subscriber's + // `start` patches the exception filter's prototype `catch` method to + // open an `exception_filter` span. + // + // Mirrors the vendored `SentryNestInstrumentation` `@Catch` wrap. + channelName: 'catchDecorator', + module: { name: '@nestjs/common', versionRange: '>=8.0.0 <12', filePath: 'decorators/core/catch.decorator.js' }, + astQuery: 'FunctionDeclaration[id.name="Catch"] ReturnStatement > ArrowFunctionExpression', + functionQuery: { kind: 'Sync' }, + }), + // @nestjs/schedule @Cron/@Interval/@Timeout: + // `function Cron(...) { return applyDecorators(...); }` + // The returned decorator has no inline arrow to target, so we match the + // factory function and reassign `data.result` in `end` to wrap the + // decorator it returns (which rewrites the user handler `descriptor.value` + // with isolation-scope + error capture). + // Mirrors `SentryNestScheduleInstrumentation`, whose supported range we + // match so opting in doesn't drop coverage the OTel path had. The compiled + // `function Cron(...)` declaration is unchanged across 2.x–5.x. + { + channelName: 'cronDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/cron.decorator.js' }, + functionQuery: { functionName: 'Cron', kind: 'Sync' }, + }, + { + channelName: 'intervalDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/interval.decorator.js' }, + functionQuery: { functionName: 'Interval', kind: 'Sync' }, + }, + { + channelName: 'timeoutDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/timeout.decorator.js' }, + functionQuery: { functionName: 'Timeout', kind: 'Sync' }, + }, + { + // @nestjs/event-emitter @OnEvent: + // `const OnEvent = (event, options) => { + // const decoratorFactory = (t, k, d) => {...}; return decoratorFactory; + // }` + // `OnEvent` is an arrow assigned to a const, so `expressionName`. `end` + // reassigns `data.result` to wrap the returned decorator, which rewrites + // the handler to open an `event.nestjs` span. + // Mirrors `SentryNestEventInstrumentation`; the `const OnEvent = (...) =>` + // shape is unchanged across 2.x–3.x. + channelName: 'onEventDecorator', + module: { + name: '@nestjs/event-emitter', + versionRange: '>=2.0.0', + filePath: 'dist/decorators/on-event.decorator.js', + }, + functionQuery: { expressionName: 'OnEvent', kind: 'Sync' }, + }, + { + // @nestjs/bullmq @Processor: + // `function Processor(...) { return (target) => {...}; }` + // The factory arg carries the queue name, so we match the factory and + // reassign `data.result` in `end` to wrap the returned class decorator + // (which patches `target.prototype.process`). + // Mirrors `SentryNestBullMQInstrumentation`; the `function Processor(...)` + // declaration is unchanged across + // 10.x–11.x. + channelName: 'processorDecorator', + module: { + name: '@nestjs/bullmq', + versionRange: '>=10.0.0', + filePath: 'dist/decorators/processor.decorator.js', + }, + functionQuery: { functionName: 'Processor', kind: 'Sync' }, + }, +] satisfies InstrumentationConfig[]; + +export const nestjsChannels = { + NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', + NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate', + NESTJS_INJECTABLE: 'orchestrion:@nestjs/common:injectableDecorator', + NESTJS_CATCH: 'orchestrion:@nestjs/common:catchDecorator', + NESTJS_SCHEDULE_CRON: 'orchestrion:@nestjs/schedule:cronDecorator', + NESTJS_SCHEDULE_INTERVAL: 'orchestrion:@nestjs/schedule:intervalDecorator', + NESTJS_SCHEDULE_TIMEOUT: 'orchestrion:@nestjs/schedule:timeoutDecorator', + NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator', + NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 1933978787fc..4fe46c7180cd 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -17,6 +17,9 @@ import { vercelAiChannelIntegration } from '../integrations/tracing-channel/verc import { expressChannelIntegration } from '../integrations/tracing-channel/express'; export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +// The `@nestjs/*` channel names live here alongside their transform config; the +// listener that subscribes to them lives in `@sentry/nestjs`, which imports this. +export { nestjsChannels } from './config/nestjs'; export { amqplibChannelIntegration, anthropicChannelIntegration, @@ -54,6 +57,10 @@ export type * from '../integrations/tracing-channel/graphql/graphql-types'; * NOTE: `ioredisChannelIntegration` and `redisChannelIntegration` are intentionally NOT here. They * only partially replace the composite OTel `Redis` integration and need the node SDK's redis cache * `responseHook` (which can't live in `server-utils`), so `@sentry/node` wires them up separately. + * + * Framework SDKs that own their own channel listener (e.g. `@sentry/nestjs`'s `Nest`) are NOT here + * either: their transform config is still in `SENTRY_INSTRUMENTATIONS`, but the listener lives in + * their package and picks the channel-vs-OTel path itself at `setupOnce`, so it needs no central swap. */ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration,