# 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.

Source: https://openobserve.ai/opentelemetry/nodejs/
Published: 2026-07-08
Language: Node.js

---

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](https://openobserve.ai/). If you are new to the project itself, start with [What is OpenTelemetry? A Complete Guide](/blog/what-is-opentelemetry/) 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:

| Signal  | Status in JS/Node.js | Practical guidance |
| ------- | -------------------- | ------------------ |
| Traces  | Stable               | Production-ready; API and SDK are stable |
| Metrics | Stable               | Production-ready; API and SDK are stable |
| Logs    | Development          | Usable 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:

```bash
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:

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

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

```bash
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:

```bash
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
```

```javascript
// 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:

```bash
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.

```javascript
// 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](/blog/distributed-tracing-in-nodejs-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.

```javascript
// 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.

```bash
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`:

```javascript
// 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:

```javascript
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:

| Signal  | HTTP 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`):

```bash
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):

```bash
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:

```javascript
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](/blog/otel-exporters-introduction/) 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:

```bash
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:

```javascript
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:

```javascript
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.
