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]| Setting | Value / default |
|---|---|
| OTLP/HTTP traces endpoint | http://localhost:4318/v1/traces |
| OTLP/gRPC endpoint | http://localhost:4317 |
| Override endpoint via env | OTEL_EXPORTER_OTLP_ENDPOINT |
| Service name via env | OTEL_SERVICE_NAME |
| Default propagator | W3C 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.

