|
| 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 }; |
0 commit comments