nestjs-otel
v8.1.0
Published
NestJS OpenTelemetry Library
Maintainers
Readme
NestJS OpenTelemetry (OTEL)
Description
OpenTelemetry module for Nest.
Why
Setting up observability metrics with nestjs requires multiple libraries and patterns. OpenTelemetry has support for multiple exporters and types of metrics such as Prometheus Metrics.
Observability
Please read this comprehensive whitepaper if that's your first time working with metrics, tracing, and logs.

Examples
A full working examples are available. This includes a nestjs application fully integrated with prometheus, grafana, loki and tempo:
Installation
npm i nestjs-otel @opentelemetry/sdk-node --saveSetup
Some peers dependencies are required when some configurations are enabled.
@opentelemetry/exporter-prometheus- Create tracing file (
tracing.ts):
import {
CompositePropagator,
W3CTraceContextPropagator,
W3CBaggagePropagator,
} from '@opentelemetry/core';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { JaegerPropagator } from '@opentelemetry/propagator-jaeger';
import { B3Propagator } from '@opentelemetry/propagator-b3';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import * as process from 'process';
const otelSDK = new NodeSDK({
metricReader: new PrometheusExporter({
port: 8081,
}),
spanProcessor: new BatchSpanProcessor(new JaegerExporter()),
contextManager: new AsyncLocalStorageContextManager(),
textMapPropagator: new CompositePropagator({
propagators: [
new JaegerPropagator(),
new W3CTraceContextPropagator(),
new W3CBaggagePropagator(),
new B3Propagator(),
],
}),
instrumentations: [getNodeAutoInstrumentations()],
});
export default otelSDK;
// You can also use the shutdown method to gracefully shut down the SDK before process shutdown
// or on some operating system signal.
process.on('SIGTERM', () => {
otelSDK
.shutdown()
.then(
() => console.log('SDK shut down successfully'),
err => console.log('Error shutting down SDK', err)
)
.finally(() => process.exit(0));
});- Import the metric file and start otel node SDK:
import otelSDK from './tracing';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino';
async function bootstrap() {
// Start SDK before nestjs factory create
await otelSDK.start();
const app = await NestFactory.create(AppModule);
app.useLogger(app.get(Logger));
await app.listen(3000);
}
bootstrap();- Configure nest-otel:
3.1. With forRoot:
const OpenTelemetryModuleConfig = OpenTelemetryModule.forRoot({
metrics: {
hostMetrics: true, // Includes Host Metrics
},
});
@Module({
imports: [OpenTelemetryModuleConfig],
})
export class AppModule {}3.2. With forRootAsync:
OpenTelemetryModule.forRootAsync({
useClass: OtelConfigService
});import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { OpenTelemetryOptionsFactory, OpenTelemetryModuleOptions } from 'nestjs-otel';
@Injectable()
export class OtelConfigService implements OpenTelemetryOptionsFactory {
private readonly logger = new Logger(OtelConfigService.name)
constructor(private configService: ConfigService) {}
createOpenTelemetryOptions(): Promise<OpenTelemetryModuleOptions> | OpenTelemetryModuleOptions {
const { hostMetrics } = this.configService.get('otel')
return {
metrics: {
hostMetrics: hostMetrics.enabled,
},
};
}
}Span Decorator
If you need, you can define a custom Tracing Span for a method. It works async or sync.
Span optionally takes one or both of the following parameters:
name- explicit name of the span; if omitted, it is derived as<class-name>.<method-name>.options-SpanOptionsto customize the span options.
You can also supply a function as the options argument. It will be called with the decorated method's arguments, so you can dynamically customize the span options.
import { Span } from 'nestjs-otel';
export class BooksService {
// span.name == 'CRITICAL_SECTION'
@Span('CRITICAL_SECTION')
async getBooks() {
return [`Harry Potter and the Philosopher's Stone`];
}
// span.name == 'BooksService.getBooksAgain'
@Span()
async getBooksAgain() {
return [`Harry Potter and the Philosopher's Stone`];
}
// explicitly set span options
@Span('getBook', { kind: SpanKind.SERVER })
async getBook(id: number) {
// ...
}
// options are set dynamically based on the id parameter
@Span('getBook', (id) => ({ attributes: { bookId: id } }))
async getBookAgain(id: number) {
// ...
}
// same as above, but span name is omitted and inferred automatically
@Span((id) => ({ attributes: { bookId: id } }))
async getBookOnceMore(id: number) {
// ...
}
// Capture return value as attribute
// Note: Explicitly type the result to ensure type safety
@Span({
onResult: (result: string[]) => ({ attributes: { 'book.count': result.length } }),
})
async getBooks() {
return ['Book 1', 'Book 2'];
}
}
Traceable Decorator
If you want to trace all methods in a class, you can use the @Traceable decorator.
import { Traceable } from 'nestjs-otel';
@Injectable()
@Traceable()
export class UsersService {
findAll() {
// This method will be automatically traced
return [];
}
findOne(id: string) {
// This method will also be automatically traced
return {};
}
}Current Span Decorator
You can access the current span in your controllers using the @CurrentSpan decorator.
Note: This decorator only works in Controllers, Resolvers, and Gateways where NestJS handles argument injection. It does not work in standard service-to-service calls.
import { Controller, Get } from '@nestjs/common';
import { Span } from '@opentelemetry/api';
import { CurrentSpan } from 'nestjs-otel';
@Controller('cats')
export class CatsController {
@Get()
findAll(@CurrentSpan() span: Span) {
if (span) {
span.setAttribute('custom.attribute', 'value');
}
return 'This action returns all cats';
}
}Baggage Decorator
You can access the OpenTelemetry Baggage (Distributed Context) in your controllers using the @Baggage decorator.
import { Controller, Get } from '@nestjs/common';
import { Baggage } from 'nestjs-otel';
@Controller('cats')
export class CatsController {
@Get()
findAll(@Baggage('tenant-id') tenantId: string) {
console.log('Tenant ID:', tenantId);
return 'This action returns all cats';
}
}Tracing Service
In case you need to access native span methods for special logics in the method block:
import { TraceService } from 'nestjs-otel';
@Injectable()
export class BookService {
constructor(private readonly traceService: TraceService) {}
@Span()
async getBooks() {
const currentSpan = this.traceService.getSpan(); // --> retrives current span, comes from http or @Span
await this.doSomething();
currentSpan.addEvent('event 1');
currentSpan.end(); // current span end
const span = this.traceService.startSpan('sub_span'); // start new span
span.setAttributes({ userId: 1 });
await this.doSomethingElse();
span.end(); // new span ends
return [`Harry Potter and the Philosopher's Stone`];
}
}Wide Events
Wide events (also known as canonical log lines) emit one context-rich event per request, with attributes accumulated across the whole request lifecycle. See A Practitioner's Guide to Wide Events for the pattern.
This library implements the pattern on top of OpenTelemetry: the WideEventInterceptor opens an attribute bag per request and, when the request finishes, flushes everything onto a single span — marking it with nestjs_otel.wide_event = true so you can filter wide-event spans in your backend. The WideEventService lets any provider enrich that bag from anywhere in the request's async call chain — no request-scoped injection needed.
By default the attributes land on the best-available recording span at flush time (the HTTP root on Express / plain Fastify, or the active handler span when an instrumentation such as @opentelemetry/instrumentation-nestjs-core or @fastify/otel nests a span per request phase). To guarantee they land on the trace root span regardless of instrumentation nesting, register the WideEventSpanProcessor.
- Register the interceptor globally:
import { APP_INTERCEPTOR } from '@nestjs/core';
import { OpenTelemetryModule, WideEventInterceptor } from 'nestjs-otel';
@Module({
imports: [OpenTelemetryModule.forRoot()],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: WideEventInterceptor,
},
],
})
export class AppModule {}The example above opens a wide event for every request in the application. To limit wide events to specific controllers instead, skip the APP_INTERCEPTOR provider and apply the interceptor directly with @UseInterceptors:
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { WideEventInterceptor } from 'nestjs-otel';
@Controller('checkout')
@UseInterceptors(WideEventInterceptor)
export class CheckoutController {
// every route in this controller now emits a wide event
}- Enrich the event from anywhere in the request:
import { WideEventService } from 'nestjs-otel';
@Injectable()
export class CheckoutService {
constructor(private readonly wideEvent: WideEventService) {}
async checkout(cart: Cart) {
this.wideEvent.setMany({
'user.id': cart.userId,
'cart.items': cart.items.length,
'cart.total': cart.total,
});
const stopTimer = this.wideEvent.startTimer('payment.duration_ms');
await this.paymentGateway.charge(cart);
stopTimer();
this.wideEvent.increment('db.queries');
}
}All accumulated attributes land on a single span as one wide event:
GET /checkout
├── nestjs_otel.wide_event: true
├── code.function.name: CheckoutController.checkout
├── user.id: u-123
├── cart.items: 3
├── cart.total: 42.5
├── payment.duration_ms: 132.7
├── db.queries: 1
├── error.type: PaymentDeclinedError (set automatically on errors)
└── error.message: card declined (set automatically on errors)Notes:
- The flushed span is marked
nestjs_otel.wide_event = true. This marker is set last, so a handler-provided field can never overwrite it. - The interceptor seeds
code.function.name(<Controller>.<handler>) automatically and recordserror.type/error.message(pluserror.stackwhen present) when the handler throws. WideEventServicemethods are safe no-ops outside a request handled by the interceptor.- An async-context-aware context manager is required (the default with
NodeSDK/AsyncLocalStorageContextManager), same as for tracing in general.
Seeding baseline attributes
Use the seed option to populate baseline attributes on every request (e.g. ids derived from the authenticated request). It runs after guards, so req.user is available. A throwing seed never breaks the request — the error is recorded under wide_event.seed.error.
OpenTelemetryModule.forRoot({
wideEvents: {
seed: (ctx) => {
const req = ctx.switchToHttp().getRequest();
return {
'app.version': process.env.BUILD_SHA,
'user.id': req.user?.id,
};
},
},
});Static values (version, region, ...) are often better modeled as OpenTelemetry Resource attributes; reserve seed for per-request derivations.
@WideEventField decorator
Capture a method's return value (resolved value for async methods) onto the current wide event without calling the service manually. Works on any provider method that runs within the request's async context. A failing projection or a non-attribute value is silently skipped.
import { WideEventField } from 'nestjs-otel';
@Injectable()
export class BooksService {
// records `books.count` = result.length
@WideEventField('books.count', (books: string[]) => books.length)
async getBooks() {
return ['Book 1', 'Book 2'];
}
}Targeting the root span (WideEventSpanProcessor)
Instrumentations like @opentelemetry/instrumentation-nestjs-core and @fastify/otel wrap each request phase (middleware, guard, handler) in its own span. As a result, the span active when the interceptor runs is a nested child, not the trace root — so by default the wide event lands on that child span (e.g. AppController.handler) rather than on the root GET /route.
Register the WideEventSpanProcessor on your NodeSDK to make the interceptor flush onto the local-root span (the span with no in-process parent — normally the HTTP server span) instead:
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { WideEventSpanProcessor } from 'nestjs-otel';
export const otelSDK = new NodeSDK({
spanProcessors: [
new WideEventSpanProcessor(),
new BatchSpanProcessor(traceExporter),
],
// ...instrumentations, contextManager, etc.
});The processor only tracks which span is the local root per trace; it never exports spans, so keep your existing exporting processor in the list. It is optional — without it, wide events still flush onto the best-available recording span (see above), just not necessarily the trace root.
Metric Service
OpenTelemetry Metrics allow a user to collect data and export it to metrics backend like Prometheus.
import { MetricService } from 'nestjs-otel';
import { Counter } from '@opentelemetry/api';
@Injectable()
export class BookService {
private customMetricCounter: Counter;
constructor(private readonly metricService: MetricService) {
this.customMetricCounter = this.metricService.getCounter('custom_counter', {
description: 'Description for counter',
});
}
async getBooks() {
this.customMetricCounter.add(1);
return [`Harry Potter and the Philosopher's Stone`];
}
}Metric Decorators
Metric Class Instances
If you want to count how many instance of a specific class has been created:
@OtelInstanceCounter() // It will generate a counter called: app_MyClass_instances_total.
export class MyClass {}Metric Class Method
If you want to increment a counter on each call of a specific method:
@Injectable()
export class MyService {
@OtelMethodCounter()
doSomething() {}
}
@Controller()
export class AppController {
@Get()
@OtelMethodCounter() // It will generate `app_AppController_doSomething_calls_total` counter.
doSomething() {
// do your stuff
}
}Metric Param Decorator
You have the following decorators:
@OtelCounter()@OtelUpDownCounter()@OtelHistogram()@OtelGauge()@OtelObservableGauge()@OtelObservableCounter()@OtelObservableUpDownCounter()
Example of usage:
import { OtelCounter } from 'nestjs-otel';
import { Counter } from '@opentelemetry/api';
@Controller()
export class AppController {
@Get('/home')
home(
@OtelCounter('app_counter_1_inc', { description: 'counter 1 description' }) counter1: Counter
) {
counter1.add(1);
}
}Prometheus Metrics
When metricExporter is defined in otel SDK with a PrometheusExporterit will start a new process on port 8081 (default port) and metrics will be available at http://localhost:8081/metrics.
Using with a logger
Pino with instrumentation
This approach uses otel instrumentation to automatically inject spanId and traceId.
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const otelSDK = new NodeSDK({
instrumentations: [new PinoInstrumentation()],
});Pino with custom formatter
This approach uses the global trace context for injecting SpanId and traceId as a property of your structured log.
import Pino, { Logger } from 'pino';
import { LoggerOptions } from 'pino';
import { trace, context } from '@opentelemetry/api';
export const loggerOptions: LoggerOptions = {
formatters: {
log(object) {
const span = trace.getSpan(context.active());
if (!span) return { ...object };
const { spanId, traceId } = trace.getSpan(context.active())?.spanContext();
return { ...object, spanId, traceId };
},
},
};
export const logger: Logger = Pino(loggerOptions);