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..631b1834c9fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md @@ -0,0 +1,45 @@ +# 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. + +> [!WARNING] +> +> ## ⚠️ Not yet runnable in CI +> +> This app installs `@apm-js-collab/code-transformer` from npm (a +> transitive dep of `@sentry/node`/`@sentry/server-utils`). +> Several spans depend on the `mutableResult` transform option, +> which is **not in the published version yet**: +> +> - `request_context` (wraps the handler returned by +> `RouterExecutionContext.create`) +> - `@Cron`/`@Interval`/`@Timeout`, `@OnEvent` (wrap the +> decorator the factory returns) +> +> `app_creation`, `request_handler`, and the +> `@Injectable`/`@Catch` spans only need `astQuery` + argument +> mutation (already published), so they should pass first. +> +> **Enable in CI once** the `@apm-js-collab/code-transformer` +> changes (`mutableResult` + documented `astQuery`) are published +> and pulled in. Until then keep this app out of the e2e run +> list. 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..c822d5cb5c76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json @@ -0,0 +1,40 @@ +{ + "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" + }, + "sentryTest": { + "// skip": "Keep out of CI until @apm-js-collab/code-transformer (mutableResult + astQuery) is published. See README.md.", + "skip": true + } +} 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..a4d018635a0a --- /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.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..cdbac674e795 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts @@ -0,0 +1,104 @@ +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 } | 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.otel.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: `{Controller}.{handler}`, carries http + controller/callback attrs. + const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.otel.nestjs'); + expect(requestContext).toBeDefined(); + expect(requestContext?.description).toBe('AppController.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.nestjs', description: 'ExampleMiddleware' }, + { route: 'test-guard', origin: 'auto.middleware.nestjs.guard', description: 'ExampleGuard' }, + { route: 'test-pipe/123', origin: 'auto.middleware.nestjs.pipe', description: 'ParseIntPipe' }, + { route: 'test-interceptor', origin: 'auto.middleware.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.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..cf79f029c781 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "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/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/README.md b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/README.md new file mode 100644 index 000000000000..b78cf0b51bfd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/README.md @@ -0,0 +1,39 @@ +# nestjs-orchestrion integration test + +In-process verification of the orchestrion (diagnostics-channel) +NestJS instrumentation. Unlike the e2e app +(`e2e-tests/test-applications/nestjs-orchestrion`, which installs +the **published** `@apm-js-collab/code-transformer`), this +harness resolves dependencies from the repo root `node_modules`, +where the code-transformer is **symlinked to the local +checkout**, so it can validate the `mutableResult`-dependent +spans (`request_context`, schedule, event) **before** the +upstream npm publish. + +- `instrument-orchestrion.mjs`: `--import`ed before the scenario; + opts in + inits. +- `scenario.ts`: a minimal NestJS app (`NestFactory.create` + one + route). +- `test.ts`: `createRunner` asserts the `app_creation`, + `request_context` and `handler` spans. **Currently + `describe.skip`.** + +> [!WARNING] +> +> ## ⚠️ Currently not runnable in CI - Prerequesitest to un-skip +> +> 1. `@apm-js-collab/code-transformer` with `mutableResult` +> available. (The local checkout is symlinked into root +> `node_modules`, so this is satisfied as soon as that work is +> built. No npm publish needed for this test). +> 2. Add `rxjs` and `reflect-metadata` to this package's +> `devDependencies`. NestJS cannot load without them. +> (`@nestjs/common`/`core`/`platform-express` are already +> present.) +> 3. Ensure `scenario.ts` compiles with `experimentalDecorators` +> and `emitDecoratorMetadata` (NestJS dependency injection +> needs them). `scenario.ts` runs via `ts-node/register`, +> which uses this package's tsconfig; add a suite-local +> tsconfig if the shared one lacks those options. +> +> Then remove `.skip` in `test.ts`. diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs new file mode 100644 index 000000000000..de142b4eb678 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs @@ -0,0 +1,19 @@ +// Loaded via `--import` BEFORE the scenario module, so the channel-injection +// hooks are installed before `@nestjs/*` is imported. Opting in via +// `experimentalUseDiagnosticsChannelInjection()` (before `init`) is all +// that's needed. + +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// opt into the orchestrion implementation +Sentry.experimentalUseDiagnosticsChannelInjection(); + +// Because we opted in, `Sentry.init()` swaps the OTel `Nest` instrumentation +// for the diagnostics-channel one and synchronously installs the module hooks. +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts new file mode 100644 index 000000000000..2679c6817f01 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { Controller, Get, Module } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; + +@Controller() +class AppController { + @Get('/test-transaction') + public testTransaction(): { ok: true } { + return { ok: true }; + } +} + +@Module({ controllers: [AppController] }) +class AppModule {} + +async function bootstrap(): Promise { + // `NestFactory.create` -> the `Create Nest App` (app_creation) span + // the route -> `request_context` + `handler` spans, all via the + // orchestrion subscriber + const app = await NestFactory.create(AppModule, { logger: false }); + await app.listen(0); + const address = app.getHttpServer().address(); + sendPortToRunner(typeof address === 'object' && address ? address.port : 0); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bootstrap(); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts new file mode 100644 index 000000000000..928cc48371cc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts @@ -0,0 +1,57 @@ +import { join } from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +// See ./README.md in this test folder for details about requirements +// to un-skip this test. +describe.skip('nestjs orchestrion auto-instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const INSTRUMENT = join(__dirname, 'instrument-orchestrion.mjs'); + + test('emits the app_creation transaction at startup', async () => { + await createRunner(__dirname, 'scenario.ts') + .withFlags('--import', INSTRUMENT) + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe('Create Nest App'); + expect(transaction.contexts?.trace?.op).toBe('app_creation.nestjs'); + expect(transaction.contexts?.trace?.origin).toBe('auto.http.otel.nestjs'); + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'component': '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.module': 'AppModule', + }), + ); + }, + }) + .start() + .completed(); + }); + + test('a route transaction nests request_context + handler spans', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .withFlags('--import', INSTRUMENT) + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe('GET /test-transaction'); + const spans = transaction.spans ?? []; + expect( + spans.find(span => span.op === 'request_context.nestjs' && span.origin === 'auto.http.otel.nestjs'), + 'expected a request_context.nestjs span', + ).toBeDefined(); + expect( + spans.find(span => span.op === 'handler.nestjs'), + 'expected a handler.nestjs span', + ).toBeDefined(); + }, + }) + .start(); + + runner.makeRequest('get', '/test-transaction'); + await runner.completed(); + }); +}); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index d51f2d86a610..7efddb33febf 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,4 +1,8 @@ -import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { + detectOrchestrionSetup, + mysqlChannelIntegration, + nestjsChannelIntegration, +} from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; @@ -38,8 +42,8 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader( (): DiagnosticsChannelInjection => ({ - integrations: [mysqlChannelIntegration()], - replacedOtelIntegrationNames: ['Mysql'], + integrations: [mysqlChannelIntegration(), nestjsChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql', 'Nest'], register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, }), diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 8c8d2e887541..2c447f75eda2 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -30,7 +30,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { - const integrations: Integration[] = [ + return [ ...getDefaultIntegrationsWithoutPerformance(), // We only add performance integrations if tracing is enabled // Note that this means that without tracing enabled, e.g. `expressIntegration()` will not be added @@ -38,24 +38,6 @@ export function getDefaultIntegrations(options: Options): Integration[] { // But `transactionName` will not be set automatically ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; - - // When the app opted into diagnostics-channel injection (via - // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is - // enabled, swap the channel-based integrations in place of OTel equivalents - // so the two don't both instrument the same library. - // - // Every channel-based integration we ship today is a 1:1 replacement for an - // OTel performance/tracing integration and produces nothing but spans (those - // only come from `getAutoPerformanceIntegrations()` above), so it's gated on - // span recording. - if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { - const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); - if (diagnosticsChannelInjection) { - const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); - return [...integrations.filter(i => !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; - } - } - return integrations; } /** @@ -90,10 +72,25 @@ function _init( diagnosticsChannelInjection.register(); } + // Only use Node SDK defaults if none provided. + let defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(options); + + // When opted into diagnostics-channel injection, swap the channel-based + // integrations in place of their OTel equivalents so the two don't both + // instrument the same library. Done here (rather than in + // `getDefaultIntegrations`) so it also covers framework SDKs (e.g. + // `@sentry/nestjs`) that pass their own `defaultIntegrations` array. + if (diagnosticsChannelInjection && Array.isArray(defaultIntegrations)) { + const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); + defaultIntegrations = [ + ...defaultIntegrations.filter(integration => !replaced.has(integration.name)), + ...diagnosticsChannelInjection.integrations, + ]; + } + const client = initNodeCore({ ...options, - // Only use Node SDK defaults if none provided - defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrationsImpl(options), + defaultIntegrations, }); // Add Node SDK specific OpenTelemetry setup diff --git a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts new file mode 100644 index 000000000000..29dd5f5e2e88 --- /dev/null +++ b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts @@ -0,0 +1,87 @@ +import type { Integration } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init } from '../../src/sdk'; +import { setDiagnosticsChannelInjectionLoader } from '../../src/sdk/diagnosticsChannelInjection'; +import { cleanupOtel, resetGlobals } from '../helpers/mockSdkInit'; + +// eslint-disable-next-line no-var +declare var global: any; + +const PUBLIC_DSN = 'https://username@domain/123'; + +function mockIntegration(name: string): Integration { + return { name, setupOnce: vi.fn() }; +} + +// These tests run in definition order: the first runs before any loader is set +// (opt-out), the second sets it (opt-in). The module-level loader state is +// isolated per test file by vitest, so it doesn't leak elsewhere. +describe('diagnostics-channel injection integration swap', () => { + beforeEach(() => { + global.__SENTRY__ = {}; + vi.spyOn(debug, 'enable').mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanupOtel(); + resetGlobals(); + vi.clearAllMocks(); + }); + + it('does not swap integrations when not opted in', () => { + // Distinct names from the opt-in test below: `@sentry/core` only runs + // `setupOnce` once per integration name per process, so reusing names across + // tests would suppress later calls. + const otelNest = mockIntegration('OptOutNest'); + const http = mockIntegration('OptOutHttp'); + + init({ + dsn: PUBLIC_DSN, + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + defaultIntegrations: [otelNest, http], + }); + + // No opt-in -> the supplied defaults are set up untouched. + expect(otelNest.setupOnce).toHaveBeenCalledTimes(1); + expect(http.setupOnce).toHaveBeenCalledTimes(1); + }); + + it('replaces the named OTel integrations with the channel integrations, even when defaultIntegrations are supplied by a framework SDK', () => { + const channelMysql = mockIntegration('Mysql'); + const channelNest = mockIntegration('Nest'); + const register = vi.fn(); + const detect = vi.fn(); + setDiagnosticsChannelInjectionLoader(() => ({ + integrations: [channelMysql, channelNest], + replacedOtelIntegrationNames: ['Mysql', 'Nest'], + register, + detect, + })); + + // Mimics `@sentry/nestjs`, which prepends its OTel `Nest` integration to + // its own `defaultIntegrations` array (so node's `getDefaultIntegrations` + // swap never sees it; swap must happen in `init`). + const otelNest = mockIntegration('Nest'); + const http = mockIntegration('Http'); + + init({ + dsn: PUBLIC_DSN, + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + defaultIntegrations: [otelNest, http], + }); + + // OTel 'Nest' filtered out, never set up. + expect(otelNest.setupOnce).not.toHaveBeenCalled(); + // Channel replacements set up instead. + expect(channelNest.setupOnce).toHaveBeenCalledTimes(1); + expect(channelMysql.setupOnce).toHaveBeenCalledTimes(1); + // Unrelated default preserved. + expect(http.setupOnce).toHaveBeenCalledTimes(1); + // Hooks installed and detection ran once. + expect(register).toHaveBeenCalledTimes(1); + expect(detect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts new file mode 100644 index 000000000000..4e0879a95984 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -0,0 +1,281 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { + addNonEnumerableProperty, + getActiveSpan, + isThenable, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + startSpan, + startSpanManual, + withActiveSpan, +} from '@sentry/core'; +import type { AnyFn } from './nestjs-shared'; + +const OP_MIDDLEWARE = 'middleware.nestjs'; +const ORIGIN_MIDDLEWARE = 'auto.middleware.nestjs'; + +/** The class an `@Injectable` decorator is applied to (`ctx.arguments[0]`). */ +export interface InjectableTarget { + name?: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; + prototype: { + use?: AnyFn; + canActivate?: AnyFn; + transform?: AnyFn; + intercept?: AnyFn; + }; +} + +/** The class a `@Catch` decorator is applied to (an exception filter). */ +export interface CatchTarget { + name?: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; + prototype: { catch?: AnyFn }; +} + +interface NestCallHandler { + handle: AnyFn; +} + +interface SubscriptionLike { + add: (teardown: () => void) => void; +} + +interface ObservableLike { + subscribe: AnyFn; +} + +/** + * Mark a target class as patched so it's instrumented only once (mirrors the + * vendored `isPatched`). Also give idempotency across repeated subscriptions. + */ +function isTargetPatched(target: { sentryPatched?: boolean }): boolean { + if (target.sentryPatched) { + return true; + } + addNonEnumerableProperty(target as object, 'sentryPatched', true); + return false; +} + +/** + * Span options for middleware/guard/pipe/interceptor spans + * name = provided name or class name. + */ +function getMiddlewareSpanOptions( + target: { name?: string }, + name?: string, + componentType?: string, +): { name: string; attributes: SpanAttributes } { + return { + name: name ?? target.name ?? 'unknown', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: OP_MIDDLEWARE, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: componentType ? `${ORIGIN_MIDDLEWARE}.${componentType}` : ORIGIN_MIDDLEWARE, + }, + }; +} + +/** + * Proxy a middleware `next()` so the span ends when `next` is called, then + * restore the previous active span for the continuation. + */ +function getNextProxy(next: AnyFn, span: Span, prevSpan: Span | undefined): AnyFn { + return new Proxy(next, { + apply: (originalNext, thisArgNext, argsNext) => { + span.end(); + if (prevSpan) { + return withActiveSpan(prevSpan, () => Reflect.apply(originalNext, thisArgNext, argsNext)); + } + return Reflect.apply(originalNext, thisArgNext, argsNext); + }, + }); +} + +/** + * End the given span when the interceptor's returned observable is + * unsubscribed (i.e. the response is sent), keeping it active across the + * subscription. + */ +function instrumentObservable(observable: ObservableLike, activeSpan: Span | undefined): void { + if (!activeSpan) { + return; + } + observable.subscribe = new Proxy(observable.subscribe, { + apply: (originalSubscribe, thisArgSubscribe, argsSubscribe) => { + return withActiveSpan(activeSpan, () => { + const subscription = originalSubscribe.apply(thisArgSubscribe, argsSubscribe) as SubscriptionLike; + subscription.add(() => activeSpan.end()); + return subscription; + }); + }, + }); +} + +function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContexts: WeakSet): AnyFn { + return new Proxy(intercept, { + apply: (originalIntercept, thisArg, argsIntercept) => { + const context = argsIntercept[0] as object | undefined; + const next = argsIntercept[1] as NestCallHandler | undefined; + const parentSpan = getActiveSpan(); + let afterSpan: Span | undefined; + + if ( + !context || + !next || + typeof next.handle !== 'function' || + target.name === 'SentryTracingInterceptor' // don't trace Sentry's own interceptor + ) { + return originalIntercept.apply(thisArg, argsIntercept); + } + + return startSpanManual(getMiddlewareSpanOptions(target, undefined, 'interceptor'), (beforeSpan: Span) => { + // `next.handle()` is the boundary between the "before" and "after" + // interceptor work: end the before-span and open the after-span (once + // per execution context), which `instrumentObservable` later closes. + next.handle = new Proxy(next.handle, { + apply: (originalHandle, thisArgHandle, argsHandle) => { + beforeSpan.end(); + const run = (): unknown => { + const handleReturn = Reflect.apply(originalHandle, thisArgHandle, argsHandle); + if (!seenContexts.has(context)) { + seenContexts.add(context); + afterSpan = startInactiveSpan( + getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), + ); + } + return handleReturn; + }; + return parentSpan ? withActiveSpan(parentSpan, run) : run(); + }, + }); + + let returned: unknown; + try { + returned = originalIntercept.apply(thisArg, argsIntercept); + } catch (e) { + beforeSpan.end(); + afterSpan?.end(); + throw e; + } + + if (!afterSpan) { + return returned; + } + + // async interceptor: returns a Promise + if (isThenable(returned)) { + return returned.then( + (observable: unknown) => { + instrumentObservable(observable as ObservableLike, afterSpan ?? parentSpan); + return observable; + }, + (e: unknown) => { + beforeSpan.end(); + afterSpan?.end(); + throw e; + }, + ); + } + + // sync interceptor: returns an Observable + if (typeof (returned as ObservableLike).subscribe === 'function') { + instrumentObservable(returned as ObservableLike, afterSpan); + } + + return returned; + }); + }, + }); +} + +/** + * Port the vendored `@Injectable` instrumentation + * patch the decorated class's prototype methods so each runtime + * invocation opens the corresponding middleware/guard/pipe/interceptor span. + * The runtime guards (req/res/next, context, value+metadata) avoid false + * positives on non-middleware classes that happen to expose a same-named + * method. + */ +export function patchInjectableTarget(target: InjectableTarget, seenContexts: WeakSet): void { + const proto = target?.prototype; + if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) { + return; + } + + // middleware + if (typeof proto.use === 'function') { + proto.use = new Proxy(proto.use, { + apply: (originalUse, thisArgUse, argsUse) => { + const [req, res, next] = argsUse as unknown[]; + if (!req || !res || !next || typeof next !== 'function') { + return originalUse.apply(thisArgUse, argsUse); + } + const prevSpan = getActiveSpan(); + return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { + const nextProxy = getNextProxy(next as AnyFn, span, prevSpan); + const rest = (argsUse as unknown[]).slice(3); + return originalUse.apply(thisArgUse, [req, res, nextProxy, rest]); + }); + }, + }); + } + + // guards + if (typeof proto.canActivate === 'function') { + proto.canActivate = new Proxy(proto.canActivate, { + apply: (originalCanActivate, thisArg, args) => { + if (!args[0]) { + return originalCanActivate.apply(thisArg, args); + } + return startSpan(getMiddlewareSpanOptions(target, undefined, 'guard'), () => + originalCanActivate.apply(thisArg, args), + ); + }, + }); + } + + // pipes + if (typeof proto.transform === 'function') { + proto.transform = new Proxy(proto.transform, { + apply: (originalTransform, thisArg, args) => { + if (!args[0] || !args[1]) { + return originalTransform.apply(thisArg, args); + } + return startSpan(getMiddlewareSpanOptions(target, undefined, 'pipe'), () => + originalTransform.apply(thisArg, args), + ); + }, + }); + } + + // interceptors + if (typeof proto.intercept === 'function') { + proto.intercept = patchInterceptor(target, proto.intercept, seenContexts); + } +} + +/** + * Port the vendored `@Catch` instrumentation. Patch the exception filter's + * prototype `catch` so each invocation opens an `exception_filter` span. The + * runtime guard (exception + host present) avoids false positives. + */ +export function patchCatchTarget(target: CatchTarget): void { + const proto = target?.prototype; + if (!proto || typeof proto.catch !== 'function' || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) { + return; + } + proto.catch = new Proxy(proto.catch, { + apply: (originalCatch, thisArg, args) => { + const [exception, host] = args as unknown[]; + if (!exception || !host) { + return originalCatch.apply(thisArg, args); + } + return startSpan(getMiddlewareSpanOptions(target, undefined, 'exception_filter'), () => + originalCatch.apply(thisArg, args), + ); + }, + }); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts new file mode 100644 index 000000000000..37b92a2ecea8 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts @@ -0,0 +1,265 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { + captureException, + isThenable, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import { CHANNELS } from '../../orchestrion/channels'; +import type { AnyFn, ChannelContext } from './nestjs-shared'; +import { isWrapped, markWrapped } from './nestjs-shared'; + +const NOOP = (): void => {}; + +// Mechanism types for scheduled-handler error capture (no span) +// match vendored `SentryNestScheduleInstrumentation` +const MECHANISM_CRON = 'auto.function.nestjs.cron'; +const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; +const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; +const MECHANISM_EVENT = 'auto.event.nestjs'; +const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq'; + +const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; +} + +/** + * The class a `@Processor` decorator is applied to (a BullMQ queue processor). */ +interface ProcessorTarget { + __SENTRY_INTERNAL__?: boolean; + prototype?: { process?: AnyFn }; +} + +function captureHandlerError(error: unknown, mechanismType: string): void { + captureException(error, { mechanism: { handled: false, type: mechanismType } }); +} + +/** + * Wrap a scheduled handler (`@Cron`/`@Interval`/`@Timeout`): fork the + * isolation scope and capture errors. NOT async. Preserve the handler's sync + * return type, so sync and async errors are handled on separate paths + * matches vendored OTel implementation + */ +function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => { + let result: unknown; + try { + result = handler.apply(this, args); + } catch (error) { + captureHandlerError(error, mechanismType); + throw error; + } + if (isThenable(result)) { + return result.then(undefined, (error: unknown) => { + captureHandlerError(error, mechanismType); + throw error; + }); + } + return result; + }); + }; +} + +function eventNameFromEvent(event: unknown): string { + if (typeof event === 'string') { + return event; + } + if (Array.isArray(event)) { + return event.map(eventNameFromEvent).join(','); + } + return String(event); +} + +/** + * Derive the event name(s) for an @OnEvent span. The wrapped handler carries + * `EVENT_LISTENER_METADATA` (set by the original decorator), which lists every + * event when multiple @OnEvent decorators are stacked; fall back to the event + * captured from the decorator factory. + */ +function deriveEventName(handler: AnyFn, fallbackEvent: unknown): string { + const R = Reflect as unknown as ReflectWithMetadata; + if (typeof R.getMetadataKeys === 'function' && typeof R.getMetadata === 'function') { + if (R.getMetadataKeys(handler)?.includes(EVENT_LISTENER_METADATA)) { + const eventData = R.getMetadata(EVENT_LISTENER_METADATA, handler); + if (Array.isArray(eventData)) { + return (eventData as unknown[]) + .map(entry => { + const event = entry && typeof entry === 'object' ? (entry as { event?: unknown }).event : undefined; + return event ? eventNameFromEvent(event) : ''; + }) + .reverse() // decorators evaluate bottom to top + .join('|'); + } + } + } + return eventNameFromEvent(fallbackEvent); +} + +/** + * Wrap an @OnEvent handler: fork the isolation scope, open an `event.nestjs` + * transaction, and capture errors. (event-handler errors bypass the global + * filter) + */ +function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { + const wrapped = async function (this: unknown, ...args: unknown[]): Promise { + const eventName = deriveEventName(wrapped, fallbackEvent); + return withIsolationScope(() => + startSpan( + { + name: `event ${eventName}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_EVENT, + }, + forceTransaction: true, + }, + async () => { + try { + return await handler.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_EVENT); + throw error; + } + }, + ), + ); + }; + return wrapped; +} + +/** + * Wrap a BullMQ `process` method: fork the isolation scope, open a + * `queue.process` transaction, and capture errors. + */ +function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => + startSpan( + { + name: `${queueName} process`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_BULLMQ, + 'messaging.system': 'bullmq', + 'messaging.destination.name': queueName, + }, + forceTransaction: true, + }, + async () => { + try { + return await process.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_BULLMQ); + throw error; + } + }, + ), + ); + }; +} + +/** + * Wrap a method decorator (the function the factory returns for + * `@Cron`/`@Interval`/`@Timeout`/`@OnEvent`) so it replaces + * `descriptor.value` with a wrapped handler before delegating to the + * original decorator (which then attaches its metadata to our wrapper). + */ +function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + const target = args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined; + const propertyKey = args[1]; + const descriptor = args[2] as PropertyDescriptor | undefined; + const handler = descriptor?.value; + if (handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler as AnyFn)) { + const wrapped = wrapHandler(handler as AnyFn); + Object.defineProperty(wrapped, 'name', { + value: (handler as AnyFn).name || String(propertyKey), + configurable: true, + }); + markWrapped(wrapped); + descriptor.value = wrapped; + } + return original.apply(this, args); + }; +} + +/** + * Wrap the class decorator @Processor returns so it patches + * `target.prototype.process` before delegating to the original decorator. + */ +function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + const target = args[0] as ProcessorTarget | undefined; + const process = target?.prototype?.process; + if (process && typeof process === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(process)) { + const wrapped = wrapBullMQProcess(process, queueName); + markWrapped(wrapped); + target.prototype!.process = wrapped; + } + return original.apply(this, args); + }; +} + +function extractQueueName(arg: unknown): string { + if (typeof arg === 'string') { + return arg; + } + if (arg && typeof arg === 'object' && 'name' in arg && typeof (arg as { name?: unknown }).name === 'string') { + return (arg as { name: string }).name; + } + return 'unknown'; +} + +/** + * Subscribe to a decorator-factory channel. The factory is matched with + * `mutableResult`, so `end` can replace `data.result` (the decorator the + * factory returns) with a wrapped version. `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 the @Cron/@Interval/@Timeout (schedule), @OnEvent (event-emitter) + * and @Processor (bullmq) decorator channels. Each factory is matched with + * `mutableResult`; we replace the decorator it returns with one that wraps the + * user handler (schedule/event) or the `process` method (bullmq). + */ +export function subscribeNestHandlerDecorators(): void { + 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/server-utils/src/integrations/tracing-channel/nestjs-shared.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts new file mode 100644 index 000000000000..2302beda23d5 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts @@ -0,0 +1,29 @@ +/** A function of unknown signature, matching the methods/handlers we wrap. */ +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +/** + * The orchestrion tracing-channel context. `arguments` is the live call args + * array; `result` is the (sync) return value, mutable when `mutableResult` is set. + */ +export interface ChannelContext { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +/** + * Marks a function as already wrapped so repeated subscriptions (eg a second + * `setupOnce`) or multiple decorators on one method don't double-wrap it. + */ +const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped'); + +/** Whether `fn` has already been wrapped by this integration. */ +export function isWrapped(fn: AnyFn): boolean { + return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; +} + +/** Mark `fn` as wrapped (see {@link isWrapped}). */ +export function markWrapped(fn: AnyFn): void { + (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts new file mode 100644 index 000000000000..aff54eac37f4 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -0,0 +1,259 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, startSpan } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; +import type { CatchTarget, InjectableTarget } from './nestjs-decorators'; +import { patchCatchTarget, patchInjectableTarget } from './nestjs-decorators'; +import { subscribeNestHandlerDecorators } from './nestjs-handler-wrappers'; +import type { AnyFn, ChannelContext } from './nestjs-shared'; +import { isWrapped, markWrapped } from './nestjs-shared'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Nest' integration is omitted from the default set. +const INTEGRATION_NAME = 'Nest'; + +// Span op/origin/attribute values inlined to match the vendored +// `@opentelemetry/instrumentation-nestjs-core` output exactly (the +// `@sentry/nestjs` e2e suite asserts these). They are NOT imported from +// `@sentry/nestjs` because that package depends on this one, not vice versa. +// Orchestrion's whole point is to keep this surface free of OTel. +const NESTJS_COMPONENT = '@nestjs/core'; +const ORIGIN_NESTJS = 'auto.http.otel.nestjs'; +const ATTR_COMPONENT = 'component'; +const ATTR_NESTJS_TYPE = 'nestjs.type'; +const ATTR_NESTJS_VERSION = 'nestjs.version'; +const ATTR_NESTJS_MODULE = 'nestjs.module'; +const ATTR_NESTJS_CONTROLLER = 'nestjs.controller'; +const ATTR_NESTJS_CALLBACK = 'nestjs.callback'; +const ATTR_HTTP_ROUTE = 'http.route'; +const ATTR_HTTP_METHOD = 'http.method'; +const ATTR_HTTP_URL = 'http.url'; +const TYPE_APP_CREATION = 'app_creation'; +const TYPE_REQUEST_CONTEXT = 'request_context'; +const TYPE_REQUEST_HANDLER = 'handler'; + +const NOOP = (): void => {}; + +/** Minimal request shape, across the express/fastify adapters. */ +interface NestRequest { + route?: { path?: string }; + routeOptions?: { url?: string }; + routerPath?: string; + method?: string; + originalUrl?: string; + url?: string; +} + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; + defineMetadata?: (key: unknown, value: unknown, target: object) => void; +} + +/** + * Copy NestJS reflect-metadata from the original handler onto the wrapper so + * other decorators (param decorators, guards, `@EventPattern`, ...) that + * read it keep working. No-op when `reflect-metadata` isn't loaded. Mirrors + * vendored `@opentelemetry/instrumentation-nestjs-core` behaviour. + */ +function copyReflectMetadata(from: object, to: object): void { + const R = Reflect as unknown as ReflectWithMetadata; + if ( + typeof R.getMetadataKeys !== 'function' || + typeof R.getMetadata !== 'function' || + typeof R.defineMetadata !== 'function' + ) { + return; + } + for (const key of R.getMetadataKeys(from)) { + R.defineMetadata(key, R.getMetadata(key, from), to); + } +} + +/** + * Wrap the route-handler callback (`create`'s `arguments[1]`) so each + * invocation opens the `handler.nestjs` span (REQUEST_HANDLER). Preserve the + * original `.name` and reflect-metadata so NestJS reflection is unaffected. + */ +function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { + if (isWrapped(callback)) { + return callback; + } + const spanName = callback.name || 'anonymous nest handler'; + const attributes: SpanAttributes = { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_REQUEST_HANDLER, + [ATTR_NESTJS_CALLBACK]: callback.name, + ...(moduleVersion ? { [ATTR_NESTJS_VERSION]: moduleVersion } : {}), + }; + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + return startSpan({ name: spanName, op: `${TYPE_REQUEST_HANDLER}.nestjs`, attributes }, () => + callback.apply(this, args), + ); + }; + if (callback.name) { + Object.defineProperty(wrapped, 'name', { value: callback.name }); + } + copyReflectMetadata(callback, wrapped); + markWrapped(wrapped); + return wrapped; +} + +/** + * Wrap per-request handler `create` returns so each request opens the + * `request_context.nestjs` span (REQUEST_CONTEXT), carrying the controller / + * callback names captured at setup plus the per-request http.* attributes. + */ +function wrapRequestContextHandler( + handler: AnyFn, + instanceName: string, + callbackName: string, + moduleVersion?: string, +): AnyFn { + const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; + const wrapped = function (this: unknown, ...handlerArgs: unknown[]): unknown { + const req = (handlerArgs[0] || {}) as NestRequest; + const httpRoute = req.route?.path || req.routeOptions?.url || req.routerPath; + const attributes: SpanAttributes = { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_REQUEST_CONTEXT, + [ATTR_NESTJS_CONTROLLER]: instanceName, + [ATTR_NESTJS_CALLBACK]: callbackName, + ...(moduleVersion ? { [ATTR_NESTJS_VERSION]: moduleVersion } : {}), + ...(httpRoute ? { [ATTR_HTTP_ROUTE]: httpRoute } : {}), + ...(req.method ? { [ATTR_HTTP_METHOD]: req.method } : {}), + ...(req.originalUrl || req.url ? { [ATTR_HTTP_URL]: req.originalUrl || req.url } : {}), + }; + return startSpan({ name: spanName, op: `${TYPE_REQUEST_CONTEXT}.nestjs`, attributes }, () => + handler.apply(this, handlerArgs), + ); + }; + markWrapped(wrapped); + return wrapped; +} + +/** + * 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` method 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, + }); +} + +const _nestjsChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + 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( + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan({ + name: 'Create Nest App', + op: `${TYPE_APP_CREATION}.nestjs`, + attributes: { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_APP_CREATION, + ...(data.moduleVersion ? { [ATTR_NESTJS_VERSION]: data.moduleVersion } : {}), + ...(moduleCls?.name ? { [ATTR_NESTJS_MODULE]: 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. We don't span `create` itself. + // `start` wraps the callback arg (-> handler span per call) and + // because the config sets `mutableResult`, `end` replaces the returned + // handler (-> request_context span per request). + // + // Both wrappers open their span at invoke time, inside the live + // request context, so they parent correctly. + 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). + subscribeNestHandlerDecorators(); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL orchestrion-driven NestJS integration. + * + * Subscribes to the diagnostics_channels the orchestrion code transform injects + * into `@nestjs/core` and `@nestjs/common` (see `orchestrion/config.ts`). + * Requires the orchestrion runtime hook or bundler plugin to be active. + */ +export const nestjsChannelIntegration = defineIntegration(_nestjsChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 28dcf0c33468..cffc617ae26b 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -13,6 +13,15 @@ */ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', + 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; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 35b326fb8eb1..b3c827b6dfa6 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -32,6 +32,110 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ // attach `'end'`/`'error'` listeners that finish the span. functionQuery: { expressionName: 'query', kind: 'Auto' }, }, + { + // `@nestjs/core/nest-factory.js` exports `class NestFactoryStatic` with an + // `async create(moduleCls, serverOrOptions, options)` method (the app + // bootstrap). A plain `className`+`methodName` match works here, unlike + // mysql's prototype-assignment shape. `Async` ends the span on + // `asyncEnd`, covering the full async bootstrap. Mirrors the vendored + // `@opentelemetry/instrumentation-nestjs-core` `NestFactory.create` wrap. + channelName: 'nestFactoryCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'nest-factory.js' }, + functionQuery: { className: 'NestFactoryStatic', methodName: 'create', kind: 'Async' }, + }, + { + // `@nestjs/core/router/router-execution-context.js` exports + // `class RouterExecutionContext` with a synchronous `create(instance, + // callback, ...)` that RETURNS the per-request handler. The subscriber + // wraps the `callback` arg (-> one handler span) and, via + // `mutableResult: true`, replaces the returned handler + // (-> request_context span). So `kind: 'Sync'` + `mutableResult: true`. + // Mirrors vendored `@opentelemetry/instrumentation-nestjs-core` + // `RouterExecutionContext.create` wrap. + channelName: 'routerExecutionContextCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, + functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync', mutableResult: true }, + }, + { + // `@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'` with no + // `mutableResult`. + 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' }, + }, + { + // `@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 use `mutableResult` to wrap the + // decorator it returns (which rewrites the user handler `descriptor.value` with + // isolation-scope + error capture). Mirrors `SentryNestScheduleInstrumentation`. + // Version range scoped to the verified compiled shape (4.x). + { + channelName: 'cronDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=4.0.0 <5', filePath: 'dist/decorators/cron.decorator.js' }, + functionQuery: { functionName: 'Cron', kind: 'Sync', mutableResult: true }, + }, + { + channelName: 'intervalDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=4.0.0 <5', filePath: 'dist/decorators/interval.decorator.js' }, + functionQuery: { functionName: 'Interval', kind: 'Sync', mutableResult: true }, + }, + { + channelName: 'timeoutDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=4.0.0 <5', filePath: 'dist/decorators/timeout.decorator.js' }, + functionQuery: { functionName: 'Timeout', kind: 'Sync', mutableResult: true }, + }, + { + // @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`. + // `mutableResult` wraps the returned decorator, which rewrites the handler to + // open an `event.nestjs` span. Mirrors `SentryNestEventInstrumentation`. + channelName: 'onEventDecorator', + module: { + name: '@nestjs/event-emitter', + versionRange: '>=2.0.0 <3', + filePath: 'dist/decorators/on-event.decorator.js', + }, + functionQuery: { expressionName: 'OnEvent', kind: 'Sync', mutableResult: true }, + }, + { + // @nestjs/bullmq @Processor: `function Processor(...) { return (target) => {…}; }` + // The factory arg carries the queue name, so we match the factory and use + // `mutableResult` to wrap the returned class decorator (which patches + // `target.prototype.process`). Mirrors `SentryNestBullMQInstrumentation`. + channelName: 'processorDecorator', + module: { + name: '@nestjs/bullmq', + versionRange: '>=10.0.0 <12', + filePath: 'dist/decorators/processor.decorator.js', + }, + functionQuery: { functionName: 'Processor', kind: 'Sync', mutableResult: true }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index dd3ecd0f8f19..97126d93f1dc 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,2 +1,3 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +export { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs'; diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts new file mode 100644 index 000000000000..d61c27df74b0 --- /dev/null +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -0,0 +1,670 @@ +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, describe, expect, it, vi } from 'vitest'; +import { nestjsChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +// 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('nestjsChannelIntegration: 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(); + nestjsChannelIntegration().setupOnce!(); + + 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.otel.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(); + nestjsChannelIntegration().setupOnce!(); + + 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('nestjsChannelIntegration: 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` replaces the returned handler + // (mutableResult). `makeHandler` stands in for the real `create` body. Returns + // the effective return (post-mutableResult, i.e. `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(); + nestjsChannelIntegration().setupOnce!(); + + 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.otel.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(); + nestjsChannelIntegration().setupOnce!(); + + 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.otel.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(); + nestjsChannelIntegration().setupOnce!(); + + 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('nestjsChannelIntegration: @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(); + nestjsChannelIntegration().setupOnce!(); + + 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.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(); + nestjsChannelIntegration().setupOnce!(); + + 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.nestjs.guard'); + }); + + it('pipe: wraps `transform` in a span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + 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.nestjs.pipe'); + }); + + it('interceptor: opens a before-span (ended at next.handle) and instruments the returned observable', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + // 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.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('skips targets flagged __SENTRY_INTERNAL__', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + 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('nestjsChannelIntegration: @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(); + nestjsChannelIntegration().setupOnce!(); + + 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.nestjs.exception_filter'); + }); + + it('does not open a span when exception or host is absent', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + 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(); + }); +}); + +describe('nestjsChannelIntegration: 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` + // (mutableResult) replaces it. 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(); + nestjsChannelIntegration().setupOnce!(); + 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(); + nestjsChannelIntegration().setupOnce!(); + 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(); + nestjsChannelIntegration().setupOnce!(); + + 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.nestjs'); + }); + + it('bullmq @Processor: patches `process` into a queue.process transaction (string queue name)', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + 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.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(); + nestjsChannelIntegration().setupOnce!(); + + 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'); + }); + }); +});