Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0bc6843
deps(server-utils): Bump @apm-js-collab/code-transformer to 0.16.0
isaacs Jul 2, 2026
7412ae7
deps(server-utils): Bump @apm-js-collab/tracing-hooks to 0.11.0
isaacs Jul 2, 2026
56dbced
feat(nestjs): initial orchestrion span for app creation
isaacs Jun 24, 2026
41fec12
feat(nest): orchestrion hooks for request_context, request_handler
isaacs Jun 24, 2026
c481865
feat(nest): port remainder of vendored NestJS OTel instrumentation
isaacs Jun 24, 2026
2bc2db1
feat(nest): final (still dormant) Orchestrion instrumentation
isaacs Jun 24, 2026
417b275
feat(nest): wire up orchestrion instrumentation
isaacs Jun 25, 2026
4572bfb
test(nestjs): e2e test for nestjs orchestrion implementation
isaacs Jul 2, 2026
470e043
fix(node): do not add back default integrations when list is empty
isaacs Jul 3, 2026
a790dfb
fix(nestjs): use waitForTracingChannelBinding properly
isaacs Jul 3, 2026
8380475
fix(nestjs): note orchestrion on span origins
isaacs Jul 7, 2026
dfc12d5
test: remove unnecessary nestjs integration test
isaacs Jul 7, 2026
e9e7a1d
fix(nestjs): wrap @Catch and @Injectable with 2 flags
isaacs Jul 7, 2026
60016b1
fix(nestjs): do not dangle spans for async iterators
isaacs Jul 7, 2026
2c11400
fix(nestjs): handle sync interceptor that short-circuits span end
isaacs Jul 7, 2026
0997aa2
ref(server-utils, nestjs): move NestJS orchestrion configs out of ser…
isaacs Jul 8, 2026
9eec1cc
chore: size limit increase
isaacs Jul 8, 2026
b34d60e
fixup! ref(server-utils, nestjs): move NestJS orchestrion configs out…
isaacs Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: `pnpm start`,
});

export default config;
Original file line number Diff line number Diff line change
@@ -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' };
}
}
Original file line number Diff line number Diff line change
@@ -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');
}
}
Original file line number Diff line number Diff line change
@@ -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' });
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class ExampleException extends Error {
public constructor() {
super('Example exception handled by the example filter');
}
}
Original file line number Diff line number Diff line change
@@ -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>();
response.status(400).json({ message: 'handled by example filter' });
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<CallHandler['handle']> {
// 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);
}),
);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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,
},
});
Original file line number Diff line number Diff line change
@@ -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<void> {
const app = await NestFactory.create(AppModule);
await app.listen(PORT);
}

// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap();
Loading
Loading