Instrumenting OpenTelemetry Node.js Web Applications
Node.js applications are asynchronous by nature (event loop, callbacks, promises) plus DB drivers and external API calls means a single user request can hop across multiple execution contexts, which makes pinpointing latency or errors by logs alone frustrating and slow.
OpenTelemetry (OTel) solves that pain by standardizing how you collect traces, metrics, and logs in a vendor-neutral way. OTel’s SDKs can auto-instrument common frameworks like Express, Fastify, and NestJS, while also letting you add custom spans and metrics around business logic; when paired with the correct observability tool it scales from a quick local setup to a production observability pipeline.
This guide takes you from a basic "zero-code" setup to a “production ready” setup.

What is OpenTelemetry?
OpenTelemetry is an open-source, vendor-neutral framework for collecting traces, metrics, and logs from your application. It gives you three ways to instrument a Node.js service:
- Zero-code (automatic): no application changes. You install the auto-instrumentation libraries and preload them at startup.
- Code-based (automatic + manual): auto-instrumentation for the common libraries, plus your own spans around business logic.
- Manual: you create every span and metric yourself, for full control over what gets recorded.
This guide walks through the first and the third. The middle option is simply the two combined.
Prerequisites
- Node.js 18.19+ or 20.6+, per the OpenTelemetry JS 2.x supported versions. Node 18 reached end of life on March 27, 2025, so use Node 20 or later.
- A destination for your telemetry data (we will use SigNoz Cloud for the examples, but the concepts apply to any OTLP-compliant backend).
Quick Start
Clone the sample Node.js app repository used throughout this guide:
git clone https://github.com/LuffySama-Dev/SampleNodejsExample.git
cd SampleNodejsExample && npm installIt is a plain Express server with no telemetry in it yet — two routes and one function that calls three public APIs in parallel (abridged):
async function fetchData() {
const [catFact, dogFact, randomJoke] = await Promise.all([
axios.get("https://catfact.ninja/fact"),
axios.get("https://dog.ceo/api/breeds/image/random"),
axios.get("https://official-joke-api.appspot.com/jokes/random"),
]);
return {
catFact: catFact.data.fact,
dogImage: dogFact.data.message,
joke: randomJoke.data.setup + " - " + randomJoke.data.punchline,
};
}
app.get("/data", async (req, res) => res.json(await fetchData()));
app.get("/addCount", (req, res) =>
res.status(200).json({ method: req.method, message: "Increased count by 1." })
);
app.listen(PORT); // 5555 unless PORT is setThose three concurrent axios calls are what make the app worth instrumenting: each becomes its own span, so you get a trace with visible concurrency instead of one flat request.
One line in package.json decides how you load OpenTelemetry later:
{
"type": "commonjs",
"dependencies": { "axios": "^1.13.2", "express": "^5.2.0" }
}How to Instrument Node.js Application with OpenTelemetry?
Auto Instrumenting your Node.js Application
You should use this instrumentation when:
- You want to quickly get your application instrumented and start exporting telemetry with no code changes.
- You don’t have any specific business requirement for custom instrumentation for traces, metrics, or logs.
Step 1: Install OpenTelemetry Packages
npm install --save @opentelemetry/api @opentelemetry/auto-instrumentations-node| Package | What it does |
|---|---|
@opentelemetry/api | The instrumentation API, including tracers, meters, and context propagation. Your code and your dependencies call this; it stays a no-op until an SDK is registered. |
@opentelemetry/auto-instrumentations-node | A meta-package pulling in the instrumentation libraries for common modules (http, express, database drivers), patching them at load time so they emit spans with no code changes. |
For how the API and the SDK differ, see OpenTelemetry API vs SDK.
Step 2: Configure Environment Variables
The zero-code path is driven entirely by environment variables, so nothing in your application changes. Export these in the terminal you will start the app from:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.<Region>.signoz.cloud:443"
export OTEL_SERVICE_NAME="<APP_NAME>"
export OTEL_NODE_RESOURCE_DETECTORS="env,host,os"
export OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=<SIGNOZ_INGESTION_KEY>"
export NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register"Here is a breakdown of the environment variables used:
| Variable | Description |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Where telemetry is sent. Set the base URL once and the SDK appends /v1/traces and /v1/metrics itself. |
OTEL_SERVICE_NAME | The name of your application. Ensure it is unique so your backend doesn't group telemetry from two distinct sources under one label. |
OTEL_NODE_RESOURCE_DETECTORS | Optional resource metadata to attach: environment, host, and OS. You can exclude these if you find them verbose or unnecessary. |
OTEL_EXPORTER_OTLP_HEADERS | Authentication headers sent with every export. This is generally configured when using cloud vendor backends. |
NODE_OPTIONS | Preloads the auto-instrumentation before your own code runs, which is what allows it to patch execution paths for libraries like http and express. |
Step 3: Start the Application
After setting the environment variables, start your application by running:
node index.jsNote that there are no flags on that command. Node reads NODE_OPTIONS from your environment at startup, so the --require you exported in Step 2 is applied for you. That is what makes this path zero-code: your existing npm start script or container CMD keeps working unchanged.
You can check your application running at:
http://localhost:5555/datahttp://localhost:5555/addCount
Hit both endpoints 10-20 times to generate some traffic, then give the exporter a minute or two to export the data from the application.
Step 4: Verify the Data
Open your backend and look for the service name you set in OTEL_SERVICE_NAME. In SigNoz Cloud it shows up under the Services tab:

Then open a slow request from the Traces tab to see where the time actually went. The three axios calls in fetchData() appear as sibling spans running concurrently, so if one is consistently the long pole, that is your bottleneck:

That is the entire zero-code setup: two packages, five environment variables, and no changes to your application.
OTel makes it easy to get started with your observability setup, and any further additions are by choice as your observability needs evolve.
Node.js Runtime Metrics: Event Loop Lag, GC, and Heap
Traces tell you how long a request took. They don't tell you why the whole process got slow. In Node.js the usual culprits are runtime-level: a blocked event loop, garbage-collection pauses, or a heap creeping toward its limit.
Because Node runs your application on a single thread, one slow synchronous operation stalls every in-flight request, and no amount of span data will point at it directly.
You already have these metrics. @opentelemetry/auto-instrumentations-node bundles @opentelemetry/instrumentation-runtime-node, and it is enabled by default, so the zero-code setup from earlier is already collecting:
| Metric | What it tells you |
|---|---|
nodejs.eventloop.delay.mean / .max | How long work is waiting before the event loop can run it. Rising values mean something is blocking the thread. |
nodejs.eventloop.utilization | How busy the loop is. Sustained values near 1 mean the process is saturated and needs scaling out. |
v8js.gc.duration | Time spent in garbage collection. Frequent long pauses show up here before they show up as user-visible latency. |
v8js.memory.heap.used / v8js.memory.heap.limit | Heap growth against its ceiling. A used figure that climbs and never falls back is the classic memory-leak signature. |
Chart these in a SigNoz dashboard alongside your RED metrics. Latency spikes that line up with event-loop delay point at blocking code; latency that tracks v8js.gc.duration points at memory pressure instead.
The entire list of Node.js runtime metrics with their unit of measurement, stability status, and more information in the official documentation.
How to Opt-in for Host-level Metrics?
The related @opentelemetry/instrumentation-host-metrics package, which reports machine-level CPU, memory, and network usage, ships in the same bundle but sits in the default-excluded list alongside instrumentation-fs.
Turning it on means replacing the zero-code entrypoint with your own instrumentation file, because you need somewhere to pass the config.
Create instrumentation.js next to your application entry point:
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-host-metrics': { enabled: true },
}),
],
})
sdk.start()This needs @opentelemetry/sdk-node alongside the packages you installed earlier:
npm install --save @opentelemetry/sdk-nodeThen preload that file instead of @opentelemetry/auto-instrumentations-node/register:
node --require ./instrumentation.js index.jsYour exporter settings do not move into the file. NodeSDK still reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_METRICS_EXPORTER and the rest from the environment, so keep the variables from Step 2 exactly as they are.
Once it restarts you should see system.cpu.time, system.memory.usage and process.cpu.utilization arriving alongside the runtime metrics above.
Two things to watch. Setting the OTEL_NODE_ENABLED_INSTRUMENTATIONS environment variable turns it into an allowlist, so enabling host metrics that way silently disables every instrumentation you did not name.
Prefer the file above unless you genuinely want a narrow list. And inside a container these metrics describe the container, not the Docker host, so for true host visibility run a Collector on the host instead.
Manually Instrumenting your Node.js Application
You should use Manual Instrumentation when:
- You need custom business logic for tracing
- You need more context in your spans
- You are Instrumenting Custom Modules or Non-Standard Libraries
- You Need High-Value Metrics
- Distributed Tracing Across Microservices
Step 1: Install OpenTelemetry packages
npm i --save @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics \
@opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/resources @opentelemetry/semantic-conventions| Package | What it does |
|---|---|
@opentelemetry/sdk-node | The SDK itself. Wires up tracing and metrics, reads the standard OTEL_* environment variables, and manages startup and shutdown. |
@opentelemetry/api | The API surface you write against — trace.getTracer() and metrics.getMeter() in your application code. |
@opentelemetry/sdk-metrics | The metrics pipeline. Supplies PeriodicExportingMetricReader, which batches and flushes measurements on an interval. |
@opentelemetry/exporter-trace-otlp-http | Sends spans over OTLP/HTTP. |
@opentelemetry/exporter-metrics-otlp-http | Sends metrics over OTLP/HTTP. |
@opentelemetry/resources | Builds the resource — the attributes describing what is emitting telemetry, rather than any single request. |
@opentelemetry/semantic-conventions | Named constants such as ATTR_SERVICE_NAME, so you get the spec's attribute keys instead of hand-typed strings. |
Step 2: Create an instrumentation.js file
This file initializes and starts the SDK. It has to run before your application code, which is why it gets preloaded rather than imported from index.js. If you already created an instrumentation.js for host metrics earlier, this replaces its contents.
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics')
const { resourceFromAttributes } = require('@opentelemetry/resources')
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions')
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http')
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http')
const sdk = new NodeSDK({
// Identifies *what* is emitting telemetry. Attached to every span and metric.
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'node-app',
[ATTR_SERVICE_VERSION]: '0.1.0',
}),
// Spans are batched and shipped over OTLP/HTTP as they finish.
traceExporter: new OTLPTraceExporter(),
// Metrics are not event-driven, so they get flushed on a timer instead.
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
exportIntervalMillis: 10000,
}),
})
sdk.start()NodeSDK registers no instrumentation libraries unless you pass them, so this setup produces only the spans you create by hand. To get the auto-instrumented HTTP, Express, and database spans as well, and nest your custom spans inside them, add:
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')
const sdk = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
// ...resource and exporters as above
})Both exporters are constructed with no arguments on purpose. NodeSDK reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS from the environment, so the same file works against a local Collector in development and a hosted backend in production without code changes.
If you would rather be explicit, pass them to the exporter directly:
traceExporter: new OTLPTraceExporter({
url: 'https://ingest.<region>.signoz.cloud:443/v1/traces',
headers: { 'signoz-ingestion-key': process.env.SIGNOZ_INGESTION_KEY },
}),Step 3: Add custom Span & Metric Counters in index.js file
After adding the custom spans and counters, index.js looks like this:
const express = require('express')
const axios = require('axios')
const { trace, metrics } = require('@opentelemetry/api')
// Tracer for creating spans
const tracer = trace.getTracer('node-app', '0.1.0')
// Meter for creating counters
const meter = metrics.getMeter('node-app', '0.1.0')
const dataCounter = meter.createCounter('data.fetchData.counter')
const randomCounter = meter.createCounter('addCount.counter')
const app = express()
const PORT = process.env.PORT || 5555
async function fetchData() {
return tracer.startActiveSpan('fetchDataFunction', async (span) => {
// Span event: request start
span.addEvent('Fetch request started')
try {
const [catFact, dogFact, randomJoke] = await Promise.all([
axios.get('https://catfact.ninja/fact'),
axios.get('https://dog.ceo/api/breeds/image/random'),
axios.get('https://official-joke-api.appspot.com/jokes/random'),
])
// Span event: request success
span.addEvent('Fetch request succeeded')
return {
catFact: catFact.data.fact,
dogImage: dogFact.data.message,
joke: randomJoke.data.setup + ' - ' + randomJoke.data.punchline,
}
} catch (error) {
// Span event: request failure
span.addEvent('Fetch request failed', { error: error.message })
return { error: 'Failed to fetch data from APIs' }
} finally {
span.end() // Close span
}
})
}
app.get('/data', async (req, res) => {
dataCounter.add(1) // Metric count increment
const data = await fetchData()
res.json(data)
})
app.get('/addCount', async (req, res) => {
randomCounter.add(1) // Metric count increment
res.status(200).json({
method: req.method,
message: 'Increased count by 1.',
...req.body,
})
})
app.listen(PORT, () => {
console.log(`
Server running on http://localhost:${PORT}
curl http://localhost:${PORT}/data
curl http://localhost:${PORT}/addCount
`)
})Three things changed. trace.getTracer() and metrics.getMeter() return the handles you record against; the name and version you pass are what identify this instrumentation in the exported data.
tracer.startActiveSpan() wraps fetchData() in a span and makes it the active span for the duration of the callback, so anything instrumented inside it is recorded as a child. span.addEvent() marks a moment in time within that span rather than a duration, which is how you record that a request started, succeeded, or failed. The span.end() in the finally block matters most: a span that is never ended is never exported.
The counters are independent of tracing. dataCounter.add(1) increments a metric each time a route is hit. It is a running total, not something derived from spans.
Step 4: Start the Application
-
Start your application by running below command in new terminal:
node --require ./instrumentation.js index.js -
You can check your application running at:
http://localhost:5555/datahttp://localhost:5555/addCount
Hit both endpoints a few times, then check your backend.
Step 5: Verify the Custom Telemetry
Your custom span appears in the trace as fetchDataFunction, with the events you recorded marked along its timeline. Note that the axios calls themselves produce no spans here and the HTTP columns read N/A; this SDK setup registers no instrumentation libraries, so the only spans that exist are the ones you created by hand:

The counters will not show up on the trace: they are metrics, so you query them separately and chart them on a dashboard. In SigNoz, add a panel on data.fetchData.counter following the dashboards guide.

How to Instrument Multiple Node.js Services with Docker Compose?
The setup above instruments a single process. Real systems usually run several Node.js services side by side, and the good news is that nothing about the instrumentation changes: you set the same environment variables on each service, varying only OTEL_SERVICE_NAME.
Because the auto-instrumentation propagates trace context across HTTP calls, a request that hops from one service to the next arrives as a single connected trace rather than as several unrelated ones.
Keep your credentials out of docker-compose.yml by putting them in a .env file, and add that file to .gitignore:
# .env
OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.<region>.signoz.cloud:443"
OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=<SIGNOZ_INGESTION_KEY>"Then reference those variables from each service:
services:
order:
build: ./order-service
ports:
- '3001:3001'
environment:
- OTEL_TRACES_EXPORTER=otlp
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}
- OTEL_NODE_RESOURCE_DETECTORS=env,host,os
- OTEL_SERVICE_NAME=order-service
- NODE_OPTIONS=--require @opentelemetry/auto-instrumentations-node/register
networks:
- myapp-network
payment:
build: ./payment-service
ports:
- '3002:3002'
environment:
- OTEL_TRACES_EXPORTER=otlp
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}
- OTEL_NODE_RESOURCE_DETECTORS=env,host,os
- OTEL_SERVICE_NAME=payment-service
- NODE_OPTIONS=--require @opentelemetry/auto-instrumentations-node/register
networks:
- myapp-network
networks:
myapp-network:Add further services by copying one block and changing build, ports, and OTEL_SERVICE_NAME.
Routing Through an OpenTelemetry Collector
Exporting straight to your backend is fine for a handful of services. Once you want to batch, filter, or enrich telemetry in one place (or collect host machine metrics alongside your application data), put an OpenTelemetry Collector between your services and the backend.
The only change on the application side is the endpoint: point it at the collector's container name on the shared Docker network instead of at your backend.
services:
order:
build: ./order-service
environment:
- OTEL_TRACES_EXPORTER=otlp
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # collector, not the backend
- OTEL_NODE_RESOURCE_DETECTORS=env,host,os
- OTEL_SERVICE_NAME=order-service
- NODE_OPTIONS=--require @opentelemetry/auto-instrumentations-node/register
depends_on:
- otel-collector
networks:
- myapp-network
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config', '/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- '4317:4317'
- '4318:4318'
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}
- SIGNOZ_INGESTION_KEY=${SIGNOZ_INGESTION_KEY}
networks:
- myapp-network
networks:
myapp-network:Note the port: use 4318 for OTLP over HTTP and 4317 for gRPC. Pointing an HTTP exporter at 4317 produces a Parse Error: Expected HTTP/ in the collector logs, which is one of the more common setup mistakes.
For the collector configuration file itself, and for troubleshooting a containerised collector, see the OTel Collector in Docker guide.
Check out our dedicated guide on the OTel Collector Contrib distribution if you are interested in learning more on the topic.
Logging in NodeJs
Native logging in Node.js is quite minimal, which is why structured logging libraries like Winston and Pino are widely adopted in real-world projects.
If you want to Instrument your logs and forward them to SigNoz, please follow below guides:
Correlating Logs with Traces
Once your logs are flowing in, the next step is making them truly useful by tying them back to the requests they belong to. This is where trace-log correlation comes in. By embedding trace IDs into your log entries, you can jump from a slow or failing span directly to the logs that were generated during that exact execution path. It turns scattered information into a connected story, making debugging far quicker and far less guess-heavy.
The good news is that you rarely have to wire this up by hand. OpenTelemetry ships instrumentation for the common Node.js loggers, and it adds the trace context for you:
npm install --save @opentelemetry/instrumentation-pino
# or @opentelemetry/instrumentation-winston, @opentelemetry/instrumentation-bunyanRegister it in your SDK setup:
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { PinoInstrumentation } = require('@opentelemetry/instrumentation-pino')
const sdk = new NodeSDK({
instrumentations: [new PinoInstrumentation()],
// ...your exporters and resource
})
sdk.start()Any log emitted while a span is active now carries trace_id, span_id, and trace_flags, which is exactly what SigNoz uses to link a log line to its span. The Pino instrumentation additionally routes log records to the OpenTelemetry Logs SDK, so a single package covers both correlation and log export.
Best Practices for Instrumenting Node.js with OpenTelemetry
As you start instrumenting more services or move your setup toward production, keeping a few best practices in mind can save you a lot of friction down the road. These aren’t hard rules, but they come from common pitfalls teams hit when rolling out OpenTelemetry in real Node.js environments.
- Name your services and spans clearly Use meaningful, stable names so traces are easy to search and understand.
- Capture only what you need Avoid overly broad manual instrumentation. Focus on key business logic and high-value operations.
- Use consistent resource attributes
Standard fields like
service.name,service.version, and environment tags help keep your data organized. - Enable trace–log correlation Include trace and span IDs in your logs to speed up debugging and reduce guesswork.
- Monitor metrics alongside traces Counters, latency metrics, and error rates provide quick signals before you even look at traces.
- Validate locally before deploying Run your app with debug logging enabled to ensure spans and metrics export correctly.
- Establish a baseline before you set alerts Track normal ranges for latency, error rate, event-loop delay, and heap usage first. Without a baseline you cannot tell a genuine regression from ordinary variation, and alerts thresholded on guesses mostly generate noise.
- Keep attribute cardinality bounded
Attributes like
order.idare fine on spans but expensive on metrics, where every distinct value creates another time series. Put identifiers on spans, and keep metric attributes to bounded sets such as status, route, or region. - Instrument toward a question, not for coverage More telemetry is not more insight. Start from what you would need to answer during an incident, instrument that, and extend as real gaps appear.
These small steps help keep your telemetry clean, actionable, and ready for production as your application grows.
Conclusion
You now have a Node.js service emitting traces, runtime metrics, and correlated logs through OpenTelemetry. Because all of it is standard OTLP, the same instrumentation points at any compliant backend. It is only the endpoint that changes.
From here, custom metrics in Node.js covers business counters and gauges, and adding manual spans goes deeper on tracing your own code paths.
Get Started with SigNoz
SigNoz Cloud is the quickest way to visualize this data, with a 30-day free trial. If your data has to stay inside your own infrastructure, there is a self-hosted community edition and an enterprise or BYOC option.
Still stuck on something in your own setup? Ask the SigNoz AI chatbot, or bring it to our slack community.
You can also subscribe to our newsletter for insights from observability nerds at SigNoz, get open source, OpenTelemetry, and devtool building stories straight to your inbox.