@snokedll/otel-for-nestjs

OpenTelemetry abstraction SDK for NestJS


Keywords
nestjs, opentelemetry, otel, tracing, logging, metrics, observability, correlation-id, kafka
License
ICU
Install
npm install @snokedll/otel-for-nestjs@2.2.0

Documentation

@snokedll/otel-for-nestjs

Observability SDK for NestJS applications, built on top of the OpenTelemetry Node.js SDK.

Description

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 NestJS LoggerService that 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.

Compatibility

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

Installation

npm install @snokedll/otel-for-nestjs

Local testing

The repository includes an automated test suite and a local testing environment (sandbox/), intended for anyone forking or contributing to the project.

SDK test suite

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 checking

Local testing environment (sandbox/)

Example NestJS application consuming the SDK, included for manual validation during development. Usage instructions are in sandbox/README.md.

Implementing the SDK

Configuration

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

TelemetryConfig fields

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.

Sourcing configuration asynchronously

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.

Correlation-id source

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 },
]

Ignored routes and events

TelemetryModule.forRoot({
  serviceName: 'billing-service',
  ignoreRoutes: ['/health', /^\/internal\//],
  ignoreEvents: [{ body: { name: 'HEALTH_CHECK' } }],
});

Logging

// 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'));

Redacting sensitive data

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.

@Span() and @Measure() decorators

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.

Metrics

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.

Searching by correlation-id

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" }

Trace continuity across asynchronous processing

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.

ContinueTraceOptions

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 }>) { ... }
}

Consuming events

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.

License

MIT + Commons Clause.

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.