Distributed Tracing in NestJS with OpenTelemetry Auto-Instrumentation

Photo by Pi.1415926535 via Wikimedia Commons (CC BY-SA 3.0)
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.
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.
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.
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.
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.

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.
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.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.
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:
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();
}
});
}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().
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.