feat: Implement `TracingChannel` support

Merged
LO
5mo ago
+1041-32

This PR implements tracing channels for command and connect operations. This allows APM libraries (OTEL, Datadog, Sentry) to instrument redis commands without monkey-patching.

It also enables users to setup their own custom instrumentations and analytics.

This is a port of redis/node-redis#3195 to ioredis, based on the proposal in redis/node-redis#2590.

Tracing Channels

All channels in this PR use the Node.js TracingChannel API, which provides start, end, asyncStart, asyncEnd, and error sub-channels automatically.

TracingChannelTracksContext fields
ioredis:commandIndividual Redis command execution from queue to reply (standalone, MULTI, and pipeline)command, args, database, serverAddress, serverPort, and optionally batchMode, batchSize for MULTI/pipeline commands
ioredis:connectClient connection lifecycle (initial connections and reconnections)serverAddress, serverPort, connectionEpoch

Context Properties

The payload sent in the trace events closely follows what OTEL currently extracts as attributes in their instrumentations:

FieldSourceOTEL attribute it enables
commandcommand namedb.operation.name
argsfull args arraydb.query.text (APM serializes/sanitizes)
databaseselected DBdb.namespace
serverAddresssocket options host (or path for IPC)server.address
serverPortsocket options port (undefined for IPC)server.port

For batch commands we include these properties:

FieldSourceOTEL attribute it enables
batchMode'PIPELINE' or 'MULTI'db.operation.name prefix
batchSizenumber of user commands (excludes MULTI/EXEC protocol wrappers)db.operation.batch.size

For connect events:

FieldSourceDescription
connectionEpochinternal reconnection counter0 for initial connect, 1+ for reconnections. APMs can use this to distinguish initial connections, detect flapping, or correlate traces across reconnection cycles.

Backward Compatibility

Zero-cost when no subscribers are registered. Silently skipped on Node.js versions where TracingChannel is unavailable (e.g. Node 18).

const dc = ('getBuiltinModule' in process)
  ? process.getBuiltinModule('node:diagnostics_channel')
  : require('node:diagnostics_channel');

Usage Examples

Consumers will be doing something like this:

  const dc = require('node:diagnostics_channel');

  // Trace individual Redis commands
  dc.tracingChannel('ioredis:command').subscribe({
    start(ctx) {
      console.log(`→ ${ctx.command}`, ctx.args, `db=${ctx.database}`);
      // APM: create span here
      // ctx.span = tracer.startSpan(`redis ${ctx.command}`, { attributes: {
      //   'db.system': 'redis',
      //   'db.operation.name': ctx.command,
      //   'db.namespace': String(ctx.database),
      //   'server.address': ctx.serverAddress,
      //   'server.port': ctx.serverPort,
      // }});
    },
    asyncEnd(ctx) {
      console.log(`✓ ${ctx.command} done`);
      // ctx.span?.end();
    },
    error(ctx) {
      console.error(`✗ ${ctx.command} failed:`, ctx.error?.message);
      // ctx.span?.setStatus({ code: SpanStatusCode.ERROR });
    },
  });

  // Trace connection
  dc.tracingChannel('ioredis:connect').subscribe({
    start(ctx) {
      console.log(`Connecting to ${ctx.serverAddress}:${ctx.serverPort} (epoch: ${ctx.connectionEpoch})`);
    },
    asyncEnd() {
      console.log('Connected');
    },
    error(ctx) {
      console.error('Connection failed:', ctx.error?.message);
    },
  });

  // Works with all ioredis operations — no monkey-patching needed
  const redis = new Redis();
  await redis.set('foo', 'bar');          // ioredis:command { command: 'SET', args: ['foo', 'bar'] }
  await redis.get('foo');                  // ioredis:command { command: 'GET', args: ['foo'] }

  // Pipeline/MULTI commands include batch metadata
  const pipeline = redis.pipeline();
  pipeline.set('a', '1');
  pipeline.set('b', '2');
  await pipeline.exec();                  // ioredis:command { command: 'SET', batchMode: 'PIPELINE', batchSize: 2 }

Future Improvements

  • Offline queue tracing channel: A dedicated ioredis:command:queue channel could emit events when commands enter the offline queue, allowing APMs to measure queue wait time separately from command execution time and track how many commands hit the offline queue (a signal of connection instability). Note that the existing OTEL monkey-patch instrumentation (@opentelemetry/instrumentation-ioredis) has the same gap — it doesn't distinguish queued vs. written commands either.
  • Sentinel-aware server address: _getServerAddress() currently uses configured host/port options. For sentinel setups, the trace context could reflect the actual resolved endpoint rather than the configured defaults.

[!NOTE]
Medium Risk
Touches core Redis.connect() and sendCommand() paths to wrap operations in tracing helpers; while intended to be zero-overhead when unused, mistakes could affect command execution flow or error handling. Adds argument sanitization rules to avoid leaking secrets, but incorrect redaction logic could still expose or over-redact data.

Overview
Adds Node.js diagnostics_channel/TracingChannel instrumentation for ioredis operations, emitting telemetry for individual commands (ioredis:command), MULTI batches (ioredis:batch), and connection attempts (ioredis:connect) when subscribers are present (silently no-op otherwise).

Redis.connect() is now traced via traceConnect, and sendCommand() wraps the written command promise with traceCommand exactly once per command (using a new Command.isTraced flag) to avoid duplicate events during offline-queue flushes or command resends after reconnect.

Introduces lib/tracing.ts with safe-by-default argument sanitization (redacting sensitive values via sanitizeArgs) and exported context types, updates docs with subscription examples, and adds comprehensive functional tests validating tracing behavior, deduplication, error tracing, and lack of unhandled rejections.

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