Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for Node.js: Traces, Metrics & Logs with OpenObserve

Instrument Node.js with OpenTelemetry: auto-instrumentation, manual traces, metrics, and logs, then ship everything to OpenObserve over OTLP.

OpenTelemetry guide

Node.js has one of the most mature OpenTelemetry implementations of any language. The JavaScript SDK ships stable tracing and metrics APIs, a rich auto-instrumentation ecosystem covering http, Express, gRPC, PostgreSQL, MySQL, Redis, MongoDB, and dozens of other libraries, and first-class OTLP exporters that talk directly to any OTLP-compatible backend — including OpenObserve. If you are new to the project itself, start with What is OpenTelemetry? A Complete Guide for the concepts; this page is the hands-on Node.js reference.

Why OpenTelemetry for Node.js

OpenTelemetry gives you a single, vendor-neutral way to produce traces, metrics, and logs from a Node.js process. Instrument once against the @opentelemetry/api package, and swap backends by changing an endpoint and a header — no code rewrite when your observability vendor changes.

Signal stability in the JavaScript SDK, per the official OpenTelemetry status page:

SignalStatus in JS/Node.jsPractical guidance
TracesStableProduction-ready; API and SDK are stable
MetricsStableProduction-ready; API and SDK are stable
LogsDevelopmentUsable today via experimental packages (@opentelemetry/api-logs, @opentelemetry/sdk-logs); expect breaking changes between minor releases

Two things follow from that table. First, you can commit to OpenTelemetry traces and metrics in Node.js today without hedging. Second, treat the logs signal as functional but moving: pin exact versions of the logs packages, read changelogs before upgrading, and keep a fallback log pipeline until the signal reaches stable.

OpenTelemetry works with any Node.js framework — Express, Fastify, Nest, Koa, or a bare http server. The examples below use a plain HTTP server so nothing framework-specific gets in the way; auto-instrumentation picks up whichever framework you actually run.

Prerequisites

  • Node.js 18.19+ or 20.6+ (an active LTS release is recommended; the current SDK line requires at least these versions)
  • npm or another package manager
  • An OpenObserve instance:
    • Self-hosted: runs on http://localhost:5080 by default; you set the root credentials via ZO_ROOT_USER_EMAIL and ZO_ROOT_USER_PASSWORD on first startup
    • OpenObserve Cloud: sign up at https://cloud.openobserve.ai, then copy your ingestion credentials from the Data Sources page

Every request to OpenObserve authenticates with HTTP Basic auth. Generate the token once and reuse it everywhere below:

echo -n 'root@example.com:Complexpass#123' | base64

The output (for example cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=) goes into an Authorization: Basic <token> header. For OpenObserve Cloud, use the credentials shown on the Data Sources page instead of your login password.

Zero-code (automatic) instrumentation

The fastest path to traces from an existing service is the auto-instrumentation register hook. It requires no changes to application code at all — configuration happens entirely through environment variables.

Install two packages:

npm install @opentelemetry/api @opentelemetry/auto-instrumentations-node

Then start your app with the register hook loaded before anything else:

env OTEL_SERVICE_NAME=checkout-service \
    OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5080/api/default \
    OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=" \
    node --require @opentelemetry/auto-instrumentations-node/register app.js

That single flag wires up a NodeSDK internally, registers instrumentations for every supported library it finds in your dependency tree, and exports over OTLP. HTTP servers, outbound fetch/http calls, and database clients start producing spans immediately, with context propagated across service boundaries via W3C traceparent headers.

Two details worth knowing:

  • The %20 in the headers value is deliberate. The OTLP specification parses OTEL_EXPORTER_OTLP_HEADERS using W3C Baggage rules, where values are percent-encoded. Encoding the space after Basic works across all SDKs and avoids a class of silent auth failures.
  • OTEL_EXPORTER_OTLP_ENDPOINT is the base URL. The exporter appends /v1/traces, /v1/metrics, or /v1/logs per signal, which lands exactly on OpenObserve’s OTLP HTTP paths. Do not add a trailing slash and do not append /v1/traces yourself here — that is only for the signal-specific OTEL_EXPORTER_OTLP_TRACES_ENDPOINT variable, which is used verbatim.

Zero-code is ideal for getting a baseline. Most teams then graduate to an explicit instrumentation file for control over exporters, sampling, and custom spans — that is the pattern the rest of this guide uses.

The NodeSDK setup file

Create an instrumentation.js that configures the SDK explicitly. This file must execute before any application module loads, so it is passed to Node.js with --require (CommonJS) or --import (ESM), never imported from your app code.

Install the packages:

npm install @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/sdk-metrics \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions
// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'checkout-service',
    [ATTR_SERVICE_VERSION]: '1.4.2',
  }),
  traceExporter: new OTLPTraceExporter(),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter(),
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown()
    .catch((err) => console.error('OTel shutdown error', err))
    .finally(() => process.exit(0));
});

Note the exporters take no constructor arguments here — endpoint and auth come from the standard OTEL_EXPORTER_OTLP_* environment variables shown in the OpenObserve section below, which keeps credentials out of source code. The SIGTERM handler matters in containers: sdk.shutdown() flushes buffered spans and metrics before the process exits, so you do not lose the last batch on every deploy.

Run your app:

node --require ./instrumentation.js app.js

For ESM projects on Node.js 20.6+, write the file as instrumentation.mjs with import syntax and load it with node --import ./instrumentation.mjs app.js.

Manual instrumentation: traces

Auto-instrumentation covers I/O boundaries. Custom spans capture what happens between them — business operations, batch steps, cache decisions. Manual instrumentation uses only @opentelemetry/api, so library code stays decoupled from the SDK.

// app.js
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const http = require('http');

const tracer = trace.getTracer('checkout-service', '1.4.2');

async function processOrder(orderId) {
  return tracer.startActiveSpan('process-order', async (span) => {
    span.setAttribute('order.id', orderId);
    try {
      const items = await loadItems(orderId);
      span.setAttribute('order.item_count', items.length);

      // Child spans created inside startActiveSpan are parented automatically
      await tracer.startActiveSpan('charge-payment', async (child) => {
        await chargePayment(orderId);
        child.end();
      });

      span.setStatus({ code: SpanStatusCode.OK });
      return items;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end();
    }
  });
}

http.createServer(async (req, res) => {
  try {
    await processOrder(req.url.slice(1) || 'demo-order');
    res.end('ok');
  } catch {
    res.statusCode = 500;
    res.end('error');
  }
}).listen(8080);

Key API points:

  • startActiveSpan makes the new span current in the async context, so any spans created inside the callback — including those from auto-instrumented libraries like your database driver — nest under it automatically. Prefer it over startSpan, which does not touch context.
  • Always call span.end(), ideally in a finally block. Unended spans never export.
  • recordException plus an ERROR status is the idiomatic error pattern; OpenObserve renders these as error spans with the exception event attached.

For a deeper walkthrough of spans, context propagation across services, and trace visualization, see Distributed Tracing in Node.js with OpenTelemetry.

Manual instrumentation: metrics

Metrics use the same pattern: get a meter from the API, create instruments once at module scope, record values on the hot path.

// metrics-example.js
const { metrics } = require('@opentelemetry/api');

const meter = metrics.getMeter('checkout-service', '1.4.2');

const ordersProcessed = meter.createCounter('orders.processed', {
  description: 'Total orders processed',
  unit: '{order}',
});

const orderDuration = meter.createHistogram('orders.duration', {
  description: 'Time to process an order',
  unit: 'ms',
});

const activeJobs = meter.createUpDownCounter('jobs.active', {
  description: 'Jobs currently in flight',
});

async function handleOrder(order) {
  activeJobs.add(1);
  const started = Date.now();
  try {
    await processOrder(order.id);
    ordersProcessed.add(1, { 'payment.method': order.paymentMethod });
  } finally {
    orderDuration.record(Date.now() - started, {
      'payment.method': order.paymentMethod,
    });
    activeJobs.add(-1);
  }
}

The PeriodicExportingMetricReader configured in instrumentation.js exports on an interval (60 seconds by default; tune with OTEL_METRIC_EXPORT_INTERVAL in milliseconds). Two habits keep metrics useful and cheap: create instruments once rather than per-request, and keep attribute values low-cardinality — payment.method is fine, order.id as a metric attribute will explode your series count.

For gauges that observe a value on demand rather than recording events, use meter.createObservableGauge with a callback — useful for queue depth or pool size.

Manual instrumentation: logs

The logs signal in JavaScript is still under development: the API and SDK packages work end-to-end and export over OTLP, but they live in the experimental release line and can break between versions. Pin versions exactly if you adopt them.

npm install @opentelemetry/api-logs @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http

Add a log record processor to the NodeSDK config in instrumentation.js:

// additions to instrumentation.js
const { BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');

// inside the NodeSDK options object:
//   logRecordProcessors: [
//     new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() }),
//   ],

Then emit structured log records anywhere in your app via the API package:

const { logs: logsApi, SeverityNumber } = require('@opentelemetry/api-logs');

const logger = logsApi.getLogger('checkout-service', '1.4.2');

logger.emit({
  severityNumber: SeverityNumber.WARN,
  severityText: 'WARN',
  body: 'payment retry scheduled',
  attributes: { 'order.id': 'o-1234', 'retry.attempt': 2 },
});

Log records emitted inside an active span automatically carry that span’s trace_id and span_id, which is what lets OpenObserve correlate a log line to the exact trace that produced it.

You usually do not need to call logger.emit by hand. The auto-instrumentation set includes bridges for popular loggers — @opentelemetry/instrumentation-pino, @opentelemetry/instrumentation-winston, and @opentelemetry/instrumentation-bunyan — that forward your existing pino/winston calls into the OpenTelemetry logs pipeline (and inject trace context into the log output) with zero call-site changes. If the experimental status is a concern, a pragmatic middle ground is to keep your current log shipping and add only trace-context injection, switching to OTLP log export once the signal stabilizes.

Sending data to OpenObserve

OpenObserve ingests OTLP natively — no collector required in between, although running one is a fine choice for fleet-level control. The exporters used above speak OTLP over HTTP, which maps to these OpenObserve paths:

SignalHTTP path
Traces/api/<organization>/v1/traces
Metrics/api/<organization>/v1/metrics
Logs/api/<organization>/v1/logs

Because the SDK appends /v1/<signal> to the base endpoint automatically, configuration is three environment variables.

Self-hosted OpenObserve (default org is default):

export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment.name=production"

node --require ./instrumentation.js app.js

OpenObserve Cloud (organization name and token are on your Data Sources page):

export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<your-token-from-data-sources>"
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment.name=production"

node --require ./instrumentation.js app.js

Rules that prevent the most common failures:

  • No trailing slash on the endpoint. The exporter appends the path itself; a trailing slash produces a doubled //v1/traces path.
  • Percent-encode the space in the header value (Basic%20...). The env var is parsed under W3C Baggage rules; a literal space may work in some SDK versions but %20 works in all of them.
  • Values set in code override env vars, so if you passed an url to an exporter constructor earlier, remove it or keep the two in sync.

If you prefer configuring the exporter in code instead of env vars, pass the full signal path and header explicitly:

const traceExporter = new OTLPTraceExporter({
  url: 'http://localhost:5080/api/default/v1/traces',
  headers: {
    Authorization: 'Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=',
  },
});

Self-hosted OpenObserve also accepts OTLP over gRPC on port 5081 (the organization is passed as a gRPC metadata header in that case). For a single Node.js service, OTLP/HTTP is simpler and has fewer dependencies; gRPC is mainly interesting when an OpenTelemetry Collector sits between your services and OpenObserve. See A Beginner’s Guide to OTLP Exporters for how the protocol variants compare.

Verifying data arrival in OpenObserve

Generate some traffic first — hit your service a few dozen times so batches actually flush (or wait one export interval).

  1. Open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL) and log in.
  2. Go to Streams. You should see a default traces stream, a metrics stream per instrument name (for example orders_processed), and a log stream if you enabled the logs pipeline. Stream stats show document counts and ingestion size, which is the quickest “is data arriving at all” check.
  3. Open Traces, select the trace stream, and filter by service_name='checkout-service'. Click any trace to see the waterfall — your manual process-order span should appear with the auto-instrumented HTTP server span as its parent and any database/HTTP client spans nested beneath.
  4. For metrics, open the Metrics explorer and query one of your instruments; for logs, open Logs, pick the stream, and confirm records carry trace_id values that match the traces you just viewed.

If nothing shows up within a couple of minutes, jump to the troubleshooting section — the cause is almost always the endpoint path or the auth header.

Production tips

Keep the batch processors (they are the default). NodeSDK wraps your trace exporter in a BatchSpanProcessor automatically. Never switch to SimpleSpanProcessor in production — it exports synchronously per span. Tune batching with OTEL_BSP_MAX_EXPORT_BATCH_SIZE and OTEL_BSP_SCHEDULE_DELAY if the defaults (512 spans, 5 s) do not fit your traffic.

Sample at the head, keep parent decisions. Full tracing of a high-throughput service is rarely worth the cost. The standard choice:

export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
export OTEL_TRACES_SAMPLER_ARG="0.1"

This samples 10% of new traces but always honors the caller’s decision, so distributed traces stay complete instead of getting holes mid-flow. Start at 1.0 in staging, then dial down in production based on volume.

Set resource attributes deliberately. service.name is the primary key for everything in the UI — an unset value shows up as unknown_service:node, which is how you know you forgot it. Add service.version and deployment.environment.name via OTEL_RESOURCE_ATTRIBUTES so you can slice by release and environment when an incident hits.

Load order is everything. The single most common Node.js pitfall: the SDK must initialize before instrumented modules are required. If you require('express') at the top of app.js and initialize OpenTelemetry later in the same file, Express is not patched and you get no spans. Using --require ./instrumentation.js guarantees ordering; importing the instrumentation file from app code does not.

Trim unused instrumentations. getNodeAutoInstrumentations() enables everything, including @opentelemetry/instrumentation-dns, which can be noisy for services that make many DNS lookups. Disable what you do not need:

getNodeAutoInstrumentations({
  '@opentelemetry/instrumentation-dns': { enabled: false },
})

Flush on shutdown. Containers get SIGTERM and a grace period. Without the sdk.shutdown() handler shown earlier, the last batch of spans from every pod dies with the process — which systematically deletes the most interesting spans (the ones during deploys and crashes).

Watch worker threads and clusters. The SDK is per-process. If you use cluster or worker_threads, each worker needs the SDK loaded (the --require flag applies to workers spawned with the same execArgv, but verify spans arrive from all workers).

Troubleshooting

No data in OpenObserve, no errors in the app. Enable SDK diagnostics before anything else — silence usually means the exporter is failing quietly:

const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api');
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);

Add this at the top of instrumentation.js (or set OTEL_LOG_LEVEL=debug when using the zero-code register hook) and read the export errors it prints.

401 Unauthorized in debug output. The Basic token is wrong or mangled. Rebuild it with echo -n 'email:password' | base64 — the -n matters, a trailing newline corrupts the token. If the header is set via OTEL_EXPORTER_OTLP_HEADERS, confirm the space is encoded: Authorization=Basic%20<token>.

404 Not Found on export. The path is wrong. OTEL_EXPORTER_OTLP_ENDPOINT must end at the organization (.../api/default, no trailing slash, no /v1/traces), while a url passed to an exporter constructor must include the full signal path (.../api/default/v1/traces). Mixing the two conventions is the usual cause.

Spans appear, but only from my manual tracer — no HTTP or DB spans. Load-order problem: application modules were required before the SDK started. Move all SDK setup into instrumentation.js and launch with node --require ./instrumentation.js app.js; never import the instrumentation file from application code.

Traces stop at a service boundary (parent in one service, orphan in the next). Context propagation is broken. Verify both services run the SDK, that the outbound HTTP call is made with an instrumented client (native http/https/fetch are covered by auto-instrumentation), and that no proxy in between strips the traceparent header.

Metrics arrive but traces do not (or vice versa). Signals are independent pipelines. Check the signal-specific overrides — OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER — for a value like none or a stale URL that only affects one signal, and confirm the sampler is not set to always_off.

Everything works locally but not in Docker/Kubernetes. localhost inside a container is the container. Point OTEL_EXPORTER_OTLP_ENDPOINT at the service DNS name (for example http://openobserve.observability.svc.cluster.local:5080/api/default) and confirm the network path with a curl -i to the endpoint from inside the pod.

With traces, metrics, and (experimentally) logs flowing from one instrumentation.js file, you have full-fidelity telemetry from Node.js into OpenObserve — correlated by trace context, queryable in one place, and portable to any OTLP backend if your needs ever change.

Frequently asked questions

Is OpenTelemetry stable for Node.js in production?

Traces and metrics are stable in the OpenTelemetry JavaScript SDK and are widely used in production. The logs signal is still under development in JavaScript, so the logs SDK packages carry an experimental version line and may introduce breaking changes between releases. Many teams run traces and metrics through the SDK and keep shipping logs through their existing pipeline until the logs signal stabilizes.

Do I need to change my application code to get traces from Node.js?

No. The @opentelemetry/auto-instrumentations-node package instruments popular libraries such as http, Express, gRPC clients, and database drivers automatically. You load it with a Node.js --require flag or a small instrumentation file that runs before your application code, and it produces spans for inbound and outbound requests without any changes to your handlers.

What OTLP endpoint does OpenObserve use for Node.js telemetry?

OpenObserve accepts OTLP over HTTP at /api/<organization>/v1/traces, /api/<organization>/v1/metrics, and /api/<organization>/v1/logs. For self-hosted deployments the default base URL is http://localhost:5080, and for OpenObserve Cloud it is https://api.openobserve.ai. OTLP over gRPC is also supported on port 5081 for self-hosted deployments.

How do I authenticate the OTLP exporter with OpenObserve?

OpenObserve uses HTTP Basic authentication. Base64-encode your user email and password (or ingestion token) as email:password and send it in the Authorization header, for example Authorization: Basic cm9vdEBleGFtcGxlLmNvbTo... When configuring this through the OTEL_EXPORTER_OTLP_HEADERS environment variable, percent-encode the space after Basic as %20 so every SDK parses it correctly.

Should I use --require or --import to load OpenTelemetry in Node.js?

Use --require with a CommonJS instrumentation file, which works on all supported Node.js versions, or --import with an ESM instrumentation file on Node.js 20.6 and later. The important part is that the OpenTelemetry SDK initializes before your application modules load, otherwise libraries that were imported first will not be instrumented.

How much overhead does OpenTelemetry add to a Node.js service?

With the batch span processor and a sensible sampling ratio, overhead is typically low single-digit percent CPU and memory. Most of the cost comes from exporting; batching (the default in NodeSDK) amortizes it. If a high-traffic service needs less overhead, lower the parentbased_traceidratio sampler argument rather than removing instrumentation.

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo