From 2caff897a26194403b7b49e94074c1d7b8805c29 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 28 Apr 2026 16:25:33 -0400 Subject: [PATCH 01/10] feat(node): use diagnostics_channel for redis >= 5.12.0 node-redis 5.12.0 publishes command, batch, and connect events via node:diagnostics_channel. Subscribe to those channels via @sentry/opentelemetry/tracing-channel to produce spans without IITM- based monkey-patching, which lets the redis integration work on runtimes that don't support IITM (Bun, Deno, Cloudflare Workers). The existing OTel patcher is narrowed to '<5.12.0' so it does not double-instrument when both paths are present. The DC subscription is deferred to the next microtask so it runs after initOpenTelemetry() sets up the Sentry context manager (required for bindStore). --- packages/node/rollup.npm.config.mjs | 1 + .../src/integrations/tracing/redis/index.ts | 6 + .../tracing/redis/redis-dc-subscriber.ts | 231 ++++++++++++++++++ .../redis/vendored/redis-instrumentation.ts | 6 +- 4 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 93fd1d8c16ca..741c6ec27fe5 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -6,6 +6,7 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], packageSpecificConfig: { + external: [/^@sentry\/opentelemetry/], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index c2bff42e4107..ee691113e516 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -23,6 +23,7 @@ import { import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; import { RedisInstrumentation } from './vendored/redis-instrumentation'; +import { subscribeRedisDiagnosticChannels } from './redis-dc-subscriber'; interface RedisOptions { /** @@ -120,6 +121,11 @@ export const instrumentRedis = Object.assign( (): void => { instrumentIORedis(); instrumentRedisModule(); + // node-redis >= 5.12.0 publishes via diagnostics_channel. The subscriber uses + // `@sentry/opentelemetry/tracing-channel`, which needs the Sentry OTel context manager + // to be registered before it can `bindStore`. `initOpenTelemetry()` runs after integration + // `setupOnce`, so defer to the next tick. + Promise.resolve().then(() => subscribeRedisDiagnosticChannels(cacheResponseHook)); // todo: implement them gradually // new LegacyRedisInstrumentation({}), diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts new file mode 100644 index 000000000000..dc815c065026 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -0,0 +1,231 @@ +import type { Span } from '@opentelemetry/api'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpanManual, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import { defaultDbStatementSerializer } from './vendored/redis-common'; +import { + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_VALUE_REDIS, +} from './vendored/semconv'; +import type { IORedisInstrumentationConfig } from './vendored/types'; + +// Channel names as published by node-redis >= 5.12.0. +// Hardcoded so we don't import `redis` at module-load time. +const CHANNEL_COMMAND = 'node-redis:command'; +const CHANNEL_BATCH = 'node-redis:batch'; +const CHANNEL_CONNECT = 'node-redis:connect'; + +const ORIGIN = 'auto.db.redis.diagnostic-channel'; + +interface CommandData { + command: string; + args: Array; + database?: number; + serverAddress?: string; + serverPort?: number; + result?: unknown; + error?: Error; +} + +interface BatchData { + batchMode?: 'MULTI' | 'PIPELINE'; + batchSize?: number; + database?: number; + clientId?: string | number; + serverAddress?: string; + serverPort?: number; + result?: unknown[]; + error?: Error; +} + +interface ConnectData { + serverAddress?: string; + serverPort?: number; + url?: string; + error?: Error; +} + +const NOOP = (): void => {}; + +let subscribed = false; +let currentResponseHook: IORedisInstrumentationConfig['responseHook'] | undefined; + +/** + * Subscribe Sentry handlers to node-redis diagnostics_channel events (>= 5.12.0). + * + * Uses `@sentry/opentelemetry/tracing-channel` so OTel AsyncLocalStorage context propagates + * automatically via `bindStore` — without it, spans created in `start` would not become + * the active context for subsequent operations. + * + * Safe on every runtime that exposes `node:diagnostics_channel` (Node, Bun, Deno, Workers). + * In node-redis < 5.12.0 the channels are never published to, so subscribers are inert and + * there is no double-instrumentation against the IITM-based patcher (gated to < 5.12.0). + */ +export function subscribeRedisDiagnosticChannels( + responseHook?: IORedisInstrumentationConfig['responseHook'], +): void { + currentResponseHook = responseHook; + if (subscribed) return; + + try { + setupCommandChannel(); + setupBatchChannel(); + setupConnectChannel(); + subscribed = true; + } catch { + // tracingChannel from @sentry/opentelemetry requires `node:diagnostics_channel`. + // On runtimes where it isn't available, fail closed. + } +} + +function setupCommandChannel(): void { + const channel = tracingChannel(CHANNEL_COMMAND, data => { + const statement = safeSerialize(data.command, data.args); + return startSpanManual( + { + name: `redis-${data.command}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(statement != null ? { [ATTR_DB_STATEMENT]: statement } : {}), + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + const span = data._sentrySpan; + if (!span) return; + runResponseHook(span, data.command, data.args, data.result); + span.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function setupBatchChannel(): void { + const channel = tracingChannel(CHANNEL_BATCH, data => { + const operationName = data.batchMode === 'PIPELINE' ? 'PIPELINE' : 'MULTI'; + + return startSpanManual( + { + name: operationName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(data.batchSize != null ? { 'db.redis.batch_size': data.batchSize } : {}), + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + data._sentrySpan?.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function setupConnectChannel(): void { + const channel = tracingChannel(CHANNEL_CONNECT, data => { + return startSpanManual( + { + name: 'redis-connect', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + ...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}), + }, + }, + span => span, + ) as Span; + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + data._sentrySpan?.end(); + }, + error: data => { + const span = data._sentrySpan; + if (!span) return; + if (data.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message }); + } + span.end(); + }, + }); +} + +function runResponseHook( + span: Span, + command: string, + args: Array, + result: unknown, +): void { + const hook = currentResponseHook; + if (!hook) return; + try { + hook(span, command, args as unknown as Parameters[2], result); + } catch { + // never let user hooks break instrumentation + } +} + +function safeSerialize(command: string, args: Array): string | undefined { + try { + return defaultDbStatementSerializer(command, args); + } catch { + return undefined; + } +} + +// Test-only helper. +export function _resetRedisDiagnosticChannelsForTesting(): void { + subscribed = false; + currentResponseHook = undefined; +} + +// Suppress unused-import lint when only used in types. +export type { TracingChannelContextWithSpan }; diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts index 8801962522aa..3e7dd78d2cde 100644 --- a/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts @@ -368,7 +368,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase=5.0.0 <5.12.0'], (moduleExports: any) => { const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; if (isWrapped(redisClientMultiCommandPrototype?.exec)) { @@ -401,7 +401,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase=5.0.0 <5.12.0'], (moduleExports: any) => { const redisClientPrototype = moduleExports?.default?.prototype; if (redisClientPrototype?.multi) { @@ -445,7 +445,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase=5.0.0 <5.12.0'], (moduleExports: any) => moduleExports, () => {}, [commanderModuleFile, multiCommanderModule, clientIndexModule], From 5097358e1566c62524bffc9b83963e9dd33723ff Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 28 Apr 2026 16:48:14 -0400 Subject: [PATCH 02/10] fix: lint and format --- packages/node/src/integrations/tracing/redis/index.ts | 2 +- .../integrations/tracing/redis/redis-dc-subscriber.ts | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index ee691113e516..dce9bf6a0381 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -125,7 +125,7 @@ export const instrumentRedis = Object.assign( // `@sentry/opentelemetry/tracing-channel`, which needs the Sentry OTel context manager // to be registered before it can `bindStore`. `initOpenTelemetry()` runs after integration // `setupOnce`, so defer to the next tick. - Promise.resolve().then(() => subscribeRedisDiagnosticChannels(cacheResponseHook)); + void Promise.resolve().then(() => subscribeRedisDiagnosticChannels(cacheResponseHook)); // todo: implement them gradually // new LegacyRedisInstrumentation({}), diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts index dc815c065026..5e339fcabbd3 100644 --- a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -68,9 +68,7 @@ let currentResponseHook: IORedisInstrumentationConfig['responseHook'] | undefine * In node-redis < 5.12.0 the channels are never published to, so subscribers are inert and * there is no double-instrumentation against the IITM-based patcher (gated to < 5.12.0). */ -export function subscribeRedisDiagnosticChannels( - responseHook?: IORedisInstrumentationConfig['responseHook'], -): void { +export function subscribeRedisDiagnosticChannels(responseHook?: IORedisInstrumentationConfig['responseHook']): void { currentResponseHook = responseHook; if (subscribed) return; @@ -198,12 +196,7 @@ function setupConnectChannel(): void { }); } -function runResponseHook( - span: Span, - command: string, - args: Array, - result: unknown, -): void { +function runResponseHook(span: Span, command: string, args: Array, result: unknown): void { const hook = currentResponseHook; if (!hook) return; try { From 7b5e46641d045c450d94f163b3f13eb71deb230d Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 4 May 2026 13:21:28 -0700 Subject: [PATCH 03/10] test: add redis 5 integration test --- .../node-integration-tests/package.json | 1 + .../tracing/redis-dc/docker-compose.yml | 15 +++ .../tracing/redis-dc/scenario-redis-5.js | 44 +++++++ .../suites/tracing/redis-dc/test.ts | 110 ++++++++++++++++++ .../tracing/redis/redis-dc-subscriber.ts | 8 +- 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 3fa61d5b6576..13cffc27fcde 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -80,6 +80,7 @@ "prisma": "6.15.0", "proxy": "^2.1.1", "redis-4": "npm:redis@^4.6.14", + "redis-5": "npm:redis@^5.12.0", "reflect-metadata": "0.2.1", "rxjs": "^7.8.2", "tedious": "^19.2.1", diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml new file mode 100644 index 000000000000..9cad2efa4eff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.9' + +services: + db: + image: redis:latest + restart: always + container_name: integration-tests-redis-dc + ports: + - '6379:6379' + healthcheck: + test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js new file mode 100644 index 000000000000..34510c68aa9f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js @@ -0,0 +1,44 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.redisIntegration({ cachePrefixes: ['dc-cache:'] })], +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const { createClient } = require('redis-5'); + +async function run() { + const redisClient = await createClient({ socket: { host: '127.0.0.1', port: 6379 } }).connect(); + + await Sentry.startSpan( + { + name: 'Test Span Redis 5 DC', + op: 'test-span-redis-5-dc', + }, + async () => { + try { + await redisClient.set('dc-test-key', 'test-value'); + await redisClient.set('dc-cache:test-key', 'test-value'); + + await redisClient.set('dc-cache:test-key-ex', 'test-value', { EX: 10 }); + + await redisClient.get('dc-test-key'); + await redisClient.get('dc-cache:test-key'); + await redisClient.get('dc-cache:unavailable-data'); + + await redisClient.mGet(['dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data']); + } finally { + await redisClient.disconnect(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts new file mode 100644 index 000000000000..789d72b8ad01 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -0,0 +1,110 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('redis v5 diagnostics_channel auto instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should create spans for redis v5 commands via diagnostics_channel', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Span Redis 5 DC', + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.otel.redis', + 'db.system': 'redis', + 'db.statement': 'SET dc-test-key [1 other arguments]', + }), + }), + // cache SET: span name updated to key by cacheResponseHook + expect.objectContaining({ + description: 'dc-cache:test-key', + op: 'cache.put', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'SET dc-cache:test-key [1 other arguments]', + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 2, + }), + }), + // cache SET with EX option: redis v5 sends SET key value EX 10 as the command + expect.objectContaining({ + description: 'dc-cache:test-key-ex', + op: 'cache.put', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'SET dc-cache:test-key-ex [3 other arguments]', + 'cache.key': ['dc-cache:test-key-ex'], + 'cache.item_size': 2, + }), + }), + expect.objectContaining({ + op: 'db.redis', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.op': 'db.redis', + 'sentry.origin': 'auto.db.otel.redis', + 'db.system': 'redis', + 'db.statement': 'GET dc-test-key', + }), + }), + // cache GET (hit) + expect.objectContaining({ + description: 'dc-cache:test-key', + op: 'cache.get', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'GET dc-cache:test-key', + 'cache.hit': true, + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 10, + }), + }), + // cache GET (miss) + expect.objectContaining({ + description: 'dc-cache:unavailable-data', + op: 'cache.get', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'GET dc-cache:unavailable-data', + 'cache.hit': false, + 'cache.key': ['dc-cache:unavailable-data'], + }), + }), + // MGET: mixed cache/non-cache keys, span name is all keys joined + expect.objectContaining({ + description: 'dc-test-key, dc-cache:test-key, dc-cache:unavailable-data', + op: 'cache.get', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'MGET [3 other arguments]', + 'cache.hit': true, + 'cache.key': ['dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data'], + }), + }), + ]), + }; + + // node-redis emits a node-redis:connect DC event for the initial connection. + // That fires before startSpan so it becomes its own root transaction, received after the main one. + const EXPECTED_CONNECT = { + transaction: 'redis-connect', + }; + + await createRunner(__dirname, 'scenario-redis-5.js') + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .expect({ transaction: EXPECTED_CONNECT }) + .start() + .completed(); + }); +}); diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts index 5e339fcabbd3..c66a0bd6aec6 100644 --- a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -85,7 +85,10 @@ export function subscribeRedisDiagnosticChannels(responseHook?: IORedisInstrumen function setupCommandChannel(): void { const channel = tracingChannel(CHANNEL_COMMAND, data => { - const statement = safeSerialize(data.command, data.args); + // node-redis >= 5.12.0 includes the command name as args[0] in the DC payload. + // Strip it so serialization and cache key extraction see only the actual arguments. + const actualArgs = data.args.slice(1); + const statement = safeSerialize(data.command, actualArgs); return startSpanManual( { name: `redis-${data.command}`, @@ -109,7 +112,8 @@ function setupCommandChannel(): void { asyncEnd: data => { const span = data._sentrySpan; if (!span) return; - runResponseHook(span, data.command, data.args, data.result); + // Same slice: strip command name from args before passing to the response hook. + runResponseHook(span, data.command, data.args.slice(1), data.result); span.end(); }, error: data => { From 2b84a6479d0c2fee6405ca1fcca3f61683044ae6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 5 May 2026 13:42:38 -0400 Subject: [PATCH 04/10] fix: hyphenation in span origin --- .../node/src/integrations/tracing/redis/redis-dc-subscriber.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts index c66a0bd6aec6..383b8b6ebf38 100644 --- a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -22,7 +22,7 @@ const CHANNEL_COMMAND = 'node-redis:command'; const CHANNEL_BATCH = 'node-redis:batch'; const CHANNEL_CONNECT = 'node-redis:connect'; -const ORIGIN = 'auto.db.redis.diagnostic-channel'; +const ORIGIN = 'auto.db.redis.diagnostic_channel'; interface CommandData { command: string; From 629eb8c9e80205a94441de4336819e2449932758 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 5 May 2026 14:18:20 -0400 Subject: [PATCH 05/10] fix(test): update MGET assertion for sanitized DC args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-redis sanitizeArgs replaces MGET key arguments with '?' in the diagnostics_channel payload, so cache prefix detection cannot match — MGET remains a plain db.redis span. --- .../suites/tracing/redis-dc/test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index 789d72b8ad01..c6aa82d2a6b2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -79,16 +79,16 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { 'cache.key': ['dc-cache:unavailable-data'], }), }), - // MGET: mixed cache/non-cache keys, span name is all keys joined + // MGET: node-redis sanitizes args for diagnostics_channel (keys become '?'), + // so cache detection cannot match prefixes — remains a plain db.redis span. expect.objectContaining({ - description: 'dc-test-key, dc-cache:test-key, dc-cache:unavailable-data', - op: 'cache.get', + op: 'db.redis', origin: 'auto.db.otel.redis', data: expect.objectContaining({ + 'sentry.op': 'db.redis', 'sentry.origin': 'auto.db.otel.redis', + 'db.system': 'redis', 'db.statement': 'MGET [3 other arguments]', - 'cache.hit': true, - 'cache.key': ['dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data'], }), }), ]), From fe5844939532cc851c791be6721aae39c35e47ea Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 5 May 2026 14:36:39 -0400 Subject: [PATCH 06/10] fix(node): stop overriding span origin in cacheResponseHook The hook unconditionally set origin to 'auto.db.otel.redis', clobbering the diagnostic_channel origin on all DC-sourced spans. Remove the override so each instrumentation path keeps its own origin. --- .../suites/tracing/redis-dc/test.ts | 28 +++++++++---------- .../src/integrations/tracing/redis/index.ts | 2 -- .../test/integrations/tracing/redis.test.ts | 4 +-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index c6aa82d2a6b2..8451690478b5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -12,10 +12,10 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { spans: expect.arrayContaining([ expect.objectContaining({ op: 'db.redis', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.op': 'db.redis', - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', 'db.statement': 'SET dc-test-key [1 other arguments]', }), @@ -24,9 +24,9 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { expect.objectContaining({ description: 'dc-cache:test-key', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.statement': 'SET dc-cache:test-key [1 other arguments]', 'cache.key': ['dc-cache:test-key'], 'cache.item_size': 2, @@ -36,9 +36,9 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { expect.objectContaining({ description: 'dc-cache:test-key-ex', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.statement': 'SET dc-cache:test-key-ex [3 other arguments]', 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, @@ -46,10 +46,10 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { }), expect.objectContaining({ op: 'db.redis', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.op': 'db.redis', - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', 'db.statement': 'GET dc-test-key', }), @@ -58,9 +58,9 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { expect.objectContaining({ description: 'dc-cache:test-key', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.statement': 'GET dc-cache:test-key', 'cache.hit': true, 'cache.key': ['dc-cache:test-key'], @@ -71,9 +71,9 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { expect.objectContaining({ description: 'dc-cache:unavailable-data', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.statement': 'GET dc-cache:unavailable-data', 'cache.hit': false, 'cache.key': ['dc-cache:unavailable-data'], @@ -83,10 +83,10 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { // so cache detection cannot match prefixes — remains a plain db.redis span. expect.objectContaining({ op: 'db.redis', - origin: 'auto.db.otel.redis', + origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.op': 'db.redis', - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', 'db.statement': 'MGET [3 other arguments]', }), diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index dce9bf6a0381..a19af5b9f9f0 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -54,8 +54,6 @@ export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( cmdArgs: IORedisCommandArgs, response: unknown, ) => { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); - const safeKey = getCacheKeySafely(redisCommand, cmdArgs); const cacheOperation = getCacheOperation(redisCommand); diff --git a/packages/node/test/integrations/tracing/redis.test.ts b/packages/node/test/integrations/tracing/redis.test.ts index ae5b879c0b8e..0eb31d6aea5f 100644 --- a/packages/node/test/integrations/tracing/redis.test.ts +++ b/packages/node/test/integrations/tracing/redis.test.ts @@ -38,12 +38,12 @@ describe('Redis', () => { { desc: 'unsupported command', cmd: 'exists', args: ['key'], response: 'test' }, { desc: 'no cache prefixes', cmd: 'get', args: ['key'], response: 'test', options: {} }, { desc: 'non-matching prefix', cmd: 'get', args: ['key'], response: 'test', options: { cachePrefixes: ['c'] } }, - ])('should always set sentry.origin but return early when $desc', ({ cmd, args, response, options = {} }) => { + ])('should return early without modifying span when $desc', ({ cmd, args, response, options = {} }) => { Object.assign(_redisOptions, options); cacheResponseHook(mockSpan, cmd, args, response); - expect(mockSpan.setAttribute).toHaveBeenCalledWith('sentry.origin', 'auto.db.otel.redis'); + expect(mockSpan.setAttribute).not.toHaveBeenCalled(); expect(mockSpan.setAttributes).not.toHaveBeenCalled(); expect(mockSpan.updateName).not.toHaveBeenCalled(); }); From 7cabfb862cd677b7b0447af278c0e15157006483 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 5 May 2026 15:45:27 -0400 Subject: [PATCH 07/10] refactor(node): set sentry.origin at span creation in vendored redis instrumentations Move origin attribute from the shared cacheResponseHook into the vendored OTel instrumentations so each path sets origin at span creation time, consistent with the DC subscriber. --- packages/node/src/integrations/tracing/redis/index.ts | 1 - .../tracing/redis/vendored/ioredis-instrumentation.ts | 3 +++ .../tracing/redis/vendored/redis-instrumentation.ts | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index a19af5b9f9f0..2e5268c14c6f 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -6,7 +6,6 @@ import { SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, truncate, } from '@sentry/core'; diff --git a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts index d55cb2e31420..a97900ab4f9d 100644 --- a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts +++ b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts @@ -22,6 +22,7 @@ import { context, diag, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; import type { Span } from '@opentelemetry/api'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, @@ -166,6 +167,7 @@ export class IORedisInstrumentation extends InstrumentationBase Date: Tue, 5 May 2026 16:15:10 -0400 Subject: [PATCH 08/10] fix(test): defer redis-5 require so DC subscriber registers before connect node-redis eagerly creates native TracingChannels on require(), and the DC subscriber is deferred via Promise.resolve().then(). Moving the require inside run() after a microtick ensures the subscriber is registered before the connect event fires. --- .../suites/tracing/redis-dc/scenario-redis-5.js | 7 +++++-- .../node-integration-tests/suites/tracing/redis-dc/test.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js index 34510c68aa9f..5cf455203d48 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5.js @@ -12,9 +12,12 @@ Sentry.init({ // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); -const { createClient } = require('redis-5'); - async function run() { + // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) + // is registered before node-redis eagerly creates its native TracingChannels on require(). + await Promise.resolve(); + + const { createClient } = require('redis-5'); const redisClient = await createClient({ socket: { host: '127.0.0.1', port: 6379 } }).connect(); await Sentry.startSpan( diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index 8451690478b5..7b7c111f4d29 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -95,15 +95,15 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { }; // node-redis emits a node-redis:connect DC event for the initial connection. - // That fires before startSpan so it becomes its own root transaction, received after the main one. + // That fires before startSpan so it arrives as the first envelope. const EXPECTED_CONNECT = { transaction: 'redis-connect', }; await createRunner(__dirname, 'scenario-redis-5.js') .withDockerCompose({ workingDirectory: [__dirname] }) - .expect({ transaction: EXPECTED_TRANSACTION }) .expect({ transaction: EXPECTED_CONNECT }) + .expect({ transaction: EXPECTED_TRANSACTION }) .start() .completed(); }); From 6fd04e52487d37c3c3a4fd14298b1c83badc014c Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 6 May 2026 13:18:06 -0700 Subject: [PATCH 09/10] fix(node): avoid double-end on error in redis dc instrumentation --- .../integrations/tracing/redis/redis-dc-subscriber.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts index 383b8b6ebf38..4a2ddaf8a9b2 100644 --- a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -111,7 +111,8 @@ function setupCommandChannel(): void { end: NOOP, asyncEnd: data => { const span = data._sentrySpan; - if (!span) return; + // only end if error handler isn't going to + if (!span || data.error) return; // Same slice: strip command name from args before passing to the response hook. runResponseHook(span, data.command, data.args.slice(1), data.result); span.end(); @@ -152,7 +153,8 @@ function setupBatchChannel(): void { asyncStart: NOOP, end: NOOP, asyncEnd: data => { - data._sentrySpan?.end(); + // only end if the error handler isn't going to + if (!data.error) data._sentrySpan?.end(); }, error: data => { const span = data._sentrySpan; @@ -187,7 +189,8 @@ function setupConnectChannel(): void { asyncStart: NOOP, end: NOOP, asyncEnd: data => { - data._sentrySpan?.end(); + // only end if the error handler isn't going to + if (!data.error) data._sentrySpan?.end(); }, error: data => { const span = data._sentrySpan; From 78b22448a47d3e112a9cb78f4e9892dbc311782b Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 6 May 2026 13:29:45 -0700 Subject: [PATCH 10/10] fix(test): add unit test for redis-dc --- .../tracing/redis/redis-dc-subscriber.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts diff --git a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts new file mode 100644 index 000000000000..852298b3370c --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts @@ -0,0 +1,214 @@ +import { SPAN_STATUS_ERROR } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// channels registry must be created before the vi.mock factory runs +const channels = vi.hoisted(() => ({}) as Record void> }>); + +vi.mock('@sentry/opentelemetry/tracing-channel', () => ({ + tracingChannel: (name: string, _transform: unknown) => { + const subs: Record void> = {}; + channels[name] = { subs }; + return { subscribe: (s: Record void>) => Object.assign(subs, s) }; + }, +})); + +import { + _resetRedisDiagnosticChannelsForTesting, + subscribeRedisDiagnosticChannels, +} from '../../../../src/integrations/tracing/redis/redis-dc-subscriber'; + +const CHANNEL_COMMAND = 'node-redis:command'; +const CHANNEL_BATCH = 'node-redis:batch'; +const CHANNEL_CONNECT = 'node-redis:connect'; + +const subs = (name: string) => + channels[name]?.subs as { + asyncEnd: (data: any) => void; + error: (data: any) => void; + }; + +function makeSpan() { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + updateName: vi.fn(), + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id', traceFlags: 1 }), + }; +} + +describe('redis-dc-subscriber', () => { + let mockSpan: ReturnType; + let responseHook: ReturnType; + + beforeEach(() => { + _resetRedisDiagnosticChannelsForTesting(); + mockSpan = makeSpan(); + responseHook = vi.fn(); + subscribeRedisDiagnosticChannels(responseHook); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('command channel', () => { + describe('asyncEnd (success path)', () => { + it('calls the response hook with sliced args and ends the span', () => { + const data = { + command: 'GET', + args: ['GET', 'cache:key'], + result: 'hit-value', + _sentrySpan: mockSpan, + }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'GET', ['cache:key'], 'hit-value'); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('strips the command name from args before passing to the response hook', () => { + const data = { + command: 'MGET', + args: ['MGET', 'key1', 'key2', 'key3'], + result: ['v1', 'v2', 'v3'], + _sentrySpan: mockSpan, + }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'MGET', ['key1', 'key2', 'key3'], ['v1', 'v2', 'v3']); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_COMMAND).asyncEnd({ command: 'GET', args: ['GET', 'k'], result: 'v' }); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('ECONNREFUSED'); + const data = { command: 'SET', args: ['SET', 'k', 'v'], error, _sentrySpan: mockSpan }; + subs(CHANNEL_COMMAND).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { + const error = new Error('ECONNREFUSED'); + const data = { command: 'GET', args: ['GET', 'k'], error, _sentrySpan: mockSpan }; + + // TracingChannel fires error first, then asyncEnd, on the same data object + subs(CHANNEL_COMMAND).error(data); + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early in error handler when _sentrySpan is absent', () => { + subs(CHANNEL_COMMAND).error({ command: 'GET', args: ['GET', 'k'], error: new Error('x') }); + + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + }); + + describe('batch channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { batchMode: 'PIPELINE', batchSize: 3, _sentrySpan: mockSpan }; + subs(CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + subs(CHANNEL_BATCH).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'MULTI aborted' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + + subs(CHANNEL_BATCH).error(data); + subs(CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('connect channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { serverAddress: '127.0.0.1', serverPort: 6379, _sentrySpan: mockSpan }; + subs(CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(CHANNEL_CONNECT).asyncEnd({ serverAddress: '127.0.0.1' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + it('sets error status and ends the span in the error handler', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', serverPort: 6379, error, _sentrySpan: mockSpan }; + subs(CHANNEL_CONNECT).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', error, _sentrySpan: mockSpan }; + + subs(CHANNEL_CONNECT).error(data); + subs(CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('subscribeRedisDiagnosticChannels', () => { + it('is idempotent — does not re-subscribe if called again', () => { + // subscribeRedisDiagnosticChannels was already called in beforeEach. + // Calling again should not throw or overwrite subscribers. + const secondHook = vi.fn(); + subscribeRedisDiagnosticChannels(secondHook); + + // The second hook should still be active (currentResponseHook is updated regardless) + // but no new channel setup should occur. + const data = { command: 'GET', args: ['GET', 'k'], result: 'v', _sentrySpan: mockSpan }; + subs(CHANNEL_COMMAND).asyncEnd(data); + + expect(secondHook).toHaveBeenCalledTimes(1); + expect(responseHook).not.toHaveBeenCalled(); + }); + }); +});