Skip to content

Commit 291507c

Browse files
committed
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).
1 parent f7f5d5e commit 291507c

4 files changed

Lines changed: 241 additions & 3 deletions

File tree

packages/node/rollup.npm.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default [
66
makeBaseNPMConfig({
77
entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'],
88
packageSpecificConfig: {
9+
external: [/^@sentry\/opentelemetry/],
910
output: {
1011
// set exports to 'named' or 'auto' so that rollup doesn't warn
1112
exports: 'named',

packages/node/src/integrations/tracing/redis/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import type { IORedisInstrumentationConfig } from './vendored/types';
2424
import { IORedisInstrumentation } from './vendored/ioredis-instrumentation';
2525
import { RedisInstrumentation } from './vendored/redis-instrumentation';
26+
import { subscribeRedisDiagnosticChannels } from './redis-dc-subscriber';
2627

2728
interface RedisOptions {
2829
/**
@@ -116,6 +117,11 @@ export const instrumentRedis = Object.assign(
116117
(): void => {
117118
instrumentIORedis();
118119
instrumentRedisModule();
120+
// node-redis >= 5.12.0 publishes via diagnostics_channel. The subscriber uses
121+
// `@sentry/opentelemetry/tracing-channel`, which needs the Sentry OTel context manager
122+
// to be registered before it can `bindStore`. `initOpenTelemetry()` runs after integration
123+
// `setupOnce`, so defer to the next tick.
124+
Promise.resolve().then(() => subscribeRedisDiagnosticChannels(cacheResponseHook));
119125

120126
// todo: implement them gradually
121127
// new LegacyRedisInstrumentation({}),
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import type { Span } from '@opentelemetry/api';
2+
import {
3+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
4+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
5+
SPAN_STATUS_ERROR,
6+
startSpanManual,
7+
} from '@sentry/core';
8+
import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel';
9+
import { defaultDbStatementSerializer } from './vendored/redis-common';
10+
import {
11+
ATTR_DB_STATEMENT,
12+
ATTR_DB_SYSTEM,
13+
ATTR_NET_PEER_NAME,
14+
ATTR_NET_PEER_PORT,
15+
DB_SYSTEM_VALUE_REDIS,
16+
} from './vendored/semconv';
17+
import type { IORedisInstrumentationConfig } from './vendored/types';
18+
19+
// Channel names as published by node-redis >= 5.12.0.
20+
// Hardcoded so we don't import `redis` at module-load time.
21+
const CHANNEL_COMMAND = 'node-redis:command';
22+
const CHANNEL_BATCH = 'node-redis:batch';
23+
const CHANNEL_CONNECT = 'node-redis:connect';
24+
25+
const ORIGIN = 'auto.db.redis.diagnostic-channel';
26+
27+
interface CommandData {
28+
command: string;
29+
args: Array<string | Buffer>;
30+
database?: number;
31+
serverAddress?: string;
32+
serverPort?: number;
33+
result?: unknown;
34+
error?: Error;
35+
}
36+
37+
interface BatchData {
38+
batchMode?: 'MULTI' | 'PIPELINE';
39+
batchSize?: number;
40+
database?: number;
41+
clientId?: string | number;
42+
serverAddress?: string;
43+
serverPort?: number;
44+
result?: unknown[];
45+
error?: Error;
46+
}
47+
48+
interface ConnectData {
49+
serverAddress?: string;
50+
serverPort?: number;
51+
url?: string;
52+
error?: Error;
53+
}
54+
55+
const NOOP = (): void => {};
56+
57+
let subscribed = false;
58+
let currentResponseHook: IORedisInstrumentationConfig['responseHook'] | undefined;
59+
60+
/**
61+
* Subscribe Sentry handlers to node-redis diagnostics_channel events (>= 5.12.0).
62+
*
63+
* Uses `@sentry/opentelemetry/tracing-channel` so OTel AsyncLocalStorage context propagates
64+
* automatically via `bindStore` — without it, spans created in `start` would not become
65+
* the active context for subsequent operations.
66+
*
67+
* Safe on every runtime that exposes `node:diagnostics_channel` (Node, Bun, Deno, Workers).
68+
* In node-redis < 5.12.0 the channels are never published to, so subscribers are inert and
69+
* there is no double-instrumentation against the IITM-based patcher (gated to < 5.12.0).
70+
*/
71+
export function subscribeRedisDiagnosticChannels(
72+
responseHook?: IORedisInstrumentationConfig['responseHook'],
73+
): void {
74+
currentResponseHook = responseHook;
75+
if (subscribed) return;
76+
77+
try {
78+
setupCommandChannel();
79+
setupBatchChannel();
80+
setupConnectChannel();
81+
subscribed = true;
82+
} catch {
83+
// tracingChannel from @sentry/opentelemetry requires `node:diagnostics_channel`.
84+
// On runtimes where it isn't available, fail closed.
85+
}
86+
}
87+
88+
function setupCommandChannel(): void {
89+
const channel = tracingChannel<CommandData>(CHANNEL_COMMAND, data => {
90+
const statement = safeSerialize(data.command, data.args);
91+
return startSpanManual(
92+
{
93+
name: `redis-${data.command}`,
94+
attributes: {
95+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
96+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis',
97+
[ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS,
98+
...(statement != null ? { [ATTR_DB_STATEMENT]: statement } : {}),
99+
...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}),
100+
...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}),
101+
},
102+
},
103+
span => span,
104+
) as Span;
105+
});
106+
107+
channel.subscribe({
108+
start: NOOP,
109+
asyncStart: NOOP,
110+
end: NOOP,
111+
asyncEnd: data => {
112+
const span = data._sentrySpan;
113+
if (!span) return;
114+
runResponseHook(span, data.command, data.args, data.result);
115+
span.end();
116+
},
117+
error: data => {
118+
const span = data._sentrySpan;
119+
if (!span) return;
120+
if (data.error) {
121+
span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message });
122+
}
123+
span.end();
124+
},
125+
});
126+
}
127+
128+
function setupBatchChannel(): void {
129+
const channel = tracingChannel<BatchData>(CHANNEL_BATCH, data => {
130+
const operationName = data.batchMode === 'PIPELINE' ? 'PIPELINE' : 'MULTI';
131+
132+
return startSpanManual(
133+
{
134+
name: operationName,
135+
attributes: {
136+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
137+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis',
138+
[ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS,
139+
...(data.batchSize != null ? { 'db.redis.batch_size': data.batchSize } : {}),
140+
...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}),
141+
...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}),
142+
},
143+
},
144+
span => span,
145+
) as Span;
146+
});
147+
148+
channel.subscribe({
149+
start: NOOP,
150+
asyncStart: NOOP,
151+
end: NOOP,
152+
asyncEnd: data => {
153+
data._sentrySpan?.end();
154+
},
155+
error: data => {
156+
const span = data._sentrySpan;
157+
if (!span) return;
158+
if (data.error) {
159+
span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message });
160+
}
161+
span.end();
162+
},
163+
});
164+
}
165+
166+
function setupConnectChannel(): void {
167+
const channel = tracingChannel<ConnectData>(CHANNEL_CONNECT, data => {
168+
return startSpanManual(
169+
{
170+
name: 'redis-connect',
171+
attributes: {
172+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
173+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect',
174+
[ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS,
175+
...(data.serverAddress != null ? { [ATTR_NET_PEER_NAME]: data.serverAddress } : {}),
176+
...(data.serverPort != null ? { [ATTR_NET_PEER_PORT]: data.serverPort } : {}),
177+
},
178+
},
179+
span => span,
180+
) as Span;
181+
});
182+
183+
channel.subscribe({
184+
start: NOOP,
185+
asyncStart: NOOP,
186+
end: NOOP,
187+
asyncEnd: data => {
188+
data._sentrySpan?.end();
189+
},
190+
error: data => {
191+
const span = data._sentrySpan;
192+
if (!span) return;
193+
if (data.error) {
194+
span.setStatus({ code: SPAN_STATUS_ERROR, message: data.error.message });
195+
}
196+
span.end();
197+
},
198+
});
199+
}
200+
201+
function runResponseHook(
202+
span: Span,
203+
command: string,
204+
args: Array<string | Buffer>,
205+
result: unknown,
206+
): void {
207+
const hook = currentResponseHook;
208+
if (!hook) return;
209+
try {
210+
hook(span, command, args as unknown as Parameters<typeof hook>[2], result);
211+
} catch {
212+
// never let user hooks break instrumentation
213+
}
214+
}
215+
216+
function safeSerialize(command: string, args: Array<string | Buffer>): string | undefined {
217+
try {
218+
return defaultDbStatementSerializer(command, args);
219+
} catch {
220+
return undefined;
221+
}
222+
}
223+
224+
// Test-only helper.
225+
export function _resetRedisDiagnosticChannelsForTesting(): void {
226+
subscribed = false;
227+
currentResponseHook = undefined;
228+
}
229+
230+
// Suppress unused-import lint when only used in types.
231+
export type { TracingChannelContextWithSpan };

packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase<RedisInstrumentation
368368

369369
const multiCommanderModule = new InstrumentationNodeModuleFile(
370370
`${basePackageName}/dist/lib/client/multi-command.js`,
371-
['^1.0.0', '^5.0.0'],
371+
['^1.0.0', '>=5.0.0 <5.12.0'],
372372
(moduleExports: any) => {
373373
const redisClientMultiCommandPrototype = moduleExports?.default?.prototype;
374374
if (isWrapped(redisClientMultiCommandPrototype?.exec)) {
@@ -398,7 +398,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase<RedisInstrumentation
398398

399399
const clientIndexModule = new InstrumentationNodeModuleFile(
400400
`${basePackageName}/dist/lib/client/index.js`,
401-
['^1.0.0', '^5.0.0'],
401+
['^1.0.0', '>=5.0.0 <5.12.0'],
402402
(moduleExports: any) => {
403403
const redisClientPrototype = moduleExports?.default?.prototype;
404404
if (redisClientPrototype?.multi) {
@@ -436,7 +436,7 @@ class RedisInstrumentationV4_V5 extends InstrumentationBase<RedisInstrumentation
436436

437437
return new InstrumentationNodeModuleDefinition(
438438
basePackageName,
439-
['^1.0.0', '^5.0.0'],
439+
['^1.0.0', '>=5.0.0 <5.12.0'],
440440
(moduleExports: any) => moduleExports,
441441
() => {},
442442
[commanderModuleFile, multiCommanderModule, clientIndexModule],

0 commit comments

Comments
 (0)