Every backend eventually hits the moment where console.log stops being enough. A request is slow, but you don't know which service caused the delay. An error surfaces in production, but you can't trace it back through the chain of calls that produced it. You need distributed tracing, and OpenTelemetry is the standard way to get it.
OpenTelemetry (OTel) is a CNCF project that provides a vendor-neutral set of APIs, SDKs, and tools for generating telemetry data: traces, metrics, and logs. It's backed by every major observability vendor (Datadog, Grafana, Honeycomb, New Relic) and has become the de facto standard for instrumentation in the cloud-native ecosystem.
This guide walks through setting it up in a Node.js application from scratch. We'll start with the manual approach using the OTel SDK, then look at how modern frameworks can eliminate most of this setup entirely. By the end, you'll have distributed tracing with automatic and custom instrumentation, exporting to a local collector and visualization backend.
OpenTelemetry's Node.js SDK is modular. There's no single package that does everything, so you assemble what you need from a set of packages. For a typical Express application with PostgreSQL, here's the install:
npm install @opentelemetry/sdk-node \ @opentelemetry/api \ @opentelemetry/sdk-trace-node \ @opentelemetry/exporter-trace-otlp-http \ @opentelemetry/exporter-metrics-otlp-http \ @opentelemetry/auto-instrumentations-node \ @opentelemetry/resources \ @opentelemetry/semantic-conventions
That's eight packages, and this is the minimal set. Each serves a purpose:
@opentelemetry/sdk-node: the main SDK entry point that ties everything together@opentelemetry/api: the tracing API you'll use for custom spans@opentelemetry/sdk-trace-node: the trace processing pipeline@opentelemetry/exporter-trace-otlp-http: sends traces to a collector over HTTP@opentelemetry/exporter-metrics-otlp-http: sends metrics to a collector over HTTP@opentelemetry/auto-instrumentations-node: automatic instrumentation for common libraries@opentelemetry/resources: identifies your service in the telemetry data@opentelemetry/semantic-conventions: standard attribute names (so every tool speaks the same language)OpenTelemetry must initialize before your application code runs. The SDK hooks into Node.js module loading to patch libraries like http, express, and pg, and those patches need to be in place before the libraries are imported.
Create a file called tracing.ts at the root of your project:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { Resource } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: "my-api",
[ATTR_SERVICE_VERSION]: "1.0.0",
}),
traceExporter: new OTLPTraceExporter({
url: "http://localhost:4318/v1/traces",
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: "http://localhost:4318/v1/metrics",
}),
exportIntervalMillis: 15000,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on("SIGTERM", () => {
sdk.shutdown().then(() => process.exit(0));
});
Then run your app with this file loaded first:
node --require ./tracing.js dist/app.js
Or if you're using ts-node:
node --require ts-node/register --require ./tracing.ts src/app.ts
The order matters. If your application imports express or pg before the SDK patches them, those libraries won't be instrumented. This is a common source of "I set everything up but I don't see any traces" debugging.
The @opentelemetry/auto-instrumentations-node package is a meta-package that bundles instrumentation for dozens of popular libraries. Out of the box, it patches:
GET /users/:id)This covers a lot of ground. When a request hits your Express endpoint, makes a database query, and calls another service over HTTP, you'll see the full chain in your trace viewer without writing any instrumentation code.
But auto-instrumentation has limits. It can't understand your business logic. It doesn't know that a POST /orders endpoint is doing order validation, then inventory reservation, then payment processing. It sees the HTTP call and the database queries, but the meaningful steps in between are invisible.
It also can't capture Pub/Sub semantics unless you're using a supported message broker library, and even then the trace context propagation across message boundaries requires careful attention.
For business logic visibility, you create spans manually using the OTel API. Here's what that looks like in practice:
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("order-service");
async function processOrder(orderId: string, items: OrderItem[]) {
return tracer.startActiveSpan("process-order", async (span) => {
span.setAttribute("order.id", orderId);
span.setAttribute("order.item_count", items.length);
try {
// Each step becomes its own span
const inventory = await tracer.startActiveSpan(
"check-inventory",
async (inventorySpan) => {
const result = await checkInventory(items);
inventorySpan.setAttribute(
"inventory.available",
result.allAvailable,
);
inventorySpan.end();
return result;
},
);
if (!inventory.allAvailable) {
span.setAttribute("order.status", "rejected");
span.end();
return { status: "out_of_stock" };
}
await tracer.startActiveSpan(
"process-payment",
async (paymentSpan) => {
const total = items.reduce((sum, i) => sum + i.price, 0);
paymentSpan.setAttribute("payment.amount", total);
await chargeCustomer(orderId, total);
paymentSpan.end();
},
);
span.setAttribute("order.status", "completed");
span.end();
return { status: "completed" };
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : "Unknown error",
});
span.recordException(error as Error);
span.end();
throw error;
}
});
}
Notice the pattern: every span must be explicitly ended, exceptions must be recorded manually, and attributes are set with string keys. It's verbose, but it gives you control over exactly what appears in your traces.
The startActiveSpan method sets the new span as the active span in the current context, so any child spans (including those from auto-instrumentation) are correctly nested. If you use startSpan instead, you'll need to manage context propagation yourself.
Context propagation is how OpenTelemetry maintains the parent-child relationship between spans across async boundaries. In Node.js, this relies on AsyncLocalStorage under the hood.
For the most part, it works transparently. When you call startActiveSpan, the span is set in the async context, and any code that runs within the callback (including awaited promises) can see it. Auto-instrumented libraries pick up the active span and create child spans under it.
Where it breaks down:
Event emitters. If you're listening to events on a stream or EventEmitter, the context may not propagate correctly depending on when the listener was registered.
// This can lose context
const stream = getDataStream();
stream.on("data", (chunk) => {
// The active span here might not be what you expect
tracer.startActiveSpan("process-chunk", (span) => {
processChunk(chunk);
span.end();
});
});
Manual promise construction. Creating a new Promise() and resolving it later can lose context if the resolution happens in a different async scope.
Callbacks across service boundaries. When making HTTP calls to other services, OpenTelemetry injects trace headers (traceparent, tracestate) into outgoing requests automatically, but only if the HTTP library is instrumented. If you're using a custom HTTP client or a library without OTel support, you'll need to inject headers manually:
import { context, propagation } from "@opentelemetry/api";
function makeRequest(url: string) {
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
// Now pass headers to your HTTP client
return fetch(url, { headers });
}
Getting context propagation right is one of the trickier parts of an OTel setup. When traces show disconnected spans that should be linked, this is usually the cause.
Traces need to go somewhere you can view them. The OpenTelemetry Collector acts as a pipeline between your application and your observability backend. It receives telemetry, processes it, and exports it to one or more destinations.
Create a docker-compose.yml:
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.96.0
command: ["--config=/etc/otel/config.yaml"]
volumes:
- ./otel-config.yaml:/etc/otel/config.yaml
ports:
- "4317:4317" # gRPC receiver
- "4318:4318" # HTTP receiver
depends_on:
- jaeger
jaeger:
image: jaegertracing/all-in-one:1.54
ports:
- "16686:16686" # Jaeger UI
- "14268:14268" # Jaeger collector
environment:
- COLLECTOR_OTLP_ENABLED=true
Create otel-config.yaml for the collector:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
logging:
loglevel: debug
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger, logging]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [logging]
Start everything:
docker compose up -d
Now run your application with the tracing configuration. Traces will flow from your app to the collector, through the batch processor, and into Jaeger. Open http://localhost:16686 to see your traces.
This is the minimum viable setup. In production, you'd add:
Once you have this working, maintaining it is an ongoing commitment. OpenTelemetry's SDK releases frequently, and the instrumentation packages need to stay in sync. When you upgrade Express from v4 to v5, you need to verify the instrumentation package supports it. When you add a new library (say, a Redis cache or an S3 client), you need to check whether auto-instrumentation covers it and add the right package if it doesn't.
Custom spans also have a cost: every developer on the team needs to know the OTel API, follow naming conventions, remember to end spans, and handle errors correctly. In a larger codebase, inconsistent instrumentation is the norm, not the exception.
None of this means OTel isn't worth it. Distributed tracing is essential for running backend services at any real scale. But the setup and maintenance cost is significant, and it falls on your team.
There's a different approach: move the instrumentation into the framework itself, so application code never touches tracing SDKs.
Encore.ts takes this approach. Because Encore understands your application structure at compile time (the services, APIs, databases, Pub/Sub topics, and cron jobs), it instruments everything automatically. Tracing works out of the box from the moment you create your first endpoint.
Here's what an Encore service looks like:
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("orders", { migrations: "./migrations" });
export const getOrder = api(
{ expose: true, method: "GET", path: "/orders/:id" },
async ({ id }: { id: string }) => {
const row = await db.queryRow`
SELECT id, status, total FROM orders WHERE id = ${id}
`;
if (!row) throw new Error("order not found");
return row;
},
);
That's it. Every request to this endpoint is traced automatically, including the database query, with proper span hierarchy and timing. Service-to-service calls carry trace context without any propagation code. Pub/Sub messages maintain trace context across publish and subscribe boundaries.
You get a local development dashboard at localhost:9400 with a trace viewer that shows the full request lifecycle, including all database queries and their execution time, without configuring a collector or running Docker containers.
For production, Encore Cloud provides built-in observability with tracing, metrics, and logging — all accessible through Encore's own dashboard without needing to configure external backends.
| Manual OpenTelemetry | Encore | |
|---|---|---|
| Setup time | 1-2 hours for basic tracing | Zero configuration |
| Packages to install | 8+ (SDK, exporters, instrumentations) | None (built into the framework) |
| Auto-instrumentation | HTTP, Express, pg, Redis (via meta-package) | All APIs, databases, Pub/Sub, cron, service calls |
| Custom business logic | Manual spans with OTel API | Manual spans with OTel API (same) |
| Context propagation | Automatic for patched libs, manual otherwise | Automatic across all Encore primitives |
| Collector setup | Docker Compose + config file | Built into local dev, Encore Cloud for production |
| Maintenance | Keep 8+ packages in sync, verify on upgrades | Framework handles it |
| Flexibility | Any Node.js app, any architecture | Encore applications |
| Trace visualization | Via collector + external backend (Jaeger, Grafana, etc.) | Built-in tracing dashboard in local dev and Encore Cloud |
The trade-off is clear: manual OTel gives you maximum flexibility to instrument any Node.js application regardless of framework. Encore gives you comprehensive instrumentation with no setup cost, but you're building with the Encore framework. If you're starting a new project and want distributed tracing from day one, the framework-level approach removes an entire category of work from your plate.
If you're going the manual OTel route:
If you want to skip the instrumentation setup: