Observability SDK for NestJS applications, built on top of the OpenTelemetry Node.js SDK.
From a single configuration, the SDK provides:
- OpenTelemetry SDK initialization (HTTP, Express, Kafka, database, and other supported library auto-instrumentation) and export of logs, traces, and metrics via OTLP.
- Automatic trace-id and correlation-id propagation on HTTP requests and
consumed events, reflected in response headers (
x-trace-id,x-correlation-id). -
TraceLogger, a NestJSLoggerServicethat correlates every log emitted by the application (and by the framework itself) to the active trace/correlation-id, and redacts configured sensitive fields (headers, request/event bodies, any nested structure). -
@Span()and@Measure()decorators for manual method instrumentation. -
MetricsService, a facade over the OpenTelemetry Metrics API (counters, histograms, up-down-counters, observable gauges). - Environment attribute (
deployment.environment.name) on traces, logs, and metrics, freely configurable (staging,production, etc.). - Trace-id/correlation-id continuity across asynchronous processing
(queues,
setTimeout, scheduled jobs), independent of the technology used. - Kafka and RabbitMQ event interceptor (
MessageTraceInterceptor) for correlating consumed messages. - Metric cardinality controls, header injection prevention, and prototype pollution prevention when extracting correlation-id.
The SDK does not perform logging on its own: it only correlates logs emitted by the application.
| Requirement | Supported version |
|---|---|
| Node.js | >= 22.0.0 |
| NestJS | ^9.0.0 || ^10.0.0 || ^11.0.0 |
| TypeScript |
>= 4.9.0, with experimentalDecorators and emitDecoratorMetadata enabled |
| Module | CommonJS and ESM |
npm install @snokedll/otel-for-nestjsThe repository includes an automated test suite and a local testing
environment (sandbox/), intended for anyone forking or contributing to
the project.
npm install
npm test # runs the suite
npm run test:watch # watch mode
npm run test:coverage # with coverage report
npm run type-check # type checkingExample NestJS application consuming the SDK, included for manual
validation during development. Usage instructions are in
sandbox/README.md.
// telemetry.config.ts
import type { TelemetryConfig } from '@snokedll/otel-for-nestjs';
export const telemetryConfig: TelemetryConfig = {
serviceName: 'billing-service',
environment: process.env.NODE_ENV,
endpoint: 'http://otel-collector:4318',
ignoreRoutes: ['/health'],
};// app.module.ts
import { Module } from '@nestjs/common';
import { TelemetryModule } from '@snokedll/otel-for-nestjs';
import { telemetryConfig } from './telemetry.config';
@Module({
imports: [TelemetryModule.forRoot(telemetryConfig)],
})
export class AppModule {}TelemetryModule.forRoot() initializes the OpenTelemetry SDK and
registers the application's interceptors. No further configuration is
required.
| Field | Type | Default | Description |
|---|---|---|---|
serviceName |
string |
— (required) | Reported as the service.name attribute on every trace, log, and metric. |
environment |
string |
not set | Reported as the deployment.environment.name attribute. Free-form value (staging, production, sandbox, etc.), not a fixed enum. If omitted, no environment attribute is reported. |
endpoint |
string |
not set | OTel Collector URL, OTLP/HTTP format (e.g. http://otel-collector:4318). Used by any enabled signal that does not define its own endpoint. A bare origin gets the signal's OTLP path appended automatically (/v1/logs, /v1/traces, /v1/metrics); a URL that already has a path of its own (a custom base path, a gateway prefix, ...) is used exactly as given. |
protocol |
'http/json' | 'http/protobuf' |
'http/protobuf' |
OTLP serialization format used when exporting every signal. |
logs |
{ enabled?: boolean; endpoint?: string; sensitiveFields?: string[]; redactionPlaceholder?: string } |
{ enabled: true } |
Logs signal configuration. endpoint, if set, overrides endpoint for this signal only. sensitiveFields is empty by default — nothing is redacted unless configured. redactionPlaceholder defaults to '[REDACTED]'. See Redacting sensitive data. |
traces |
{ enabled?: boolean; endpoint?: string } |
{ enabled: true } |
Traces signal configuration. |
metrics |
{ enabled?: boolean; endpoint?: string; temporalityPreference?: 'cumulative' | 'delta' | 'lowmemory' } |
{ enabled: true } |
Metrics signal configuration. temporalityPreference, if unset, defers entirely to the exporter's own default (cumulative, or OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE if set). Some backends only accept delta for Sum-type metrics (counters, histograms) and silently drop cumulative ones downstream of the Collector — with no export error on the sending side, since the Collector already accepted the payload. If metrics never show up despite a clean diag log, try delta. |
correlationIdSources |
CorrelationIdSource[] |
x-correlation-id, correlation-id, correlationId headers, in this order |
Locations searched for the correlation-id. See Correlation-id source. |
ignoreRoutes |
RoutePattern[] (string | RegExp) |
[] |
HTTP routes for which no trace, correlation-id, or metric is generated. |
ignoreEvents |
EventIgnoreRule[] |
[] |
Consumed events, identified by partial match on the message body and/or headers, for which no trace, correlation-id, or metric is generated. |
TelemetryModule exposes forRoot() only, not a DI-based forRootAsync().
TelemetryModule.forRoot() initializes the OpenTelemetry SDK synchronously,
at the moment AppModule's @Module() decorator is evaluated — before
NestFactory.create() runs, before http/express are ever imported.
That ordering is required, not just preferred: Node core-module
instrumentation (http included) does not retroactively patch a module
that was already require()d before the SDK starts. Since
NestFactory.create() imports http/express internally, near the very
start of its own execution, a forRootAsync() resolving through Nest's
dependency injection container (useFactory/inject) would always run
too late — this was implemented and verified empirically: every HTTP
request went completely untraced, consistently, not as an edge case.
If a value must come from an asynchronous source (a secrets manager, a
remote config service), resolve it in main.ts before anything
NestJS/OpenTelemetry-related is imported, then load the application with
a dynamic import():
// main.ts
import 'reflect-metadata';
async function bootstrap(): Promise<void> {
const secrets = await fetchConfigFromVault(); // the only async work; nothing else has been imported yet
const { setTelemetryConfig } = await import('./telemetry.config');
setTelemetryConfig({ serviceName: secrets.serviceName, endpoint: secrets.otelEndpoint });
const { AppModule } = await import('./app.module'); // first import of anything Nest/OTel-related
const { NestFactory } = await import('@nestjs/core');
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
void bootstrap();// telemetry.config.ts
import type { TelemetryConfig } from '@snokedll/otel-for-nestjs';
export let telemetryConfig: TelemetryConfig;
export function setTelemetryConfig(config: TelemetryConfig): void {
telemetryConfig = config;
}This preserves the ordering guarantee in full — nothing instrumented is imported until after the asynchronous resolution completes — without involving Nest's DI container at all.
correlationIdSources is evaluated in order; the first non-empty value
found is used as the correlation-id. The same list is applied both to
HTTP requests (HttpTraceInterceptor) and to consumed events
(MessageTraceInterceptor), regardless of the configured messaging
technology.
correlationIdSources: [
{ from: 'header', key: 'x-correlation-id' },
{ from: 'body', key: 'metadata.correlationId' },
]-
from: 'header'reads a header, case-insensitive — from an HTTP request or a message, depending on the transport being processed. -
from: 'body'reads a dot-notation path (e.g.'metadata.correlationId') from the already-deserialized request or message body.
To restrict an entry to a single transport, use the source field with
the CorrelationSource enum. Entries without source are evaluated for
both transports.
import { CorrelationSource } from '@snokedll/otel-for-nestjs';
correlationIdSources: [
{ from: 'header', key: 'x-correlation-id', source: CorrelationSource.HTTP },
{ from: 'body', key: 'eventId', source: CorrelationSource.MESSAGE },
]TelemetryModule.forRoot({
serviceName: 'billing-service',
ignoreRoutes: ['/health', /^\/internal\//],
ignoreEvents: [{ body: { name: 'HEALTH_CHECK' } }],
});// main.ts
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(new TraceLogger());After this configuration, NestJS's default Logger is already
correlated automatically, with no further changes needed in the
application's services.
TraceLogger's constructor accepts two optional positional arguments:
context (prefixed onto every log call unless overridden per-call) and
consoleLevel (minimum level written to the console — 'debug' | 'info' | 'warn' | 'error' | 'fatal', defaults to 'info'). This only
affects the console output; every call still emits a LogRecord via the
OpenTelemetry Logs API regardless of consoleLevel.
app.useLogger(new TraceLogger(undefined, 'debug'));Every metadata object passed to a TraceLogger call — headers, a
request/response body, a Kafka or RabbitMQ event, any nested structure —
is redacted before it reaches either destination (console and the OTel
Logs API), based on logs.sensitiveFields. Nothing is redacted unless
you configure it explicitly — there is no built-in list applied on
your behalf:
TelemetryModule.forRoot({
serviceName: 'billing-service',
logs: {
sensitiveFields: ['cpf', 'cardNumber', 'body.customer.email'],
},
});A bare name ('cpf') redacts that field wherever it appears, at any
nesting depth, regardless of which transport produced it — the same rule
applies whether it came from an HTTP request body, a Kafka message, or a
RabbitMQ event, since matching is by field name, not by a fixed shape. A
dot-notation path ('body.customer.email') instead matches only that
exact location, for when a bare name would be too broad. Matching is
case-insensitive either way, and the original object passed to the log
call is never mutated — only the copy written to the console/OTel is
redacted.
this.logger.log('processing event', {
headers: kafkaMessage.headers, // authorization redacted only if configured below
body: event, // cpf/cardNumber/etc. redacted per your configured list
});Recommended starting point. RECOMMENDED_SENSITIVE_FIELDS ships with
the SDK — common credential/secret carriers seen across HTTP headers and
message metadata (authorization, cookie, set-cookie, password,
token, secret, apikey, api-key, x-api-key, access-token,
refresh-token). It is not applied automatically; spread it into
your own list to opt in:
import { RECOMMENDED_SENSITIVE_FIELDS } from '@snokedll/otel-for-nestjs';
TelemetryModule.forRoot({
serviceName: 'billing-service',
logs: {
sensitiveFields: [...RECOMMENDED_SENSITIVE_FIELDS, 'cpf', 'cardNumber'],
},
});Customizing the placeholder. Every redacted field's value is
replaced by logs.redactionPlaceholder, which defaults to '[REDACTED]'
— any string works:
TelemetryModule.forRoot({
serviceName: 'billing-service',
logs: {
sensitiveFields: ['cpf', 'cardNumber'],
redactionPlaceholder: '***',
},
});// input
{ headers: { authorization: 'Bearer super-secret-token' } }
// what reaches the console and the OTel LogRecord, with the placeholder above
{ headers: { authorization: '***' } }The key itself is always kept — only the value is replaced. Nothing is ever removed or omitted entirely, so it stays visible in the log that the field existed and was redacted, rather than silently disappearing.
class InvoicesService {
@Span('invoice.process-payment')
@Measure('invoice.process-payment')
async process(id: string) { ... }
}@Span() creates a named span. @Measure() records a call counter and
a duration histogram. The order between the decorators, and between them
and NestJS's own decorators, is irrelevant.
import { MetricsService } from '@snokedll/otel-for-nestjs';
private readonly invoicesCreatedCounter = MetricsService.counter('invoices.created', {
description: 'Total invoices created',
attributes: { module: 'invoices' },
});
this.invoicesCreatedCounter.add(1, { outcome: 'success' });attributes must contain low-cardinality values only.
Every active span is tagged with the app.correlation_id attribute
whenever a correlation-id is resolved, enabling search via TraceQL:
{ span.app.correlation_id = "order-123" }
import { captureTraceCarrier, ContinueTrace, Span, type TraceCarrier } from '@snokedll/otel-for-nestjs';
// At the point where asynchronous processing is scheduled
const trace = captureTraceCarrier();
await queue.add('charge-invoice', { invoiceId, trace });// On the processing consumer
class InvoiceProcessor {
@ContinueTrace()
@Span('invoice.charge')
async process(job: { invoiceId: string; trace: TraceCarrier }) {
// runs with the same trace-id and correlation-id as the original request
}
}captureTraceCarrier() returns a plain, serializable object, meant to be
included in the job payload — independent of the queue technology used
(Bull, RabbitMQ, Kafka, scheduling, etc.).
@ContinueTrace() only resumes trace/correlation-id context — it never
creates a span of its own. Naming a span is @Span()'s job, the same
separation already used for @Span()/@Measure(). Stack them together to
get a visible, named span for the resumed processing unit — in either
order: @Span() always ends up parented to the resumed trace regardless
of which of the two is declared first, the same way no other decorator
pair in this SDK requires a specific order. Using @ContinueTrace() alone
is valid too — logs and any further auto-instrumented call made from
within the method (an HTTP request, a DB query, another publish) still
correctly attach to the resumed trace; only a span marking the processing
unit itself is missing.
| Field | Type | Description |
|---|---|---|
extractCarrier |
(...args: unknown[]) => TraceCarrier | undefined |
Reads the TraceCarrier off the decorated method's own arguments. Defaults to args[0].trace. |
extractCarrier's default (args[0].trace) matches a processor that
receives the enqueued payload directly as its first argument — RabbitMQ
consumers, @nestjs/microservices custom transports, a raw setTimeout
callback. Bull and BullMQ processors do not match this shape: their
first argument is a Job wrapper, and the enqueued payload — the carrier
included — lives one level deeper, at job.data. Override extractCarrier
in that case:
// Enqueue side (Bull/BullMQ) — trace is just another field in the job's data
await queue.add('charge-invoice', { invoiceId, trace: captureTraceCarrier() });// Bull (@nestjs/bull) processor
import { Processor, Process } from '@nestjs/bull';
import type { Job } from 'bull';
@Processor('invoices')
class InvoiceProcessor {
@Process('charge-invoice')
@ContinueTrace({
extractCarrier: (job: Job<{ invoiceId: string; trace: TraceCarrier }>) => job.data.trace,
})
@Span('invoice.charge')
async handleCharge(job: Job<{ invoiceId: string; trace: TraceCarrier }>) {
// job.data.invoiceId, resumed into the original trace
}
}// BullMQ (@nestjs/bullmq) WorkerHost — same Job.data shape
import { Processor, WorkerHost } from '@nestjs/bullmq';
import type { Job } from 'bullmq';
@Processor('invoices')
class InvoiceProcessor extends WorkerHost {
@ContinueTrace({ extractCarrier: (job: Job) => job.data.trace })
@Span('invoice.charge')
async process(job: Job<{ invoiceId: string; trace: TraceCarrier }>) { ... }
}MessageTraceInterceptor supports both @nestjs/microservices' Kafka and
RabbitMQ transports — auto-detected per invocation from the shape of the
RPC context (KafkaContext vs RmqContext), so the same interceptor
instance instruments both if an application consumes from more than one:
@Controller()
@UseInterceptors(MessageTraceInterceptor)
export class InvoicesEventsController {
@EventPattern('invoice.created')
async handle(@Payload() data: unknown) { ... }
}Both transports extract the traceparent/correlation-id headers and
re-parent the invocation's trace the same way — Kafka reads them from
KafkaContext.getMessage().headers, RabbitMQ from the raw AMQP message's
properties.headers (RmqContext.getMessage()). Metrics are recorded
separately per broker, since they're different signals in practice:
| Broker | Counter | Histogram | Attributes |
|---|---|---|---|
| Kafka | messaging.kafka.messages_consumed |
messaging.kafka.processing.duration |
topic, partition, outcome
|
| RabbitMQ | messaging.rabbitmq.messages_consumed |
messaging.rabbitmq.processing.duration |
exchange, routingKey, outcome
|
A producer sets the traceparent header the same way regardless of
broker — via injectW3CTraceParent(), same as the trace continuity
examples above, just written into the message's AMQP headers (or Kafka
record headers) instead of a job payload field.
Use, modification, forking, and contribution are free, including commercial use as a dependency of any project or product. Selling the SDK itself — reselling the code, or offering a hosted service whose value derives substantially from the SDK — is not permitted.