Skip to content

Commit 8307401

Browse files
authored
feat(nestjs): add orchestrion diagnostics-channel instrumentation (#22176)
Add the NestJS channel subscriber in `@sentry/nestjs`, plus its code-transform config and channel names in `@sentry/server-utils`. `nestIntegration` now uses the diagnostics-channel path when orchestrion is injected and the vendored OTel path otherwise. Only the span origin differs between the two implementations. Fix: #20947 Fix: #20904 Fix: #20905 Fix: #20906 Fix: #20907 Fix: JS-2519 Fix: JS-2477 Fix: JS-2478 Fix: JS-2479 Fix: JS-2480
1 parent 0ea8a51 commit 8307401

35 files changed

Lines changed: 2019 additions & 14 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# compiled output
2+
/dist
3+
/node_modules
4+
/build
5+
6+
# Logs
7+
logs
8+
*.log
9+
npm-debug.log*
10+
pnpm-debug.log*
11+
yarn-debug.log*
12+
yarn-error.log*
13+
lerna-debug.log*
14+
15+
# OS
16+
.DS_Store
17+
18+
# Tests
19+
/coverage
20+
/.nyc_output
21+
22+
# IDEs and editors
23+
/.idea
24+
.project
25+
.classpath
26+
.c9/
27+
*.launch
28+
.settings/
29+
*.sublime-workspace
30+
31+
# IDE - VSCode
32+
.vscode/*
33+
!.vscode/settings.json
34+
!.vscode/tasks.json
35+
!.vscode/launch.json
36+
!.vscode/extensions.json
37+
38+
# dotenv environment variable files
39+
.env
40+
.env.development.local
41+
.env.test.local
42+
.env.production.local
43+
.env.local
44+
45+
# temp directory
46+
.temp
47+
.tmp
48+
49+
# Runtime data
50+
pids
51+
*.pid
52+
*.seed
53+
*.pid.lock
54+
55+
# Diagnostic reports (https://nodejs.org/api/report.html)
56+
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# nestjs-orchestrion
2+
3+
E2E test app for the **orchestrion** (diagnostics-channel
4+
injection) NestJS instrumentation. It is a normal
5+
`@sentry/nestjs` app whose only difference from `nestjs-basic` is
6+
that `src/instrument.ts` calls
7+
`Sentry.experimentalUseDiagnosticsChannelInjection()` before
8+
`Sentry.init()`. That swaps the OTel `Nest` integration for the
9+
orchestrion subscriber (`@sentry/server-utils/orchestrion`) and
10+
injects the diagnostics channels into `@nestjs/*` at load time.
11+
12+
The tests assert the **same** span tree the OTel path produces
13+
(`nestjs-basic`), so this app is the opt-in side of an A/B
14+
against that baseline:
15+
16+
- `transactions.test.ts`: `app_creation`, `request_context`,
17+
`handler`, and the
18+
`middleware.nestjs[.guard|.pipe|.interceptor|.exception_filter]`
19+
spans.
20+
- `schedule.test.ts`: `@Cron`/`@Interval`/`@Timeout` error
21+
mechanisms.
22+
- `events.test.ts`: the `@OnEvent` `event.nestjs` transaction.
23+
24+
The spans that reassign the value a decorator factory / route
25+
handler returns (`request_context`,
26+
`@Cron`/`@Interval`/`@Timeout`, `@OnEvent`) rely on the
27+
transformer returning the (mutated) `ctx.result`. That is the
28+
default for `Sync`/`Async` transforms as of
29+
`@apm-js-collab/code-transformer` `0.16.0` (no `mutableResult`
30+
opt-in), which `@sentry/node`/`@sentry/server-utils` depend on,
31+
so this app runs against the published transformer.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"$schema": "https://json.schemastore.org/nest-cli",
3+
"collection": "@nestjs/schematics",
4+
"sourceRoot": "src",
5+
"compilerOptions": {
6+
"deleteOutDir": true
7+
}
8+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "nestjs-orchestrion",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"build": "nest build",
7+
"start": "nest start",
8+
"start:prod": "node dist/main",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test": "playwright test",
11+
"test:build": "pnpm install",
12+
"test:assert": "pnpm test"
13+
},
14+
"dependencies": {
15+
"@nestjs/common": "^11.0.0",
16+
"@nestjs/core": "^11.0.0",
17+
"@nestjs/event-emitter": "^2.0.0",
18+
"@nestjs/platform-express": "^11.0.0",
19+
"@nestjs/schedule": "^4.1.0",
20+
"@sentry/nestjs": "file:../../packed/sentry-nestjs-packed.tgz",
21+
"reflect-metadata": "^0.2.0",
22+
"rxjs": "^7.8.1"
23+
},
24+
"devDependencies": {
25+
"@playwright/test": "~1.56.0",
26+
"@sentry-internal/test-utils": "link:../../../test-utils",
27+
"@nestjs/cli": "^11.0.0",
28+
"@nestjs/schematics": "^11.0.0",
29+
"@types/express": "^5.0.0",
30+
"@types/node": "^18.19.1",
31+
"typescript": "~5.5.0"
32+
},
33+
"volta": {
34+
"extends": "../../package.json"
35+
}
36+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
});
6+
7+
export default config;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Controller, Get, Param, ParseIntPipe, UseGuards, UseInterceptors } from '@nestjs/common';
2+
import { AppService } from './app.service';
3+
import { ExampleException } from './example.exception';
4+
import { ExampleGuard } from './example.guard';
5+
import { ExampleInterceptor } from './example.interceptor';
6+
import { ScheduleService } from './schedule.service';
7+
8+
@Controller()
9+
export class AppController {
10+
public constructor(
11+
private readonly appService: AppService,
12+
private readonly scheduleService: ScheduleService,
13+
) {}
14+
15+
@Get('test-transaction')
16+
public testTransaction(): unknown {
17+
return this.appService.testSpan();
18+
}
19+
20+
@Get('test-middleware')
21+
public testMiddleware(): unknown {
22+
return this.appService.testSpan();
23+
}
24+
25+
@Get('test-guard')
26+
@UseGuards(ExampleGuard)
27+
public testGuard(): unknown {
28+
return {};
29+
}
30+
31+
@Get('test-interceptor')
32+
@UseInterceptors(ExampleInterceptor)
33+
public testInterceptor(): unknown {
34+
return this.appService.testSpan();
35+
}
36+
37+
@Get('test-pipe/:id')
38+
public testPipe(@Param('id', ParseIntPipe) id: number): unknown {
39+
return { value: id };
40+
}
41+
42+
@Get('test-exception')
43+
public testException(): never {
44+
throw new ExampleException();
45+
}
46+
47+
@Get('test-event')
48+
public testEvent(): unknown {
49+
this.appService.emitEvent();
50+
return { message: 'emitted' };
51+
}
52+
53+
// Triggers the `@Timeout`-decorated handler directly (its real delay is long
54+
// so it never fires on its own during the test).
55+
@Get('trigger-timeout-error')
56+
public triggerTimeoutError(): unknown {
57+
try {
58+
this.scheduleService.handleTimeoutError();
59+
} catch {
60+
// Swallow, the error is captured by the schedule instrumentation; the
61+
// route itself should still succeed.
62+
}
63+
return { message: 'triggered' };
64+
}
65+
66+
// Stop the auto-firing scheduled jobs so they don't keep throwing after the
67+
// assertions have run.
68+
@Get('kill-schedules')
69+
public killSchedules(): unknown {
70+
this.scheduleService.killCron('test-cron-error');
71+
this.scheduleService.killInterval('test-interval-error');
72+
return { message: 'killed' };
73+
}
74+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { MiddlewareConsumer, Module } from '@nestjs/common';
2+
import { APP_FILTER } from '@nestjs/core';
3+
import { EventEmitterModule } from '@nestjs/event-emitter';
4+
import { ScheduleModule } from '@nestjs/schedule';
5+
import { AppController } from './app.controller';
6+
import { AppService } from './app.service';
7+
import { EventsService } from './events.service';
8+
import { ExampleExceptionFilter } from './example.filter';
9+
import { ExampleMiddleware } from './example.middleware';
10+
import { ScheduleService } from './schedule.service';
11+
12+
@Module({
13+
imports: [EventEmitterModule.forRoot(), ScheduleModule.forRoot()],
14+
controllers: [AppController],
15+
providers: [
16+
AppService,
17+
EventsService,
18+
ScheduleService,
19+
// Global exception filter
20+
// exercises the `@Catch` (exception_filter) instrumentation.
21+
{
22+
provide: APP_FILTER,
23+
useClass: ExampleExceptionFilter,
24+
},
25+
],
26+
})
27+
export class AppModule {
28+
configure(consumer: MiddlewareConsumer): void {
29+
consumer.apply(ExampleMiddleware).forRoutes('test-middleware');
30+
}
31+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { EventEmitter2 } from '@nestjs/event-emitter';
3+
import * as Sentry from '@sentry/nestjs';
4+
5+
@Injectable()
6+
export class AppService {
7+
public constructor(private readonly eventEmitter: EventEmitter2) {}
8+
9+
public testSpan(): void {
10+
// A child span, to verify request handling nests under the nestjs spans.
11+
Sentry.startSpan({ name: 'test-controller-span' }, () => undefined);
12+
}
13+
14+
public emitEvent(): void {
15+
this.eventEmitter.emit('test.event', { hello: 'world' });
16+
}
17+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { OnEvent } from '@nestjs/event-emitter';
3+
import * as Sentry from '@sentry/nestjs';
4+
5+
@Injectable()
6+
export class EventsService {
7+
// `@OnEvent` opens an `event.nestjs` transaction per handled event.
8+
@OnEvent('test.event')
9+
public handleTestEvent(): void {
10+
Sentry.startSpan({ name: 'test-event-child-span' }, () => undefined);
11+
}
12+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class ExampleException extends Error {
2+
public constructor() {
3+
super('Example exception handled by the example filter');
4+
}
5+
}

0 commit comments

Comments
 (0)