Skip to content
FLAVIO COPES
flaviocopes.com
2026

OpenTelemetry tutorial for Node.js: traces and metrics from scratch

By

Instrument a Node.js API with OpenTelemetry, export traces and metrics through a collector, and learn how to debug slow production requests.

~~~

Logs tell us that something happened.

Metrics tell us how often it happens.

Traces tell us what one request did across the whole system.

OpenTelemetry is an open standard and a set of tools for producing this telemetry without tying an application to one monitoring vendor.

In this tutorial we’ll instrument a small Node.js API.

We’ll collect:

Then we’ll send the data through the OpenTelemetry Collector.

The three signals

OpenTelemetry works with three main signals.

A trace follows one operation through a system. A trace contains spans.

A span is one timed piece of work, such as an HTTP request, SQL query, or call to another service.

A metric is a number measured over time, such as request count, memory use, or response duration.

A log is a timestamped event.

In the current OpenTelemetry JavaScript project, traces and metrics are stable. The logs API and SDK are still less mature, so we’ll keep using our normal logger and correlate it with traces.

Create the API

Create a project:

mkdir otel-books-api
cd otel-books-api
npm init -y
npm install express

Add "type": "module" to package.json.

Create app.js:

import express from 'express'

const app = express()

app.get('/books/:id', async (request, response) => {
  await new Promise((resolve) => {
    setTimeout(resolve, Math.random() * 200)
  })

  response.json({
    id: request.params.id,
    title: 'The Hobbit',
  })
})

app.listen(3000, () => {
  console.log('API listening on http://localhost:3000')
})

Run it:

node app.js

Open http://localhost:3000/books/1.

The API works, but if it becomes slow in production we only know what the person reports.

Install OpenTelemetry

Install the Node SDK, automatic instrumentations, and OTLP exporters:

npm install \
  @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http

The packages have separate jobs:

OTLP is the OpenTelemetry Protocol.

Start instrumentation before the app

Create instrumentation.js:

import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
import { resourceFromAttributes } from '@opentelemetry/resources'
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions'

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'books-api',
    [ATTR_SERVICE_VERSION]: '1.0.0',
  }),
  traceExporter: new OTLPTraceExporter(),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter(),
    exportIntervalMillis: 5000,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
})

sdk.start()

const shutdown = async () => {
  await sdk.shutdown()
  process.exit(0)
}

process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)

Load it before the application:

node --import ./instrumentation.js app.js

Order matters.

Automatic instrumentation patches modules as they load. If Express or the HTTP module loads first, OpenTelemetry may miss the chance to instrument it.

Run a local Collector

Applications can send data directly to a backend, but the recommended production path usually includes the OpenTelemetry Collector.

The Collector receives telemetry, processes it, and exports it somewhere else.

Create otel-collector.yaml:

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]

Run the official Collector image:

docker run --rm \
  -p 4317:4317 \
  -p 4318:4318 \
  -v "$PWD/otel-collector.yaml:/etc/otelcol/config.yaml" \
  otel/opentelemetry-collector:latest

OTLP commonly uses port 4317 for gRPC and 4318 for HTTP.

The HTTP exporters use these defaults:

http://localhost:4318/v1/traces
http://localhost:4318/v1/metrics

Start the app in another terminal and make a request:

curl http://localhost:3000/books/1

The Collector prints a server span for the request.

What automatic instrumentation gives us

The HTTP and Express instrumentations create spans around incoming requests.

A span contains:

The trace ID groups every span belonging to the same operation.

If the API called another instrumented service, OpenTelemetry would propagate trace context in HTTP headers. That service’s span could then join the same trace.

Add a custom span

Automatic spans show framework activity. They do not know which part of our business logic matters.

Import the API in app.js:

import { trace } from '@opentelemetry/api'

const tracer = trace.getTracer('books-api')

Extract the fake database work:

async function findBook(id) {
  return tracer.startActiveSpan('find book', async (span) => {
    span.setAttribute('book.id', id)

    try {
      await new Promise((resolve) => {
        setTimeout(resolve, Math.random() * 200)
      })

      return {
        id,
        title: 'The Hobbit',
      }
    } catch (error) {
      span.recordException(error)
      span.setStatus({
        code: 2,
        message: error.message,
      })
      throw error
    } finally {
      span.end()
    }
  })
}

Use it in the route:

app.get('/books/:id', async (request, response) => {
  const book = await findBook(request.params.id)
  response.json(book)
})

startActiveSpan() makes this span active while the callback runs. It becomes a child of the current HTTP span.

Always end a manually created span. A finally block prevents unfinished spans when an error occurs.

Add a custom metric

Get a meter:

import { metrics, trace } from '@opentelemetry/api'

const meter = metrics.getMeter('books-api')

const lookupCounter = meter.createCounter('book.lookup.count', {
  description: 'Number of book lookups',
})

const lookupDuration = meter.createHistogram('book.lookup.duration', {
  description: 'Book lookup duration',
  unit: 'ms',
})

Measure the operation:

async function findBook(id) {
  const startedAt = performance.now()

  return tracer.startActiveSpan('find book', async (span) => {
    span.setAttribute('book.id', id)

    try {
      lookupCounter.add(1)

      await new Promise((resolve) => {
        setTimeout(resolve, Math.random() * 200)
      })

      return {
        id,
        title: 'The Hobbit',
      }
    } finally {
      lookupDuration.record(performance.now() - startedAt)
      span.end()
    }
  })
}

A counter only increases. A histogram records a distribution of values, which is what we want for durations.

Be careful with metric attributes

It is tempting to attach every useful value to every measurement:

lookupCounter.add(1, {
  'book.id': id,
})

Do not do that when id can have millions of values.

Every unique attribute combination creates another metric time series. This is called high cardinality, and it can make a metrics backend expensive or unusable.

Good metric attributes have a small, controlled set of values:

Put a specific request ID, user ID, or book ID on a span instead. Traces are a better place for high-detail debugging data.

Configure with environment variables

OpenTelemetry SDKs use standard environment variables.

For example:

OTEL_SERVICE_NAME=books-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
node --import ./instrumentation.js app.js

You can also set signal-specific endpoints:

export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://localhost:4318/v1/metrics

This lets the same application image run in development, staging, and production without hardcoded backend addresses.

Correlate logs with traces

When handling an error, include the current trace ID in the log:

import { trace } from '@opentelemetry/api'

function logError(error) {
  const span = trace.getActiveSpan()
  const traceId = span?.spanContext().traceId

  console.error({
    message: error.message,
    traceId,
  })
}

Now a log search can lead to the exact trace, and the trace can show which child operation was slow.

In a real app, configure your structured logger once so it adds trace and span IDs automatically.

Record errors correctly

An exception event does not automatically mean a span has an error status.

For a manual span, do both:

span.recordException(error)
span.setStatus({
  code: 2,
  message: error.message,
})

Use the semantic status that represents the operation. A handled 404 is not necessarily an internal error. A failed database connection is.

Avoid putting secrets or personal data in span attributes. Telemetry leaves the process and is often retained for a long time.

Sampling

Recording every trace is useful locally. A high-traffic service may produce too much data.

Sampling decides which traces to keep.

A common production setup samples a percentage of new traces while respecting a parent service’s decision. The exact percentage depends on traffic, cost, and how rare the failures are.

Head sampling makes the decision at the beginning of a trace. It is simple but cannot know that a later span will fail.

Tail sampling happens in a Collector after spans arrive. It can keep slow or failed traces, but it needs more infrastructure and memory.

Start by collecting enough data to understand actual volume. Do not choose a random aggressive sampling rate on day one.

Send data to a real backend

The debug exporter proves the pipeline works. For production, replace or add an exporter in the Collector configuration.

The application does not need to know whether the final destination is Jaeger, Grafana, Honeycomb, a cloud provider, or another compatible backend.

That separation is a major OpenTelemetry benefit:

Node.js app -> OTLP -> Collector -> observability backend

The Collector can also:

Debug missing telemetry

If no spans appear, check these in order:

  1. instrumentation loads before the application
  2. the Collector listens on the expected port
  3. the exporter protocol matches the receiver
  4. the endpoint includes /v1/traces or /v1/metrics when required
  5. the process stays alive long enough to export
  6. shutdown calls sdk.shutdown() so buffered data is flushed

You can temporarily use console exporters inside the application to separate an instrumentation problem from a network problem.

Also watch version compatibility between OpenTelemetry packages. Install related packages together and avoid mixing a very old instrumentation package with a new SDK.

What we built

Our API now produces:

The most useful mental model is simple.

Metrics tell you that the API is slow. Traces help you find where the time went. Logs explain what the application said while it happened.

OpenTelemetry connects those signals using an open format, while the Collector keeps your application separate from the backend that stores and visualizes them.

Tagged: Node.js · All topics
~~~

Related posts about node: