Instrumenting Express with OpenTelemetry
Register NodeSDK with HttpInstrumentation and ExpressInstrumentation in a file that Node loads with --require before express is ever imported — the HTTP instrumentation creates the server span and the Express instrumentation renames it to the matched route. Load order is the whole game: require Express first and you get zero spans.
Context & When It Matters
In Node.js, OpenTelemetry instruments Express by monkey-patching the http and express modules the moment they are required. Two packages cooperate: HttpInstrumentation hooks the HTTP server and creates the SERVER span (and injects the traceparent header on outbound http client calls), while ExpressInstrumentation observes the router and rewrites that span’s name from HTTP GET to the matched route like GET /orders/:id. The active span travels through the request via AsyncLocalStorage, Node’s built-in continuation-local storage, which propagates across await and most async primitives without any manual work. The one hard rule — and the source of nearly every “it emits nothing” report — is that the SDK must register its patches before the application requires express. Patch a module that is already loaded and the original, un-instrumented functions are already referenced everywhere.
Minimal Working Setup
Put tracing in its own file so it can be loaded first, before any application code touches Express.
// tracing.js — MUST be loaded before express is required
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
const sdk = new NodeSDK({
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'orders-api' }),
traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
instrumentations: [
new HttpInstrumentation(), // creates the SERVER span + outbound traceparent
new ExpressInstrumentation(), // renames it to the matched route
],
});
sdk.start(); // patches http and express — nothing may require express before this
process.on('SIGTERM', () => sdk.shutdown());
// server.js — pure application code, no tracing logic here
const express = require('express');
const app = express();
app.get('/orders/:id', (req, res) => {
res.json({ id: req.params.id, status: 'shipped' });
});
app.listen(8000);
# Load tracing first via --require so it patches before server.js runs
node --require ./tracing.js server.js
A request to /orders/42 now yields one server span named GET /orders/:id — the route pattern, not the literal /orders/42 — with http.route set accordingly. Set OTEL_PROPAGATORS=tracecontext,baggage and an inbound traceparent will be continued rather than replaced by a new trace ID.
Implementation
For production, add a manual span inside a handler for business logic and see how AsyncLocalStorage carries the context across await for free — and where you must intervene when it does not.
// orders.js — manual spans nested under the auto server span
const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('orders-handler');
app.post('/orders/:id/confirm', async (req, res) => {
// startActiveSpan makes confirm_order active in AsyncLocalStorage for the callback.
await tracer.startActiveSpan('confirm_order', async (span) => {
span.setAttribute('order.id', req.params.id);
try {
// await keeps the same AsyncLocalStorage scope — child spans nest correctly.
const total = await computeTotal(req.params.id);
span.setAttribute('order.total', total);
// Callback handed to an emitter escapes the async scope: bind it explicitly.
const boundDone = context.bind(context.active(), () => {
// Runs later, outside this async scope — without bind() it would orphan.
tracer.startActiveSpan('emit_receipt', (s) => { s.end(); });
});
receiptQueue.once('flushed', boundDone);
span.setStatus({ code: SpanStatusCode.OK });
res.json({ id: req.params.id, confirmed: true });
} catch (err) {
span.recordException(err); // captures stack trace
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
res.status(500).json({ error: 'confirm failed' });
} finally {
span.end(); // always end — startActiveSpan does not auto-close
}
});
});
The load-bearing lines map to tracing concepts as follows: startActiveSpan writes the span into AsyncLocalStorage so any instrumented database or HTTP call inside the callback nests underneath it; await computeTotal(...) preserves that storage scope automatically, which is why no manual context handling is needed for ordinary async flow; context.bind(context.active(), cb) captures the current context and reinstalls it when the emitter later invokes the callback, the one place AsyncLocalStorage needs help; and recordException plus an ERROR status ensure the span reflects the failure. The broader set of these Node context fixes — worker threads, raw callbacks, and native addons — is covered in the async boundaries guide.
Verification
Confirm the setup is correct before relying on it:
- Spans exist at all. If the backend receives nothing, tracing loaded after Express. Verify with
node --require ./tracing.js server.jsand check that no modulerequired beforetracing.jspulls inexpress. - Route-named server span. The operation should read
GET /orders/:idwithspan.kind = serverand anhttp.routeattribute. A bareHTTP GETmeansExpressInstrumentationis not registered. - Nested children. A manual span like
confirm_orderand any auto client spans (outbound HTTP, database) must share the server span’strace_idand list it as parent. Use the backend’s trace waterfall to confirm the hierarchy. - Outbound propagation. Make the service call another instrumented service and confirm the downstream server span continues the same trace — proof that
HttpInstrumentationinjectedtraceparent.
Common Pitfalls
- Instrumentation loaded after Express. The dominant Express failure: any
require('express')— direct, or transitively through a framework or router imported above the SDK start — freezes the un-patched module and no spans appear. Keeptracing.jsfirst via--require, and never import application modules from inside it. - Layer vs route span naming.
ExpressInstrumentationcan emit a span per middleware layer (middleware - query,request handler - /orders/:id). This is useful but noisy; if only the server span matters, setignoreLayersor aspanNameHookto suppress per-layer spans, and always rely on the matched route for the server span name rather than constructing it fromreq.path. - AsyncLocalStorage gaps. Context propagates through
async/await,Promise,setImmediate, and timers, but not through callbacks stored and fired later from outside the request scope, nor intoworker_threads. Wrap those withcontext.bind(context.active(), cb)before handing them off, as in the implementation above.
FAQ
Why does Express produce no spans even though the SDK started successfully?
Express was required before the SDK registered its instrumentation. OpenTelemetry patches the express and http modules at require time, so if your entry file imports express above the tracing setup, the un-patched originals are already bound. Load tracing first with node --require ./tracing.js, or make the tracing import the very first line of the entry module.
Why is the Express server span named by HTTP method only, like HTTP GET?
The root HTTP span is created by HttpInstrumentation and initially named from the method. ExpressInstrumentation updates it to the matched route once routing resolves. If it stays HTTP GET, ExpressInstrumentation is missing or disabled, so install @opentelemetry/instrumentation-express alongside the HTTP instrumentation and confirm both are registered.
Why does trace context get lost across an event emitter or setTimeout callback in Express?
OpenTelemetry Node uses AsyncLocalStorage, which propagates through async/await and most Node async primitives automatically, but not through callbacks stored and invoked later outside the originating async scope. Bind the callback to the active context with context.bind(context.active(), cb) before handing it to the emitter or timer.
Related
- Instrumenting Web Frameworks with OpenTelemetry — the framework-agnostic server-span model this guide specialises.
- Instrumenting FastAPI with OpenTelemetry — the Python counterpart, with contextvars in place of AsyncLocalStorage.
- Handling Async Boundaries in Node.js and Python — context.bind, worker threads, and the full AsyncLocalStorage toolkit.