When a request fails or slows down in a distributed application, metrics say "something broke", logs show individual events — but neither explains which path a specific request took and where exactly the failure happened. Distributed tracing solves this problem: it records the request's path through all services as a linked chain of events — a trace.
Let's break down how this works in NestJS through OpenTelemetry JS.
What a trace and a span are
Imagine: a user clicks "Pay". The request goes to order-service, which hits the database, then calls payment-service, which makes an HTTP call to Sber.
Span is a single step in this path. Each step records a name, start and end time, status and attributes. Trace is the whole chain of linked spans from the first request to the last response.
The link between spans is built through the HTTP header traceparent (the W3C Trace Context standard):
traceparent: 00-5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4-1f2e3d4c5b6a7980-01
│ │ │
trace-id (shared for the chain) span-id flags
A service gets the traceparent from the incoming request, creates a child span and passes the header on. This keeps the whole path linked, even if there are ten services.
Wiring up OpenTelemetry
OpenTelemetry is the industry standard for tracing, metrics and logs, which displaced Zipkin and Jaeger as standalone libraries.
We install the packages:
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc
We create the file src/tracing.ts:
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'otel-collector:4317',
}),
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(
parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG ?? '0.1'),
),
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
And we wire it up as the first line of main.ts — before NestFactory.create. This is critical: if the import is later, the modules are already loaded and auto-instrumentation won't work.
import './tracing'; // must be first
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
After this, without a single line of extra code, spans appear for:
- incoming HTTP requests to NestJS (Express/Fastify)
- outgoing HTTP calls (
axios,fetch) - requests to PostgreSQL via
pg— with the SQL text in the attributes - Kafka produce/consume via
kafkajs - commands to Redis via
ioredis
The full picture "request → database → Kafka → HTTP out" for free.
Manual spans for important operations
Auto-instrumentation covers the infrastructure. But sometimes you need to add a span for a business operation — to see in the trace, for example, "order confirmation" rather than just "SQL query to the orders table".
import { Injectable } from '@nestjs/common';
import { trace, SpanStatusCode } from '@opentelemetry/api';
@Injectable()
export class ConfirmOrderHandler {
private readonly tracer = trace.getTracer('order-service');
async handle(cmd: ConfirmOrderCommand): Promise<Order> {
return this.tracer.startActiveSpan('confirmOrder', async (span) => {
try {
span.setAttribute('order.id', cmd.orderId);
span.setAttribute('customer.id', cmd.customerId);
const order = await this.orderRepository.findByIdOrFail(cmd.orderId);
order.confirm();
await this.orderRepository.save(order);
span.setAttribute('order.status', order.status);
return order;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end(); // span.end() — always in finally
}
});
}
}
startActiveSpan with a callback function makes the span active in the current context — all child operations (SQL queries, HTTP calls) automatically become its child spans. This is exactly why it's important to use the callback form rather than creating the span directly.
For a recurring pattern a decorator is convenient:
import { trace, SpanStatusCode } from '@opentelemetry/api';
function WithSpan(name: string): MethodDecorator {
return (target, key, descriptor: PropertyDescriptor) => {
const original = descriptor.value;
descriptor.value = async function (...args: unknown[]) {
const tracer = trace.getTracer('order-service');
return tracer.startActiveSpan(name, async (span) => {
try {
return await original.apply(this, args);
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
};
return descriptor;
};
}
@Injectable()
export class GetProductHandler {
@WithSpan('getProduct')
async handle(query: GetProductQuery): Promise<Product> {
return this.productRepository.findByIdOrFail(query.productId);
}
}
What to put in the attributes
Span attributes are the context that helps figure things out during analysis. But trace data is stored in separate systems (Tempo, Jaeger) with a different access mode, and if users' personal data ends up there — that's a security violation.
The rule is simple: internal identifiers and enum statuses — allowed, user data — not allowed.
Good attributes: order.id, customer.id (internal UUIDs), order.status, payment.method (statuses and types), external.system (the name of an external system like "sber").
Bad attributes: customer.email, customer.phone, card.number, iban, the entire request body content.
Sampling — how many traces to keep
Recording 100% of requests in production is expensive and usually unnecessary. Normal requests look alike, the value of each is low. The value is in the errors.
The standard approach: 1–10% of requests in production + 100% for requests with errors.
Configuration via environment variables:
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
ParentBasedSampler works like this: if the incoming request carries a traceparent with the sampled flag (the decision was already made by an upstream service) — we participate in the trace. Otherwise we make the decision ourselves by the given percentage.
"100% for errors" is configured at the OTel Collector level via tail-based sampling: the collector looks at the completed trace as a whole and decides whether to keep it. This is not an application configuration — it's an infrastructure configuration.
For services with low traffic (less than 10 requests per second) you can keep 1.0 — the storage won't be overloaded.
trace_id in the logs
The most useful scenario: you found a line in the logs → jumped to Tempo/Jaeger by trace_id → saw the entire distributed path of the request.
For this, trace_id and span_id must appear in every log record. @opentelemetry/instrumentation-pino does this automatically:
npm install @opentelemetry/instrumentation-pino
// src/tracing.ts
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations(),
new PinoInstrumentation(),
],
// ...
});
The result in the JSON log:
{
"level": 50,
"time": 1748991600000,
"msg": "payment failed",
"orderId": "ord-9281",
"err": { "type": "PaymentGatewayError", "message": "timeout" },
"trace_id": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
"span_id": "1f2e3d4c5b6a7980"
}
If for some reason PinoInstrumentation doesn't fit, you can add the fields via a mixin:
LoggerModule.forRoot({
pinoHttp: {
mixin: () => {
const span = trace.getActiveSpan();
if (!span) return {};
const ctx = span.spanContext();
return { trace_id: ctx.traceId, span_id: ctx.spanId };
},
},
}),
Common mistakes
tracing.ts not imported first. If import './tracing' comes after other imports — the modules are already loaded without patches, and automatic spans won't appear. Always the first line.
span.end() not in finally. If an exception is thrown and span.end() is in the body without finally — the span won't close, the trace will be incomplete. Always in finally.
Sampling at 100% in a loaded service. At thousands of requests per second this quickly overflows the trace storage. Set 1–10% via TraceIdRatioBasedSampler.
Passing context to background jobs. When offloading to worker_threads or BullMQ, the trace context isn't passed automatically. You need to explicitly save the traceparent in the job payload and restore it in the handler.
In short
- Trace — the full path of a request through the services, span — a single step in that path.
- The link between spans across services — through the
traceparentheader (W3C Trace Context); OTel passes it automatically. import './tracing'— the first line ofmain.ts, beforeNestFactory.create.getNodeAutoInstrumentations()covers HTTP, pg, kafkajs, ioredis for free.- Manual spans via
startActiveSpanwith a callback — close the context correctly;span.end()— infinally. - Into span attributes: internal IDs and enum statuses. Personal data (email, phone, card number) — not allowed.
- Sampling 1–10% in production; 100% for errors — via tail-based sampling on the collector.
PinoInstrumentationautomatically addstrace_id/span_idto every log record.
What to read next
- Context propagation in NestJS — passing context to BullMQ and worker_threads.
- Logging in NestJS — structured logs via pino, PII hygiene.
- Metrics in NestJS — prom-client, histograms, low-cardinality labels.
- SLO and alerts — error budget, multi-window alerts.