npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@prelamelo/nest-otel

v0.2.2

Published

OpenTelemetry distributed tracing library for NestJS microservices (HTTP + RabbitMQ)

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 traceparent extracted 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 optional attributes and mapArgs for safe data capture
  • TracingClientProxy — drop-in ClientProxy wrapper that injects trace headers into every outgoing RPC message
  • initTracing() 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: false mode for public gateways
  • Trace-correlated logging — optional LokiLoggerModule ships Pino logs to Grafana Loki, stamping every line with the active trace_id / span_id

Contents


Installation

# Required peer dependencies
pnpm add @prelamelo/nest-otel \
  @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Published 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 errorSpanStatusCode.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: mapArgs is opt-in by design — no argument data is ever recorded unless you return it. If mapArgs throws, 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-pretty at runtime: if you enable pretty anywhere other than local dev (e.g. a staging build installed with --omit=dev), install pino-pretty as 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') },
    },
  }),
})

forRootAsync also accepts providers, so you can inject a 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 autoLogging on, pino-http serialises full request/response headers. By default the module redacts authorization, cookie, set-cookie, x-api-key, and proxy-authorization (replaced with [Redacted]) so secrets never reach Loki. Override with your own redact paths, or pass redact: [] 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 },
    })
  • basicAuth requires HTTPS. pino-loki sends Authorization: Basic … on every push; base64 is not encryption. The module throws at config time if loki.basicAuth is combined with a non-https:// host, rather than leaking credentials over cleartext.

  • loki.silenceErrors defaults to true. A flaky/unreachable Loki can never crash the app — but push failures are dropped silently. Set loki: { 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 (fataltrace, 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, pretty nor stdout is set, the logger auto-selects: pretty output on a TTY when pino-pretty is 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 :16686

Security

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