Skip to main content

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.

TL;DR

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
  • Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
  • Familiarity with NestJS dependency injection and decorators

Compatibility Matrix

ComponentMinimum VersionRecommended Version
Node.js18.0.020.x LTS
NestJS9.0.010.3.0+
@opentelemetry/sdk-node0.40.00.200+
@opentelemetry/auto-inst...0.40.00.76+
TypeORM (if used)0.3.00.3.20+
BullMQ (if used)4.0.05.x
@nestjs/websockets (optional)10.0.010.3.0+
TypeScript4.9.05.3.0+

Installation

Install the OpenTelemetry SDK and auto-instrumentation packages:

Install OpenTelemetry for NestJS
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

Create a NestJS module for OpenTelemetry initialization:

src/tracing/tracing.module.ts
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:

src/app.module.ts
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 {}

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/await and RxJS observables

View traces in your base14 Scout dashboard to follow request flows and find the slow span in a chain.

Reference

Official Traces Documentation

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:

src/tracing/tracing.module.ts
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:

src/articles/articles.service.ts
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:

src/tracing/tracing.production.ts
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

Dockerfile
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 --from=builder /app/dist ./dist
COPY --from=builder /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"]
docker-compose.yml
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:

src/users/users.controller.ts
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:

src/users/users.service.ts
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):

src/users/entities/user.entity.ts
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:

src/app.module.ts
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:

src/auth/guards/jwt-auth.guard.ts
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:

src/common/interceptors/tracing.interceptor.ts
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:

src/main.ts
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:

src/jobs/email.processor.ts
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:

src/jobs/jobs.module.ts
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:

src/chat/chat.gateway.ts
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:

src/orders/orders.service.ts
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

ecosystem.config.js
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:

  1. Ensure TracingModule is imported first in AppModule:
@Module({
imports: [
TracingModule, // MUST be first
TypeOrmModule.forRoot(/*...*/),
// other modules
],
})
export class AppModule {}
  1. Verify HTTP instrumentation is enabled:
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
enabled: true, // Explicitly enable
},
}),
];

Issue: TypeORM Queries Not Appearing in Traces

Solutions:

  1. Install TypeORM instrumentation explicitly if needed:
npm install @opentelemetry/instrumentation-typeorm
  1. Verify database connection is established after SDK initialization

  2. 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

Deployment and Operations

Complete Example

Here's a complete working NestJS application with OpenTelemetry instrumentation:

package.json

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

.env.production
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

Was this page helpful?