diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index c78a73bc7440..cf859c705083 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/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..dce9bf6a0381 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. + 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 new file mode 100644 index 000000000000..c66a0bd6aec6 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts @@ -0,0 +1,228 @@ +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 => { + // 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}`, + 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; + // 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 => { + 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],