diff --git a/.size-limit.js b/.size-limit.js index c0c4a2c25a32..6fc6cad995b7 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '135 KB', + limit: '140 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md new file mode 100644 index 000000000000..5e35268cd1fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md @@ -0,0 +1,31 @@ +# nestjs-orchestrion + +E2E test app for the **orchestrion** (diagnostics-channel +injection) NestJS instrumentation. It is a normal +`@sentry/nestjs` app whose only difference from `nestjs-basic` is +that `src/instrument.ts` calls +`Sentry.experimentalUseDiagnosticsChannelInjection()` before +`Sentry.init()`. That swaps the OTel `Nest` integration for the +orchestrion subscriber (`@sentry/server-utils/orchestrion`) and +injects the diagnostics channels into `@nestjs/*` at load time. + +The tests assert the **same** span tree the OTel path produces +(`nestjs-basic`), so this app is the opt-in side of an A/B +against that baseline: + +- `transactions.test.ts`: `app_creation`, `request_context`, + `handler`, and the + `middleware.nestjs[.guard|.pipe|.interceptor|.exception_filter]` + spans. +- `schedule.test.ts`: `@Cron`/`@Interval`/`@Timeout` error + mechanisms. +- `events.test.ts`: the `@OnEvent` `event.nestjs` transaction. + +The spans that reassign the value a decorator factory / route +handler returns (`request_context`, +`@Cron`/`@Interval`/`@Timeout`, `@OnEvent`) rely on the +transformer returning the (mutated) `ctx.result`. That is the +default for `Sync`/`Async` transforms as of +`@apm-js-collab/code-transformer` `0.16.0` (no `mutableResult` +opt-in), which `@sentry/node`/`@sentry/server-utils` depend on, +so this app runs against the published transformer. diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json new file mode 100644 index 000000000000..f7d11be2e9ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json @@ -0,0 +1,36 @@ +{ + "name": "nestjs-orchestrion", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:prod": "node dist/main", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^4.1.0", + "@sentry/nestjs": "file:../../packed/sentry-nestjs-packed.tgz", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/express": "^5.0.0", + "@types/node": "^18.19.1", + "typescript": "~5.5.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts new file mode 100644 index 000000000000..169a4aa03313 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Param, ParseIntPipe, UseGuards, UseInterceptors } from '@nestjs/common'; +import { AppService } from './app.service'; +import { ExampleException } from './example.exception'; +import { ExampleGuard } from './example.guard'; +import { ExampleInterceptor } from './example.interceptor'; +import { ScheduleService } from './schedule.service'; + +@Controller() +export class AppController { + public constructor( + private readonly appService: AppService, + private readonly scheduleService: ScheduleService, + ) {} + + @Get('test-transaction') + public testTransaction(): unknown { + return this.appService.testSpan(); + } + + @Get('test-middleware') + public testMiddleware(): unknown { + return this.appService.testSpan(); + } + + @Get('test-guard') + @UseGuards(ExampleGuard) + public testGuard(): unknown { + return {}; + } + + @Get('test-interceptor') + @UseInterceptors(ExampleInterceptor) + public testInterceptor(): unknown { + return this.appService.testSpan(); + } + + @Get('test-pipe/:id') + public testPipe(@Param('id', ParseIntPipe) id: number): unknown { + return { value: id }; + } + + @Get('test-exception') + public testException(): never { + throw new ExampleException(); + } + + @Get('test-event') + public testEvent(): unknown { + this.appService.emitEvent(); + return { message: 'emitted' }; + } + + // Triggers the `@Timeout`-decorated handler directly (its real delay is long + // so it never fires on its own during the test). + @Get('trigger-timeout-error') + public triggerTimeoutError(): unknown { + try { + this.scheduleService.handleTimeoutError(); + } catch { + // Swallow, the error is captured by the schedule instrumentation; the + // route itself should still succeed. + } + return { message: 'triggered' }; + } + + // Stop the auto-firing scheduled jobs so they don't keep throwing after the + // assertions have run. + @Get('kill-schedules') + public killSchedules(): unknown { + this.scheduleService.killCron('test-cron-error'); + this.scheduleService.killInterval('test-interval-error'); + return { message: 'killed' }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts new file mode 100644 index 000000000000..d29a0cc68d72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts @@ -0,0 +1,31 @@ +import { MiddlewareConsumer, Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { EventsService } from './events.service'; +import { ExampleExceptionFilter } from './example.filter'; +import { ExampleMiddleware } from './example.middleware'; +import { ScheduleService } from './schedule.service'; + +@Module({ + imports: [EventEmitterModule.forRoot(), ScheduleModule.forRoot()], + controllers: [AppController], + providers: [ + AppService, + EventsService, + ScheduleService, + // Global exception filter + // exercises the `@Catch` (exception_filter) instrumentation. + { + provide: APP_FILTER, + useClass: ExampleExceptionFilter, + }, + ], +}) +export class AppModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(ExampleMiddleware).forRoutes('test-middleware'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts new file mode 100644 index 000000000000..faafa5d28ddd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class AppService { + public constructor(private readonly eventEmitter: EventEmitter2) {} + + public testSpan(): void { + // A child span, to verify request handling nests under the nestjs spans. + Sentry.startSpan({ name: 'test-controller-span' }, () => undefined); + } + + public emitEvent(): void { + this.eventEmitter.emit('test.event', { hello: 'world' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts new file mode 100644 index 000000000000..596de32724af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class EventsService { + // `@OnEvent` opens an `event.nestjs` transaction per handled event. + @OnEvent('test.event') + public handleTestEvent(): void { + Sentry.startSpan({ name: 'test-event-child-span' }, () => undefined); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts new file mode 100644 index 000000000000..36b7444fead6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts @@ -0,0 +1,5 @@ +export class ExampleException extends Error { + public constructor() { + super('Example exception handled by the example filter'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts new file mode 100644 index 000000000000..1af3d1f28769 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts @@ -0,0 +1,12 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { Response } from 'express'; +import { ExampleException } from './example.exception'; + +// `@Catch` exercises the exception_filter instrumentation. +@Catch(ExampleException) +export class ExampleExceptionFilter implements ExceptionFilter { + public catch(_exception: ExampleException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + response.status(400).json({ message: 'handled by example filter' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts new file mode 100644 index 000000000000..a9069f4e6f9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts @@ -0,0 +1,12 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ExampleGuard implements CanActivate { + public canActivate(_context: ExecutionContext): boolean { + // Child span + // should nest under the guard span (middleware.nestjs / .guard). + Sentry.startSpan({ name: 'test-guard-span' }, () => undefined); + return true; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts new file mode 100644 index 000000000000..670ae0e0d3df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts @@ -0,0 +1,19 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { tap } from 'rxjs'; + +@Injectable() +export class ExampleInterceptor implements NestInterceptor { + public intercept(_context: ExecutionContext, next: CallHandler): ReturnType { + // Runs before `next.handle()` + // nests under the interceptor "before" span. + Sentry.startSpan({ name: 'test-interceptor-span-before' }, () => undefined); + return next.handle().pipe( + tap(() => { + // Runs after the route + // nests under the "Interceptors - After Route" span. + Sentry.startSpan({ name: 'test-interceptor-span-after' }, () => undefined); + }), + ); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts new file mode 100644 index 000000000000..c04904ef62ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts @@ -0,0 +1,13 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { NextFunction, Request, Response } from 'express'; + +@Injectable() +export class ExampleMiddleware implements NestMiddleware { + public use(_req: Request, _res: Response, next: NextFunction): void { + // Child span + // should nest under the middleware span. + Sentry.startSpan({ name: 'test-middleware-span' }, () => undefined); + next(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts new file mode 100644 index 000000000000..4c784cacce09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nestjs'; + +// Opt into diagnostics-channel injection BEFORE `Sentry.init()`. This swaps +// the OTel `Nest` instrumentation for the orchestrion (diagnostics-channel) +// one and synchronously installs the module hooks that inject the channels +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1, + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts new file mode 100644 index 000000000000..b7a2a41921cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts @@ -0,0 +1,16 @@ +// Import this first. It opts into diagnostics-channel injection and installs +// the module hooks before any `@nestjs/*` module is loaded below. +import './instrument'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule); + await app.listen(PORT); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts new file mode 100644 index 000000000000..a0efa7ef33cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, Interval, SchedulerRegistry, Timeout } from '@nestjs/schedule'; + +// Scheduled-handler instrumentation captures errors (no span) under +// `auto.function.nestjs.{cron,interval,timeout}`. +@Injectable() +export class ScheduleService { + public constructor(private readonly schedulerRegistry: SchedulerRegistry) {} + + @Cron('*/5 * * * * *', { name: 'test-cron-error' }) + public handleCronError(): void { + throw new Error('Test error from cron'); + } + + @Interval('test-interval-error', 2000) + public handleIntervalError(): void { + throw new Error('Test error from interval'); + } + + // Long delay so it never fires on its own; the test triggers it via HTTP. + @Timeout('test-timeout-error', 600000) + public handleTimeoutError(): void { + throw new Error('Test error from timeout'); + } + + public killCron(name: string): void { + this.schedulerRegistry.deleteCronJob(name); + } + + public killInterval(name: string): void { + this.schedulerRegistry.deleteInterval(name); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs new file mode 100644 index 000000000000..ba90624b2481 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs-orchestrion', +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts new file mode 100644 index 000000000000..6796c439506a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// `@OnEvent` opens an `event.nestjs` transaction per handled event. +test('@OnEvent opens an event.nestjs transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return transactionEvent?.transaction === 'event test.event'; + }); + + await fetch(`${baseURL}/test-event`); + const transactionEvent = await transactionPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('event.nestjs'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.event.orchestrion.nestjs'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts new file mode 100644 index 000000000000..0029cc0fea43 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// `@Cron`/`@Interval` auto-fire (every few seconds) and throw; the schedule +// instrumentation captures the error (no span) with the per-decorator mechanism. +test('@Cron error is captured with the cron mechanism', async () => { + const error = await waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from cron'; + }); + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.cron', handled: false }), + ); +}); + +test('@Interval error is captured with the interval mechanism', async () => { + const error = await waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from interval'; + }); + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.interval', handled: false }), + ); +}); + +// `@Timeout`'s real delay is long, so the route triggers the handler directly. +test('@Timeout error is captured with the timeout mechanism', async ({ baseURL }) => { + const errorPromise = waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from timeout'; + }); + + await fetch(`${baseURL}/trigger-timeout-error`); + const error = await errorPromise; + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.timeout', handled: false }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts new file mode 100644 index 000000000000..856e60dde4bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// Find a child span by op + origin within a transaction event. +function findSpan( + transactionEvent: Awaited>, + op: string, + origin: string, +): { description?: string; op?: string; origin?: string; data?: Record } | undefined { + return (transactionEvent.spans ?? []).find(span => span.op === op && span.origin === origin); +} + +test('app_creation: emits a "Create Nest App" transaction at startup', async () => { + // Emitted once at startup (NestFactory.create), before any request, so look + // back through buffered envelopes rather than waiting for a new transaction. + const envelopeItem = await waitForEnvelopeItem( + PROXY, + item => item[0].type === 'transaction' && (item[1] as { transaction?: string }).transaction === 'Create Nest App', + 0, + ); + + const transaction = envelopeItem[1] as { + contexts: { trace: { op?: string; origin?: string; data?: Record } }; + }; + + expect(transaction.contexts.trace.op).toBe('app_creation.nestjs'); + expect(transaction.contexts.trace.origin).toBe('auto.http.orchestrion.nestjs'); + expect(transaction.contexts.trace.data).toEqual( + expect.objectContaining({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.module': 'AppModule', + }), + ); +}); + +test('request_context + handler: a route transaction nests the nestjs spans', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + const transactionEvent = await transactionPromise; + + // request_context span, identified by its controller/callback attributes. Its description isn't asserted: + // the span carries `http.*` attributes, so the OTel span-name inference rewrites it to `GET /test-transaction` + // (same as the OTel `Nest` integration — this matches, rather than diverges from, the baseline). + const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.orchestrion.nestjs'); + expect(requestContext).toBeDefined(); + expect(requestContext?.data).toMatchObject({ + 'nestjs.type': 'request_context', + 'nestjs.controller': 'AppController', + 'nestjs.callback': 'testTransaction', + }); + + // request_handler span: wraps the controller method itself. + const handler = (transactionEvent.spans ?? []).find( + span => span.op === 'handler.nestjs' && span.description === 'testTransaction', + ); + expect(handler).toBeDefined(); +}); + +// op + origin produced by `@Injectable`/`@Catch` instrumentation, per component type. +const MIDDLEWARE_CASES = [ + { route: 'test-middleware', origin: 'auto.middleware.orchestrion.nestjs', description: 'ExampleMiddleware' }, + { route: 'test-guard', origin: 'auto.middleware.orchestrion.nestjs.guard', description: 'ExampleGuard' }, + { route: 'test-pipe/123', origin: 'auto.middleware.orchestrion.nestjs.pipe', description: 'ParseIntPipe' }, + { + route: 'test-interceptor', + origin: 'auto.middleware.orchestrion.nestjs.interceptor', + description: 'ExampleInterceptor', + }, +] as const; + +for (const { route, origin, description } of MIDDLEWARE_CASES) { + test(`middleware span: ${origin} (${description})`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === `GET /${route.replace('/123', '/:id')}` + ); + }); + + await fetch(`${baseURL}/${route}`); + const transactionEvent = await transactionPromise; + + const span = findSpan(transactionEvent, 'middleware.nestjs', origin); + expect(span, `expected a ${origin} span`).toBeDefined(); + expect(span?.description).toBe(description); + }); +} + +test('exception_filter span: a @Catch filter opens a middleware.nestjs span', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-exception' + ); + }); + + await fetch(`${baseURL}/test-exception`); + const transactionEvent = await transactionPromise; + + const span = findSpan(transactionEvent, 'middleware.nestjs', 'auto.middleware.orchestrion.nestjs.exception_filter'); + expect(span).toBeDefined(); + expect(span?.description).toBe('ExampleExceptionFilter'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json new file mode 100644 index 000000000000..dd356751ac4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + // `Node16` (not `commonjs`) to match `moduleResolution: Node16` below — TS 5.5 (this app's pinned + // version) errors on the `commonjs` + `Node16` combo (TS5110). The package is CommonJS (no + // `"type": "module"`), so `Node16` still emits CJS. + "module": "Node16", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "moduleResolution": "Node16" + } +} diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index c0c9ac73fa79..5e4b00a055e0 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -38,10 +38,11 @@ type UnknownPlugin = any; // module system. import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun'; import { - INSTRUMENTED_MODULE_NAMES, + instrumentedModuleNames, SENTRY_INSTRUMENTATIONS, withoutInstrumentedExternals, } from '@sentry/server-utils/orchestrion/config'; +import type { OrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; const BUNDLER_MARKER_BANNER = ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; @@ -61,18 +62,25 @@ interface BunPluginBuilder { * * Pass the result to `Bun.build({ plugins: [...] })`. * + * A framework SDK that owns its own instrumentation can inject its + * `OrchestrionInstrumentation` descriptor via the `instrumentations` option, merged with the + * built-in configs. + * * @example * ```ts * import { sentryBunPlugin } from '@sentry/bun/plugin'; * await Bun.build({ entrypoints: ['./app.ts'], plugins: [sentryBunPlugin()] }); * ``` */ -export function sentryBunPlugin(): UnknownPlugin { +export function sentryBunPlugin(options?: { instrumentations?: OrchestrionInstrumentation[] }): UnknownPlugin { + const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options?.instrumentations ?? []).flatMap(i => i.configs)]; + const moduleNames = instrumentedModuleNames(instrumentations); + // Typed upstream as an esbuild `Plugin`, but Bun passes its own // `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`. // Cast to the Bun-compatible shape so we can forward Bun's builder to its // `setup`. - const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as { + const transformer = codeTransformer({ instrumentations }) as unknown as { setup: (build: BunPluginBuilder) => void; }; @@ -91,7 +99,7 @@ export function sentryBunPlugin(): UnknownPlugin { // the transform's `onLoad`, so its diagnostics_channel calls would // be silently never injected. Bun has no runtime fallback here, so // bundling is the only injection path. - build.config.external = withoutInstrumentedExternals(build.config.external); + build.config.external = withoutInstrumentedExternals(build.config.external, moduleNames); // A blanket externalization strategy like `packages: 'external'` or // `'*'` in `external` externalizes instrumented packages too, and @@ -113,7 +121,7 @@ export function sentryBunPlugin(): UnknownPlugin { console.warn( `[Sentry] This Bun build externalizes all dependencies (${blanketExternal}), so Sentry ` + 'cannot instrument bundled libraries. Instrumentation will be missing for any of ' + - `these packages your app uses: ${INSTRUMENTED_MODULE_NAMES.join(', ')}. To instrument them, ` + + `these packages your app uses: ${moduleNames.join(', ')}. To instrument them, ` + 'externalize only the specific packages you need external instead of all of them.', ); } diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index ae27a986c352..9b82eb604750 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -22,7 +22,7 @@ import { onUnhandledRejectionIntegration, processSessionIntegration, } from '@sentry/node'; -import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; +import { getChannelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; @@ -31,8 +31,8 @@ import type { BunOptions } from './types'; * The orchestrion channel-subscriber integrations, listening on the diagnostics * channels that `@sentry/bun/plugin` injects at build time. */ -function getChannelIntegrations(): Integration[] { - return Object.values(channelIntegrations).map(integrationFactory => integrationFactory()); +function getChannelIntegrationInstances(): Integration[] { + return getChannelIntegrations().map(integrationFactory => integrationFactory()); } /** @@ -53,7 +53,7 @@ function getPerformanceIntegrations(options: Options): Integration[] { return autoPerformanceIntegrations; } - const channelIntegrationInstances = getChannelIntegrations(); + const channelIntegrationInstances = getChannelIntegrationInstances(); // The OTel integrations these channel subscribers replace, keyed by the name they share with them. const replacedOtelIntegrationNames = new Set(channelIntegrationInstances.map(integration => integration.name)); diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index e638532a3aea..2e4a51c3f579 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -38,6 +38,11 @@ "types": "./setup.d.ts", "default": "./build/cjs/setup.js" } + }, + "./import": { + "import": { + "default": "./build/import.mjs" + } } }, "publishConfig": { @@ -48,7 +53,8 @@ "@opentelemetry/instrumentation": "^0.220.0", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.64.0", - "@sentry/node": "10.64.0" + "@sentry/node": "10.64.0", + "@sentry/server-utils": "10.64.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nestjs/rollup.npm.config.mjs b/packages/nestjs/rollup.npm.config.mjs index 0ce71546935c..1b18447a64e9 100644 --- a/packages/nestjs/rollup.npm.config.mjs +++ b/packages/nestjs/rollup.npm.config.mjs @@ -1,7 +1,29 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/setup.ts'], +// EXPERIMENTAL: NestJS orchestrion `--import` runtime hook. A hand-written +// `.mjs` shim referenced via `node --import @sentry/nestjs/import`. We pass it +// through rollup only to copy it into `build/import.mjs` at the path the +// package.json `exports` map expects; `external: /.*/` keeps every import (e.g. +// `@sentry/nestjs/orchestrion`) as a runtime resolution against the installed +// package. +const orchestrionRuntimeHooks = [ + defineConfig({ + input: 'src/import.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/import.mjs' }, }), -); +]; + +export default [ + ...orchestrionRuntimeHooks, + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + // `src/orchestrion/index.ts` is internal (NOT a public export). It's listed + // as an entrypoint only to guarantee a stable `build/esm/orchestrion/index.js` + // output path, which the `build/import.mjs` `--import` hook references via a + // relative import to register the `nestjsOrchestrion` descriptor. + entrypoints: ['src/index.ts', 'src/setup.ts', 'src/orchestrion/index.ts'], + }), + ), +]; diff --git a/packages/nestjs/src/debug-build.ts b/packages/nestjs/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/nestjs/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nestjs/src/import.mjs b/packages/nestjs/src/import.mjs new file mode 100644 index 000000000000..bd2053db10a6 --- /dev/null +++ b/packages/nestjs/src/import.mjs @@ -0,0 +1,20 @@ +// EXPERIMENTAL: NestJS diagnostics-channel injection runtime hook. The +// side-effecting `--import` entry (e.g. `node --import @sentry/nestjs/import app.js`) +// that injects the @nestjs channels unconditionally before the app loads. +// +// Order matters and static ESM imports are hoisted: we import the register +// FUNCTION (not a side-effecting base hook) and call it AFTER registering the +// NestJS instrumentation, so the transform config includes the @nestjs configs. +// The `nestjsOrchestrion` descriptor is an internal detail (not a public export), +// so we reach it via a relative path into this package's own build output; +// importing it has no side effects. It only defines the descriptor. +// +// This file ships verbatim to `build/import.mjs`; the relative import below +// resolves to `build/esm/orchestrion/index.js` at runtime. + +import { registerOrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; +import { nestjsOrchestrion } from './esm/orchestrion/index.js'; + +registerOrchestrionInstrumentation(nestjsOrchestrion); +registerDiagnosticsChannelInjection(); diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index d8b50957f979..24af563d1e92 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -5,42 +5,87 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan, } from '@sentry/core'; +import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types'; -const sentryPatched = 'sentryPatched'; +/** A function of unknown signature, matching the methods/handlers we wrap. */ +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; /** - * Helper checking if a concrete target class is already patched. + * Marks a function as already wrapped so repeated subscriptions/decoration + * don't double-wrap it. Shared by the OTel and orchestrion paths. + */ +const SENTRY_WRAPPED = Symbol.for('sentry.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; +} + +/** + * Mark a target class as patched (for the given pass) so it's instrumented only + * once, and to stay idempotent across repeated subscriptions/decoration. * - * We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes. - * This check might not be necessary, but better to play it safe. + * The `@Injectable` and `@Catch` passes use *separate* flags on purpose: they + * wrap disjoint method sets (use/canActivate/transform/intercept vs catch), and + * a class can be decorated with both (an exception filter that also uses DI). + * A single shared flag would let whichever pass fired first latch it and block + * the other, dropping that pass's spans regardless of ordering. */ -export function isPatched(target: InjectableTarget | CatchTarget): boolean { - if (target.sentryPatched) { +export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean { + if ((target as Record)[flag]) { return true; } - - addNonEnumerableProperty(target, sentryPatched, true); + addNonEnumerableProperty(target, flag, true); return false; } +// The instrumentation path is reflected in the span origin: orchestrion-created +// spans carry an `orchestrion` segment so they're distinguishable from OTel. +// Only one path is ever live in a process (the OTel `Nest` integration is +// swapped out whenever orchestrion is injected), so the global injection flag +// reliably selects the right origin. Everything else about the span is identical. + +/** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */ +function middlewareOrigin(componentType?: string): string { + const base = isOrchestrionInjected() ? 'auto.middleware.orchestrion.nestjs' : 'auto.middleware.nestjs'; + return componentType ? `${base}.${componentType}` : base; +} + +/** Origin for the app-creation / request-context / request-handler HTTP spans. */ +export function httpOrigin(): string { + return isOrchestrionInjected() ? 'auto.http.orchestrion.nestjs' : 'auto.http.otel.nestjs'; +} + +/** Origin for `@OnEvent` spans. */ +function eventOrigin(): string { + return isOrchestrionInjected() ? 'auto.event.orchestrion.nestjs' : 'auto.event.nestjs'; +} + +/** Origin for BullMQ `@Processor` `process` spans. */ +function bullmqOrigin(): string { + return isOrchestrionInjected() ? 'auto.queue.orchestrion.nestjs.bullmq' : 'auto.queue.nestjs.bullmq'; +} + /** * Returns span options for nest middleware spans. + * name = provided name or class name. */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function getMiddlewareSpanOptions( - target: InjectableTarget | CatchTarget, + target: InjectableTarget | CatchTarget | { name?: string }, name: string | undefined = undefined, componentType: string | undefined = undefined, -) { - const span_name = name ?? target.name; // fallback to class name if no name is provided - const origin = componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs'; - +): { name: string; attributes: Record } { return { - name: span_name, + name: name ?? target.name ?? 'unknown', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: middlewareOrigin(componentType), }, }; } @@ -57,7 +102,7 @@ export function getEventSpanOptions(event: string): { name: `event ${event}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: eventOrigin(), }, forceTransaction: true, }; @@ -75,7 +120,7 @@ export function getBullMQProcessSpanOptions(queueName: string): { name: `${queueName} process`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.nestjs.bullmq', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: bullmqOrigin(), 'messaging.system': 'bullmq', 'messaging.destination.name': queueName, }, diff --git a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts index b18bab1dc07c..6ab8faab15df 100644 --- a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts @@ -5,9 +5,9 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; -import { getBullMQProcessSpanOptions } from './helpers'; +import { SDK_VERSION } from '@sentry/core'; import type { ProcessorDecoratorTarget } from './types'; +import { extractQueueName, patchProcessorTarget } from './wrap-handlers'; const supportedVersions = ['>=10.0.0']; const COMPONENT = '@nestjs/bullmq'; @@ -18,6 +18,9 @@ const COMPONENT = '@nestjs/bullmq'; * This hooks into the `@Processor` class decorator, which is applied on queue processor classes. * It wraps the `process` method on the decorated class to fork the isolation scope for each job * invocation, create a span, and capture errors. + * + * The `process`-wrapping logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path. */ export class SentryNestBullMQInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -62,50 +65,13 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase { return function wrapProcessor(original: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrappedProcessor(...decoratorArgs: any[]) { - // Extract queue name from decorator args - // @Processor('queueName') or @Processor({ name: 'queueName' }) - const queueName = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - typeof decoratorArgs[0] === 'string' ? decoratorArgs[0] : decoratorArgs[0]?.name || 'unknown'; + const queueName = extractQueueName(decoratorArgs[0]); - // Get the original class decorator // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const classDecorator = original(...decoratorArgs); - // Return a new class decorator that wraps the process method return function (target: ProcessorDecoratorTarget) { - const originalProcess = target.prototype.process; - - if ( - originalProcess && - typeof originalProcess === 'function' && - !target.__SENTRY_INTERNAL__ && - !originalProcess.__SENTRY_INSTRUMENTED__ - ) { - target.prototype.process = new Proxy(originalProcess, { - apply: (originalProcessFn, thisArg, args) => { - return withIsolationScope(() => { - return startSpan(getBullMQProcessSpanOptions(queueName), async () => { - try { - return await originalProcessFn.apply(thisArg, args); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.queue.nestjs.bullmq', - }, - }); - throw error; - } - }); - }); - }, - }); - - target.prototype.process.__SENTRY_INSTRUMENTED__ = true; - } - - // Apply the original class decorator + patchProcessorTarget(target, queueName); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return classDecorator(target); }; diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index b4f8784eea05..7918f2cb34e2 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -5,9 +5,10 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; -import { getEventSpanOptions } from './helpers'; +import { SDK_VERSION } from '@sentry/core'; +import type { AnyFn } from './helpers'; import type { OnEventTarget } from './types'; +import { patchMethodDescriptor, wrapEventHandler } from './wrap-handlers'; const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/event-emitter'; @@ -19,6 +20,9 @@ const COMPONENT = '@nestjs/event-emitter'; * Wrapped handlers run inside a forked isolation scope to ensure event-scoped data * (breadcrumbs, tags, etc.) does not leak between concurrent event invocations * or into subsequent HTTP requests. + * + * The handler-wrapping logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path. */ export class SentryNestEventInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -62,87 +66,10 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapOnEvent(original: any) { return function wrappedOnEvent(event: unknown, options?: unknown) { - // Get the original decorator result const decoratorResult = original(event, options); - // Return a new decorator function that wraps the handler return (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if ( - !descriptor.value || - typeof descriptor.value !== 'function' || - target.__SENTRY_INTERNAL__ || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ - ) { - return decoratorResult(target, propertyKey, descriptor); - } - - function eventNameFromEvent(event: unknown): string { - if (typeof event === 'string') { - return event; - } else if (Array.isArray(event)) { - return event.map(eventNameFromEvent).join(','); - } else return String(event); - } - - const originalHandler = descriptor.value; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const handlerName = originalHandler.name || propertyKey; - let eventName = eventNameFromEvent(event); - - // Instrument the actual handler - descriptor.value = async function (...args: unknown[]) { - // When multiple @OnEvent decorators are used on a single method, we need to get all event names - // from the reflector metadata as there is no information during execution which event triggered it - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect - if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect - const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value); - if (Array.isArray(eventData)) { - eventName = eventData - .map((data: unknown) => { - if (data && typeof data === 'object' && 'event' in data && data.event) { - return eventNameFromEvent(data.event); - } - return ''; - }) - .reverse() // decorators are evaluated bottom to top - .join('|'); - } - } - - return withIsolationScope(() => { - return startSpan(getEventSpanOptions(eventName), async () => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const result = await originalHandler.apply(this, args); - return result; - } catch (error) { - // exceptions from event handlers are not caught by global error filter - captureException(error, { - mechanism: { - handled: false, - type: 'auto.event.nestjs', - }, - }); - throw error; - } - }); - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ = true; - - // Preserve the original function name - Object.defineProperty(descriptor.value, 'name', { - value: handlerName, - configurable: true, - }); - - // Apply the original decorator + patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => wrapEventHandler(handler, event)); return decoratorResult(target, propertyKey, descriptor); }; }; diff --git a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts index e1d7fa978020..b0cc582a728a 100644 --- a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts @@ -5,18 +5,9 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import type { Span } from '@sentry/core'; -import { - getActiveSpan, - isThenable, - SDK_VERSION, - startInactiveSpan, - startSpan, - startSpanManual, - withActiveSpan, -} from '@sentry/core'; -import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isPatched } from './helpers'; -import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types'; +import { SDK_VERSION } from '@sentry/core'; +import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; +import type { CatchTarget, InjectableTarget } from './types'; const supportedVersions = ['>=8.0.0 <12']; const COMPONENT = '@nestjs/common'; @@ -27,6 +18,10 @@ const COMPONENT = '@nestjs/common'; * This hooks into * 1. @Injectable decorator, which is applied on class middleware, interceptors and guards. * 2. @Catch decorator, which is applied on exception filters. + * + * The span-emitting logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path; this class only wraps the decorators to feed the + * decorated classes into it. */ export class SentryNestInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -87,191 +82,18 @@ export class SentryNestInstrumentation extends InstrumentationBase { } /** - * Creates a wrapper function for the @Injectable decorator. + * Creates a wrapper function for the @Injectable decorator. It patches the + * decorated class (via the shared `patchInjectableTarget`) and delegates to + * the original decorator. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private _createWrapInjectable(): (original: any) => (options?: unknown) => (target: InjectableTarget) => any { - const SeenNestjsContextSet = new WeakSet(); + const seenContexts = new WeakSet(); // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapInjectable(original: any) { return function wrappedInjectable(options?: unknown) { return function (target: InjectableTarget) { - // patch middleware - if (typeof target.prototype.use === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.use = new Proxy(target.prototype.use, { - apply: (originalUse, thisArgUse, argsUse) => { - const [req, res, next, ...args] = argsUse; - - // Check that we can reasonably assume that the target is a middleware. - // Without these guards, instrumentation will fail if a function named 'use' on a service, which is - // decorated with @Injectable, is called. - if (!req || !res || !next || typeof next !== 'function') { - return originalUse.apply(thisArgUse, argsUse); - } - - const prevSpan = getActiveSpan(); - - return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { - // proxy next to end span on call - const nextProxy = getNextProxy(next, span, prevSpan); - return originalUse.apply(thisArgUse, [req, res, nextProxy, args]); - }); - }, - }); - } - - // patch guards - if (typeof target.prototype.canActivate === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.canActivate = new Proxy(target.prototype.canActivate, { - apply: (originalCanActivate, thisArgCanActivate, argsCanActivate) => { - const context = argsCanActivate[0]; - - if (!context) { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'guard'), () => { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - }); - }, - }); - } - - // patch pipes - if (typeof target.prototype.transform === 'function' && !target.__SENTRY_INTERNAL__) { - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.transform = new Proxy(target.prototype.transform, { - apply: (originalTransform, thisArgTransform, argsTransform) => { - const value = argsTransform[0]; - const metadata = argsTransform[1]; - - if (!value || !metadata) { - return originalTransform.apply(thisArgTransform, argsTransform); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'pipe'), () => { - return originalTransform.apply(thisArgTransform, argsTransform); - }); - }, - }); - } - - // patch interceptors - if (typeof target.prototype.intercept === 'function' && !target.__SENTRY_INTERNAL__) { - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.intercept = new Proxy(target.prototype.intercept, { - apply: (originalIntercept, thisArgIntercept, argsIntercept) => { - const context = argsIntercept[0] as MinimalNestJsExecutionContext | undefined; - const next = argsIntercept[1] as CallHandler | undefined; - - const parentSpan = getActiveSpan(); - let afterSpan: Span | undefined; - - if ( - !context || - !next || - typeof next.handle !== 'function' || // Check that we can reasonably assume that the target is an interceptor. - target.name === 'SentryTracingInterceptor' // We don't want to trace this internal interceptor - ) { - return originalIntercept.apply(thisArgIntercept, argsIntercept); - } - - return startSpanManual( - getMiddlewareSpanOptions(target, undefined, 'interceptor'), - (beforeSpan: Span) => { - // eslint-disable-next-line @typescript-eslint/unbound-method - next.handle = new Proxy(next.handle, { - apply: (originalHandle, thisArgHandle, argsHandle) => { - beforeSpan.end(); - - if (parentSpan) { - return withActiveSpan(parentSpan, () => { - const handleReturnObservable = Reflect.apply(originalHandle, thisArgHandle, argsHandle); - - if (!SeenNestjsContextSet.has(context)) { - SeenNestjsContextSet.add(context); - afterSpan = startInactiveSpan( - getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), - ); - } - - return handleReturnObservable; - }); - } else { - const handleReturnObservable = Reflect.apply(originalHandle, thisArgHandle, argsHandle); - - if (!SeenNestjsContextSet.has(context)) { - SeenNestjsContextSet.add(context); - afterSpan = startInactiveSpan( - getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), - ); - } - - return handleReturnObservable; - } - }, - }); - - let returnedObservableInterceptMaybePromise: Observable | Promise>; - - try { - returnedObservableInterceptMaybePromise = originalIntercept.apply( - thisArgIntercept, - argsIntercept, - ); - } catch (e) { - beforeSpan.end(); - afterSpan?.end(); - throw e; - } - - if (!afterSpan) { - return returnedObservableInterceptMaybePromise; - } - - // handle async interceptor - if (isThenable(returnedObservableInterceptMaybePromise)) { - return returnedObservableInterceptMaybePromise.then( - observable => { - instrumentObservable(observable, afterSpan ?? parentSpan); - return observable; - }, - e => { - beforeSpan.end(); - afterSpan?.end(); - throw e; - }, - ); - } - - // handle sync interceptor - if (typeof returnedObservableInterceptMaybePromise.subscribe === 'function') { - instrumentObservable(returnedObservableInterceptMaybePromise, afterSpan); - } - - return returnedObservableInterceptMaybePromise; - }, - ); - }, - }); - } - + patchInjectableTarget(target, seenContexts); return original(options)(target); }; }; @@ -286,28 +108,7 @@ export class SentryNestInstrumentation extends InstrumentationBase { return function wrapCatch(original: any) { return function wrappedCatch(...exceptions: unknown[]) { return function (target: CatchTarget) { - if (typeof target.prototype.catch === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(...exceptions)(target); - } - - target.prototype.catch = new Proxy(target.prototype.catch, { - apply: (originalCatch, thisArgCatch, argsCatch) => { - const exception = argsCatch[0]; - const host = argsCatch[1]; - - if (!exception || !host) { - return originalCatch.apply(thisArgCatch, argsCatch); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'exception_filter'), () => { - return originalCatch.apply(thisArgCatch, argsCatch); - }); - }, - }); - } - + patchCatchTarget(target); return original(...exceptions)(target); }; }; diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index ea0261164f9c..97ae4f8548ff 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -5,8 +5,16 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, isThenable, SDK_VERSION, withIsolationScope } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import type { AnyFn } from './helpers'; import type { ScheduleDecoratorTarget } from './types'; +import { + MECHANISM_CRON, + MECHANISM_INTERVAL, + MECHANISM_TIMEOUT, + patchMethodDescriptor, + wrapScheduleHandler, +} from './wrap-handlers'; const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/schedule'; @@ -16,6 +24,9 @@ const COMPONENT = '@nestjs/schedule'; * * This hooks into the `@Cron`, `@Interval`, and `@Timeout` decorators, which are applied on scheduled task handlers. * It forks the isolation scope for each handler invocation, preventing data leakage to subsequent HTTP requests. + * + * The handler-wrapping logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path. */ export class SentryNestScheduleInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -28,68 +39,37 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { public init(): InstrumentationNodeModuleDefinition { const moduleDef = new InstrumentationNodeModuleDefinition(COMPONENT, supportedVersions); - moduleDef.files.push(this._getCronFileInstrumentation(supportedVersions)); - moduleDef.files.push(this._getIntervalFileInstrumentation(supportedVersions)); - moduleDef.files.push(this._getTimeoutFileInstrumentation(supportedVersions)); - return moduleDef; - } - - /** - * Wraps the @Cron decorator. - */ - private _getCronFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/cron.decorator.js', - versions, - (moduleExports: { Cron: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Cron)) { - this._unwrap(moduleExports, 'Cron'); - } - this._wrap(moduleExports, 'Cron', this._createWrapDecorator('auto.function.nestjs.cron')); - return moduleExports; - }, - (moduleExports: { Cron: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Cron'); - }, + moduleDef.files.push(this._getDecoratorFileInstrumentation('Cron', 'cron', MECHANISM_CRON, supportedVersions)); + moduleDef.files.push( + this._getDecoratorFileInstrumentation('Interval', 'interval', MECHANISM_INTERVAL, supportedVersions), ); - } - - /** - * Wraps the @Interval decorator. - */ - private _getIntervalFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/interval.decorator.js', - versions, - (moduleExports: { Interval: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Interval)) { - this._unwrap(moduleExports, 'Interval'); - } - this._wrap(moduleExports, 'Interval', this._createWrapDecorator('auto.function.nestjs.interval')); - return moduleExports; - }, - (moduleExports: { Interval: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Interval'); - }, + moduleDef.files.push( + this._getDecoratorFileInstrumentation('Timeout', 'timeout', MECHANISM_TIMEOUT, supportedVersions), ); + return moduleDef; } /** - * Wraps the @Timeout decorator. + * Wraps a schedule decorator (`@Cron`/`@Interval`/`@Timeout`). */ - private _getTimeoutFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + private _getDecoratorFileInstrumentation( + exportName: 'Cron' | 'Interval' | 'Timeout', + fileName: string, + mechanismType: string, + versions: string[], + ): InstrumentationNodeModuleFile { return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/timeout.decorator.js', + `@nestjs/schedule/dist/decorators/${fileName}.decorator.js`, versions, - (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Timeout)) { - this._unwrap(moduleExports, 'Timeout'); + (moduleExports: Record) => { + if (isWrapped(moduleExports[exportName])) { + this._unwrap(moduleExports, exportName); } - this._wrap(moduleExports, 'Timeout', this._createWrapDecorator('auto.function.nestjs.timeout')); + this._wrap(moduleExports, exportName, this._createWrapDecorator(mechanismType)); return moduleExports; }, - (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Timeout'); + (moduleExports: Record) => { + this._unwrap(moduleExports, exportName); }, ); } @@ -102,72 +82,12 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { return function wrapDecorator(original: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrappedDecorator(...decoratorArgs: any[]) { - // Get the original decorator result const decoratorResult = original(...decoratorArgs); - // Return a new decorator function that wraps the handler return (target: ScheduleDecoratorTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if ( - !descriptor.value || - typeof descriptor.value !== 'function' || - target.__SENTRY_INTERNAL__ || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ - ) { - return decoratorResult(target, propertyKey, descriptor); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const originalHandler: (...handlerArgs: unknown[]) => unknown = descriptor.value; - const handlerName = originalHandler.name || propertyKey; - - // Not using async/await here to avoid changing the return type of sync handlers. - // This means we need to handle sync and async errors separately. - descriptor.value = function (...args: unknown[]) { - return withIsolationScope(() => { - let result; - try { - // Catches errors from sync handlers - result = originalHandler.apply(this, args); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - } - - // Catches errors from async handlers (rejected promises bypass try/catch) - if (isThenable(result)) { - return result.then(undefined, (error: unknown) => { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - }); - } - - return result; - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ = true; - - // Preserve the original function name - Object.defineProperty(descriptor.value, 'name', { - value: handlerName, - configurable: true, - enumerable: true, - writable: true, - }); - - // Apply the original decorator + patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => + wrapScheduleHandler(handler, mechanismType), + ); return decoratorResult(target, propertyKey, descriptor); }; }; diff --git a/packages/nestjs/src/integrations/types.ts b/packages/nestjs/src/integrations/types.ts index 6dd00caa8cc1..553708c37bcb 100644 --- a/packages/nestjs/src/integrations/types.ts +++ b/packages/nestjs/src/integrations/types.ts @@ -63,8 +63,8 @@ export interface CallHandler { * Represents an injectable target class in NestJS. */ export interface InjectableTarget { - name: string; - sentryPatched?: boolean; + name?: string; + sentryPatchedInjectable?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void; @@ -78,8 +78,8 @@ export interface InjectableTarget { * Represents a target class in NestJS annotated with @Catch. */ export interface CatchTarget { - name: string; - sentryPatched?: boolean; + name?: string; + sentryPatchedCatch?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { catch?: (...args: any[]) => any; diff --git a/packages/nestjs/src/integrations/vendored/instrumentation.ts b/packages/nestjs/src/integrations/vendored/instrumentation.ts index 670d7b32f813..bb9574df3752 100644 --- a/packages/nestjs/src/integrations/vendored/instrumentation.ts +++ b/packages/nestjs/src/integrations/vendored/instrumentation.ts @@ -6,6 +6,10 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-nestjs-core * - Upstream version: @opentelemetry/instrumentation-nestjs-core@0.64.0 * - Some types vendored from @nestjs/core and @nestjs/common with simplifications + * - The span-emitting logic (app-creation / request-context / request-handler + * spans) has been extracted to `../wrappers` and is shared with the + * orchestrion (diagnostics-channel) path; this file only wraps the + * `NestFactory.create` / `RouterExecutionContext.create` methods to feed into it. */ import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { @@ -14,15 +18,12 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; -import type { SpanAttributes } from '@sentry/core'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; -import { AttributeNames, NestType } from './enums'; +import { SDK_VERSION, startSpan } from '@sentry/core'; +import type { AnyFn } from '../helpers'; +import { getAppCreationSpanOptions, wrapRequestContextHandler, wrapRouteHandler } from '../wrap-route'; const PACKAGE_NAME = '@sentry/instrumentation-nestjs-core'; -type AnyFn = (this: unknown, ...args: unknown[]) => unknown; - type Controller = object; declare const NestFactory: { @@ -33,28 +34,10 @@ interface RouterExecutionContext { create(instance: Controller, callback: (...args: unknown[]) => unknown, ...args: unknown[]): unknown; } -interface NestRequest { - route?: { path?: string }; - routeOptions?: { url?: string }; - routerPath?: string; - method?: string; - originalUrl?: string; - url?: string; -} - -declare namespace Reflect { - function getMetadataKeys(target: unknown): unknown[]; - function getMetadata(metadataKey: unknown, target: unknown): unknown; - function defineMetadata(metadataKey: unknown, metadataValue: unknown, target: unknown): void; -} - const supportedVersions = ['>=4.0.0 <12']; export class NestInstrumentation extends InstrumentationBase { static readonly COMPONENT = '@nestjs/core'; - static readonly COMMON_ATTRIBUTES = { - component: NestInstrumentation.COMPONENT, - }; constructor(config: InstrumentationConfig = {}) { super(PACKAGE_NAME, SDK_VERSION, config); @@ -123,20 +106,7 @@ function createWrapNestFactoryCreate(moduleVersion?: string) { return function wrapCreate(original: typeof NestFactory.create): typeof NestFactory.create { return function createWithTrace(this: typeof NestFactory, ...args: unknown[]) { const nestModule = args[0] as { name?: string }; - return startSpan( - { - name: 'Create Nest App', - op: `${NestType.APP_CREATION}.nestjs`, - attributes: { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.TYPE]: NestType.APP_CREATION, - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.MODULE]: nestModule.name, - }, - }, - () => original.apply(this, args), - ); + return startSpan(getAppCreationSpanOptions(moduleVersion, nestModule?.name), () => original.apply(this, args)); }; }; } @@ -146,56 +116,11 @@ function createWrapCreateHandler(moduleVersion: string | undefined) { return function createHandlerWithTrace(this: RouterExecutionContext, ...args: unknown[]) { const instance = args[0] as { constructor?: { name?: string } }; const callback = args[1] as AnyFn; - args[1] = createWrapHandler(moduleVersion, callback); + const instanceName = instance?.constructor?.name || 'UnnamedInstance'; + const callbackName = typeof callback === 'function' ? callback.name : ''; + args[1] = wrapRouteHandler(callback, moduleVersion); const handler = original.apply(this, args) as AnyFn; - const callbackName = callback.name; - const instanceName = instance.constructor?.name || 'UnnamedInstance'; - const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; - - return function (this: unknown, ...handlerArgs: unknown[]) { - const req = handlerArgs[0] as NestRequest; - const attributes: SpanAttributes = { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, - [HTTP_ROUTE]: req.route?.path || req.routeOptions?.url || req.routerPath, - [AttributeNames.CONTROLLER]: instanceName, - [AttributeNames.CALLBACK]: callbackName, - }; - attributes['http.method'] = req.method; - attributes['http.url'] = req.originalUrl || req.url; - return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => - handler.apply(this, handlerArgs), - ); - }; + return wrapRequestContextHandler(handler, instanceName, callbackName, moduleVersion); }; }; } - -function createWrapHandler(moduleVersion: string | undefined, handler: AnyFn): AnyFn { - const spanName = handler.name || 'anonymous nest handler'; - const attributes: SpanAttributes = { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, - [AttributeNames.CALLBACK]: handler.name, - }; - const wrappedHandler = function (this: unknown, ...args: unknown[]) { - return startSpan({ name: spanName, op: `${NestType.REQUEST_HANDLER}.nestjs`, attributes }, () => - handler.apply(this, args), - ); - }; - - if (handler.name) { - Object.defineProperty(wrappedHandler, 'name', { value: handler.name }); - } - - // Get the current metadata and set onto the wrapper to ensure other decorators ( ie: NestJS EventPattern / RolesGuard ) - // won't be affected by the use of this instrumentation - Reflect.getMetadataKeys(handler).forEach(metadataKey => { - Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, handler), wrappedHandler); - }); - return wrappedHandler; -} diff --git a/packages/nestjs/src/integrations/wrap-components.ts b/packages/nestjs/src/integrations/wrap-components.ts new file mode 100644 index 000000000000..aebf21c1b4c7 --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-components.ts @@ -0,0 +1,198 @@ +import type { Span } from '@sentry/core'; +import { getActiveSpan, isThenable, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isTargetPatched } from './helpers'; +import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types'; + +/** + * Shared span-emitting logic for `@Injectable` (middleware/guard/pipe/interceptor) + * and `@Catch` (exception filter) classes. Used by both the OTel decorator wraps + * (`SentryNestInstrumentation`) and the orchestrion channel subscriber; only the + * span origin differs (via `isOrchestrionInjected()` in `./helpers`). + */ + +function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContexts: WeakSet): AnyFn { + return new Proxy(intercept, { + apply: (originalIntercept, thisArg, argsIntercept) => { + const context = argsIntercept[0] as MinimalNestJsExecutionContext | undefined; + const next = argsIntercept[1] as CallHandler | 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. + // eslint-disable-next-line @typescript-eslint/unbound-method + 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; + } + + // async interceptor: returns a Promise + if (isThenable(returned)) { + return returned.then( + (observable: unknown) => { + if (afterSpan) { + instrumentObservable(observable as Observable, afterSpan); + } else { + // `next.handle()` was never called, so nothing ended the + // before-span (its `handle` proxy never ran); close it here. + beforeSpan.end(); + } + return observable; + }, + (e: unknown) => { + beforeSpan.end(); + afterSpan?.end(); + throw e; + }, + ); + } + + // Sync interceptor: `next.handle()` (if it was going to be called) has + // already run synchronously, so `afterSpan` is settled. + if (!afterSpan) { + // `next.handle()` was never called (e.g. the interceptor + // short-circuited for a cache/validation hit), so its `handle` proxy + // never ended the before-span; close it here. + beforeSpan.end(); + return returned; + } + + // sync interceptor: returns an Observable + if (typeof (returned as Observable).subscribe === 'function') { + instrumentObservable(returned as Observable, afterSpan); + } + + return returned; + }); + }, + }); +} + +/** + * Patch an `@Injectable`-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, 'sentryPatchedInjectable')) { + 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 as AnyFn).apply(thisArgUse, [req, res, nextProxy, ...rest]); + }); + }, + }) as InjectableTarget['prototype']['use']; + } + + // 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), + ); + }, + }) as InjectableTarget['prototype']['canActivate']; + } + + // 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), + ); + }, + }) as InjectableTarget['prototype']['transform']; + } + + // interceptors + if (typeof proto.intercept === 'function') { + proto.intercept = patchInterceptor( + target, + proto.intercept as AnyFn, + seenContexts, + ) as InjectableTarget['prototype']['intercept']; + } +} + +/** + * Patch an 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, 'sentryPatchedCatch') + ) { + 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), + ); + }, + }) as CatchTarget['prototype']['catch']; +} diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts new file mode 100644 index 000000000000..82b39fbe89b7 --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -0,0 +1,183 @@ +import { captureException, isThenable, startSpan, withIsolationScope } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers'; + +/** + * Shared span-emitting / error-capturing logic for the `@Cron`/`@Interval`/ + * `@Timeout` (schedule), `@OnEvent` (event), and `@Processor` (bullmq) handlers. + * Used by both the OTel decorator wraps and the orchestrion channel subscriber; + * only the span origin differs (via `isOrchestrionInjected()` in `./helpers`). + */ + +// Error-capture mechanism types. These do NOT carry an `orchestrion` segment. +// They're identical across both paths so captured errors attribute and group +// the same regardless of which instrumentation caught them. +export const MECHANISM_CRON = 'auto.function.nestjs.cron'; +export const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; +export const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; +export const MECHANISM_EVENT = 'auto.event.nestjs'; +export 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; +} + +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. + */ +export 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). + */ +export function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { + const wrapped = async function (this: unknown, ...args: unknown[]): Promise { + const eventName = deriveEventName(wrapped, fallbackEvent); + return withIsolationScope(() => + startSpan(getEventSpanOptions(eventName), 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. + */ +export function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => + startSpan(getBullMQProcessSpanOptions(queueName), async () => { + try { + return await process.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_BULLMQ); + throw error; + } + }), + ); + }; +} + +/** + * Replace a method decorator's `descriptor.value` with a wrapped handler (via + * `wrapHandler`), preserving the handler name and marking it wrapped. Shared by + * the OTel schedule/event decorator wraps and the orchestrion factory subscriber. + */ +export function patchMethodDescriptor( + target: { __SENTRY_INTERNAL__?: boolean } | undefined, + propertyKey: string | symbol | undefined, + descriptor: PropertyDescriptor | undefined, + wrapHandler: (handler: AnyFn) => AnyFn, +): void { + const handler = descriptor?.value as AnyFn | undefined; + if (descriptor && handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler)) { + const wrapped = wrapHandler(handler); + Object.defineProperty(wrapped, 'name', { + value: handler.name || String(propertyKey), + configurable: true, + }); + markWrapped(wrapped); + descriptor.value = wrapped; + } +} + +/** Extract the queue name from `@Processor('name')` or `@Processor({ name })`. */ +export 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'; +} + +/** + * Patch a `@Processor`-decorated class's `prototype.process` with a wrapped + * version. Shared by the OTel `@Processor` wrap and the orchestrion subscriber. + */ +export function patchProcessorTarget(target: ProcessorTarget | undefined, queueName: string): void { + 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; + } +} diff --git a/packages/nestjs/src/integrations/wrap-route.ts b/packages/nestjs/src/integrations/wrap-route.ts new file mode 100644 index 000000000000..d4b78ea2c7a9 --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-route.ts @@ -0,0 +1,133 @@ +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import type { SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { httpOrigin, isWrapped, markWrapped } from './helpers'; +import { AttributeNames, NestType } from './vendored/enums'; + +/** + * Shared span-emitting logic for the NestJS route/controller spans + * (app-creation / request-context / request-handler). Used by both the OTel + * `NestFactory.create` / `RouterExecutionContext.create` wraps (`./vendored`) and + * the orchestrion channel subscriber; only the span origin differs (via + * `isOrchestrionInjected()` in `./helpers`). + */ + +const NESTJS_COMPONENT = '@nestjs/core'; + +/** 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. + */ +export 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); + } +} + +/** Span options for the `Create Nest App` (app_creation) span. */ +export function getAppCreationSpanOptions( + moduleVersion?: string, + moduleName?: string, +): { name: string; op: string; attributes: SpanAttributes } { + return { + name: 'Create Nest App', + op: `${NestType.APP_CREATION}.nestjs`, + attributes: { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.APP_CREATION, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + ...(moduleName ? { [AttributeNames.MODULE]: moduleName } : {}), + }, + }; +} + +/** + * Wrap the route-handler callback so each invocation opens the `handler.nestjs` + * span (REQUEST_HANDLER). Preserve the original `.name` and reflect-metadata so + * NestJS reflection is unaffected. + */ +export function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { + if (isWrapped(callback)) { + return callback; + } + const spanName = callback.name || 'anonymous nest handler'; + const attributes: SpanAttributes = { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, + [AttributeNames.CALLBACK]: callback.name, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + }; + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + return startSpan({ name: spanName, op: `${NestType.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 the per-request handler that `RouterExecutionContext.create` returns so + * each request opens the `request_context.nestjs` span (REQUEST_CONTEXT), + * carrying controller/callback names plus the per-request http.* attributes. + */ +export 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 = { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, + [AttributeNames.CONTROLLER]: instanceName, + [AttributeNames.CALLBACK]: callbackName, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + ...(httpRoute ? { [HTTP_ROUTE]: httpRoute } : {}), + ...(req.method ? { ['http.method']: req.method } : {}), + ...(req.originalUrl || req.url ? { ['http.url']: req.originalUrl || req.url } : {}), + }; + return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => + handler.apply(this, handlerArgs), + ); + }; + markWrapped(wrapped); + return wrapped; +} diff --git a/packages/nestjs/src/orchestrion/config.ts b/packages/nestjs/src/orchestrion/config.ts new file mode 100644 index 000000000000..b274d6c6e8d0 --- /dev/null +++ b/packages/nestjs/src/orchestrion/config.ts @@ -0,0 +1,148 @@ +import type { FunctionKind, InstrumentationConfig } from '@sentry/server-utils/orchestrion'; + +/** + * Wrap an instrumentation that targets nodes via a raw esquery selector + * (`NstQuery`) rather than the structured `functionQuery`. Needed when the + * target can't be named, e.g. the anonymous arrow a decorator factory returns + * The transformer supports `astQuery` at runtime (it takes precedence over + * `functionQuery`, which then only supplies `kind`), but it isn't in the + * published `InstrumentationConfig` type. Hence the cast. + */ +function astQueryInstrumentation(config: { + channelName: string; + module: InstrumentationConfig['module']; + astQuery: string; + functionQuery: { kind: FunctionKind }; +}): InstrumentationConfig { + return config as unknown as InstrumentationConfig; +} + +export const nestjsConfig = [ + { + // `@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 reassigns the + // returned handler (-> request_context span). + channelName: 'routerExecutionContextCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, + functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync' }, + }, + + astQueryInstrumentation({ + // `@nestjs/common/decorators/core/injectable.decorator.js`: + // `function Injectable(options) { return (target) => { ... }; }` + // The inner decorator arrow is anonymous + returned, so only a raw + // `astQuery` can target it. The subscriber's `start` receives the + // decorated class as `arguments[0]` and patches its prototype + // use/canActivate/transform/intercept methods, reproducing the + // vendored `SentryNestInstrumentation` middleware/guard/pipe/interceptor + // spans. No span on the decorator itself, so `kind: 'Sync'`. + channelName: 'injectableDecorator', + module: { + name: '@nestjs/common', + versionRange: '>=8.0.0 <12', + filePath: 'decorators/core/injectable.decorator.js', + }, + astQuery: 'FunctionDeclaration[id.name="Injectable"] ReturnStatement > ArrowFunctionExpression', + functionQuery: { kind: 'Sync' }, + }), + + astQueryInstrumentation({ + // `@nestjs/common/decorators/core/catch.decorator.js`: + // `function Catch(...exceptions) { return (target) => { ... }; }` + // Same anonymous-returned-arrow shape as `Injectable`. The subscriber's + // `start` patches the exception filter's prototype `catch` method to + // open an `exception_filter` span. + // + // Mirrors the vendored `SentryNestInstrumentation` `@Catch` wrap. + channelName: 'catchDecorator', + module: { name: '@nestjs/common', versionRange: '>=8.0.0 <12', filePath: 'decorators/core/catch.decorator.js' }, + astQuery: 'FunctionDeclaration[id.name="Catch"] ReturnStatement > ArrowFunctionExpression', + functionQuery: { kind: 'Sync' }, + }), + + // @nestjs/schedule @Cron/@Interval/@Timeout: + // `function Cron(...) { return applyDecorators(...); }` + // + // The returned decorator has no inline arrow to target, so we match the + // factory function and reassign `data.result` in `end` + // to wrap the decorator it returns (which rewrites the user handler + // `descriptor.value` with isolation-scope + error capture). Mirrors + // `SentryNestScheduleInstrumentation`, whose supported range (`>=2.0.0`) + // we match so opting in doesn't drop coverage the OTel path had. The + // compiled `function Cron(...)` declaration is unchanged across 2.x–5.x. + { + channelName: 'cronDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/cron.decorator.js' }, + functionQuery: { functionName: 'Cron', kind: 'Sync' }, + }, + { + channelName: 'intervalDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/interval.decorator.js' }, + functionQuery: { functionName: 'Interval', kind: 'Sync' }, + }, + { + channelName: 'timeoutDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/timeout.decorator.js' }, + functionQuery: { functionName: 'Timeout', kind: 'Sync' }, + }, + { + // @nestjs/event-emitter @OnEvent: `const OnEvent = (event, options) => { + // const decoratorFactory = (t, k, d) => {…}; return decoratorFactory; }` + // `OnEvent` is an arrow assigned to a const, so `expressionName`. `end` + // reassigns `data.result` to wrap the returned decorator, which rewrites the + // handler to open an `event.nestjs` span. Mirrors + // `SentryNestEventInstrumentation` (`>=2.0.0`); the `const OnEvent = (…) =>` + // shape is unchanged across 2.x–3.x. + channelName: 'onEventDecorator', + module: { + name: '@nestjs/event-emitter', + versionRange: '>=2.0.0', + filePath: 'dist/decorators/on-event.decorator.js', + }, + functionQuery: { expressionName: 'OnEvent', kind: 'Sync' }, + }, + + { + // @nestjs/bullmq @Processor: + // `function Processor(...) { return (target) => {…}; }` + // The factory arg carries the queue name, so we match the factory and + // reassign `data.result` in `end` to wrap the returned class decorator + // (which patches `target.prototype.process`). + // + // Mirrors `SentryNestBullMQInstrumentation` (`>=10.0.0`); the + // `function Processor(...)` declaration is unchanged across 10.x–11.x. + channelName: 'processorDecorator', + module: { + name: '@nestjs/bullmq', + versionRange: '>=10.0.0', + filePath: 'dist/decorators/processor.decorator.js', + }, + functionQuery: { functionName: 'Processor', kind: 'Sync' }, + }, +] satisfies InstrumentationConfig[]; + +export const nestjsChannels = { + NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', + NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate', + NESTJS_INJECTABLE: 'orchestrion:@nestjs/common:injectableDecorator', + NESTJS_CATCH: 'orchestrion:@nestjs/common:catchDecorator', + NESTJS_SCHEDULE_CRON: 'orchestrion:@nestjs/schedule:cronDecorator', + NESTJS_SCHEDULE_INTERVAL: 'orchestrion:@nestjs/schedule:intervalDecorator', + NESTJS_SCHEDULE_TIMEOUT: 'orchestrion:@nestjs/schedule:timeoutDecorator', + NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator', + NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator', +} as const; diff --git a/packages/nestjs/src/orchestrion/index.ts b/packages/nestjs/src/orchestrion/index.ts new file mode 100644 index 000000000000..5975f0eed3de --- /dev/null +++ b/packages/nestjs/src/orchestrion/index.ts @@ -0,0 +1,21 @@ +import type { OrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; +import { nestjsConfig } from './config'; +import { nestjsChannelIntegration } from './subscriber'; + +export { nestjsChannelIntegration } from './subscriber'; + +/** + * The NestJS orchestrion instrumentation descriptor. + * + * Inject it into the diagnostics-channel assembly so `@sentry/server-utils` + * never has to depend on `@sentry/nestjs`: + * - RUNTIME: `@sentry/nestjs` `init()` (and the `@sentry/nestjs/import` + * preload entry) register via `registerOrchestrionInstrumentation`. + * - BUILD: pass it to the bundler plugin's `instrumentations` option, e.g. + * `sentryBunPlugin({ instrumentations: [nestjsOrchestrion] })`. + */ +export const nestjsOrchestrion: OrchestrionInstrumentation = { + name: 'nestjs', + configs: nestjsConfig, + integration: nestjsChannelIntegration, +}; diff --git a/packages/nestjs/src/orchestrion/subscriber.ts b/packages/nestjs/src/orchestrion/subscriber.ts new file mode 100644 index 000000000000..5f69c26b0541 --- /dev/null +++ b/packages/nestjs/src/orchestrion/subscriber.ts @@ -0,0 +1,227 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core'; +import { bindTracingChannelToSpan } from '@sentry/server-utils'; +import { DEBUG_BUILD } from '../debug-build'; +import type { AnyFn } from '../integrations/helpers'; +import { isWrapped, markWrapped } from '../integrations/helpers'; +import type { CatchTarget, InjectableTarget } from '../integrations/types'; +import { patchCatchTarget, patchInjectableTarget } from '../integrations/wrap-components'; +import { + extractQueueName, + MECHANISM_CRON, + MECHANISM_INTERVAL, + MECHANISM_TIMEOUT, + patchMethodDescriptor, + patchProcessorTarget, + wrapEventHandler, + wrapScheduleHandler, +} from '../integrations/wrap-handlers'; +import { getAppCreationSpanOptions, wrapRequestContextHandler, wrapRouteHandler } from '../integrations/wrap-route'; +import { nestjsChannels as CHANNELS } from './config'; + +// 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'; + +const NOOP = (): void => {}; + +/** + * The orchestrion tracing-channel context. `arguments` is the live call args + * array; `result` is the return value, which an `end` handler may reassign to + * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). + */ +interface ChannelContext { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +/** + * Subscribe to a decorator channel (`Injectable`/`Catch`). + * + * The orchestrion transform targets the decorator's inner arrow, so `start` + * receives the decorated class as `arguments[0]`. There is no span around the + * decorator itself; `patch` installs the prototype-method proxies (the shared + * span-emitting logic) that open spans later. + */ +function subscribeDecoratorChannel(channelName: string, patch: (target: T) => void): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start(data) { + const target = data.arguments?.[0] as T | undefined; + if (target) { + patch(target); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +/** + * Wrap the method decorator the factory returns (`@Cron`/`@Interval`/`@Timeout`/ + * `@OnEvent`) so it replaces `descriptor.value` with a wrapped handler (shared + * logic) before delegating to the original decorator. + */ +function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + patchMethodDescriptor( + args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined, + args[1] as string | symbol | undefined, + args[2] as PropertyDescriptor | undefined, + wrapHandler, + ); + return original.apply(this, args); + }; +} + +/** + * Wrap the class decorator `@Processor` returns so it patches + * `target.prototype.process` (shared logic) before delegating. + */ +function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + patchProcessorTarget(args[0] as { __SENTRY_INTERNAL__?: boolean; prototype?: { process?: AnyFn } }, queueName); + return original.apply(this, args); + }; +} + +/** + * Subscribe to a decorator-factory channel. `end` reassigns `data.result` (the + * decorator the factory returns) with a wrapped version -> `traceSync` returns + * whatever `end` leaves there. `wrap` receives the original decorator and the + * channel context (for the factory's args, e.g. the BullMQ queue name). + */ +function subscribeFactoryDecorator(channelName: string, wrap: (decorator: AnyFn, data: ChannelContext) => AnyFn): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start: NOOP, + end(data) { + const decorator = data.result; + if (typeof decorator === 'function' && !isWrapped(decorator as AnyFn)) { + const wrapped = wrap(decorator as AnyFn, data); + markWrapped(wrapped); + data.result = wrapped; + } + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +const _nestjsChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing + // in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && debug.log('[orchestrion:nestjs] subscribing to @nestjs channels'); + + // App-creation span: `bindTracingChannelToSpan` opens the span on + // `start`, makes it the active context for the bootstrap, and ends it + // on `asyncEnd` (or `end` if `create` throws synchronously). + // + // `captureError: false`: a failed bootstrap surfaces to the caller. + // We just annotate the span. + // + // `bindTracingChannelToSpan` uses `bindStore`, which needs the + // async-context binding registered after integration `setupOnce`; defer + // until it's available. Only this bind is deferred (it fires at + // `NestFactory.create`, so a retry tick is fine); the plain `.subscribe` + // calls below stay synchronous because the decorator channels fire at + // module-load time, which a deferred subscription could miss. + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); + }, + { captureError: false }, + ); + }); + + // request_context + request_handler. `RouterExecutionContext.create` + // runs once per route at setup: it receives `(instance, callback, ...)` + // and RETURNS the per-request handler. `start` wraps the callback arg + // (-> handler span per call) and `end` reassigns `data.result` to + // replace the returned handler (-> request_context span per request). + const routerMeta = new WeakMap(); + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT).subscribe({ + start(data) { + const instance = data.arguments?.[0] as { constructor?: { name?: string } } | undefined; + const callback = data.arguments?.[1]; + routerMeta.set(data, { + instanceName: instance?.constructor?.name || 'UnnamedInstance', + callbackName: typeof callback === 'function' ? callback.name : '', + moduleVersion: data.moduleVersion, + }); + if (typeof callback === 'function') { + data.arguments[1] = wrapRouteHandler(callback as AnyFn, data.moduleVersion); + } + }, + end(data) { + const handler = data.result; + const meta = routerMeta.get(data); + if (typeof handler === 'function' && meta && !isWrapped(handler as AnyFn)) { + data.result = wrapRequestContextHandler( + handler as AnyFn, + meta.instanceName, + meta.callbackName, + meta.moduleVersion, + ); + } + routerMeta.delete(data); + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error(data) { + routerMeta.delete(data); + }, + }); + + // @Injectable (middleware/guard/pipe/interceptor) and @Catch + // (exception filter): both decorators share the + // `(target) => {...}` inner-arrow shape. + const seenInterceptorContexts = new WeakSet(); + subscribeDecoratorChannel(CHANNELS.NESTJS_INJECTABLE, target => + patchInjectableTarget(target, seenInterceptorContexts), + ); + subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); + + // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq). + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_TIMEOUT, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_TIMEOUT)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_ONEVENT, (decorator, data) => + makeMethodDecorator(decorator, handler => wrapEventHandler(handler, data.arguments?.[0])), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_PROCESSOR, (decorator, data) => + makeProcessorDecorator(decorator, extractQueueName(data.arguments?.[0])), + ); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL orchestrion-driven NestJS integration. + * + * Subscribes to the diagnostics_channels the orchestrion code transform + * injects into `@nestjs/*` (see `./config`). Requires the orchestrion runtime + * hook or bundler plugin to be active. Shares all span-emitting logic with + * the OTel path (`../integrations/wrappers`) + */ +export const nestjsChannelIntegration = defineIntegration(_nestjsChannelIntegration); diff --git a/packages/nestjs/src/sdk.ts b/packages/nestjs/src/sdk.ts index deba80d4fbba..1d02c3e61bfa 100644 --- a/packages/nestjs/src/sdk.ts +++ b/packages/nestjs/src/sdk.ts @@ -2,12 +2,21 @@ import type { Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node'; +import { registerOrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; import { nestIntegration } from './integrations/nest'; +import { nestjsOrchestrion } from './orchestrion'; /** * Initializes the NestJS SDK */ export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + // Inject the NestJS orchestrion instrumentation into the shared diagnostics-channel + // assembly BEFORE `nodeInit()` runs — that's where the opt-in helper builds its + // channel-integration list and the runtime hook registers the transform config. + // A no-op unless the user opted into diagnostics-channel injection. On the + // `--import` preload path, `@sentry/nestjs/import` registers it even earlier. + registerOrchestrionInstrumentation(nestjsOrchestrion); + const opts: NodeOptions = { defaultIntegrations: getDefaultIntegrations(options), ...options, diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 6b758d44c982..a369060ece3d 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { isPatched } from '../../src/integrations/helpers'; +import { isTargetPatched } from '../../src/integrations/helpers'; import type { InjectableTarget } from '../../src/integrations/types'; describe('Nest', () => { - describe('isPatched', () => { + describe('isTargetPatched', () => { it('should return true if target is already patched', () => { - const target = { name: 'TestTarget', sentryPatched: true, prototype: {} }; - expect(isPatched(target)).toBe(true); + const target = { name: 'TestTarget', sentryPatchedInjectable: true, prototype: {} }; + expect(isTargetPatched(target, 'sentryPatchedInjectable')).toBe(true); }); - it('should add the sentryPatched property and return false if target is not patched', () => { + it('should add the patch flag and return false if target is not patched', () => { const target: InjectableTarget = { name: 'TestTarget', prototype: {} }; - expect(isPatched(target)).toBe(false); - expect(target.sentryPatched).toBe(true); + expect(isTargetPatched(target, 'sentryPatchedInjectable')).toBe(false); + expect(target.sentryPatchedInjectable).toBe(true); }); }); }); diff --git a/packages/nestjs/test/orchestrion/nestjs.test.ts b/packages/nestjs/test/orchestrion/nestjs.test.ts new file mode 100644 index 000000000000..9cacace8f249 --- /dev/null +++ b/packages/nestjs/test/orchestrion/nestjs.test.ts @@ -0,0 +1,883 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + getIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nestjsChannelIntegration } from '../../src/orchestrion'; +import { nestjsChannels as CHANNELS } from '../../src/orchestrion/config'; + +// The subscriber only ever runs when orchestrion has injected the channels. +// `isOrchestrionInjected()` selects the `orchestrion` span origins that the +// assertions below expect, and so must be true for these tests. +beforeEach(() => { + (globalThis as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__ = { runtime: true }; +}); +afterEach(() => { + delete (globalThis as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__; +}); + +// Mirrors harness in `tracing-channel.test.ts`: `bindTracingChannelToSpan` +// only creates/ends spans when an async-context binding is available, so the +// strategy below must be installed for the subscriber to do anything. +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + //@ts-expect-error - just a mock for the test, this is fine + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +interface NestFactoryCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +describe('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.orchestrion.nestjs'); + expect(json.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.version': '10.4.1', + 'nestjs.module': 'AppModule', + }); + // Span was ended on `asyncEnd`. + expect(json.timestamp).toBeDefined(); + }); + + it('omits optional attributes when version/module are absent', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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` reassigns the returned handler on + // `data.result`. `makeHandler` stands in for the real `create` body. Returns + // the effective return (the substituted `data.result`) and the + // wrapped callback (`data.arguments[1]`). + function driveCreate( + instance: object, + callback: AnyFn, + moduleVersion: string | undefined, + makeHandler: (data: RouterCreateData) => AnyFn, + ): { effectiveHandler: AnyFn; wrappedCallback: AnyFn } { + const channel = tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT); + const data: RouterCreateData = { arguments: [instance, callback], moduleVersion }; + channel.traceSync(() => makeHandler(data), data); + return { effectiveHandler: data.result as AnyFn, wrappedCallback: data.arguments[1] as AnyFn }; + } + + it('opens a request_context span (named Controller.method) with OTel-compatible attributes', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.orchestrion.nestjs'); + expect(contextSpanJson!.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'request_context', + 'nestjs.controller': 'CatsController', + 'nestjs.callback': 'getCats', + 'nestjs.version': '10.4.1', + 'http.route': '/cats', + 'http.method': 'GET', + 'http.url': '/cats?q=1', + }); + }); + + it('wraps the callback arg into a request_handler span, preserving its name', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.orchestrion.nestjs'); + expect(handlerSpanJson!.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'handler', + 'nestjs.callback': 'getCats', + 'nestjs.version': '10.4.1', + }); + }); + + it('nests the request_handler span under the request_context span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.orchestrion.nestjs'); + // startSpanManual span ends when the proxied `next` is called. + expect(json.timestamp).toBeDefined(); + }); + + it('guard: wraps `canActivate` in a span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.orchestrion.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.orchestrion.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.orchestrion.nestjs.interceptor'); + // before-span ends when `next.handle()` is called. + expect(beforeJson.timestamp).toBeDefined(); + + // The returned observable was instrumented: subscribing registers an + // after-span teardown (proving the after-span was created). + returned.subscribe(); + expect(teardowns).toHaveLength(1); + expect(() => teardowns.forEach(fn => fn())).not.toThrow(); + }); + + it('async interceptor that awaits before next.handle(): still instruments the after-span', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class AsyncInterceptor { + // Awaits *before* calling `next.handle()`, so `intercept` returns a + // pending Promise while the after-span does not yet exist. + public async intercept(_context: unknown, next: { handle: () => unknown }): Promise { + beforeSpan = getActiveSpan(); + await Promise.resolve(); + return next.handle(); + } + } + applyInjectable(AsyncInterceptor); + + const next = { handle: () => observable }; + const returned = (await new AsyncInterceptor().intercept({}, next)) as typeof observable; + + expect(returned).toBe(observable); + // before-span ended (when `next.handle()` ran, post-await) + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // after-span was created AND the observable instrumented despite the await + returned.subscribe(); + expect(teardowns).toHaveLength(1); + expect(() => teardowns.forEach(fn => fn())).not.toThrow(); + }); + + it('async interceptor that never calls next.handle(): ends the before-span, no after-span', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class ShortCircuitInterceptor { + public async intercept(_context: unknown, _next: { handle: () => unknown }): Promise { + beforeSpan = getActiveSpan(); + await Promise.resolve(); + return observable; // short-circuits without calling `next.handle()` + } + } + applyInjectable(ShortCircuitInterceptor); + + const next = { handle: vi.fn() }; + const returned = (await new ShortCircuitInterceptor().intercept({}, next)) as typeof observable; + + expect(returned).toBe(observable); + expect(next.handle).not.toHaveBeenCalled(); + // before-span is closed even though `next.handle()` (which normally ends it) never ran + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // no after-span, so the observable is left un-instrumented + returned.subscribe(); + expect(teardowns).toHaveLength(0); + }); + + it('sync interceptor that short-circuits without next.handle(): ends the before-span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class CachingInterceptor { + // Synchronously returns an Observable without calling `next.handle()` + // (a cache/validation short-circuit). + public intercept(_context: unknown, _next: { handle: () => unknown }): unknown { + beforeSpan = getActiveSpan(); + return observable; + } + } + applyInjectable(CachingInterceptor); + + const next = { handle: vi.fn() }; + const returned = new CachingInterceptor().intercept({}, next) as typeof observable; + + expect(returned).toBe(observable); + expect(next.handle).not.toHaveBeenCalled(); + // before-span is closed even though `next.handle()` (which normally ends it) never ran + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // no after-span, so the observable is left un-instrumented + returned.subscribe(); + expect(teardowns).toHaveLength(0); + }); + + it('skips targets flagged __SENTRY_INTERNAL__', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.orchestrion.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(); + }); + + // A class can be decorated with both `@Injectable` and `@Catch` (an exception + // filter that uses DI). Which channel fires first depends on decorator + // stacking order (decorators apply inner-first): `@Catch` over `@Injectable` + // fires @Injectable first; `@Injectable` over `@Catch` fires @Catch first. + // Because the two passes use separate patched-flags, both must wrap their own + // methods regardless of which channel fires first. + function fireInjectable(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_INJECTABLE).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('still wraps `catch` when the @Injectable channel fired first (dual @Injectable @Catch filter)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + fireInjectable(HttpExceptionFilter); + applyCatch(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + it('still wraps `catch` when the @Catch channel fired first (dual @Injectable @Catch filter)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + applyCatch(HttpExceptionFilter); + fireInjectable(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + // A (contrived) class that is BOTH a guard (`canActivate`) and an exception + // filter (`catch`) proves the two passes are independent: neither ordering may + // let one pass's patched-flag block the other. Both spans must appear either way. + for (const order of ['injectable-first', 'catch-first'] as const) { + it(`wraps BOTH canActivate and catch when the ${order} channel fired first`, () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let guardSpan: ReturnType; + let filterSpan: ReturnType; + class GuardAndFilter { + public canActivate(_ctx: unknown): boolean { + guardSpan = getActiveSpan(); + return true; + } + public catch(exception: unknown, _host: unknown): string { + filterSpan = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + + if (order === 'injectable-first') { + fireInjectable(GuardAndFilter); + applyCatch(GuardAndFilter); + } else { + applyCatch(GuardAndFilter); + fireInjectable(GuardAndFilter); + } + + expect(new GuardAndFilter().canActivate({ ctx: true })).toBe(true); + expect(new GuardAndFilter().catch('boom', { switchToHttp: () => ({}) })).toBe('handled:boom'); + + expect(spanToJSON(guardSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.guard'); + expect(spanToJSON(filterSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + } +}); + +describe('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` + // reassigns `data.result`. Returns the effective (wrapped) decorator. + function driveFactory(channelName: string, factoryArgs: unknown[], originalDecorator: AnyFn): AnyFn { + const data: { arguments: unknown[]; result?: unknown } = { arguments: factoryArgs }; + tracingChannel<{ arguments: unknown[]; result?: unknown }>(channelName).traceSync(() => originalDecorator, data); + return data.result as AnyFn; + } + + it('schedule @Cron: wraps the handler with isolation scope + error capture, preserving name', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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.orchestrion.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.orchestrion.nestjs.bullmq'); + expect(json.data).toMatchObject({ + 'messaging.system': 'bullmq', + 'messaging.destination.name': 'emails', + }); + }); + + it('bullmq @Processor: derives the queue name from an options object', () => { + installTestAsyncContextStrategy(); + initTestClient(); + 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'); + }); + }); +}); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 08d24479480f..4cfdd8cd5f4c 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,5 +1,6 @@ import { channelIntegrations, + getChannelIntegrations, ioredisChannelIntegration, redisChannelIntegration, detectOrchestrionSetup, @@ -47,8 +48,10 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - // The registry integrations 1:1 replace the OTel integration of the same name. - const integrations = Object.values(channelIntegrations).map(createIntegration => createIntegration()); + // The registry integrations 1:1 replace the OTel integration of the same name. `getChannelIntegrations()` + // includes any externally-injected ones (e.g. `@sentry/nestjs`'s `Nest`), which must have registered + // before `init()` runs this loader — `@sentry/nestjs`'s `init()` does so before calling `nodeInit()`. + const integrations = getChannelIntegrations().map(createIntegration => createIntegration()); const replacedOtelIntegrationNames = integrations.map(i => i.name); return { diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 8c8d2e887541..77c3c3bc6fcc 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; } /** @@ -77,8 +59,7 @@ function _init( // EXPERIMENTAL: diagnostics-channel injection, opted into via // `experimentalUseDiagnosticsChannelInjection()`. Gated on span recording to // match the OTel integrations it replaces. With tracing off there are no - // channel subscribers, so injecting is pointless work. `resolve...()` is - // memoized, so `getDefaultIntegrations()` (below) sees the same instance. + // channel subscribers, so injecting is pointless work. const diagnosticsChannelInjection = isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options) ? resolveDiagnosticsChannelInjection() @@ -90,10 +71,31 @@ 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. + // + // Only when there's a non-empty default set to swap: + // `defaultIntegrations: false` (not an array) and `[]` / + // `initWithoutDefaultIntegrations()` (explicitly no defaults) are left + // untouched, as appending channel integrations there would resurrect + // defaults the caller opted out of. + if (diagnosticsChannelInjection && Array.isArray(defaultIntegrations) && defaultIntegrations.length > 0) { + 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..b1412904e33c --- /dev/null +++ b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts @@ -0,0 +1,117 @@ +import type { Integration } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init, initWithoutDefaultIntegrations } 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); + }); + + it('does not add channel integrations when defaults are explicitly empty', () => { + const channelEmptyMysql = mockIntegration('EmptyMysql'); + setDiagnosticsChannelInjectionLoader(() => ({ + integrations: [channelEmptyMysql], + replacedOtelIntegrationNames: ['EmptyMysql'], + register: vi.fn(), + detect: vi.fn(), + })); + + // `defaultIntegrations: []` opts out of all defaults; the swap must not + // resurrect them by appending the channel integrations. + init({ dsn: PUBLIC_DSN, tracesSampleRate: 1, skipOpenTelemetrySetup: true, defaultIntegrations: [] }); + + expect(channelEmptyMysql.setupOnce).not.toHaveBeenCalled(); + }); + + it('does not add channel integrations to initWithoutDefaultIntegrations()', () => { + const channelNoDefaults = mockIntegration('NoDefaultsMysql'); + setDiagnosticsChannelInjectionLoader(() => ({ + integrations: [channelNoDefaults], + replacedOtelIntegrationNames: ['NoDefaultsMysql'], + register: vi.fn(), + detect: vi.fn(), + })); + + initWithoutDefaultIntegrations({ dsn: PUBLIC_DSN, tracesSampleRate: 1, skipOpenTelemetrySetup: true }); + + expect(channelNoDefaults.setupOnce).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index a09654179232..4240d4337287 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -83,8 +83,8 @@ }, "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", - "@apm-js-collab/code-transformer": "^0.15.0", - "@apm-js-collab/tracing-hooks": "^0.10.1", + "@apm-js-collab/code-transformer": "^0.16.0", + "@apm-js-collab/tracing-hooks": "^0.11.0", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.64.0", "magic-string": "~0.30.0" diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 4cadc7b40925..273e9772c47c 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -15,7 +15,8 @@ type UnknownPlugin = any; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; import MagicString from 'magic-string'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; +import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config'; +import type { OrchestrionInstrumentation } from '../registry'; // `vite` types live in the package's ESM-only subpath; under Node16 module // resolution with TS treating @sentry/server-utils as CJS, importing them produces a @@ -42,6 +43,10 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. * + * A framework SDK that owns its own instrumentation can inject its + * `OrchestrionInstrumentation` descriptor via the `instrumentations` option, which is merged + * with the built-in configs. + * * @example * ```ts * // vite.config.ts @@ -49,15 +54,18 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(): UnknownPlugin[] { - const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); +export function sentryOrchestrionPlugin(options?: { + instrumentations?: OrchestrionInstrumentation[]; +}): UnknownPlugin[] { + const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options?.instrumentations ?? []).flatMap(i => i.configs)]; + const codeTransformerPlugins = codeTransformer({ instrumentations }); const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) ? codeTransformerPlugins : [codeTransformerPlugins]; - return [bundlerMarkerPlugin(), ...codeTransformerArray]; + return [bundlerMarkerPlugin(instrumentedModuleNames(instrumentations)), ...codeTransformerArray]; } -function bundlerMarkerPlugin(): UnknownPlugin { +function bundlerMarkerPlugin(moduleNames: string[]): UnknownPlugin { const banner = [ 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', @@ -75,7 +83,7 @@ function bundlerMarkerPlugin(): UnknownPlugin { // diagnostics_channel calls never get injected. Vite merges array // `noExternal` entries with the user's config, so we don't overwrite // their additions. - return { ssr: { noExternal: INSTRUMENTED_MODULE_NAMES } }; + return { ssr: { noExternal: moduleNames } }; }, renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: unknown } | null { if (!chunk.isEntry) return null; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bbbbf37d0b70..02a86d236739 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -11,7 +11,16 @@ import { vercelAiConfig } from './vercel-ai'; import { hapiConfig } from './hapi'; import { redisConfig } from './redis'; import { expressConfig } from './express'; +import { getInjectedOrchestrionInstrumentations } from '../registry'; +/** + * The built-in orchestrion code-transform configs shipped by `@sentry/server-utils`. + * + * Framework packages that own their own instrumentation (e.g. `@sentry/nestjs`) + * are NOT here — they inject via the registry (runtime) or the bundler plugin's + * `instrumentations` option (build time). Use {@link getSentryInstrumentations} + * to get the built-ins merged with any injected ones. + */ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, ...lruMemoizerConfig, @@ -28,15 +37,28 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ]; /** - * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` - * (e.g. `['mysql']`). + * The built-in configs merged with any externally-injected ones (see the + * registry). This is the list the runtime hook feeds to the code transform. + */ +export function getSentryInstrumentations(): InstrumentationConfig[] { + return [...SENTRY_INSTRUMENTATIONS, ...getInjectedOrchestrionInstrumentations().flatMap(i => i.configs)]; +} + +/** The unique set of instrumented package names for the given configs. */ +export function instrumentedModuleNames(configs: InstrumentationConfig[]): string[] { + return Array.from(new Set(configs.map(i => i.module.name))); +} + +/** + * The unique set of package names instrumented by the built-in + * `SENTRY_INSTRUMENTATIONS` (e.g. `['mysql']`). * * Bundler plugins MUST ensure these are actually bundled rather than * externalized: an externalized dependency is resolved from `node_modules` at * runtime and never passes through the code transform's `onLoad`, so its * diagnostics_channel calls are silently never injected. */ -export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); +export const INSTRUMENTED_MODULE_NAMES: string[] = instrumentedModuleNames(SENTRY_INSTRUMENTATIONS); /** * Returns `external` with any instrumented packages removed, so a bundler that @@ -48,11 +70,12 @@ export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INS * (Vite uses an `ssr.noExternal` allowlist instead, so it consumes * `INSTRUMENTED_MODULE_NAMES` directly rather than this helper.) */ -export function withoutInstrumentedExternals(external: readonly string[] | undefined): string[] | undefined { +export function withoutInstrumentedExternals( + external: readonly string[] | undefined, + names: string[] = INSTRUMENTED_MODULE_NAMES, +): string[] | undefined { if (!external) { return undefined; } - return external.filter( - entry => !INSTRUMENTED_MODULE_NAMES.some(name => entry === name || entry.startsWith(`${name}/`)), - ); + return external.filter(entry => !names.some(name => entry === name || entry.startsWith(`${name}/`))); } diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 9dfa88bf427d..8ccb779ebb0a 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -1,9 +1,12 @@ import { debug } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import type { OrchestrionInstrumentation } from './registry'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; registry?: OrchestrionInstrumentation[] } + | undefined; } /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 6ac9761087f6..7ff39f2d77ad 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,5 @@ +import type { IntegrationFn } from '@sentry/core'; +import { getInjectedOrchestrionInstrumentations } from './registry'; import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; @@ -11,6 +13,8 @@ import { vercelAiChannelIntegration } from '../integrations/tracing-channel/verc import { expressChannelIntegration } from '../integrations/tracing-channel/express'; export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +export { registerOrchestrionInstrumentation, getInjectedOrchestrionInstrumentations } from './registry'; +export type { OrchestrionInstrumentation, InstrumentationConfig, FunctionKind } from './registry'; export { anthropicChannelIntegration, googleGenAIChannelIntegration, @@ -41,6 +45,13 @@ export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integ * NOTE: `ioredisChannelIntegration` and `redisChannelIntegration` are intentionally NOT here. They * only partially replace the composite OTel `Redis` integration and need the node SDK's redis cache * `responseHook` (which can't live in `server-utils`), so `@sentry/node` wires them up separately. + * + * NOTE: `ioredisChannelIntegration` is intentionally NOT here. It only partially replaces the + * composite OTel `Redis` integration and needs the node SDK's redis cache `responseHook` (which + * can't live in `server-utils`), so `@sentry/node` wires it up separately. + * + * Framework packages that own their own channel integration (e.g. `@sentry/nestjs`'s `Nest`) are + * NOT here either: they inject via the registry, and {@link getChannelIntegrations} merges them in. */ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, @@ -54,3 +65,12 @@ export const channelIntegrations = { hapiIntegration: hapiChannelIntegration, expressIntegration: expressChannelIntegration, } as const; + +/** + * The built-in channel-integration factories merged with any externally-injected ones (see the + * registry). Each 1:1 replaces the OTel integration of the same `name`. This is the list the Node + * SDK's opt-in helper instantiates and swaps in for the matching OTel integrations. + */ +export function getChannelIntegrations(): IntegrationFn[] { + return [...Object.values(channelIntegrations), ...getInjectedOrchestrionInstrumentations().map(i => i.integration)]; +} diff --git a/packages/server-utils/src/orchestrion/registry.ts b/packages/server-utils/src/orchestrion/registry.ts new file mode 100644 index 000000000000..5d450077ffda --- /dev/null +++ b/packages/server-utils/src/orchestrion/registry.ts @@ -0,0 +1,55 @@ +import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { IntegrationFn } from '@sentry/core'; + +export type { FunctionKind, InstrumentationConfig }; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; registry?: OrchestrionInstrumentation[] } + | undefined; +} + +/** + * A self-contained orchestrion instrumentation contributed by an SDK package. + * + * Bundles the three pieces a diagnostics-channel instrumentation needs: + * - `configs`: the code-transform configs (consumed at startup/bundler time), + * - `integration`: the channel-subscriber span-emitting integration factory. + * + * A package that owns its own instrumentation (e.g. `@sentry/nestjs`) defines + * one of these and injects it via {@link registerOrchestrionInstrumentation} + * (runtime) and by passing it to the bundler plugin's `instrumentations` + * option (build time), so `@sentry/server-utils` never has to depend on that + * package. + */ +export interface OrchestrionInstrumentation { + name: string; + configs: InstrumentationConfig[]; + integration: IntegrationFn; +} + +/** + * Inject an externally-defined orchestrion instrumentation into the shared + * assembly. Must be called before the runtime hook registers (see + * `registerDiagnosticsChannelInjection`) and before the Node SDK builds its + * channel-integration list. + * + * Backed by `globalThis` (not a module-scoped array) so the CJS copy of this + * module that `Sentry.init()` `require()`s synchronously and the ESM copy an + * injecting package imports share one store. Idempotent per `name`. + */ +export function registerOrchestrionInstrumentation(instrumentation: OrchestrionInstrumentation): void { + const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + const registry = (g.registry ??= []); + if (!registry.some(existing => existing.name === instrumentation.name)) { + registry.push(instrumentation); + } +} + +/** + * The externally-injected orchestrion instrumentations, in registration order. + */ +export function getInjectedOrchestrionInstrumentations(): OrchestrionInstrumentation[] { + return globalThis.__SENTRY_ORCHESTRION__?.registry ?? []; +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 195464f7375f..9c281f028333 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -3,11 +3,14 @@ import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; import { DEBUG_BUILD } from '../../debug-build'; -import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { getSentryInstrumentations } from '../config'; +import type { OrchestrionInstrumentation } from '../registry'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; registry?: OrchestrionInstrumentation[] } + | undefined; } /** @@ -76,7 +79,7 @@ export function registerDiagnosticsChannelInjection(): void { resolve: unknown; load: unknown; }; - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + initialize({ instrumentations: getSentryInstrumentations() }); mod.registerHooks({ resolve, load }); DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { @@ -93,7 +96,7 @@ export function registerDiagnosticsChannelInjection(): void { mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { parentURL, - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + data: { instrumentations: getSentryInstrumentations() }, }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -104,7 +107,7 @@ export function registerDiagnosticsChannelInjection(): void { const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { patch: () => void; }; - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + new ModulePatch({ instrumentations: getSentryInstrumentations() }).patch(); DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); } else { DEBUG_BUILD && diff --git a/yarn.lock b/yarn.lock index 4913e2a20b85..e66c8ba79e24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,12 +426,24 @@ semifies "^1.0.0" source-map "^0.6.0" -"@apm-js-collab/tracing-hooks@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.1.tgz#52a463f6dd03e99582a7dde9816257efabdf63b9" - integrity sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA== +"@apm-js-collab/code-transformer@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.16.0.tgz#f49f3f88839907aea86a23c0a8102ad5038b8273" + integrity sha512-J3YRXRsxr/d48E7iDOAyVLrqQMCGU1iHWxXPKq7EeXQTRDDJ50piOfQNqxsM7u4XogJlirXvLHIznr0T33HTKw== dependencies: - "@apm-js-collab/code-transformer" "^0.15.0" + "@types/estree" "^1.0.8" + astring "^1.9.0" + esquery "^1.7.0" + meriyah "^6.1.4" + semifies "^1.0.0" + source-map "^0.6.0" + +"@apm-js-collab/tracing-hooks@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.11.0.tgz#4c9c378695d65e90574893c1faba3c13b5bdbd14" + integrity sha512-5hWEcCGF4hcNh9lyB70p58pXn4HyUAVCad44wK6j110Ky+ivG19TfKHLB2aIDgItabCj6VPZm0DMAMNRnJDNBw== + dependencies: + "@apm-js-collab/code-transformer" "^0.16.0" debug "^4.4.1" module-details-from-path "^1.0.4"