OpenTelemetry’s tracing capabilities can be added to Express.js applications with surprisingly little effort, effectively turning your web server into a data source for distributed tracing systems.
Let’s see this in action. Imagine a simple Express app:
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
Now, let’s instrument it with OpenTelemetry. First, install the necessary packages:
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http
Then, modify your application’s entry point to load the OpenTelemetry SDK and its instrumentation:
// index.js
require('@opentelemetry/sdk-node').getNodeAutoInstrumentations().register();
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
That’s it for basic instrumentation. When you run this app and send a request to http://localhost:3000/, OpenTelemetry will automatically create a trace for the incoming request. By default, it will try to export these traces using OTLP (OpenTelemetry Protocol) over HTTP.
To make this useful, you need an OpenTelemetry Collector or a compatible tracing backend (like Jaeger, Zipkin, or Honeycomb) running and configured to receive OTLP data. For local testing, you can run a simple collector:
docker run -p 4318:4318 -p 4317:4317 otel/opentelemetry-collector-contrib --config=/etc/otel-contrib-collector/config.yaml
And configure your Express app to send data to it. You can do this by setting environment variables or by providing explicit configuration to the exporter. For example, to export to the collector running on localhost:4318 (the default OTLP/HTTP port for the collector):
// index.js
const { getNodeAutoInstrumentations } = require('@opentelemetry/sdk-node');
const { trace } = require('@opentelemetry/api');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { NodeSDK } = require('@opentelemetry/sdk-node');
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'my-express-app',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces', // Default OTLP HTTP endpoint
}),
instrumentations: getNodeAutoInstrumentations(),
});
sdk.start();
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
trace.getTracer('my-express-app').startActiveSpan('handle-root-request', () => {
res.send('Hello World!');
});
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error));
});
With this setup, when you hit http://localhost:3000/, you’ll see a trace in your collector’s output, showing an "express" span for the incoming HTTP request and a "handle-root-request" span if you explicitly added it. The auto-instrumentation will automatically instrument core Node.js modules and popular libraries like Express, creating spans for incoming requests, outgoing HTTP requests, database calls, and more.
The real power comes from understanding how these spans relate. Each incoming request to your Express app becomes the root span (or a child of an upstream span if it’s part of a larger distributed system). All subsequent operations initiated by that request – like calling another internal service, querying a database, or even a specific piece of business logic you’ve instrumented manually – will be created as child spans. This creates a hierarchical tree of operations, allowing you to pinpoint exactly where latency or errors are occurring within your application’s request lifecycle.
The @opentelemetry/auto-instrumentations-node package is a marvel of convenience. It uses a technique called "patching" or "shimming" to wrap existing functions in Node.js modules and popular libraries before your application code runs. When a patched function is called, the instrumentation code intercepts it, starts a span, executes the original function, and then ends the span. This means you get tracing for many common operations without touching your application’s business logic, making adoption incredibly fast.
One subtle but important aspect of OpenTelemetry instrumentation is the concept of the "active span." When a span is started using trace.getTracer('your-service-name').startActiveSpan('your-span-name'), it becomes the "active" span in the current execution context. Any new spans created as children of this active span will automatically be linked correctly in the trace. This context propagation is crucial for building accurate distributed traces, and libraries like async_hooks and async_context in Node.js help OpenTelemetry manage this context across asynchronous operations seamlessly. Without this, you’d have to manually pass trace context around, which would be a nightmare.
The next step in your tracing journey will likely involve diving deeper into manual instrumentation to capture custom business logic and understanding how to configure sampling strategies to manage the volume of trace data.