Library / SDK
pragmaticivan/nestjs-otel avatar
pragmaticivan/nestjs-otel

nestjs-otel: OpenTelemetry traces and metrics inside a Nest application

OpenTelemetry (Tracing + Metrics) module for Nest framework (node.js) 🔭

799 stars65 forksTypeScriptApache-2.0

At a glance

What is it?
nestjs-otel is a Nest module for OpenTelemetry that wraps tracing and metrics setup for Node and adds Nest-native decorators for spans. It suits teams already running Nest who want Prometheus metrics and distributed tracing without assembling the SDK, propagators and instrumentations by hand.
Who is it for?
Adopt nestjs-otel when your service already runs on Nest and you want spans and metrics that follow Nest's own structure rather than a generic instrumentation wrapper, particularly if you use the @Span and @Traceable decorators to control span granularity in code.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 47 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Why a Nest module for observability at all

nestjs-otel is an OpenTelemetry module for the Nest framework, published to npm as `nestjs-otel`. The stated reason for its existence is blunt: setting up observability metrics with nestjs requires multiple libraries and patterns. The README adds the second half of the argument, that OpenTelemetry has support for multiple exporters and metric types such as Prometheus metrics.

Both halves matter, and they are different problems. The first is plumbing. Getting a Node OpenTelemetry SDK into a Nest application means choosing a span processor, an exporter, a context manager, a propagator set and a set of instrumentations, then wiring them in an order that actually works. The second is choice: a single standard, but many back ends, and a decision about which exporter talks to which collector.

The audience is a Nest team that wants OpenTelemetry semantics rather than a vendor-specific SDK, and that does not want every service in the estate to re-solve the same wiring problem. That is the same argument every framework-level instrumentation library makes, and it is a good one when the framework is opinionated enough to have a lifecycle you can hook.

The project is also pointed at a practical starting point. The README links a comprehensive whitepaper from the CNCF tag-observability repository for readers working with metrics, tracing and logs for the first time, and a separate example repository, nestjs-otel-prom-grafana-tempo, that is a full working Nest application integrated with Prometheus, Grafana, Loki and Tempo. Four components wired together is the honest shape of a real observability stack, and having a reference that already connects them is worth more than another configuration snippet.

The primary language is TypeScript, the licence is Apache-2.0, and the current release is v8.1.1.

The tracing file that decides exporters and propagators

The setup begins with a tracing file that constructs the Node SDK. The import list tells you exactly which pieces are involved:

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

The SDK construction shows how the signals are wired:

ts
const otelSDK = new NodeSDK({
  metricReader: new PrometheusExporter({
    port: 8081,
  }),
  spanProcessor: new BatchSpanProcessor(new JaegerExporter()),
  contextManager: new AsyncLocalStorageContextManager(),

Three decisions are worth noting. Metrics are exposed by scraping, not by pushing, through the Prometheus exporter on port 8081, which is a separate endpoint from the application port and needs to be reachable by your collector. Spans are batched before export, which is the right default for a server that would otherwise pay a network round trip per span. The context manager is the async hooks implementation, which is what carries the active span across `await` boundaries in Node, and getting that wrong is the usual reason traces come out as a row of unrelated root spans.

The propagator configuration continues past that block, and it is where the interoperability decisions sit. The example composes the Jaeger propagator, the W3C trace context propagator, the W3C baggage propagator and the B3 propagator into a composite, and pairs that with the Node auto-instrumentations package. Accepting several propagation formats at once means services that still speak Jaeger or B3 headers can participate in a trace, which is the pragmatic choice during a migration.

The README also shows a graceful shutdown path on SIGTERM, calling `otelSDK.shutdown()` and exiting in a `finally` block so that buffered spans are flushed before the process goes away.

Starting the SDK before the Nest factory

The order of operations is the part of this setup that people get wrong, and the README states it explicitly in a comment. The SDK must be started before the Nest factory creates the application:

ts
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);
}

The mechanism is straightforward once you know it. `await otelSDK.start()` installs the context manager and the instrumentations into the runtime, and only then does `NestFactory.create` build the application, register providers and open the HTTP server. Any module loading, provider instantiation and connection work that happens inside `NestFactory.create` is therefore inside a traced context.

Swap the order and you get a working application with a partially traced trace. The spans for the requests are fine, because the context manager is installed before the first request arrives, but the bootstrap work is missing and the first few spans can be orphaned. The symptom is confusing rather than fatal, which is exactly why it is worth following the documented order rather than working around it.

The file also shows an unrelated but telling detail: the application logger is replaced with the `nestjs-pino` logger. Nest's own logger writes to the console, and a structured JSON logger is what makes request logs joinable with spans in a log backend such as Loki. The README's example repository includes Loki for that reason. Whether the pino logger belongs in your stack is your decision, but the pairing of a Nest module with a structured logger is what turns two separate signals into one trace.

The application listens on port 3000, while the metrics endpoint sits on 8081. Those are separate listeners and conflating them is a common early mistake.

Registering the module with forRoot or forRootAsync

The Nest module itself is registered like any other dynamic module. The synchronous form passes the options inline:

ts
const OpenTelemetryModuleConfig = OpenTelemetryModule.forRoot({
  metrics: {
    hostMetrics: true, // Includes Host Metrics
  },
});

The asynchronous form exists for the usual reason, that the configuration comes from somewhere else at runtime:

ts
OpenTelemetryModule.forRootAsync({
  useClass: OtelConfigService
});

where the factory class implements the `OpenTelemetryOptionsFactory` interface and returns the same options shape. The README's example pulls the settings from Nest's own `ConfigService`, reading an `otel` configuration object and mapping its `hostMetrics.enabled` flag into the module options. That is the pattern to copy when you want metrics behaviour to differ per environment, since environment variables and files are usually where that kind of switch already lives.

The `hostMetrics` option is the one to understand. It includes host metrics, which is the runtime statistics of the machine the service runs on, such as CPU, memory and process level counters. Those numbers are the ones that answer the question every incident review eventually asks, which is whether the problem was the code or the box. The dependency behind it is `@opentelemetry/host-metrics`, and it is a direct dependency of this package rather than something you install separately.

The practical boundary here is that the module handles Nest-side configuration, while the tracing file handles SDK construction. There are two places to change when you add an exporter, and the README keeps them separate on purpose: the SDK decides where data goes, and the module decides what Nest-side options are enabled.

The Span decorator and what it lets you control

Beyond the automatic instrumentation, nestjs-otel gives you Nest-native decorators, and the `@Span` decorator is the one that shapes your trace quality. It works on sync and async methods and takes an optional name and optional span options.

ts
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`];
  }

If you omit the name, the span is derived as `<class-name>.<method-name>`, so the second method produces `BooksService.getBooksAgain`. That default is the one to keep in mind, because a trace where every span is named after a method is long but hard to read, and a trace where the important spans are named after a domain concept is much easier to follow. Choosing a name by hand is how you decide what the trace is about.

The options argument is where the decorator earns its keep. It accepts `SpanOptions`, and the README also shows it accepting a function that is called with the decorated method's arguments, which lets you set attributes dynamically. The example sets a `bookId` attribute from the `id` parameter, and there is a variant that captures the return value as an attribute through an `onResult` callback, with a note to type the result explicitly to keep type safety.

That combination is the difference between a trace that says a method was called and one that says which book was fetched. Attributes are indexed and queryable in most back ends, so a dynamic attribute turns a wall of similar spans into a filterable set.

There is also a class-level decorator for the broad case. The `@Traceable` decorator traces all methods in a class:

ts
@Injectable()
@Traceable()
export class UsersService {
  findAll() {
    // This method will be automatically traced
    return [];
  }

  findOne(id: string) {
    // This method will also be automatically traced
    return {};
  }
}

This is convenient and it is coarse. Applied to a large service it produces a lot of spans, which is the usual cause of a slow and expensive tracing backend. The `@Span` decorator gives per-method control, and using it selectively beats tracing a whole class by default.

CurrentSpan stops at the edge of Nest's argument injection

The `@CurrentSpan` decorator gives you the active span from inside a controller, which is how you add attributes to a span you did not create yourself. The import set is small:

ts
import { Controller, Get } from '@nestjs/common';
import { Span } from '@opentelemetry/api';
import { CurrentSpan } from 'nestjs-otel';

The README carries an explicit note about the boundary, and it is the kind of limitation that is easy to lose an afternoon to. The decorator only works in Controllers, Resolvers and Gateways, the places where NestJS handles argument injection. It does not work in standard service-to-service calls.

The reason is that Nest resolves those parameters through its own dependency injection and parameter decorator machinery, and it only knows how to inject what it can construct. A service method called directly from another service is an ordinary JavaScript call, and Nest has no way to step into it and hand over the current span, so there is nothing to inject.

That constraint shapes how you design around the decorator. Attributes that describe an incoming request belong on the controller span, where the decorator works. Attributes that describe something deep inside a service have to be attached differently, for example by passing the span down explicitly, or by creating a child span inside the service with the `@Span` decorator and reading from it.

This is also where the automatic instrumentation and the manual decorators meet. The auto-instrumented HTTP span exists whether or not you decorate anything, and it is the one that carries the request attributes that the framework already knows. The decorators are for the parts the framework cannot infer.

Node 22, Apache-2.0 and the shape of the release line

The package manifest sets the runtime floor explicitly:

json
  "engines": {
    "node": ">= 22"
  },

That is a meaningful constraint rather than a formality. A Node floor of 22 excludes a large part of the installed base, and it is the kind of requirement that has to line up with your platform team's supported runtime versions before you plan a rollout. The published version is 8.1.1, and the recent release history shows v8.0.3 on 2026-05-15, v8.1.0 on 2026-06-18 and v8.1.1 on 2026-09-07. The last push was on 2026-09-07, the same day as the v8.1.1 release, and the repository is not archived.

A major version of 8 with regular minor releases inside it suggests an API that is still moving. The repository carries an `UPGRADING.md` alongside `REFERENCE.md` and `CHANGELOG.md`, and for a library at version 8 those two files are where the compatibility story lives. Upgrade notes are worth reading before you bump, because a change in how spans are named or how the module registers changes what your dashboards and alerts expect.

The toolchain around it is conventional and modern. The build is `tsc`, the formatter and linter is Ultracite on top of Biome, tests are Jest with separate unit and end-to-end configurations under `tests/`, Husky handles git hooks, and Commitlint enforces conventional commit messages. Nothing here is unusual, which is a good sign for a package that other teams will maintain internally.

The licence is Apache-2.0, which is permissive and carries no copyleft obligation on the code that uses the library. For a module this small, with dependencies limited to the OpenTelemetry API and the host metrics package, the licence question is rarely the deciding factor.

One last detail is worth checking before you install. The README notes that some peer dependencies are required when some configurations are enabled, and names `@opentelemetry/exporter-prometheus` as the example. The installation command in the README covers the module and the SDK:

bash
npm i nestjs-otel @opentelemetry/sdk-node --save

but the exporter is a separate peer, which is what npm will warn you about if you skip it and then enable the Prometheus reader.

Editorial conclusion

Adopt nestjs-otel when your service already runs on Nest and you want spans and metrics that follow Nest's own structure rather than a generic instrumentation wrapper, particularly if you use the @Span and @Traceable decorators to control span granularity in code. Do not adopt it if you only need auto-instrumented HTTP spans, since the Node SDK alone with getNodeAutoInstrumentations already covers most frameworks, and do not adopt it on Node older than 22, which the package manifest requires. Verify first that the SDK is started before NestFactory.create, because a late start leaves the early bootstrap work untraced, and check the port your Prometheus exporter binds, which the README example sets to 8081 and which will collide with an existing service in most deployments.

Frequently asked questions

How do I install nestjs-otel in a Nest application?

The README gives npm i nestjs-otel @opentelemetry/sdk-node --save, and notes that some peer dependencies are required when certain configurations are enabled, naming @opentelemetry/exporter-prometheus. The package manifest requires Node 22 or above.

Why must the OpenTelemetry SDK be started before NestFactory.create?

The tracing file calls await otelSDK.start() before the Nest factory creates the application so that the context manager and instrumentations are installed before providers are created and the HTTP server opens. The README states the ordering requirement directly in a comment in the bootstrap code.

How do I name spans and add attributes in nestjs-otel?

The @Span decorator takes an optional name, derived as <class-name>.<method-name> when omitted, and an options argument that can be a function of the method arguments for dynamic attributes. It also supports an onResult callback to capture the return value as an attribute.

Does the CurrentSpan decorator work inside Nest services?

No. The README states that @CurrentSpan only works in Controllers, Resolvers and Gateways where NestJS handles argument injection, and that it does not work in standard service-to-service calls.

Which port does nestjs-otel expose Prometheus metrics on?

The tracing file in the README configures the Prometheus exporter with port 8081, which is separate from the application port 3000 used by the example bootstrap code. The port is set in your own tracing file, so you can change it there.

Official sources

  1. Issues
  2. License: Apache-2.0
  3. pragmaticivan/nestjs-otel on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/pragmaticivan-nestjs-otel.svg)](https://hysenlabs.com/projects/pragmaticivan-nestjs-otel)

Was this useful?