Skip to main content
BlogBackend

Distributed Tracing in NestJS with OpenTelemetry Auto-Instrumentation

How I added distributed tracing to a NestJS API with OpenTelemetry auto-instrumentation — the SDK setup, context propagation, and exporting spans to a collector.

Matthews Wong

Software Engineer

July 20269 min read
Distributed Tracing in NestJS with OpenTelemetry Auto-Instrumentation

Photo by Pi.1415926535 via Wikimedia Commons (CC BY-SA 3.0)

Key Takeaway

To add distributed tracing to a NestJS API, install @opentelemetry/sdk-node with @opentelemetry/auto-instrumentations-node, start the SDK in a preloaded file before Nest boots, and point an OTLP exporter at a collector. Auto-instrumentation patches HTTP, Express, and TypeORM automatically, and W3C traceparent headers propagate context across services with no manual wiring.

A single slow request in a NestJS service is easy to debug. A single slow request that fans out to three internal APIs, a Postgres query, and a Redis cache is not, because your logs only show you one hop at a time. Distributed tracing stitches those hops into one timeline, and OpenTelemetry is now the vendor-neutral standard for producing it. The best part is that you barely write any tracing code: auto-instrumentation does the heavy lifting.

I run a few TypeScript services on a self-hosted VPS, and wiring OpenTelemetry into them took an afternoon, most of which was spent learning one rule the docs bury: the SDK must start before anything else loads. Get that wrong and you get empty traces with zero errors, which is the worst kind of failure. This guide walks through the SDK setup, how context propagation actually works, and how to export spans to a collector.

The packages you actually need

OpenTelemetry ships as many small packages, which is intimidating at first. For a traces-only NestJS setup exporting over OTLP/HTTP you need five: the API, the Node SDK, the auto-instrumentations metapackage, the OTLP trace exporter, and the semantic-conventions helper. The auto-instrumentations metapackage bundles the HTTP, Express, NestJS-core, TypeORM, Prisma, and PG instrumentations so you do not install them one by one.

npm install @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/semantic-conventions

# As of mid-2026, auto-instrumentations-node is ~0.78.x.
# Note: instrumentation-fastify was removed from the metapackage
# in early 2026 — if you run Fastify, add @fastify/otel separately.

The golden rule: start the SDK before Nest

Auto-instrumentation works by monkey-patching library modules as they are required. That means the SDK has to run before Node loads Express, TypeORM, or the pg driver — if Nest imports them first, the patches attach to nothing and you get silent, empty traces. The reliable way to guarantee ordering is a separate tracing file loaded with Node's --require flag, so it executes before your entrypoint is even parsed.

// tracing.ts — loaded via --require, NOT imported by main.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'orders-api',
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? '0.0.0',
  }),
  traceExporter: new OTLPTraceExporter({
    // default is http://localhost:4318/v1/traces
    url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // fs spans are noisy and rarely useful
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) => req.url === '/health',
      },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});
// package.json — preload tracing before the app
{
  "scripts": {
    "start:prod": "node --require ./dist/tracing.js dist/main.js"
  }
}

Do not import tracing.ts at the top of main.ts and assume that is early enough. If ESM hoists another import above it, or Nest's dependency graph pulls in a driver first, the patch order breaks. The --require (or --import for ESM) preload is the only ordering guarantee that survives a bundler.

What auto-instrumentation gives you for free

Once the SDK is running, every incoming HTTP request becomes a root span, and each downstream operation nests underneath it. You did not touch a single controller. Here is what lights up in a typical NestJS + TypeORM + Postgres stack:

  • The incoming request span, tagged with HTTP method, route, and status code
  • A child span per outgoing fetch or Axios call to another service
  • A span per database query, with the SQL statement captured as an attribute
  • Redis, gRPC, and message-queue operations if those libraries are present

You only write manual spans when you have business logic worth measuring — a pricing calculation, a PDF render, a batch job step. For those, inject a tracer from the API and wrap the work in an active span; everything you start inside it automatically becomes a child of the current request span.

import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('orders-api');

async function settleInvoice(id: string) {
  return tracer.startActiveSpan('settle-invoice', async (span) => {
    try {
      span.setAttribute('invoice.id', id);
      const result = await doWork(id); // nested DB spans attach here
      return result;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}

How context propagation actually crosses the network

A trace stays connected across services because of one HTTP header. When your service calls another, the HTTP instrumentation injects a traceparent header carrying the trace ID and the current span ID; the receiving service's instrumentation reads it and makes its root span a child of yours. This is the W3C Trace Context standard, and it is OpenTelemetry's default propagator, so two OTel-instrumented NestJS services link up with zero configuration.

You only override the propagator when you talk to a system that speaks a different dialect — many older Zipkin and Java services emit B3 headers instead. In that case, register a composite propagator that both reads and writes W3C and B3, so context survives no matter which side initiates the call.

import { CompositePropagator, W3CTraceContextPropagator } from '@opentelemetry/core';
import { B3Propagator, B3InjectEncoding } from '@opentelemetry/propagator-b3';

const sdk = new NodeSDK({
  textMapPropagator: new CompositePropagator({
    propagators: [
      new W3CTraceContextPropagator(),
      new B3Propagator({ injectEncoding: B3InjectEncoding.MULTI_HEADER }),
    ],
  }),
  // ...resource, traceExporter, instrumentations
});

Propagation only carries context to the next hop if your own async code preserves it. Node's AsyncLocalStorage does this for you inside a single process, but if you push work onto a raw setInterval or an unpatched queue, the span context can be lost. Prefer libraries the auto-instrumentation already patches, or wrap the handoff in context.with().

Exporting to a collector, not straight to a backend

You can point the OTLP exporter directly at a vendor, but the pattern that ages well is to export to an OpenTelemetry Collector running as a sidecar or a shared service, and let the collector fan out to your backend. This decouples your app from any specific vendor, lets you batch and sample centrally, and means a backend change is a collector config edit, not a redeploy of every service. The exporter defaults to OTLP/HTTP on port 4318; the gRPC exporter uses 4317.

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:

exporters:
  debug:
    verbosity: detailed
  # otlphttp/jaeger, otlp/tempo, etc. go here

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
SettingValue / default
OTLP/HTTP traces endpointhttp://localhost:4318/v1/traces
OTLP/gRPC endpointhttp://localhost:4317
Override endpoint via envOTEL_EXPORTER_OTLP_ENDPOINT
Service name via envOTEL_SERVICE_NAME
Default propagatorW3C Trace Context (traceparent)

With the collector receiving spans, run one request against your API and watch the debug exporter print a connected trace: the HTTP root span, the controller, the service method, and the SQL query, all under one trace ID. From there, swapping the collector's export target for Jaeger, Tempo, or a hosted vendor is a config change you make once — the application never needs to know.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why are my OpenTelemetry traces empty in NestJS?

Almost always because the SDK started after Nest already loaded the libraries you wanted traced. Auto-instrumentation patches modules as they are required, so it must run first. Load your tracing file with Node's --require flag (or --import for ESM) instead of importing it inside main.ts, and the patches will attach before Express, TypeORM, or the pg driver loads.

Do I need to write span code in every controller?

No. Auto-instrumentation creates a root span for every incoming HTTP request and nests database queries, outbound HTTP calls, and Redis operations under it automatically. You only write manual spans for business logic you specifically want to measure, such as a pricing calculation or a batch step, using tracer.startActiveSpan.

How does trace context travel between two NestJS services?

Through the W3C traceparent HTTP header. The HTTP instrumentation injects it on outbound calls and reads it on inbound ones, so the receiving service makes its root span a child of the caller's span. W3C Trace Context is OpenTelemetry's default propagator, so two instrumented services link up with no configuration.

Should I export traces to a collector or straight to a backend?

Export to an OpenTelemetry Collector in production. It decouples your app from any specific vendor, lets you batch and sample centrally, and turns a backend swap into a collector config edit rather than a redeploy of every service. Point the OTLP exporter at the collector's port 4318 for HTTP or 4317 for gRPC.

What is the difference between the OTLP HTTP and gRPC exporters?

They send the same OTLP data over different transports. The HTTP exporter defaults to port 4318 at the /v1/traces path and is easier to route through proxies and firewalls. The gRPC exporter uses port 4317 and can be more efficient at high volume. Either works with a standard collector; pick HTTP unless you have a reason not to.