feat: implement diagnostic channels for observability

Merged
LO
6mo ago
+1610-1365

Actual Diff is here: https://github.com/logaretm/node-redis/pull/1/changes
This is currently waiting on #3110 to get merged and then it will show the true size of changes


Re-implements otel metrics tracing using diagnostic and tracing channels, while setting up more channels for tracing.

The key shift: core code now describes what is happening, not what to compute.

Before, every instrumented code path had to know about OTel, creating closures, capturing timestamps, building attribute maps, recording histograms. The code was telling the system how to measure itself. And every metric class needed a noop counterpart for when its metric group was disabled.

Now core code says "a command started," "a connection closed," "an error occurred." Subscribers like OTel, Sentry, a debug logger, anything can independently decide what to compute from those events.

The domain knowledge in #3110 (which metrics to emit, which semantic conventions to follow, how to classify errors) is preserved. It just moved from being inline in the hot path to being a subscriber that opts in when needed.

Noops are gone. When a metric group is disabled, no subscription is created. No subscribers means hasSubscribers is false, which means trace() and publish() skip the channel entirely, zero cost by design, not by maintaining parallel noop implementations. Both noop-metrics.ts and noop-meter.ts are deleted — when OTel is not initialized, no OTelMetrics instance is created, no instruments are registered, no subscriptions exist.

Two functions for all observability:

  • trace() — wraps async operations via TracingChannel (commands, batches, connections, pool waits)
  • publish() — emits point events via dc.channel() (errors, cache hits/misses, pubsub, maintenance, connection lifecycle)

Both use factory callbacks + hasSubscribers checks for zero overhead when no APM subscribes. This is true for metrics, if the user never calls the opentelemetry plugin then no channel subscriptions occur, and no object allocation happens compared to what we had before.

Channels

TracingChannel (async lifecycle: start/end/asyncStart/asyncEnd/error)

This sets up tracing with spans in the future, but still can be used by metrics or logs.

ChannelWraps
node-redis:commandIndividual command execution
node-redis:batchMULTI/PIPELINE as a whole
node-redis:connectClient connection
node-redis:connection:waitPool wait for available client

Single instance events

This is what metrics usually use, not suitable for traces but can also be used by logs.

ChannelEmits
node-redis:connection:readyConnection established (includes create time)
node-redis:connection:closedConnection closed (includes reason)
node-redis:connection:relaxed-timeoutMaintenance timeout relaxation
node-redis:connection:handoffConnection handed off to new node
node-redis:errorClient/cluster errors
node-redis:maintenanceEnterprise maintenance notifications
node-redis:pubsubPubSub messages in/out
node-redis:cache:requestClient-side cache hit/miss
node-redis:cache:evictionCache eviction
node-redis:command:replyCommand reply (for stream lag, pubsub out)

What this enables

Any APM can subscribe without monkey-patching or depending on OTel:

import dc from 'node:diagnostics_channel';
import { CHANNELS } from 'redis';

// Create spans
dc.tracingChannel(CHANNELS.TRACE_COMMAND).subscribe({
  start(ctx) { /* start span */ },
  asyncEnd(ctx) { /* end span */ },
  error(ctx) { /* record error, end span */ },
});

// Track errors
dc.channel(CHANNELS.ERROR).subscribe((ctx) => { /* record error metric */ });

Key Changes

  • Zero OTelMetrics imports in core code
  • Zero noop files — both noop-metrics.ts and noop-meter.ts deleted
  • Existing production code: +603 / -1,152 = -549 lines
  • New channel infrastructure (tracing.ts): +280 lines
  • Net production code: -269 lines
  • All channel names exported via CHANNELS const map
  • All event types exported for consumer typing

Setting Up OTEL Tracing

Almost Everything Is Already Done

  • Async context propagation: tracePromise (used by trace()) already manages AsyncLocalStorage context. The OTel context manager piggybacks on this. No context.with(trace.setSpan(...)) needed, spans created in start are automatically the active span for downstream operations.
  • Sanitized args: ctx.args is already sanitized by sanitizeArgs(), so db.query.text is just a simple join (e.g., SET mykey ?).
  • Error classification: getErrorInfo() already extracts errorType, category, and statusCode for span attributes.

Here is an example of tracing for command calls:

import { trace, context, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import dc from "node:diagnostics_channel";
import { CHANNELS } from "redis";

const tracer = trace.getTracer("node-redis");

const commandTC = dc.tracingChannel(CHANNELS.TRACE_COMMAND);
commandTC.subscribe({
  start(ctx) {
    const span = tracer.startSpan(ctx.command, {
      kind: SpanKind.CLIENT,
      attributes: {
        "db.system.name": "redis",
        "db.operation.name": ctx.command,
        "db.query.text": ctx.args.join(" "),
        "db.namespace": String(ctx.database),
        "server.address": ctx.serverAddress,
        "server.port": ctx.serverPort,
      },
    });
    // Set span on OTel context — AsyncLocalStorage propagates it
    // to asyncEnd/error automatically via TracingChannel
    trace.setSpan(context.active(), span);
  },
  asyncEnd() {
    trace.getSpan(context.active())?.end();
  },
  error(ctx) {
    const span = trace.getSpan(context.active());
    if (span) {
      span.recordException(ctx.error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: ctx.error?.message,
      });
      span.end();
    }
  },
});                                      

Note that the exact implementation can differ since we may need to use bindStore on the AsyncLocalStorage instance inside OTEL itself, but should be straight forward.


[!NOTE]
Medium Risk
Touches core command execution, connection, pooling, and cache paths to emit diagnostics events, so regressions could impact performance or error handling despite the largely additive nature. Also raises the minimum supported Node.js version to >=18.19.0, which can be a breaking runtime requirement for some consumers.

Overview
Adds first-class observability via Node.js diagnostics_channel/TracingChannel: core client paths now emit tracing events for commands/batches/connect and point events for connections, errors, maintenance, pubsub, client-side cache, command replies, and pool waits (with argument sanitization).

Refactors OpenTelemetry metrics to be implemented as diagnostics_channel subscribers (removing inline metric recording/noop implementations), updates tests accordingly, and exports CHANNELS plus payload types from @redis/client with new documentation. Updates package engines.node across packages to require >= 18.19.0.

Written by Cursor Bugbot for commit dc7da510733d278acdf3a1959297be3c8b24e6be. This will update automatically on new commits. Configure here.