@prelamelo/nest-otel
v0.2.2
Published
OpenTelemetry distributed tracing library for NestJS microservices (HTTP + RabbitMQ)
Maintainers
Readme
@prelamelo/nest-otel
OpenTelemetry distributed tracing for NestJS microservices — HTTP, RabbitMQ, TCP, NATS, and Redis transports out of the box.
Features
- Zero-boilerplate global tracing — one module import traces every controller and message handler automatically
- Distributed context propagation — W3C
traceparentextracted from HTTP headers and RPC payloads, with validation and size limits - Full OTel semantic conventions — HTTP method, route, status code, user agent, peer address; RPC messaging system and destination
@Span()method decorator — fine-grained spans on any method with optionalattributesandmapArgsfor safe data captureTracingClientProxy— drop-inClientProxywrapper that injects trace headers into every outgoing RPC messageinitTracing()SDK helper — one-line OTLP setup with env-var fallbacks, graceful shutdown hooks, and opt-in instrumentation plugins- Security-first — traceparent W3C format validation, header count and size limits,
trustExternalContext: falsemode for public gateways - Trace-correlated logging — optional
LokiLoggerModuleships Pino logs to Grafana Loki, stamping every line with the activetrace_id/span_id
Contents
- Installation
- Quick Start
- Step 1 — SDK Setup
- Step 2 — Global Interceptor
- Step 3 — Outbound RPC Tracing
- @Span() Decorator
- Logging to Loki
- Transport Support
- Configuration Reference
- Local Development Stack
- Security
- API Reference
Installation
# Required peer dependencies
pnpm add @prelamelo/nest-otel \
@opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventionsPublished on the public npm registry — see npmjs.com/package/@prelamelo/nest-otel. No extra registry config needed.
Quick Start
HTTP service (2 files)
src/main.ts — tracing must be initialised before NestJS boots:
// ⚠️ These two lines MUST come before any other import
import { initTracing } from '@prelamelo/nest-otel/sdk'
initTracing({ serviceName: process.env.SERVICE_NAME ?? 'my-service' })
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
bootstrap()src/app.module.ts — register the global interceptor:
import { Module } from '@nestjs/common'
import { TraceModule } from '@prelamelo/nest-otel'
@Module({
imports: [TraceModule.forRoot()],
})
export class AppModule {}That's it. Every incoming HTTP request now gets a span named ControllerName.methodName with HTTP attributes, status code, and error recording.
Step 1 — SDK Setup (initTracing)
initTracing() must be the very first code that runs in main.ts — before NestJS, before any module import — so the OTel SDK is active when providers are instantiated.
import { initTracing } from '@prelamelo/nest-otel/sdk'
initTracing({
serviceName: 'orders-service',
serviceVersion: '1.2.0', // default: process.env.npm_package_version
environment: 'production', // default: process.env.NODE_ENV
exporterUrl: 'http://otel-collector:4318/v1/traces', // default: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
shutdownTimeoutMs: 5_000, // default: 5000
})All fields except serviceName fall back to environment variables — most services only need:
initTracing({ serviceName: process.env.SERVICE_NAME ?? 'my-service' })Adding instrumentation plugins
Install only the plugins for the libraries your service actually uses:
import { initTracing } from '@prelamelo/nest-otel/sdk'
import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
initTracing({
serviceName: 'orders-service',
instrumentations: [
new MongoDBInstrumentation(), // traces every Mongoose / MongoDB query
new HttpInstrumentation(), // traces outbound axios / fetch calls
],
})| Plugin package | What it traces |
|---|---|
| @opentelemetry/instrumentation-mongodb | MongoDB queries, Mongoose ops |
| @opentelemetry/instrumentation-http | Outbound HTTP / axios calls |
| @opentelemetry/instrumentation-nestjs-core | Guards, pipes, interceptors |
| @opentelemetry/auto-instrumentations-node | Everything (use for prototyping) |
Step 2 — Global Interceptor (TraceModule)
TraceModule.forRoot() registers TraceInterceptor as a global APP_INTERCEPTOR. Import it once in your root AppModule.
Sync config
import { TraceModule } from '@prelamelo/nest-otel'
@Module({
imports: [TraceModule.forRoot()],
})
export class AppModule {}Async config (from ConfigService)
import { TraceModule } from '@prelamelo/nest-otel'
import { ConfigModule, ConfigService } from '@nestjs/config'
@Module({
imports: [
ConfigModule.forRoot(),
TraceModule.forRootAsync({
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({
trustExternalContext: cfg.get<boolean>('TRUST_EXTERNAL_TRACE', true),
messagingSystem: cfg.get<string>('MESSAGING_SYSTEM', 'rabbitmq'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}What the interceptor records automatically
HTTP requests — span named Controller.handler:
| Attribute | Example |
|---|---|
| http.request.method | GET |
| url.path | /api/orders/123 |
| http.route | /api/orders/:id |
| http.response.status_code | 200 |
| user_agent.original | Mozilla/5.0 … |
| network.peer.address | 10.0.0.5 |
RPC handlers (@MessagePattern, @EventPattern) — span with:
| Attribute | Example |
|---|---|
| messaging.system | rabbitmq |
| messaging.operation.type | receive |
| messaging.destination.name | handleOrderCreated |
On error — SpanStatusCode.ERROR + exception.type and exception.stacktrace attributes.
TraceInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
| trustExternalContext | boolean | true | Extract W3C context from incoming headers. Set false for public-facing gateways that must always start a new root span. |
| messagingSystem | string | 'rabbitmq' | Value for the messaging.system attribute on RPC spans. |
Step 3 — Outbound RPC Tracing (TracingClientProxy)
To propagate trace context from a producer to a consumer, wrap your ClientProxy with TracingClientProxy. Every emit / send call automatically injects the current W3C traceparent into the payload.
import { Module } from '@nestjs/common'
import { Transport } from '@nestjs/microservices'
import { TracingClientProxy } from '@prelamelo/nest-otel'
@Module({
providers: [
{
provide: 'ORDER_SERVICE',
useFactory: () =>
new TracingClientProxy({
transport: Transport.RMQ,
options: {
urls: ['amqp://localhost:5672'],
queue: 'order_queue',
queueOptions: { durable: true },
},
}),
},
],
exports: ['ORDER_SERVICE'],
})
export class OrdersModule {}@Injectable()
export class OrdersService {
constructor(@Inject('ORDER_SERVICE') private readonly client: TracingClientProxy) {}
async placeOrder(dto: CreateOrderDto) {
// traceparent is injected into the payload automatically — no manual work
return this.client.emit('order.created', { id: dto.id, userId: dto.userId })
}
}On the consumer side, TraceModule / TraceInterceptor extracts the injected _headers field and continues the distributed trace automatically:
[orders-service] POST /orders
└── OrdersController.createOrder
└── [notification-service] order.created handler ← linked via traceparent
└── MongoDB insert span (MongoDBInstrumentation)@Span() Decorator
Use @Span() to wrap any method in a child span — service-layer operations, repository calls, or any business logic you want to observe independently.
Basic usage
import { Span } from '@prelamelo/nest-otel'
@Injectable()
export class OrdersService {
@Span()
async findOrder(id: string): Promise<Order> {
// span name defaults to method name: "findOrder"
return this.repo.findById(id)
}
@Span('order.create')
async createOrder(dto: CreateOrderDto): Promise<Order> {
// explicit span name
return this.repo.save(dto)
}
}Static attributes
Attach fixed labels to every invocation — component name, layer, feature flag:
@Span({ attributes: { component: 'order-repo', 'db.system': 'postgresql' } })
async findAll(): Promise<Order[]> {
return this.repo.findAll()
}Dynamic attributes from arguments
mapArgs receives the raw argument list and returns span attributes. Use it to capture safe, non-sensitive identifiers:
@Span({
name: 'order.find',
mapArgs: ([id]) => ({ 'order.id': id as string }),
})
async findOrder(id: string): Promise<Order> { ... }
// Pick multiple safe fields from a DTO
@Span({
mapArgs: ([dto]) => ({
'user.id': (dto as CreateOrderDto).userId,
'order.currency': (dto as CreateOrderDto).currency,
}),
})
async createOrder(dto: CreateOrderDto): Promise<Order> { ... }
// Combine static + dynamic
@Span({
attributes: { component: 'order-svc' },
mapArgs: ([dto]) => ({ 'order.type': (dto as CreateOrderDto).type }),
})
async processOrder(dto: CreateOrderDto): Promise<void> { ... }Security:
mapArgsis opt-in by design — no argument data is ever recorded unless you return it. IfmapArgsthrows, the error is silently swallowed and your handler continues normally.
SpanOptions
| Option | Type | Default | Description |
|---|---|---|---|
| name | string | method name | Custom span name |
| attributes | Attributes | — | Static key-value attributes on every invocation |
| mapArgs | (args: readonly unknown[]) => Attributes \| null \| undefined | — | Dynamic attributes from method arguments. Errors are swallowed. |
Logging to Loki (LokiLoggerModule)
An optional, drop-in Pino logger that ships structured logs straight to Grafana
Loki — and stamps every line with the active trace_id / span_id, so a log
in Loki links back to its trace in Tempo/Jaeger.
It lives in a separate entry point (@prelamelo/nest-otel/logger) so the core
tracing import never pulls in Pino. Install the logger peers only if you use it:
pnpm add nestjs-pino pino pino-loki
pnpm add -D pino-pretty # for pretty local logs
pino-prettyat runtime: if you enableprettyanywhere other than local dev (e.g. a staging build installed with--omit=dev), installpino-prettyas a regular dependency — pino loads it in a worker at runtime, so a dev-only install crashes at boot.
Quick start
src/app.module.ts — register the module:
import { Module } from '@nestjs/common'
import { LokiLoggerModule } from '@prelamelo/nest-otel/logger'
@Module({
imports: [
LokiLoggerModule.forRoot({
name: 'orders-api',
pretty: process.env.NODE_ENV !== 'production',
loki: {
host: process.env.LOKI_HOST ?? 'http://localhost:3100',
labels: { app: 'orders-api', env: process.env.NODE_ENV ?? 'dev' },
},
}),
],
})
export class AppModule {}src/main.ts — make it the application logger:
import { NestFactory } from '@nestjs/core'
import { Logger, LoggerErrorInterceptor } from '@prelamelo/nest-otel/logger'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true })
app.useLogger(app.get(Logger))
app.useGlobalInterceptors(new LoggerErrorInterceptor()) // logs full error stacks
await app.listen(3000)
}
bootstrap()HTTP requests are now logged automatically, and Logger / PinoLogger is
injectable anywhere.
Logging from a provider
import { Injectable } from '@nestjs/common'
import { InjectPinoLogger, PinoLogger } from '@prelamelo/nest-otel/logger'
@Injectable()
export class OrdersService {
constructor(
@InjectPinoLogger(OrdersService.name) private readonly logger: PinoLogger,
) {}
place(orderId: string) {
this.logger.info('order placed', { orderId }) // message first
this.logger.info({ orderId }, 'order placed') // pino-native order — also works
}
fail(orderId: string, err: unknown) {
this.logger.error('order failed', { err, orderId }) // `err` is serialized with its stack
}
}Message-first argument order
By default the logger accepts the message first
(logger.info('order placed', { orderId })), while still honouring
pino's native (object, message) order. A pino logMethod hook reorders the
arguments before serialisation, so the structured object always lands as real
log fields (never dropped as an unused printf argument) — including for
context-bound loggers created with @InjectPinoLogger() / setContext().
Pass messageFirst: false to LokiLoggerModule.forRoot(...) to keep pino's
native order exclusively (e.g. if you rely on %o / %j printf interpolation
of objects).
Async config (from ConfigService)
LokiLoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
name: cfg.get('SERVICE_NAME'),
loki: {
host: cfg.get('LOKI_HOST'), // must be https:// when basicAuth is set
basicAuth: { username: cfg.get('LOKI_USER'), password: cfg.get('LOKI_TOKEN') },
labels: { app: cfg.get('SERVICE_NAME'), env: cfg.get('NODE_ENV') },
},
}),
})
forRootAsyncalso acceptsproviders, so you caninjecta token declared locally (not only ones exported by an imported module).
Redaction & transport security
The logger is safe by default:
Credential headers are redacted automatically. With
autoLoggingon, pino-http serialises full request/response headers. By default the module redactsauthorization,cookie,set-cookie,x-api-key, andproxy-authorization(replaced with[Redacted]) so secrets never reach Loki. Override with your ownredactpaths, or passredact: []to opt out:LokiLoggerModule.forRoot({ redact: ['req.headers.authorization', 'req.body.password'], // your own set // redact: [], // opt out entirely loki: { host: process.env.LOKI_HOST }, })basicAuthrequires HTTPS. pino-loki sendsAuthorization: Basic …on every push; base64 is not encryption. The module throws at config time ifloki.basicAuthis combined with a non-https://host, rather than leaking credentials over cleartext.loki.silenceErrorsdefaults totrue. A flaky/unreachable Loki can never crash the app — but push failures are dropped silently. Setloki: { silenceErrors: false }to surface them while debugging.
Pairs with TraceModule
Use both together and the correlation is automatic — TraceModule creates the
span, LokiLoggerModule copies its trace_id / span_id onto every log line:
@Module({
imports: [
TraceModule.forRoot(),
LokiLoggerModule.forRoot({ loki: { host: process.env.LOKI_HOST } }),
],
})
export class AppModule {}In Grafana, add a Derived Field on the Loki data source (regex
"trace_id":"(\w+)") to jump straight from a log line to its trace. Trace ids
live in the log body, never as Loki labels, to avoid high-cardinality blow-ups.
LokiLoggerModuleOptions
| Option | Type | Default | Description |
|---|---|---|---|
| loki | LokiConnectionOptions | — | Loki connection. Omit to log to the console only (great for local dev). |
| loki.host | string | http://localhost:3100 | Loki push API base URL. |
| loki.labels | Record<string,string> | — | Static, low-cardinality stream labels (app, env, version). |
| loki.basicAuth | { username, password } | — | Auth for secured Loki / Grafana Cloud. Requires an https:// host (throws otherwise). |
| loki.batching | boolean | true | Batch lines and flush on an interval. |
| loki.interval | number (seconds) | 5 | Flush interval when batching. |
| loki.silenceErrors | boolean | true | Swallow Loki push failures (never crash the app). Set false to surface them. |
| level | LogLevel | info | Minimum level (fatal…trace, silent). |
| name | string | — | Service name added to every line. |
| pretty | boolean | false | Human-readable console output via pino-pretty. |
| stdout | boolean | false | Also emit JSON to stdout (for an external scraper like Alloy/Promtail). |
| traceCorrelation | boolean | true | Add OTel trace_id / span_id to each line. |
| autoLogging | boolean | true | Auto-log incoming HTTP requests. |
| messageFirst | boolean | true | Accept message-first logger.info('msg', { data }) order (pino-native order still works). Set false to disable. |
| redact | string[] | RedactOptions | safe header defaults | Redact sensitive fields. Defaults to credential headers; pass your own paths, or [] to opt out. |
| pinoHttp | pino-http options | — | Escape hatch merged last — overrides anything above. |
If neither
loki,prettynorstdoutis set, the logger auto-selects: pretty output on a TTY whenpino-prettyis installed, plain JSON stdout otherwise (so the zero-config path never crashes on a missing peer).
Transport Support
| Transport | Inbound tracing | Outbound tracing |
|---|---|---|
| HTTP (Express / Fastify) | ✅ automatic | — |
| RabbitMQ (RMQ) | ✅ automatic | ✅ TracingClientProxy |
| TCP | ✅ automatic | ✅ TracingClientProxy |
| Redis | ✅ automatic | ✅ TracingClientProxy |
| NATS | ✅ automatic | ✅ TracingClientProxy |
| Kafka | ⚠️ JSON payload only | ⚠️ JSON payload only |
| gRPC | ❌ needs gRPC metadata | ❌ needs gRPC metadata |
All JSON-based transports carry trace context inside a _headers field in the payload body. The field is automatically stripped before your handler receives the data — your @Payload() DTOs stay clean.
Configuration Reference
Environment variables
| Variable | Used by | Purpose |
|---|---|---|
| OTEL_EXPORTER_OTLP_ENDPOINT | initTracing() | Collector URL (default http://localhost:4318/v1/traces) |
| NODE_ENV | initTracing() | deployment.environment resource attribute |
| npm_package_version | initTracing() | service.version resource attribute |
initTracing(config) — full options
| Field | Type | Default | Description |
|---|---|---|---|
| serviceName | string | required | service.name shown in Jaeger / Tempo / Datadog |
| serviceVersion | string | npm_package_version \|\| '0.0.0' | service.version resource attribute |
| environment | string | NODE_ENV \|\| 'development' | deployment.environment resource attribute |
| exporterUrl | string | OTEL_EXPORTER_OTLP_ENDPOINT \|\| 'http://localhost:4318/v1/traces' | OTLP HTTP collector URL |
| shutdownTimeoutMs | number | 5000 | Hard-kill timeout if graceful shutdown stalls |
| instrumentations | Instrumentation[] | [] | OTel instrumentation plugins |
Local Development Stack
Spin up an OTel Collector + Jaeger UI with one command:
docker compose -f docker/docker-compose.yml up -d| URL | Purpose |
|---|---|
| http://localhost:16686 | Jaeger UI — browse and search traces |
| http://localhost:4318/v1/traces | OTLP HTTP — where your services send spans |
| http://localhost:4317 | OTLP gRPC — alternative SDK endpoint |
| http://localhost:8888/metrics | Collector self-metrics (Prometheus) |
The default exporterUrl in initTracing() already points to :4318, so no code changes are needed for local development.
NestJS service → OTLP HTTP :4318 → OTel Collector → Jaeger UI :16686Security
W3C traceparent validation
All incoming traceparent values are validated against the W3C format (00-<32hex>-<16hex>-<2hex>) before extraction. Invalid values are silently dropped.
RPC header size limits
The _headers carrier in RPC payloads is sanitised before context extraction:
| Limit | Value | |---|---| | Maximum header entries | 16 | | Maximum key length | 64 bytes | | Maximum value length | 512 bytes |
Public-facing gateways
Set trustExternalContext: false so every request starts a fresh root span — external callers cannot inject trace context into your internal spans:
TraceModule.forRoot({ trustExternalContext: false })Preventing PII leakage with mapArgs
mapArgs is opt-in by design — no argument data is ever recorded unless you explicitly return it from the function. Never map passwords, tokens, emails, or personal identifiers:
// ✅ Safe — only non-sensitive identifiers
@Span({ mapArgs: ([dto]) => ({ 'user.id': (dto as CreateUserDto).userId }) })
// ❌ Never do this
@Span({ mapArgs: ([dto]) => ({ password: (dto as any).password }) })API Reference
@prelamelo/nest-otel
export { TraceModule } // DynamicModule — registers global APP_INTERCEPTOR
export { TraceInterceptor } // NestInterceptor — HTTP + RPC span creation
export { TracingClientProxy } // ClientProxy wrapper — injects _headers on emit/send
export { Span } // Method decorator — wraps a method in a child span
export { getTracer } // Returns the library's OTel Tracer instance
export { sanitizeTraceCarrier } // W3C carrier sanitization utility
export { TRACE_HEADERS_FIELD } // Constant: '_headers'
export type { TraceModuleOptions }
export type { TraceModuleAsyncOptions }
export type { TraceInterceptorOptions }
export type { SpanOptions }@prelamelo/nest-otel/sdk
export { initTracing } // Initialises NodeSDK + SIGINT/SIGTERM shutdown hooks
export type { TracingConfig }@prelamelo/nest-otel/logger
export { LokiLoggerModule } // DynamicModule — Pino logging to Loki (forRoot / forRootAsync)
export { buildPinoParams } // Builds nestjs-pino Params from LokiLoggerModuleOptions
export { traceContextMixin } // pino mixin: active OTel trace_id / span_id
// Re-exported from nestjs-pino for convenience
export { Logger, PinoLogger, InjectPinoLogger, LoggerErrorInterceptor, getLoggerToken }
export type { LokiLoggerModuleOptions }
export type { LokiLoggerModuleAsyncOptions }
export type { LokiConnectionOptions }
export type { LogLevel }License
MIT © Prelamelo
