Conversation
Add two TracingChannel instances (`ioredis:command` and `ioredis:connect`) that APM libraries (OTEL, Datadog, Sentry) can subscribe to without monkey-patching. Zero-cost when no subscribers are attached. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Node diagnostics_channel tracing support to ioredis so consumers can observe connect/command lifecycles (with near-zero overhead when unused), along with functional coverage and exported context types.
Changes:
- Introduce
lib/tracing.tswithtraceCommand/traceConnecthelpers and exportedCommandContext/ConnectContexttypes. - Wrap
Redis.connect()andRedis.sendCommand()to emitioredis:connectandioredis:commandtracing events (including pipeline batch metadata). - Add functional tests validating tracing behavior (skipped on Node versions without
TracingChannel).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/functional/tracing.ts | New functional test suite for ioredis:command / ioredis:connect tracing channels (Node-gated). |
| lib/tracing.ts | Tracing channel integration + context type definitions and runtime compatibility shim. |
| lib/Redis.ts | Wrap connect/command promises with tracing; build tracing contexts including server/db/batch info. |
| lib/Pipeline.ts | Annotate queued commands with batchMode and batchSize for tracing. |
| lib/index.ts | Export tracing context types for downstream consumers. |
| lib/Command.ts | Add optional batchMode/batchSize fields to Command for pipeline/transaction metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| const redis = new Redis({ lazyConnect: true }); | ||
| await redis.connect(); | ||
| await redis.set("tracing-test-key", "tracing-test-value"); | ||
| const result = await redis.get("tracing-test-key"); | ||
| expect(result).to.eql("tracing-test-value"); | ||
| redis.disconnect(); | ||
|
|
There was a problem hiding this comment.
redis.disconnect() is called inside the main try block, so if any assertion throws before it runs the test can leak an open socket/handle and make the suite hang or cascade-fail. Consider moving the disconnect/quit into a finally (or adding an afterEach that always disconnects created clients) so cleanup happens even on assertion failures.
| if ("path" in this.options && this.options.path) { | ||
| return { address: this.options.path, port: undefined }; | ||
| } | ||
| return { | ||
| address: ("host" in this.options && this.options.host) || "localhost", | ||
| port: ("port" in this.options && this.options.port) || 6379, |
There was a problem hiding this comment.
_getServerAddress() currently falls back to localhost:6379 whenever options.host/port aren’t explicitly set, which can be misleading for non-standalone setups (e.g., sentinel mode where defaults are present but not the actual endpoint). Since tracing contexts rely on this, it can report incorrect serverAddress/serverPort. Consider preferring the actual connected endpoint (this.stream.remoteAddress/remotePort when available) and/or handling sentinel options (e.g., first sentinel endpoint) so traces reflect the real target.
| if ("path" in this.options && this.options.path) { | |
| return { address: this.options.path, port: undefined }; | |
| } | |
| return { | |
| address: ("host" in this.options && this.options.host) || "localhost", | |
| port: ("port" in this.options && this.options.port) || 6379, | |
| // Unix domain socket: path represents the endpoint. | |
| if ("path" in this.options && this.options.path) { | |
| return { address: this.options.path, port: undefined }; | |
| } | |
| // Prefer the actual connected endpoint when available. | |
| const stream = this.stream as NetStream | undefined; | |
| const remoteAddress = | |
| stream && typeof (stream as any).remoteAddress === "string" | |
| ? (stream as any).remoteAddress | |
| : undefined; | |
| const remotePort = | |
| stream && typeof (stream as any).remotePort === "number" | |
| ? (stream as any).remotePort | |
| : undefined; | |
| if (remoteAddress) { | |
| return { address: remoteAddress, port: remotePort }; | |
| } | |
| let host: string | undefined = | |
| "host" in this.options && this.options.host | |
| ? this.options.host | |
| : undefined; | |
| let port: number | undefined = | |
| "port" in this.options && this.options.port | |
| ? this.options.port | |
| : undefined; | |
| // For sentinel setups, fall back to the first sentinel endpoint | |
| // if an explicit host/port is not set. | |
| if ( | |
| (!host || !port) && | |
| "sentinels" in this.options && | |
| Array.isArray((this.options as any).sentinels) && | |
| (this.options as any).sentinels.length > 0 | |
| ) { | |
| const firstSentinel = (this.options as any).sentinels[0]; | |
| if (!host && firstSentinel && typeof firstSentinel.host === "string") { | |
| host = firstSentinel.host; | |
| } | |
| if (!port && firstSentinel && typeof firstSentinel.port === "number") { | |
| port = firstSentinel.port; | |
| } | |
| } | |
| return { | |
| address: host || "localhost", | |
| port: port ?? 6379, |
Use WRONGTYPE error (HSET on string key) and assert that error events are actually emitted with the expected context fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CommandContext → CommandTraceContext ConnectContext → ConnectTraceContext Added BatchCommandTraceContext as separate type extending CommandTraceContext Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PIPELINE and MULTI instead of pipeline and multi. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@logaretm Thanks for the PR. I’ll take a look when I get a chance and follow up with feedback. |
|
@PavelPashov Thanks! happy to work as much as needed to get this through as long as no one is opposed to the idea 🙏 P.S: I created a PR for |
TracingChannel.hasSubscribers is undefined on Node versions that support TracingChannel but predate the hasSubscribers property. A truthiness check skips tracing on those versions. The new helper checks !== false so that undefined (missing property) traces unconditionally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For MULTI transactions, batchSize now counts only user commands. EXEC already has inTransaction=false (exec() decrements _transactions before sendCommand), and MULTI is excluded by name since multi() increments _transactions before sendCommand runs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exposes connectionEpoch in the ioredis:connect trace context so APM consumers can distinguish initial connections (epoch 0) from reconnections (epoch 1+) and correlate traces across reconnection cycles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a command was queued offline, traceCommand wrapped command.promise at queue time. When the connection became ready and the command was re-sent via sendCommand, it was wrapped again — producing duplicate start/end/error trace events and inflated latency in the first span. Move tracing to the write path only so offline-queued commands are traced once when actually sent to Redis. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@PavelPashov No rush, but let me know if we can jump on a call to discuss the PR if that may help move things along. Otherwise, I will be waiting for your 👀 |
Node's TracingChannel.tracePromise returns a wrapper promise that re-rejects on error via PromiseReject(err). When Pipeline discards the sendCommand return value, this wrapper has no rejection handler, causing unhandledRejection events that don't exist without tracing. Silence the wrapper with .catch(noop) so tracing remains transparent. The error trace event still fires and callers that await the promise still see the rejection through their own .then() chain. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hey @logaretm, I'm on the official Redis discord server, feel free to message me @pavelpashov_16203. I answered in this thread let's continue the github discussion there. |

This PR implements tracing channels for
commandandconnectoperations. 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
TracingChannelAPI, which providesstart,end,asyncStart,asyncEnd, anderrorsub-channels automatically.ioredis:commandcommand,args,database,serverAddress,serverPort, and optionallybatchMode,batchSizefor MULTI/pipeline commandsioredis:connectserverAddress,serverPort,connectionEpochContext Properties
The payload sent in the trace events closely follows what OTEL currently extracts as attributes in their instrumentations:
commanddb.operation.nameargsdb.query.text(APM serializes/sanitizes)databasedb.namespaceserverAddresspathfor IPC)server.addressserverPortundefinedfor IPC)server.portFor batch commands we include these properties:
batchMode'PIPELINE'or'MULTI'db.operation.nameprefixbatchSizedb.operation.batch.sizeFor connect events:
connectionEpoch0for 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
TracingChannelis unavailable (e.g. Node 18).Usage Examples
Consumers will be doing something like this:
Future Improvements
ioredis:command:queuechannel 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._getServerAddress()currently uses configuredhost/portoptions. For sentinel setups, the trace context could reflect the actual resolved endpoint rather than the configured defaults.Note
Medium Risk
Touches core
connect()andsendCommand()paths to emit tracing events; while designed to be no-op without subscribers, mistakes could impact command execution flow or cause duplicate/unhandled promise behavior.Overview
Adds Node.js
diagnostics_channelTracingChannelinstrumentation for Redis connections and command execution.Redis.connect()is now wrapped withtraceConnectto emitioredis:connectlifecycle events (including server address/port andconnectionEpoch), andsendCommand()is wrapped withtraceCommandto emitioredis:commandevents only when commands are actually written (avoiding duplicate traces for offline-queued commands).Pipeline/MULTI commands now annotate each queued
CommandwithbatchModeandbatchSizeso trace contexts can represent batched operations; tracing types are exported fromlib/index.ts, and new functional tests validate contexts, batching behavior, error tracing, and lack of unhandled rejections.Written by Cursor Bugbot for commit 17d7de7. This will update automatically on new commits. Configure here.