NestJS
Overview
Implement OpenTelemetry instrumentation for NestJS applications to enable comprehensive application performance monitoring (APM), distributed tracing, and observability across your enterprise Node.js applications. This guide shows you how to auto-instrument NestJS controllers, services, guards, interceptors, TypeORM queries, BullMQ background jobs, and WebSocket gateways using the OpenTelemetry Node.js SDK.
NestJS is an opinionated, structured framework that runs on top of Express or Fastify.
NestJS applications benefit from automatic instrumentation of the dependency injection container, decorators, HTTP endpoints, TypeORM database queries, Redis operations, BullMQ job processing, WebSocket connections, GraphQL resolvers, and microservice communication. With OpenTelemetry, you can trace requests through the entire dependency injection hierarchy, monitor async context propagation, identify N+1 query problems, debug background job failures, and track distributed transactions across microservices without significant code changes.
Whether you're implementing observability for the first time, migrating from New Relic or Datadog, troubleshooting performance issues in production, or building enterprise-grade monitoring for microservices, this guide provides production-ready configurations and best practices for NestJS OpenTelemetry instrumentation with Base14 Scout.
Add a TracingModule that initializes the OpenTelemetry Node.js SDK and import
it first in your AppModule - controllers, TypeORM queries, and HTTP calls are
then traced automatically. Use trace.getTracer() directly in guards,
interceptors, and BullMQ processors for custom span coverage.
Who This Guide Is For
This documentation is designed for:
- NestJS developers: implementing observability and distributed tracing for enterprise applications with dependency injection
- Backend engineers: deploying NestJS microservices with comprehensive production monitoring requirements
- DevOps teams: standardizing observability across multiple NestJS services in Kubernetes environments
- Enterprise architects: building observable systems with GraphQL, WebSockets, message queues, and microservices
- Full-stack developers: debugging TypeORM queries, BullMQ jobs, and async operations in production NestJS apps
Prerequisites
Before starting, ensure you have:
- Node.js 18.x or later (20.x LTS recommended for production)
- NestJS 10.x or later installed (
@nestjs/core,@nestjs/common) - TypeScript 4.9+ (5.x recommended)
- Scout Collector configured and accessible
- See Docker Compose Setup for local development
- See Kubernetes Helm Setup for production
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
- Familiarity with NestJS dependency injection and decorators
Compatibility Matrix
| Component | Minimum Version | Recommended Version |
|---|---|---|
| Node.js | 18.0.0 | 20.x LTS |
| NestJS | 9.0.0 | 10.3.0+ |
| @opentelemetry/sdk-node | 0.40.0 | 0.200+ |
| @opentelemetry/auto-inst... | 0.40.0 | 0.76+ |
| TypeORM (if used) | 0.3.0 | 0.3.20+ |
| BullMQ (if used) | 4.0.0 | 5.x |
| @nestjs/websockets (optional) | 10.0.0 | 10.3.0+ |
| TypeScript | 4.9.0 | 5.3.0+ |
Installation
Install the OpenTelemetry SDK and auto-instrumentation packages:
npm install --save \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/api
Install NestJS-specific packages if not already installed:
npm install --save \
@nestjs/core \
@nestjs/common \
@nestjs/platform-express
Configuration
- NestJS Module (Recommended)
- Standalone File
- Environment Variables
Create a NestJS module for OpenTelemetry initialization:
import { Module, OnModuleInit } from '@nestjs/common';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
@Module({})
export class TracingModule implements OnModuleInit {
private sdk: NodeSDK;
onModuleInit() {
this.sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]:
process.env.OTEL_SERVICE_NAME || 'nestjs-api',
[ATTR_SERVICE_VERSION]:
process.env.npm_package_version || '1.0.0',
'deployment.environment.name':
process.env.NODE_ENV || 'development',
'environment':
process.env.NODE_ENV || 'development',
}),
traceExporter: new OTLPTraceExporter({
url:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const ignorePaths = ['/health', '/metrics'];
return ignorePaths.some((path) => req.url?.includes(path));
},
},
}),
],
});
this.sdk.start();
}
async onModuleDestroy() {
await this.sdk.shutdown();
}
}
Import the module in your root AppModule:
import { Module } from '@nestjs/common';
import { TracingModule } from './tracing/tracing.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
TracingModule, // Import FIRST for proper initialization
UsersModule,
// ... other modules
],
})
export class AppModule {}
Create instrumentation file loaded before application bootstrap:
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
} from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'nestjs-api',
'deployment.environment.name': process.env.NODE_ENV,
'environment': process.env.NODE_ENV,
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on('SIGTERM', async () => {
await sdk.shutdown();
process.exit(0);
});
export default sdk;
Update main.ts:
// Import instrumentation FIRST
import './instrumentation';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
For containerized deployments:
# Service identification
OTEL_SERVICE_NAME=nestjs-api
OTEL_SERVICE_VERSION=1.0.0
NODE_ENV=production
# Exporter configuration
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
# Resource attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development,environment=development,service.namespace=backend
# Performance tuning
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=5000
Run with Node.js instrumentation:
node --require ./instrumentation.js dist/main.js
Traces
Traces show the complete path of a request through your NestJS application, from the incoming HTTP route, down through the NestJS handler, into database queries and Redis calls, and back out as the response.
Automatic Trace Collection
Once the TracingModule is loaded, NestJS captures trace data for every request
with no per-handler code:
Captured Information:
- HTTP method, route, and status code for every controller endpoint
- Request duration and a timing breakdown across each span
- PostgreSQL queries, including the executed SQL (with
instrumentation-pg); TypeORM, Prisma, and Sequelize need their own instrumentation packages - Redis commands, including those issued by BullMQ (with
instrumentation-ioredis) - Outbound HTTP calls to other services (with
instrumentation-http) - Exceptions and stack traces recorded on the failing span
- Distributed context propagation across microservices (W3C Trace Context)
Trace Hierarchy:
HTTP Request Span (root: GET /users/:id)
├── UsersController.findOne Span
│ ├── PostgreSQL Query Span (SELECT ... FROM users)
│ └── Redis GET Span (cache lookup)
└── Redis Span (BullMQ enqueue: welcome-email job)
Key Tracing Features
- Automatic HTTP tracking: every controller route is traced with no code changes
- NestJS-aware spans: controller handlers, guards, interceptors, and pipes
are traced through
instrumentation-nestjs-core; spans for your own service methods are added manually - Error capturing: thrown exceptions and filtered errors are recorded with full stack traces
- Context propagation: distributed traces follow requests across HTTP, gRPC, and message-queue boundaries
- Async support: context propagates across
async/awaitand RxJS observables
View traces in your base14 Scout dashboard to follow request flows and find the slow span in a chain.
Reference
Metrics
Metrics aggregate runtime measurements over time, such as request rate, latency distributions, and error counts. Where traces explain a single request, metrics power dashboards and alerts across all of them.
Enable Metrics in the SDK
Add a metric reader to the NodeSDK so OpenTelemetry exports runtime and HTTP
metrics alongside traces:
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
// inside new NodeSDK({ ... })
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 10000,
}),
The HTTP instrumentation also emits server request-duration and request-count metrics without further code.
Custom Business Metrics
The HTTP instrumentation already emits the standard server metrics, including
the http.server.request.duration histogram (its sample count gives you request
rate, latency percentiles, and error ratio per route), so there is no need to
hand-roll request latency. Reserve custom metrics for business events the
instrumentation cannot see, such as domain actions:
import { Injectable } from '@nestjs/common';
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('nestjs-api');
const articlesCreated = meter.createCounter('articles.created', {
description: 'Articles created',
});
@Injectable()
export class ArticlesService {
async create(authorId: string /* ... */): Promise<void> {
// ... persist the article ...
articlesCreated.add(1, { 'author.id': authorId });
}
}
View metrics in your base14 Scout dashboard to chart request rate, latency percentiles, and error ratio per route from the automatic HTTP histogram, alongside your custom business counters.
Reference
Official Metrics Documentation
Production Configuration
For production deployments with BatchSpanProcessor and resource attributes:
import { Module, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_SERVICE_INSTANCE_ID,
} from '@opentelemetry/semantic-conventions';
@Module({})
export class TracingModule implements OnModuleInit, OnModuleDestroy {
private sdk: NodeSDK;
onModuleInit() {
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: {
// Optional: Add authentication for Scout
// 'Authorization': `Bearer ${process.env.SCOUT_API_KEY}`,
},
timeoutMillis: 15000,
});
this.sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME,
[ATTR_SERVICE_VERSION]: process.env.npm_package_version,
'deployment.environment.name': process.env.NODE_ENV,
'environment': process.env.NODE_ENV,
[ATTR_SERVICE_INSTANCE_ID]: process.env.HOSTNAME || process.pid.toString(),
'service.namespace': process.env.SERVICE_NAMESPACE || 'default',
'container.id': process.env.CONTAINER_ID,
'k8s.pod.name': process.env.K8S_POD_NAME,
'k8s.namespace.name': process.env.K8S_NAMESPACE,
}),
spanProcessor: new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
return ['/health', '/metrics', '/ready'].some((path) =>
req.url?.includes(path),
);
},
},
}),
],
});
this.sdk.start();
console.log('OpenTelemetry SDK initialized');
}
async onModuleDestroy() {
console.log('Shutting down OpenTelemetry SDK...');
await this.sdk.shutdown();
}
}
Docker Deployment
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
COPY package*.json ./
ENV NODE_ENV=production
ENV OTEL_SERVICE_NAME=nestjs-api
EXPOSE 3000
CMD ["node", "dist/main.js"]
version: '3.8'
services:
nestjs-api:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- OTEL_SERVICE_NAME=nestjs-api
- OTEL_SERVICE_VERSION=1.0.0
- OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
- DATABASE_URL=postgres://user:pass@postgres:5432/nestjs
- REDIS_URL=redis://redis:6379
depends_on:
- postgres
- redis
- scout-collector
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: nestjs
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
ports:
- '5432:5432'
redis:
image: redis:7-alpine
ports:
- '6379:6379'
scout-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- '4318:4318'
NestJS-Specific Instrumentation
Controllers and Routes
NestJS controllers are automatically instrumented via HTTP instrumentation:
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// Automatically traced as "GET /users"
@Get()
async findAll() {
return this.usersService.findAll();
}
// Automatically traced as "GET /users/:id"
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
// Automatically traced as "POST /users"
@Post()
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
Traces show:
- HTTP method and route pattern
- Response status codes
- Request/response headers (configurable)
- Timing for entire request lifecycle
Services with Dependency Injection
Services are traced when called from instrumented controllers:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
// Database queries automatically traced by TypeORM instrumentation
async findAll(): Promise<User[]> {
return this.usersRepository.find();
}
async findOne(id: number): Promise<User> {
return this.usersRepository.findOne({ where: { id } });
}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
}
TypeORM Database Instrumentation
The SQL that TypeORM issues is traced at the driver level by instrumentation-pg
in the auto-instrumentations bundle (ORM-level spans require the separate
@opentelemetry/instrumentation-typeorm package):
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { Order } from '../../orders/entities/order.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
name: string;
@OneToMany(() => Order, (order) => order.user)
orders: Order[];
}
TypeORM Module configuration:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TracingModule } from './tracing/tracing.module';
@Module({
imports: [
TracingModule,
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
}),
// ... other modules
],
})
export class AppModule {}
Traces show:
- SQL queries with parameters
- Query execution time
- Connection pool metrics
- Transaction boundaries
Guards and Authentication
Guards are traced as part of the request lifecycle:
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { trace } from '@opentelemetry/api';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
async canActivate(context: ExecutionContext): Promise<boolean> {
const tracer = trace.getTracer('auth-guard');
return tracer.startActiveSpan('JwtAuthGuard.canActivate', async (span) => {
try {
const result = (await super.canActivate(context)) as boolean;
span.setAttribute('auth.success', result);
span.setStatus({ code: 1 }); // OK
return result;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
}
}
Interceptors for Custom Tracing
Add custom attributes using interceptors:
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { trace, context } from '@opentelemetry/api';
@Injectable()
export class TracingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const activeSpan = trace.getActiveSpan();
if (activeSpan) {
// Add custom attributes
activeSpan.setAttribute('user.id', request.user?.id);
activeSpan.setAttribute('tenant.id', request.headers['x-tenant-id']);
activeSpan.setAttribute('request.path', request.path);
}
return next.handle().pipe(
tap(() => {
if (activeSpan) {
activeSpan.setAttribute('response.status', 'success');
}
}),
);
}
}
Apply globally:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TracingInterceptor } from './common/interceptors/tracing.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new TracingInterceptor());
await app.listen(3000);
}
bootstrap();
BullMQ Background Jobs
Instrument BullMQ job processing:
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { trace } from '@opentelemetry/api';
@Processor('email')
export class EmailProcessor {
@Process('send-welcome')
async handleWelcomeEmail(job: Job) {
const tracer = trace.getTracer('email-processor');
return tracer.startActiveSpan('EmailProcessor.sendWelcome', async (span) => {
try {
span.setAttributes({
'job.id': job.id.toString(),
'job.name': job.name,
'job.attempts': job.attemptsMade,
'user.email': job.data.email,
});
// Simulate email sending
await this.sendEmail(job.data.email, job.data.name);
span.setStatus({ code: 1 }); // OK
return { sent: true };
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
}
private async sendEmail(email: string, name: string) {
// Email sending logic
console.log(`Sending welcome email to ${email}`);
}
}
Queue module setup:
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { EmailProcessor } from './email.processor';
@Module({
imports: [
BullModule.registerQueue({
name: 'email',
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
},
}),
],
providers: [EmailProcessor],
})
export class JobsModule {}
WebSocket Gateway Instrumentation
Trace WebSocket connections and messages:
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayConnection,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { trace } from '@opentelemetry/api';
@WebSocketGateway({ cors: true })
export class ChatGateway implements OnGatewayConnection {
private tracer = trace.getTracer('chat-gateway');
handleConnection(client: Socket) {
const span = this.tracer.startSpan('ChatGateway.handleConnection');
span.setAttributes({
'websocket.client.id': client.id,
'websocket.event': 'connection',
});
span.end();
}
@SubscribeMessage('message')
async handleMessage(
@MessageBody() data: { room: string; message: string },
@ConnectedSocket() client: Socket,
) {
return this.tracer.startActiveSpan('ChatGateway.handleMessage', async (span) => {
try {
span.setAttributes({
'websocket.client.id': client.id,
'websocket.room': data.room,
'message.length': data.message.length,
});
// Broadcast message to room
client.to(data.room).emit('message', {
sender: client.id,
message: data.message,
});
span.setStatus({ code: 1 });
return { status: 'sent' };
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
}
}
Custom Instrumentation
For business logic and application-specific operations:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { Order } from './entities/order.entity';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class OrdersService {
private tracer = trace.getTracer('orders-service');
constructor(
@InjectRepository(Order)
private ordersRepository: Repository<Order>,
) {}
async createOrder(userId: number, createOrderDto: CreateOrderDto) {
return this.tracer.startActiveSpan('OrdersService.createOrder', async (span) => {
try {
span.setAttributes({
'user.id': userId,
'order.items.count': createOrderDto.items.length,
'order.total': this.calculateTotal(createOrderDto.items),
});
// Validate inventory
await this.tracer.startActiveSpan('validateInventory', async (validateSpan) => {
const available = await this.checkInventory(createOrderDto.items);
validateSpan.setAttribute('inventory.available', available);
if (!available) {
throw new Error('Insufficient inventory');
}
validateSpan.end();
});
// Create order
const order = await this.tracer.startActiveSpan('saveOrder', async (dbSpan) => {
const newOrder = this.ordersRepository.create({
userId,
items: createOrderDto.items,
total: this.calculateTotal(createOrderDto.items),
});
const saved = await this.ordersRepository.save(newOrder);
dbSpan.setAttribute('order.id', saved.id);
dbSpan.end();
return saved;
});
// Process payment
await this.tracer.startActiveSpan('processPayment', async (paymentSpan) => {
await this.processPayment(order.id, order.total);
paymentSpan.setAttribute('payment.status', 'completed');
paymentSpan.end();
});
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
throw error;
} finally {
span.end();
}
});
}
private calculateTotal(items: any[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
private async checkInventory(items: any[]): Promise<boolean> {
// Inventory check logic
return true;
}
private async processPayment(orderId: number, amount: number): Promise<void> {
// Payment processing logic
}
}
Running Your Application
Development Mode
# With console output for debugging
export OTEL_TRACES_EXPORTER=console
npm run start:dev
Production Mode
export NODE_ENV=production
export OTEL_SERVICE_NAME=nestjs-api
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout.yourdomain.com/v1/traces
npm run start:prod
Using PM2
module.exports = {
apps: [
{
name: 'nestjs-api',
script: 'dist/main.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
OTEL_SERVICE_NAME: 'nestjs-api',
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://scout-collector:4318',
},
},
],
};
Start with PM2:
pm2 start ecosystem.config.js
pm2 logs nestjs-api
Troubleshooting
Issue: No Traces from NestJS Controllers
Solutions:
- Ensure TracingModule is imported first in AppModule:
@Module({
imports: [
TracingModule, // MUST be first
TypeOrmModule.forRoot(/*...*/),
// other modules
],
})
export class AppModule {}
- Verify HTTP instrumentation is enabled:
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
enabled: true, // Explicitly enable
},
}),
];
Issue: TypeORM Queries Not Appearing in Traces
Solutions:
- Install TypeORM instrumentation explicitly if needed:
npm install @opentelemetry/instrumentation-typeorm
-
Verify database connection is established after SDK initialization
-
Check TypeORM logging is enabled in development:
TypeOrmModule.forRoot({
// ...
logging: true, // See queries in console
});
Issue: Missing Context in Async Operations
Solutions:
Use async/await instead of callbacks:
// WRONG - loses context
setTimeout(() => {
const span = trace.getActiveSpan(); // undefined
}, 1000);
// CORRECT - preserves context
await new Promise((resolve) => setTimeout(resolve, 1000));
const span = trace.getActiveSpan(); // Works!
Issue: Guard/Interceptor Spans Not Showing
Solutions:
Guards and interceptors need manual span creation. Add custom tracing as shown in the Guards and Interceptors sections above.
Security Considerations
Sensitive Data Protection
Avoid capturing passwords, tokens, and PII in spans:
// BAD - Exposes sensitive data
span.setAttributes({
'user.password': password,
'user.email': email,
'credit_card': cardNumber,
});
// GOOD - Use safe identifiers
span.setAttributes({
'user.id': userId,
'user.type': 'customer',
'payment.method': 'credit_card',
});
HTTP Header Filtering
Configure header filtering to exclude authentication tokens:
'@opentelemetry/instrumentation-http': {
headersToSpanAttributes: {
requestHeaders: ['content-type', 'user-agent'],
responseHeaders: ['content-type'],
},
},
Database Query Sanitization
TypeORM automatically sanitizes parameters, but verify in traces:
// Parameters are automatically sanitized
const user = await this.usersRepository.findOne({
where: { email: userEmail }, // Safe - uses parameterized query
});
Environment Variable Security
Never log sensitive environment variables:
// BAD
console.log('DB_PASSWORD:', process.env.DB_PASSWORD);
// GOOD - Use configuration service
@Injectable()
export class ConfigService {
get(key: string): string {
const value = process.env[key];
if (!value && this.isProduction()) {
throw new Error(`Missing required config: ${key}`);
}
return value;
}
}
Performance Considerations
OpenTelemetry adds minimal overhead to NestJS applications:
Expected Impact:
- Latency: +0.5-2ms per request with auto-instrumentation
- CPU: +2-5% in production with BatchSpanProcessor
- Memory: +15-35MB for trace buffers and SDK
- Throughput: <1% reduction in requests/second
Optimization Best Practices
1. Use BatchSpanProcessor in Production
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
spanProcessor: new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
});
2. Skip Health Check Endpoints
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
return ['/health', '/metrics', '/ready'].some((path) =>
req.url?.includes(path),
);
},
},
3. Disable Filesystem Tracing
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
4. Optimize TypeORM Queries
Use query builder for complex queries to reduce overhead:
// Efficient - single query with joins
const users = await this.usersRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.where('user.active = :active', { active: true })
.getMany();
// Inefficient - N+1 queries
const users = await this.usersRepository.find({ where: { active: true } });
for (const user of users) {
user.orders = await this.ordersRepository.find({ where: { userId: user.id } });
}
FAQ
Does OpenTelemetry work with NestJS dependency injection?
Yes, OpenTelemetry fully supports NestJS DI. TracingModule can be imported and services are automatically traced when called from instrumented controllers.
What's the performance impact on NestJS applications?
With BatchSpanProcessor, expect +0.5-2ms latency per request, +2-5% CPU, and +15-35MB memory. Minimal impact for most production workloads.
Can I trace TypeORM, Prisma, and Sequelize?
The SQL these ORMs issue is traced at the driver level by instrumentation-pg
(or the equivalent driver package) in the auto-instrumentations bundle. ORM-level
spans for TypeORM, Prisma, and Sequelize need their own instrumentation packages,
which are not part of auto-instrumentations-node.
How do I trace BullMQ background jobs?
BullMQ has no dedicated auto-instrumentation package. The Redis commands it
issues are traced by instrumentation-ioredis, so add a manual span in the
processor with trace.getTracer() and propagate trace context through the job
data to link the job to the request that enqueued it. See the
BullMQ guide.
Does OpenTelemetry work with NestJS WebSocket gateways?
WebSocket connections and messages require manual instrumentation. Use
trace.getTracer() in gateway methods as shown in the WebSocket section.
How do I trace GraphQL resolvers?
GraphQL queries are traced via HTTP instrumentation. Add custom spans in resolvers for field-level tracing using decorators or interceptors.
Can I use it with NestJS microservices?
Yes, OpenTelemetry traces distributed microservices automatically. Context propagates across HTTP, gRPC, and message queue boundaries.
How do I handle multi-tenant applications?
Add tenant ID as span attribute in guards or interceptors:
span.setAttribute('tenant.id', tenantId) and filter in Scout Dashboard.
What's the difference between traces and metrics?
Traces show request flow and timing through your NestJS app (spans). Metrics aggregate performance data (counters, histograms). Both are supported.
Can I trace custom decorators and metadata?
Yes, use interceptors or method decorators to add custom spans. Access metadata
using Reflector and add attributes to active spans.
What's Next?
Scout Platform Features
- Creating Alerts - Set up alerts for API latency, errors, and database queries
- Dashboard Creation - Build custom dashboards for NestJS metrics
Deployment and Operations
- Docker Compose Setup - Local development environment with PostgreSQL and Redis
- Kubernetes Helm Setup - Production deployment on Kubernetes
Complete Example
Here's a complete working NestJS application with OpenTelemetry instrumentation:
package.json
{
"name": "nestjs-otel-example",
"version": "1.0.0",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/typeorm": "^10.0.1",
"@nestjs/bull": "^10.0.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
"@opentelemetry/sdk-metrics": "^2.7.1",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@opentelemetry/api": "^1.9.0",
"typeorm": "^0.3.20",
"pg": "^8.11.0",
"bull": "^4.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}
Environment Variables
NODE_ENV=production
OTEL_SERVICE_NAME=nestjs-api
OTEL_SERVICE_VERSION=1.0.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
OTEL_SEMCONV_STABILITY_OPT_IN=http,database
DATABASE_URL=postgres://user:pass@postgres:5432/nestjs
REDIS_URL=redis://redis:6379
OTEL_SEMCONV_STABILITY_OPT_IN=http,database opts the instrumentation into the
stable HTTP and database semantic conventions (for example
http.request.method, http.response.status_code, and db.query.text).
Without it, the instrumentation keeps emitting the older experimental attribute
names (http.method, http.status_code, db.statement). Use http/dup and
database/dup instead to emit both old and new during a migration.
GitHub Repository
Complete working example: GitHub: base-14/examples/nodejs/nestjs-postgres
References
- Official OpenTelemetry Node.js Documentation
- NestJS Documentation
- OpenTelemetry Semantic Conventions
- TypeORM Documentation
Related Guides
- Express Instrumentation - The HTTP layer NestJS runs on by default
- Next.js Instrumentation - Full-stack React framework on Node.js
- Fastify Instrumentation - Alternate NestJS HTTP adapter
- Hono Instrumentation - Lightweight Node.js web framework
- BullMQ Instrumentation - Trace the background jobs NestJS enqueues
- Node.js Custom Instrumentation - Manual spans and advanced patterns
- Docker Compose Setup - Set up the collector for local development
- Kubernetes Helm Setup - Production collector deployment
- All framework guides - Auto-instrumentation overview for every language