CubeAPM
CubeAPM CubeAPM

Observability for Node.js Applications: Logs, Metrics, and Traces

Observability for Node.js Applications: Logs, Metrics, and Traces

Table of Contents

Node.js applications fail in ways that logs alone cannot explain. An Express API can slow down because a single downstream HTTP call is timing out on 3% of requests. A database connection pool can silently exhaust itself while the application continues accepting new requests and returning errors with misleading stack traces. An event loop blocked by a CPU-intensive operation causes all concurrent requests to queue, and the only visible symptom is rising p99 latency with no errors in the logs.

Observability for Node.js means having structured logs, metrics, and distributed traces working together under a single instrumentation framework. OpenTelemetry is that framework. It graduated as a CNCF project in May 2026, is vendor-neutral, and produces telemetry that can be sent to any OTLP-compatible backend without changing application code. The JavaScript SDK 2.0 was released in March 2025, raising the minimum supported Node.js to ^18.19.0 || >=20.6.0 and dropping support for Node.js 14 and 16.

This guide covers structured logging for Node.js, installing and configuring the OpenTelemetry JS SDK 2.0, auto-instrumentation, manual spans for business logic, custom metrics, ESM considerations, and how to send all three signals to CubeAPM.

Key Takeaways

  • The OpenTelemetry JS SDK 2.0 requires Node.js ^18.19.0 or >=20.6.0 and TypeScript 5.0.4+; Node.js 14 and 16 are no longer supported.
  • @opentelemetry/sdk-node follows an experimental versioning scheme (currently 0.218.0); @opentelemetry/api is stable at 1.x.
  • @opentelemetry/instrumentation-fastify was removed from @opentelemetry/auto-instrumentations-node in March 2026; install @fastify/otel separately for Fastify instrumentation.
  • The official OTel Node.js docs (updated March 2026) recommend –import over –require for preloading the instrumentation file; –require still works for CommonJS.
  • ESM auto-instrumentation requires the register hook from node:module on Node.js 20.6+; CommonJS is fully supported without additional setup.
  • The default OTLP protocol in SDK 2.0 is http/protobuf (port 4318); use port 4317 for gRPC.
  • Disable @opentelemetry/instrumentation-fs explicitly in getNodeAutoInstrumentations() unless needed, as it generates high-volume, low-signal spans for every file read.

Why All Three Signals Matter for Node.js

All three signals, metrics, logs, and traces, are important: 

  • Logs alone miss the context. Node.js applications running under PM2 or in containers write unstructured logs to stdout. When an incident affects 3% of requests at p99, searching free-text logs for the affected requests is slow and incomplete. Structured JSON logs with a trace ID attached let you jump from a metric alert directly to the log events from the specific requests that were affected.
  • Metrics alone miss the cause. A spike in p99 latency tells you something is degrading. It does not tell you whether it is the database connection pool, a slow third-party API, or your own middleware. Distributed traces pinpoint the slow span.
  • Traces alone miss the trends. A single slow trace tells you one request was slow. Metrics across 100,000 requests tell you whether slowness is increasing, whether it affects all endpoints or just one, and whether it started after a deployment. Metrics and traces together reduce the mean time to diagnose from hours to minutes.

Step 1: Structured Logging in Node.js

Replace console.log with a structured logger that outputs JSON. pino is the standard choice for production Node.js applications: it is the fastest JSON logger in the ecosystem with the smallest overhead on the event loop.

Install pino:

npm install pino pino-pretty

Configure a module-level logger:

// logger.js

const pino = require('pino');

const logger = pino({

  level: process.env.LOG_LEVEL || 'info',

  formatters: {

    level(label) {

      return { severity: label };

    },

  },

  timestamp: pino.stdTimeFunctions.isoTime,

});

module.exports = logger;

Use the logger in application code:

const logger = require('./logger');

function processOrder(orderId, userId) {

  logger.info({ orderId, userId, stage: 'validate' }, 'processing order');

}

This produces structured output:

{

  "level": 30,

  "time": "2026-06-09T10:22:31.412Z",

  "severity": "info",

  "orderId": "ord_123",

  "userId": "usr_456",

  "stage": "validate",

  "msg": "processing order"

}

Connecting logs to traces: Once OpenTelemetry tracing is configured (Step 3 below), inject the active trace ID and span ID into every log record using a child logger per request:

const { trace } = require('@opentelemetry/api');

function getTraceContext() {

  const span = trace.getActiveSpan();

  if (!span) return {};

  const ctx = span.spanContext();

  return {

    traceId: ctx.traceId,

    spanId: ctx.spanId,

  };

}

// Use a child logger per request that includes trace context

app.use((req, res, next) => {

  req.log = logger.child(getTraceContext());

  next();

});

Step 2: Install OpenTelemetry JS SDK 2.0

The OpenTelemetry JS SDK 2.0 requires Node.js ^18.19.0 or >=20.6.0. Node.js 14 and 16 are no longer supported. TypeScript users need TypeScript 5.0.4 or higher.

Install the core SDK and OTLP exporters:

npm install @opentelemetry/api \

  @opentelemetry/sdk-node \

  @opentelemetry/auto-instrumentations-node \

  @opentelemetry/exporter-trace-otlp-proto \

  @opentelemetry/exporter-metrics-otlp-proto \

  @opentelemetry/sdk-metrics

Versioning note: @opentelemetry/api follows a stable versioning scheme (currently 1.x). @opentelemetry/sdk-node uses an experimental versioning scheme (currently 0.218.0 as of June 2026) and may include breaking changes between releases. Per the OTel JS SDK 2.0 release notes, packages at >=0.200.0 are in the experimental/unstable track; packages at >=2.0.0 are in the stable track.

Note on Fastify: @opentelemetry/instrumentation-fastify was removed from @opentelemetry/auto-instrumentations-node in March 2026. Fastify instrumentation is now maintained by the Fastify team as @fastify/otel. Install it separately if your application uses Fastify:

npm install @fastify/otel

Step 3: Initialize the SDK Before Application Code

The OTel SDK must be loaded before any other application module. Create a dedicated instrumentation.js file and load it first.

For CommonJS (CJS) applications:

// instrumentation.js

'use strict';

const { NodeSDK } = require('@opentelemetry/sdk-node');

const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto');

const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-proto');

const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');

const sdk = new NodeSDK({

  serviceName: process.env.OTEL_SERVICE_NAME || 'my-node-service',

  traceExporter: new OTLPTraceExporter({

    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT

      ? `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`

      : 'http://localhost:4318/v1/traces',

  }),

  metricReader: new PeriodicExportingMetricReader({

    exporter: new OTLPMetricExporter({

      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT

        ? `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/metrics`

        : 'http://localhost:4318/v1/metrics',

    }),

    exportIntervalMillis: 30_000,

  }),

  instrumentations: [

    getNodeAutoInstrumentations({

      '@opentelemetry/instrumentation-fs': { enabled: false },

    }),

  ],

});

sdk.start();

process.on('SIGTERM', () => {

  sdk.shutdown().then(() => process.exit(0));

});

The official OpenTelemetry Node.js documentation (updated March 2026) recommends the –import flag to preload the instrumentation file, particularly for TypeScript and ESM contexts. For CJS applications –require also works:

# Recommended (works for both CJS and ESM contexts)

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

# Alternative for CommonJS only

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

Or configure via environment variables:

OTEL_SERVICE_NAME=my-node-service \

OTEL_EXPORTER_OTLP_ENDPOINT=http://your-cubeapm-instance:4318 \

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

For ES Modules (ESM) applications: Auto-instrumentation in ESM requires an additional loader hook because OTel’s module patching relies on CommonJS require() interception, which does not fire for ESM import statements. Node.js 20.6+ supports a register hook for this:

// instrumentation.mjs

import { register } from 'node:module';

import { pathToFileURL } from 'node:url';

register('@opentelemetry/instrumentation/hook.mjs', pathToFileURL('./'));

import { NodeSDK } from '@opentelemetry/sdk-node';

import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({

  serviceName: 'my-node-service',

  instrumentations: [getNodeAutoInstrumentations()],

});

sdk.start();

Start with the –import flag:

node --import ./instrumentation.mjs app.mjs

Disabling @opentelemetry/instrumentation-fs: The filesystem instrumentation is included in getNodeAutoInstrumentations() but generates high-volume, low-signal spans for every file read. Disable it explicitly as shown in the CJS example above, unless you specifically need it.

Step 4: What Auto-Instrumentation Covers

@opentelemetry/auto-instrumentations-node instruments the following commonly used libraries without code changes:

LibraryWhat is instrumented
http / httpsAll inbound HTTP server requests and outbound HTTP client calls
expressRoute-level spans with route pattern as span name
@fastify/otel (separate, maintained by Fastify)Route-level spans for Fastify (removed from auto-instrumentations-node March 2026)
pg (node-postgres)PostgreSQL query spans with sanitized query text
mysql / mysql2MySQL query spans
mongodbMongoDB operation spans
redis / ioredisRedis command spans
@grpc/grpc-jsgRPC client and server call spans
@aws-sdk/client-*AWS SDK v3 service call spans
undiciNode.js native fetch / undici HTTP client spans

Step 5: Manual Instrumentation for Custom Spans

Auto-instrumentation covers frameworks and libraries. Add custom spans for business-critical operations that sit between framework calls.

Get a tracer:

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

const tracer = trace.getTracer('my-service', '1.0.0');

Wrap a business operation in a span:

async function calculatePricing(orderId, items) {

  return tracer.startActiveSpan('calculate_pricing', async (span) => {

    try {

      span.setAttribute('order.id', orderId);

      span.setAttribute('order.item_count', items.length);

      const pricing = await pricingEngine.compute(items);

      span.setAttribute('pricing.total', pricing.total);

      span.setAttribute('pricing.discount_applied', pricing.discountApplied);

      return pricing;

    } catch (err) {

      span.recordException(err);

      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });

      throw err;

    } finally {

      span.end();

    }

  });

}

Step 6: Custom Metrics

Auto-instrumentation provides HTTP request rate, duration, and error rate. Add custom metrics for business signals that frameworks do not expose automatically.

Get a meter:

const { metrics } = require('@opentelemetry/api');

const meter = metrics.getMeter('my-service', '1.0.0');

Counter: monotonically increasing, use for totals:

js

const ordersProcessed = meter.createCounter('orders.processed', {

  description: 'Total number of orders processed',

  unit: '1',

});

function processOrder(order) {

  // ... processing logic

  ordersProcessed.add(1, { 'order.type': order.type, region: order.region });

}

Histogram: records a distribution, use for latency and size:

const orderProcessingDuration = meter.createHistogram('orders.processing.duration', {

  description: 'Time to process an order in milliseconds',

  unit: 'ms',

});

async function processOrder(order) {

  const start = Date.now();

  try {

    // ... processing logic

  } finally {

    orderProcessingDuration.record(Date.now() - start, { 'order.type': order.type });

  }

}

UpDownCounter: can increase and decrease, use for queue depth or active connections:\

js

const activeJobs = meter.createUpDownCounter('jobs.active', {

  description: 'Number of jobs currently being processed',

  unit: '1',

});

function startJob(jobId) {

  activeJobs.add(1, { 'job.type': 'background' });

}

function finishJob(jobId) {

  activeJobs.add(-1, { 'job.type': 'background' });

}

Step 7: Configure via Environment Variables

The OTel JS SDK 2.0 supports full configuration via environment variables, making containerized deployments easier:

export OTEL_SERVICE_NAME=my-node-service

export OTEL_SERVICE_VERSION=1.2.0

export OTEL_EXPORTER_OTLP_ENDPOINT=http://your-cubeapm-instance:4318

export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

export OTEL_TRACES_EXPORTER=otlp

export OTEL_METRICS_EXPORTER=otlp

export OTEL_LOGS_EXPORTER=otlp

The default OTLP protocol in SDK 2.0, when using NodeSDK without an explicit exporter, is http/protobuf. Use port 4318 for HTTP/protobuf and port 4317 for gRPC.

Step 8: Monitor Node.js Application Health with CubeAPM

cubeapm

CubeAPM receives all three signal types from Node.js applications via OTLP: traces, metrics, and structured logs. Point OTEL_EXPORTER_OTLP_ENDPOINT at your CubeAPM instance and all signals flow without additional configuration.

What CubeAPM monitors for Node.js applications:

  • HTTP request rate, error rate, and p99 latency per route (from http/express auto-instrumentation)
  • Database query spans with sanitized SQL or query text, duration, and error status
  • Outbound HTTP client call spans with URL, method, and status code
  • AWS SDK call spans (service name, operation, status) from @aws-sdk/client-* instrumentation
  • Redis and cache operation spans
  • Custom business metrics (counters, histograms, up-down counters)
  • Structured JSON logs correlated to traces via trace ID and span ID
  • Distributed traces across Node.js microservices with end-to-end flame graphs

Key alerts to configure for Node.js applications in CubeAPM:

AlertConditionSeverity
High error rateHTTP error rate > 1% for 5 minWarning
High p99 latencyp99 request duration > 2,000 msWarning
Slow database queriesAny DB span duration > 500 msWarning
Downstream call failuresOutbound HTTP error rate > 5%Warning
Custom metric thresholdorders.processed rate drops to 0Critical
High exception rateException spans > 10x baselineWarning

Read the docs to configure OTLP ingestion and Node.js application monitoring.

Summary

Node.js application observability requires all three signal types. Structured logs with trace IDs provide request-level context. Metrics show aggregate trends. Distributed traces show the exact request path and which operation caused a specific latency or error. The OpenTelemetry JS SDK 2.0, released March 2025, provides a single instrumentation framework for all three.

SignalCollection methodKey data
Structured logspino + trace context injection per requestJSON log lines with traceId and spanId
Distributed traces@opentelemetry/auto-instrumentations-node + manual spansHTTP, DB, cache, and outbound HTTP spans
MetricsOTel auto-instrumentation + custom meter.create* instrumentsRequest rate, error rate, latency histograms, custom counters
Fastify-specific@fastify/otel (separate package, not in auto-instrumentations-node)Route-level spans for Fastify

Disclaimer: All OpenTelemetry JS package names, API calls, and configuration sourced from the official OpenTelemetry JS documentation at opentelemetry.io/docs/languages/js/ and npm, verified June 2026. OpenTelemetry JS SDK 2.0 requires Node.js ^18.19.0 or >=20.6.0 and TypeScript 5.0.4+ (source: opentelemetry.io/blog/2025/otel-js-sdk-2-0/). @opentelemetry/sdk-node is at 0.218.0 under the experimental versioning scheme; @opentelemetry/api is stable at 1.x. @opentelemetry/instrumentation-fastify was removed from @opentelemetry/auto-instrumentations-node in March 2026; use @fastify/otel instead (source: npmjs.com/@opentelemetry/auto-instrumentations-node). The default OTLP protocol in SDK 2.0 is http/protobuf (port 4318). ESM auto-instrumentation requires the register hook from node:module on Node.js 20.6+ (source: OTel JS GitHub esm-support documentation). The official OTel Node.js getting-started guide (updated March 2026) recommends –import over –require for preloading. OTel graduated as CNCF project May 21, 2026. CubeAPM: $0.15/GB, no per-service or per-host fees.

Also read:

Observability for Python Applications: Logs, Metrics, and Traces

Observability for Serverless Applications on AWS Lambda: What to Track and How

Observability for Docker Containers: What to Track and How

×
×