diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index e3e11a56e592..6a1ebe80d601 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -7,10 +7,7 @@ describe('redis cache auto instrumentation', () => { cleanupChildProcesses(); }); - // Under orchestrion, ioredis <5.11 is instrumented by the diagnostics-channel - // subscriber instead of the OTel monkey-patch, so ioredis span origins differ. - // node-redis (redis-4/redis-5) is not ported, so those keep `auto.db.otel.redis`. - const ioredisOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.redis' : 'auto.db.otel.redis'; + const redisOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.redis' : 'auto.db.otel.redis'; describe('ioredis non-cache keys', () => { const EXPECTED_TRANSACTION = { @@ -19,7 +16,7 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'set test-key [1 other arguments]', op: 'db', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ 'sentry.op': 'db', 'db.system': 'redis', @@ -31,7 +28,7 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'get test-key', op: 'db', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ 'sentry.op': 'db', 'db.system': 'redis', @@ -62,9 +59,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'ioredis-cache:test-key', op: 'cache.put', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': ioredisOrigin, + 'sentry.origin': redisOrigin, 'db.statement': 'set ioredis-cache:test-key [1 other arguments]', 'cache.key': ['ioredis-cache:test-key'], 'cache.item_size': 2, @@ -76,9 +73,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'ioredis-cache:test-key-set-EX', op: 'cache.put', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': ioredisOrigin, + 'sentry.origin': redisOrigin, 'db.statement': 'set ioredis-cache:test-key-set-EX [3 other arguments]', 'cache.key': ['ioredis-cache:test-key-set-EX'], 'cache.item_size': 2, @@ -90,9 +87,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'ioredis-cache:test-key-setex', op: 'cache.put', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': ioredisOrigin, + 'sentry.origin': redisOrigin, 'db.statement': 'setex ioredis-cache:test-key-setex [2 other arguments]', 'cache.key': ['ioredis-cache:test-key-setex'], 'cache.item_size': 2, @@ -104,9 +101,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'ioredis-cache:test-key', op: 'cache.get', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': ioredisOrigin, + 'sentry.origin': redisOrigin, 'db.statement': 'get ioredis-cache:test-key', 'cache.hit': true, 'cache.key': ['ioredis-cache:test-key'], @@ -119,9 +116,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'ioredis-cache:unavailable-data', op: 'cache.get', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': ioredisOrigin, + 'sentry.origin': redisOrigin, 'db.statement': 'get ioredis-cache:unavailable-data', 'cache.hit': false, 'cache.key': ['ioredis-cache:unavailable-data'], @@ -133,9 +130,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'test-key, ioredis-cache:test-key, ioredis-cache:unavailable-data', op: 'cache.get', - origin: ioredisOrigin, + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': ioredisOrigin, + 'sentry.origin': redisOrigin, 'db.statement': 'mget [3 other arguments]', 'cache.hit': true, 'cache.key': ['test-key', 'ioredis-cache:test-key', 'ioredis-cache:unavailable-data'], @@ -162,6 +159,37 @@ describe('redis cache auto instrumentation', () => { transaction: 'redis-connect', }; + const batchSpans = isOrchestrionEnabled() + ? [ + expect.objectContaining({ + description: 'MULTI', + op: 'db.redis', + origin: redisOrigin, + data: expect.objectContaining({ + 'sentry.origin': redisOrigin, + 'db.system.name': 'redis', + 'db.operation.batch.size': 2, + }), + }), + ] + : [ + expect.objectContaining({ + description: 'SET redis-multi-key [1 other arguments]', + op: 'db', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'SET redis-multi-key [1 other arguments]', + }), + }), + expect.objectContaining({ + description: 'GET redis-multi-key', + op: 'db', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ 'db.system': 'redis', 'db.statement': 'GET redis-multi-key' }), + }), + ]; + const EXPECTED_TRANSACTION = { transaction: 'Test Span Redis 4', spans: expect.arrayContaining([ @@ -169,9 +197,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-cache:test-key', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'SET redis-cache:test-key [1 other arguments]', 'cache.key': ['redis-cache:test-key'], 'cache.item_size': 2, @@ -181,9 +209,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-cache:test-key-set-EX', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'SET redis-cache:test-key-set-EX [3 other arguments]', 'cache.key': ['redis-cache:test-key-set-EX'], 'cache.item_size': 2, @@ -193,9 +221,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-cache:test-key-setex', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'SETEX redis-cache:test-key-setex [2 other arguments]', 'cache.key': ['redis-cache:test-key-setex'], 'cache.item_size': 2, @@ -205,9 +233,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-cache:test-key', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'GET redis-cache:test-key', 'cache.hit': true, 'cache.key': ['redis-cache:test-key'], @@ -218,9 +246,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-cache:unavailable-data', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'GET redis-cache:unavailable-data', 'cache.hit': false, 'cache.key': ['redis-cache:unavailable-data'], @@ -230,43 +258,23 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-test-key, redis-cache:test-key, redis-cache:unavailable-data', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'MGET [3 other arguments]', 'cache.hit': true, 'cache.key': ['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data'], }), }), - // MULTI/EXEC: one span per queued command - expect.objectContaining({ - description: 'SET redis-multi-key [1 other arguments]', - op: 'db', - origin: 'auto.db.otel.redis', - data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', - 'db.system': 'redis', - 'db.statement': 'SET redis-multi-key [1 other arguments]', - }), - }), - expect.objectContaining({ - description: 'GET redis-multi-key', - op: 'db', - origin: 'auto.db.otel.redis', - data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', - 'db.system': 'redis', - 'db.statement': 'GET redis-multi-key', - }), - }), + ...batchSpans, // a failing command produces a span with an error status expect.objectContaining({ description: 'INCR redis-test-key', op: 'db', status: 'internal_error', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.system': 'redis', 'db.statement': 'INCR redis-test-key', }), @@ -294,6 +302,37 @@ describe('redis cache auto instrumentation', () => { transaction: 'redis-connect', }; + const batchSpans = isOrchestrionEnabled() + ? [ + expect.objectContaining({ + description: 'MULTI', + op: 'db.redis', + origin: redisOrigin, + data: expect.objectContaining({ + 'sentry.origin': redisOrigin, + 'db.system.name': 'redis', + 'db.operation.batch.size': 2, + }), + }), + ] + : [ + expect.objectContaining({ + description: 'SET redis-5-multi-key [1 other arguments]', + op: 'db', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'SET redis-5-multi-key [1 other arguments]', + }), + }), + expect.objectContaining({ + description: 'GET redis-5-multi-key', + op: 'db', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ 'db.system': 'redis', 'db.statement': 'GET redis-5-multi-key' }), + }), + ]; + const EXPECTED_TRANSACTION = { transaction: 'Test Span Redis 5', spans: expect.arrayContaining([ @@ -301,9 +340,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-5-cache:test-key', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'SET redis-5-cache:test-key [1 other arguments]', 'cache.key': ['redis-5-cache:test-key'], 'cache.item_size': 2, @@ -313,9 +352,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-5-cache:test-key-set-EX', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'SET redis-5-cache:test-key-set-EX [3 other arguments]', 'cache.key': ['redis-5-cache:test-key-set-EX'], 'cache.item_size': 2, @@ -325,9 +364,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-5-cache:test-key-setex', op: 'cache.put', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'SETEX redis-5-cache:test-key-setex [2 other arguments]', 'cache.key': ['redis-5-cache:test-key-setex'], 'cache.item_size': 2, @@ -337,9 +376,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-5-cache:test-key', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'GET redis-5-cache:test-key', 'cache.hit': true, 'cache.key': ['redis-5-cache:test-key'], @@ -350,9 +389,9 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-5-cache:unavailable-data', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'GET redis-5-cache:unavailable-data', 'cache.hit': false, 'cache.key': ['redis-5-cache:unavailable-data'], @@ -362,43 +401,23 @@ describe('redis cache auto instrumentation', () => { expect.objectContaining({ description: 'redis-5-test-key, redis-5-cache:test-key, redis-5-cache:unavailable-data', op: 'cache.get', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.statement': 'MGET [3 other arguments]', 'cache.hit': true, 'cache.key': ['redis-5-test-key', 'redis-5-cache:test-key', 'redis-5-cache:unavailable-data'], }), }), - // MULTI/EXEC: one span per queued command - expect.objectContaining({ - description: 'SET redis-5-multi-key [1 other arguments]', - op: 'db', - origin: 'auto.db.otel.redis', - data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', - 'db.system': 'redis', - 'db.statement': 'SET redis-5-multi-key [1 other arguments]', - }), - }), - expect.objectContaining({ - description: 'GET redis-5-multi-key', - op: 'db', - origin: 'auto.db.otel.redis', - data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', - 'db.system': 'redis', - 'db.statement': 'GET redis-5-multi-key', - }), - }), + ...batchSpans, // a failing command produces a span with an error status expect.objectContaining({ description: 'INCR redis-5-test-key', op: 'db', status: 'internal_error', - origin: 'auto.db.otel.redis', + origin: redisOrigin, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.redis', + 'sentry.origin': redisOrigin, 'db.system': 'redis', 'db.statement': 'INCR redis-5-test-key', }), diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index 7595d252a642..6a1d6728e91d 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -33,14 +33,14 @@ const instrumentRedisModule = generateInstrumentOnce(`${INTEGRATION_NAME}.Redis` */ export const instrumentRedis = Object.assign( (): void => { - // When diagnostics-channel injection is opted in, orchestrion owns ioredis - // `<5.11.0`, so skip the OTel ioredis monkey-patch to avoid double instrumentation. - // On Node without `tracingChannel` (<18.19) orchestrion can't run, so keep the - // OTel patch there — otherwise ioredis `<5.11.0` would not be traced at all. + // When diagnostics-channel injection is opted in, orchestrion fully owns the older + // ioredis (`<5.11.0`) and redis/node-redis (`<5.12.0`) ranges — commands, connect, and + // batches — so skip both OTel monkey-patches to avoid double instrumentation. On Node + // without `tracingChannel` (<18.19) orchestrion can't run, so keep the OTel patches there. if (!isDiagnosticsChannelInjectionEnabled() || !dc.tracingChannel) { instrumentIORedis(); + instrumentRedisModule(); } - instrumentRedisModule(); // node-redis >= 5.12.0 and ioredis >= 5.11.0 publish via diagnostics_channel. // `bindTracingChannelToSpan` (inside the subscriber) makes the span the active // OTel context via `bindStore`, which needs the Sentry OTel context manager to diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index f9506731993b..08d24479480f 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,6 +1,7 @@ import { channelIntegrations, ioredisChannelIntegration, + redisChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; @@ -51,12 +52,14 @@ export function experimentalUseDiagnosticsChannelInjection(): void { const replacedOtelIntegrationNames = integrations.map(i => i.name); return { - // ioredis is wired here rather than in the shared `channelIntegrations` registry: it needs - // the node redis cache `responseHook`, and it only partially replaces the composite OTel - // `Redis` integration (which also covers node-redis and ioredis >=5.11 native diagnostics_channel). - // So it's added to the integration set but kept OUT of `replacedOtelIntegrationNames` — `Redis` - // must stay; its ioredis <5.11 monkey-patch is gated off in `redisIntegration` instead. - integrations: [...integrations, ioredisChannelIntegration({ responseHook: cacheResponseHook })], + // ioredis and redis are wired separately (not in `channelIntegrations`): they need the node + // redis cache `responseHook` and only partially replace the composite OTel `Redis` integration, + // so they're kept OUT of `replacedOtelIntegrationNames` — `Redis` must stay (batch + >=5.11 native DC). + integrations: [ + ...integrations, + ioredisChannelIntegration({ responseHook: cacheResponseHook }), + redisChannelIntegration({ responseHook: cacheResponseHook }), + ], replacedOtelIntegrationNames, register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, diff --git a/packages/node/test/integrations/tracing/redis-ioredis-gating.test.ts b/packages/node/test/integrations/tracing/redis-ioredis-gating.test.ts index 9f2cd3692b15..7f8f256c1c67 100644 --- a/packages/node/test/integrations/tracing/redis-ioredis-gating.test.ts +++ b/packages/node/test/integrations/tracing/redis-ioredis-gating.test.ts @@ -42,13 +42,12 @@ describe('instrumentRedis ioredis gating', () => { expect(instrumentCalls).toContain('Redis.Redis'); }); - it('skips the OTel ioredis monkey-patch when diagnostics-channel injection is enabled', () => { + it('skips both OTel monkey-patches when diagnostics-channel injection is enabled', () => { injection.enabled = true; instrumentRedis(); - // ioredis is owned by orchestrion; node-redis is still instrumented by OTel. expect(instrumentCalls).not.toContain('Redis.IORedis'); - expect(instrumentCalls).toContain('Redis.Redis'); + expect(instrumentCalls).not.toContain('Redis.Redis'); }); }); diff --git a/packages/server-utils/src/integrations/tracing-channel/redis.ts b/packages/server-utils/src/integrations/tracing-channel/redis.ts new file mode 100644 index 000000000000..15f7ead12829 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/redis.ts @@ -0,0 +1,342 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- we intentionally emit the OLD db/net semconv + to match `@opentelemetry/instrumentation-redis`. TODO(v11): switch to the non-deprecated + `db.system.name`/`db.query.text`/`server.address`/`server.port` conventions and drop this disable. */ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { + DB_OPERATION_BATCH_SIZE, + DB_STATEMENT, + DB_SYSTEM, + DB_SYSTEM_NAME, + NET_PEER_NAME, + NET_PEER_PORT, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + SPAN_STATUS_ERROR, + startInactiveSpan, + waitForTracingChannelBinding, + withActiveSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { defaultDbStatementSerializer } from '../../redis/redis-statement-serializer'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// A distinct name from the composite OTel `Redis` integration — they can't share one, and +// `Redis` stays in the set for its native diagnostics_channel subscriber (node-redis >=5.12 / +// ioredis >=5.11). When this integration is active, the OTel `RedisInstrumentation` monkey-patch +// is fully gated off in the node SDK. +const INTEGRATION_NAME = 'RedisChannel' as const; + +const ORIGIN = 'auto.db.orchestrion.redis'; + +// todo(v11): drop this — it is already covered by host and port. +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const DB_SYSTEM_VALUE_REDIS = 'redis'; + +/** Mirrors `@opentelemetry/instrumentation-redis`' response hook. Not called for failed commands. */ +export type RedisResponseHook = (span: Span, command: string, args: Array, result: unknown) => void; + +export interface RedisChannelIntegrationOptions { + responseHook?: RedisResponseHook; +} + +/** Structural type for a node-redis (`@redis/client`) command definition. */ +interface RedisCommandDefinition { + transformArguments?: (...args: unknown[]) => Array; +} + +/** Structural type for the `command_obj` `redis` v2-v3 passes to `internal_send_command`. */ +interface LegacyRedisCommand { + command: string; + args: Array; + callback?: (err: Error | null | undefined, reply: unknown) => unknown; +} + +interface LegacyRedisClient { + connection_options?: { host?: string; port?: number }; + address?: string; +} + +interface NodeRedisClientOptions { + socket?: { host?: string; port?: number }; + url?: string; +} + +interface NodeRedisClient { + options?: NodeRedisClientOptions; +} + +interface CommandContext { + arguments?: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +function endSpan(span: Span, err: unknown): void { + if (err) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: err instanceof Error ? err.message : String(err) }); + } + span.end(); +} + +function runResponseHook( + hook: RedisResponseHook | undefined, + span: Span, + command: string, + args: Array, + result: unknown, +): void { + if (!hook) { + return; + } + try { + hook(span, command, args, result); + } catch { + // never let a user-provided response hook break instrumentation + } +} + +// Strip a leading `commandOptions(...)` object (tagged with a `Symbol`) before +// deriving the wire arguments, mirroring `@redis/client`'s `transformCommandArguments`. +function stripCommandOptions(args: unknown[]): unknown[] { + const first = args[0]; + if (first && typeof first === 'object' && Object.getOwnPropertySymbols(first).length > 0) { + return args.slice(1); + } + return args; +} + +function removeCredentialsFromConnectionString(url: string | undefined): string | undefined { + if (typeof url !== 'string' || !url) { + return undefined; + } + try { + const parsed = new URL(url); + parsed.searchParams.delete('user_pwd'); + parsed.username = ''; + parsed.password = ''; + return parsed.href; + } catch { + return undefined; + } +} + +function nodeRedisAttributes(options: NodeRedisClientOptions | undefined): SpanAttributes { + return { + [DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [NET_PEER_NAME]: options?.socket?.host, + [NET_PEER_PORT]: options?.socket?.port, + [ATTR_DB_CONNECTION_STRING]: removeCredentialsFromConnectionString(options?.url), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }; +} + +function startCommandSpan(commandName: string, commandArgs: Array, attributes: SpanAttributes): Span { + return startInactiveSpan({ + name: `redis-${commandName}`, + kind: SPAN_KIND.CLIENT, + attributes: { ...attributes, [DB_STATEMENT]: defaultDbStatementSerializer(commandName, commandArgs) }, + }); +} + +// --- redis v2-v3: `RedisClient.prototype.internal_send_command(command_obj)` --- + +// Settles via `command_obj.callback`, not the sync return — so instead of +// `bindTracingChannelToSpan` we open the span in `start`, wrap the callback to end it, and end on `error` for sync throws. +function subscribeLegacyRedisCommand(responseHook: RedisResponseHook | undefined): void { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.REDIS_COMMAND); + const noop = (): void => {}; + channel.subscribe({ + end: noop, + asyncStart: noop, + asyncEnd: noop, + start(data) { + const command = data.arguments?.[0] as LegacyRedisCommand | undefined; + if (!command || typeof command !== 'object') { + return; + } + const client = data.self as LegacyRedisClient | undefined; + const attributes: SpanAttributes = { + [DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }; + if (client?.connection_options) { + attributes[NET_PEER_NAME] = client.connection_options.host; + attributes[NET_PEER_PORT] = client.connection_options.port; + } + if (client?.address) { + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${client.address}`; + } + const span = startCommandSpan(command.command, command.args ?? [], attributes); + (data as CommandContext & { _sentrySpan?: Span })._sentrySpan = span; + + const originalCallback = command.callback; + if (typeof originalCallback === 'function') { + const parentSpan = getActiveSpan(); + command.callback = function (this: unknown, err: Error | null | undefined, reply: unknown) { + if (!err) { + runResponseHook(responseHook, span, command.command, command.args ?? [], reply); + } + endSpan(span, err); + // eslint-disable-next-line prefer-rest-params + const args = arguments as unknown as [Error | null | undefined, unknown]; + return withActiveSpan(parentSpan ?? null, () => originalCallback.apply(this, args)); + }; + } + }, + error(data) { + // Synchronous throw: the wrapped callback never fires, so end here instead. + const span = (data as CommandContext & { _sentrySpan?: Span })._sentrySpan; + if (span) { + endSpan(span, data.error); + } + }, + }); +} + +// --- node-redis v4/v5 (`@redis/client`) --- + +function bindNodeRedisCommandChannel( + channelName: string, + getWireArgs: (data: CommandContext) => Array | undefined, + responseHook: RedisResponseHook | undefined, +): void { + const channel = diagnosticsChannel.tracingChannel(channelName); + bindTracingChannelToSpan( + channel, + data => { + const wireArgs = getWireArgs(data); + if (!wireArgs?.length) { + return undefined; + } + const commandName = String(wireArgs[0]); + const options = (data.self as NodeRedisClient | undefined)?.options; + return startCommandSpan(commandName, wireArgs.slice(1), nodeRedisAttributes(options)); + }, + { + captureError: false, + beforeSpanEnd(span, data) { + if ('error' in data || !responseHook) { + return; + } + const wireArgs = getWireArgs(data); + if (wireArgs?.length) { + runResponseHook(responseHook, span, String(wireArgs[0]), wireArgs.slice(1), data.result); + } + }, + }, + ); +} + +// `sendCommand(args, options)` — `args` are already the wire arguments. +function getSendCommandArgs(data: CommandContext): Array | undefined { + const args = data.arguments?.[0]; + return Array.isArray(args) ? (args as Array) : undefined; +} + +// `commandsExecutor(command, jsArgs)` — derive the wire arguments the same way +// `@redis/client` does internally, via `command.transformArguments`. +function getExecutorArgs(data: CommandContext): Array | undefined { + const command = data.arguments?.[0] as RedisCommandDefinition | undefined; + const jsArgs = data.arguments?.[1]; + if (typeof command?.transformArguments !== 'function' || !Array.isArray(jsArgs)) { + return undefined; + } + try { + return command.transformArguments(...stripCommandOptions(jsArgs)); + } catch { + return undefined; + } +} + +function bindNodeRedisConnectChannel(): void { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.NODE_REDIS_CONNECT); + bindTracingChannelToSpan( + channel, + data => { + const options = (data.self as NodeRedisClient | undefined)?.options; + return startInactiveSpan({ + name: 'redis-connect', + kind: SPAN_KIND.CLIENT, + attributes: nodeRedisAttributes(options), + }); + }, + { captureError: false }, + ); +} + +// Batch (multi/pipeline): one span per `exec`. Batched commands bypass `sendCommand`, so +// the executor's `ctx.arguments[0]` (the queued commands) gives the batch size. Span shape +// mirrors the native `node-redis:batch` span (see `redis-dc-subscriber.ts`). +function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: CommandContext) => string): void { + const channel = diagnosticsChannel.tracingChannel(channelName); + bindTracingChannelToSpan( + channel, + data => { + const commands = data.arguments?.[0]; + const size = Array.isArray(commands) ? commands.length : undefined; + const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; + return startInactiveSpan({ + name: getOperation(data), + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, + ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), + ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), + ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), + }, + }); + }, + { captureError: false }, + ); +} + +const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { + const responseHook = options.responseHook; + + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && + debug.log(`[orchestrion:redis] subscribing to "${CHANNELS.REDIS_COMMAND}" and node-redis channels`); + + // redis v2-v3 uses a nested callback rather than `bindStore`, so it can be + // subscribed synchronously here. + subscribeLegacyRedisCommand(responseHook); + + waitForTracingChannelBinding(() => { + bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_COMMAND, getSendCommandArgs, responseHook); + bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_EXECUTOR, getExecutorArgs, responseHook); + bindNodeRedisConnectChannel(); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_MULTI, () => 'MULTI'); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_PIPELINE, () => 'PIPELINE'); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_BATCH, data => + data.arguments?.[2] !== undefined ? 'MULTI' : 'PIPELINE', + ); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven redis integration for `redis` v2-v3 and + * node-redis v4/v5 `<5.12.0` (`@redis/client`). Covers single commands, `connect`, + * and multi/pipeline batches, fully replacing `@opentelemetry/instrumentation-redis`. + * Requires the orchestrion runtime hook or bundler plugin. + */ +export const redisChannelIntegration = defineIntegration(_redisChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index b008aad618a2..3e96a2b26c7c 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -5,6 +5,7 @@ import { pgChannels } from './config/pg'; import { openaiChannels } from './config/openai'; import { anthropicAiChannels } from './config/anthropic-ai'; import { vercelAiChannels } from './config/vercel-ai'; +import { redisChannels } from './config/redis'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -27,6 +28,7 @@ export const CHANNELS = { ...openaiChannels, ...anthropicAiChannels, ...vercelAiChannels, + ...redisChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 218cbf7e8875..00be3c437c24 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -6,6 +6,7 @@ import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { anthropicAiConfig } from './anthropic-ai'; import { vercelAiConfig } from './vercel-ai'; +import { redisConfig } from './redis'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, @@ -15,6 +16,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...pgConfig, ...anthropicAiConfig, ...vercelAiConfig, + ...redisConfig, ]; /** diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts new file mode 100644 index 000000000000..c704eb43d08f --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -0,0 +1,73 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const redisConfig = [ + // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an + // anonymous prototype assignment (`expressionName`); it settles via the nested + // `command_obj.callback`, so `kind: 'Sync'` and the subscriber wraps that callback. + { + channelName: 'command', + module: { name: 'redis', versionRange: '>=2.6.0 <4', filePath: 'index.js' }, + functionQuery: { expressionName: 'internal_send_command', kind: 'Sync' }, + }, + // node-redis v4 (`@redis/client` v1). The real chokepoint (private `#sendCommand`) + // isn't matchable, so wrap both public entry points: `commandsExecutor` (friendly + // commands) and `sendCommand` (direct calls). They never overlap, so no double span. + { + channelName: 'executor', + module: { name: '@redis/client', versionRange: '^1.0.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: 'commandsExecutor', kind: 'Async' }, + }, + { + channelName: 'command', + module: { name: '@redis/client', versionRange: '^1.0.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: 'sendCommand', kind: 'Async' }, + }, + { + channelName: 'connect', + module: { name: '@redis/client', versionRange: '^1.0.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: 'connect', kind: 'Async' }, + }, + // node-redis `>=5.0.0 <5.12.0` (`@redis/client` v5; >=5.12.0 has its own + // `node-redis:*` diagnostics_channel, see `redis-dc-subscriber.ts`). Friendly + // commands route through the public `sendCommand`, so it covers them all — no + // `executor` entry (would double-count). + { + channelName: 'command', + module: { name: '@redis/client', versionRange: '>=5.0.0 <5.12.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: 'sendCommand', kind: 'Async' }, + }, + { + channelName: 'connect', + module: { name: '@redis/client', versionRange: '>=5.0.0 <5.12.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: 'connect', kind: 'Async' }, + }, + // Batch (multi/pipeline) — one span per `exec`. Batched commands bypass `sendCommand`, + // so they go through the client's batch executors, which receive the queued commands + // array (→ batch size). v5 splits MULTI/PIPELINE into two methods; v4's single + // `multiExecutor` is MULTI when a `chainId` arg is present, PIPELINE otherwise. + { + channelName: 'multi', + module: { name: '@redis/client', versionRange: '>=5.0.0 <5.12.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: '_executeMulti', kind: 'Async' }, + }, + { + channelName: 'pipeline', + module: { name: '@redis/client', versionRange: '>=5.0.0 <5.12.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: '_executePipeline', kind: 'Async' }, + }, + { + channelName: 'batch', + module: { name: '@redis/client', versionRange: '^1.0.0', filePath: 'dist/lib/client/index.js' }, + functionQuery: { className: 'RedisClient', methodName: 'multiExecutor', kind: 'Async' }, + }, +] satisfies InstrumentationConfig[]; + +export const redisChannels = { + REDIS_COMMAND: 'orchestrion:redis:command', + NODE_REDIS_COMMAND: 'orchestrion:@redis/client:command', + NODE_REDIS_EXECUTOR: 'orchestrion:@redis/client:executor', + NODE_REDIS_CONNECT: 'orchestrion:@redis/client:connect', + NODE_REDIS_MULTI: 'orchestrion:@redis/client:multi', + NODE_REDIS_PIPELINE: 'orchestrion:@redis/client:pipeline', + NODE_REDIS_BATCH: 'orchestrion:@redis/client:batch', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 3682ca752232..134c3c3bafab 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -17,6 +17,8 @@ export { vercelAiChannelIntegration, }; export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; +export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; +export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; /** * The canonical set of orchestrion diagnostics-channel integrations, keyed by their public @@ -27,9 +29,9 @@ export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../i * `diagnosticsChannelInjectionIntegrations()` map — picks it up automatically, so there's no separate * list to keep in sync. * - * NOTE: `ioredisChannelIntegration` is intentionally NOT here. It only partially replaces the - * composite OTel `Redis` integration and needs the node SDK's redis cache `responseHook` (which - * can't live in `server-utils`), so `@sentry/node` wires it up separately. + * NOTE: `ioredisChannelIntegration` and `redisChannelIntegration` are intentionally NOT here. They + * only partially replace the composite OTel `Redis` integration and need the node SDK's redis cache + * `responseHook` (which can't live in `server-utils`), so `@sentry/node` wires them up separately. */ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, diff --git a/packages/server-utils/test/integrations/tracing-channel/redis.test.ts b/packages/server-utils/test/integrations/tracing-channel/redis.test.ts new file mode 100644 index 000000000000..1b52eb374235 --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/redis.test.ts @@ -0,0 +1,365 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getClient, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { redisChannelIntegration } from '../../../src/integrations/tracing-channel/redis'; +import { CHANNELS } from '../../../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +let responseHookSpy: ReturnType | undefined; +const endedSpans: Span[] = []; + +async function driveCommand( + channelName: string, + context: Record, + outcome: { result?: unknown; error?: Error }, + { withParent = true }: { withParent?: boolean } = {}, +): Promise<{ activeInside: Span | undefined; resolved: unknown }> { + const channel = tracingChannel(channelName); + let activeInside: Span | undefined; + let resolved: unknown; + + const drive = async (): Promise => { + const run = channel.tracePromise(async () => { + activeInside = getActiveSpan(); + if (outcome.error) { + throw outcome.error; + } + return outcome.result; + }, context); + resolved = await run.catch(() => undefined); + }; + + if (withParent) { + await startSpan({ name: 'parent' }, drive); + } else { + await drive(); + } + + return { activeInside, resolved }; +} + +function lastRedisSpan(): Span | undefined { + return endedSpans.filter(s => spanToJSON(s).data['sentry.origin'] === 'auto.db.orchestrion.redis').at(-1); +} + +describe('redisChannelIntegration', () => { + beforeAll(() => { + installTestAsyncContextStrategy(); + initTestClient(); + const integration = redisChannelIntegration({ responseHook: (...args) => responseHookSpy?.(...args) }); + integration.setupOnce?.(); + getClient()?.on('spanEnd', span => endedSpans.push(span)); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getGlobalScope().clear(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + endedSpans.length = 0; + responseHookSpy = vi.fn(); + }); + + describe('node-redis command channel (sendCommand)', () => { + const clientSelf = { options: { socket: { host: 'localhost', port: 6380 }, url: 'redis://localhost:6380' } }; + + it('creates a db span matching the OTel redis shape and runs the response hook', async () => { + const { resolved } = await driveCommand( + CHANNELS.NODE_REDIS_COMMAND, + { arguments: [['GET', 'test-key'], undefined], self: clientSelf }, + { result: 'value' }, + ); + + const span = lastRedisSpan(); + expect(span).toBeDefined(); + const json = spanToJSON(span!); + expect(json.description).toBe('redis-GET'); + expect(json.data['sentry.origin']).toBe('auto.db.orchestrion.redis'); + expect(json.data['db.system']).toBe('redis'); + expect(json.data['db.statement']).toBe('GET test-key'); + expect(json.data['db.connection_string']).toBe('redis://localhost:6380'); + expect(json.data['net.peer.name']).toBe('localhost'); + expect(json.data['net.peer.port']).toBe(6380); + + expect(resolved).toBe('value'); + expect(responseHookSpy).toHaveBeenCalledTimes(1); + expect(responseHookSpy).toHaveBeenCalledWith(span, 'GET', ['test-key'], 'value'); + }); + + it('redacts sensitive command arguments via the statement serializer', async () => { + await driveCommand( + CHANNELS.NODE_REDIS_COMMAND, + { arguments: [['SET', 'test-key', 'super-secret-value'], undefined], self: clientSelf }, + { result: 'OK' }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('redis-SET'); + expect(json.data['db.statement']).toBe('SET test-key [1 other arguments]'); + expect(JSON.stringify(json)).not.toContain('super-secret-value'); + }); + + it('sets error status and does NOT run the response hook on failure', async () => { + await driveCommand( + CHANNELS.NODE_REDIS_COMMAND, + { arguments: [['INCR', 'test-key'], undefined], self: clientSelf }, + { error: new Error('value is not an integer') }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('redis-INCR'); + expect(json.status).toBe('value is not an integer'); + expect(responseHookSpy).not.toHaveBeenCalled(); + }); + + it('creates a span even without an active parent (OTel redis has no requireParentSpan)', async () => { + await driveCommand( + CHANNELS.NODE_REDIS_COMMAND, + { arguments: [['GET', 'test-key'], undefined], self: clientSelf }, + { result: 'value' }, + { withParent: false }, + ); + + expect(lastRedisSpan()).toBeDefined(); + }); + }); + + describe('node-redis executor channel (commandsExecutor)', () => { + const command = { transformArguments: (...args: unknown[]) => ['SET', ...(args as string[])] }; + const clientSelf = { options: { socket: { host: 'h', port: 1 } } }; + + it('derives wire args via transformArguments', async () => { + await driveCommand( + CHANNELS.NODE_REDIS_EXECUTOR, + { arguments: [command, ['my-key', 'my-value']], self: clientSelf }, + { result: 'OK' }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('redis-SET'); + expect(json.data['db.statement']).toBe('SET my-key [1 other arguments]'); + expect(responseHookSpy).toHaveBeenCalledWith(expect.anything(), 'SET', ['my-key', 'my-value'], 'OK'); + }); + + it('strips a leading command-options object before deriving wire args', async () => { + const commandOptions: Record = { [Symbol('Command Options')]: true }; + const spy = vi.fn((...args: unknown[]) => ['GET', ...(args as string[])]); + + await driveCommand( + CHANNELS.NODE_REDIS_EXECUTOR, + { arguments: [{ transformArguments: spy }, [commandOptions, 'my-key']], self: clientSelf }, + { result: 'value' }, + ); + + expect(spy).toHaveBeenCalledWith('my-key'); + expect(spanToJSON(lastRedisSpan()!).description).toBe('redis-GET'); + }); + }); + + describe('node-redis connect channel', () => { + it('creates a connect span', async () => { + await driveCommand( + CHANNELS.NODE_REDIS_CONNECT, + { self: { options: { socket: { host: 'localhost', port: 6380 }, url: 'redis://user:pass@localhost:6380' } } }, + { result: undefined }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('redis-connect'); + expect(json.data['db.system']).toBe('redis'); + expect(json.data['sentry.origin']).toBe('auto.db.orchestrion.redis'); + expect(json.data['db.statement']).toBeUndefined(); + // credentials are stripped from the connection string + expect(json.data['db.connection_string']).toBe('redis://localhost:6380'); + }); + }); + + describe('redis v2-v3 command channel (internal_send_command)', () => { + const legacySelf = { connection_options: { host: 'localhost', port: 6379 }, address: 'localhost:6379' }; + + function driveLegacy(commandObj: Record, { throwErr }: { throwErr?: Error } = {}): void { + const channel = tracingChannel(CHANNELS.REDIS_COMMAND); + try { + channel.traceSync( + () => { + if (throwErr) { + throw throwErr; + } + }, + { arguments: [commandObj], self: legacySelf }, + ); + } catch { + // the sync-throw path is asserted via the span below + } + } + + it('opens a span, ends it in the wrapped callback and runs the response hook', () => { + const callback = vi.fn((_err: unknown, reply: unknown) => `wrapped:${reply}`); + const commandObj: Record = { command: 'get', args: ['test-key'], callback }; + + driveLegacy(commandObj); + // The command has only been queued; the span ends when the callback fires. + expect(lastRedisSpan()).toBeUndefined(); + + const returned = (commandObj.callback as (e: unknown, r: unknown) => unknown)(null, 'value'); + // The original callback's return value is preserved. + expect(returned).toBe('wrapped:value'); + expect(callback).toHaveBeenCalledWith(null, 'value'); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('redis-get'); + expect(json.data['db.system']).toBe('redis'); + expect(json.data['db.statement']).toBe('get test-key'); + expect(json.data['net.peer.name']).toBe('localhost'); + expect(json.data['net.peer.port']).toBe(6379); + expect(json.data['db.connection_string']).toBe('redis://localhost:6379'); + expect(responseHookSpy).toHaveBeenCalledWith(expect.anything(), 'get', ['test-key'], 'value'); + }); + + it('sets error status and skips the response hook when the callback reports an error', () => { + const commandObj: Record = { command: 'incr', args: ['test-key'], callback: vi.fn() }; + + driveLegacy(commandObj); + (commandObj.callback as (e: unknown, r: unknown) => unknown)(new Error('not an integer'), undefined); + + expect(spanToJSON(lastRedisSpan()!).status).toBe('not an integer'); + expect(responseHookSpy).not.toHaveBeenCalled(); + }); + + it('ends the span with error status on a synchronous throw', () => { + const commandObj: Record = { command: 'get', args: ['k'], callback: vi.fn() }; + + driveLegacy(commandObj, { throwErr: new Error('connection lost') }); + + expect(spanToJSON(lastRedisSpan()!).status).toBe('connection lost'); + }); + }); + + describe('node-redis batch channels', () => { + const clientSelf = { options: { socket: { host: 'localhost', port: 6380 } } }; + const queue = [{ args: ['SET', 'a', '1'] }, { args: ['GET', 'a'] }]; + + it('creates a single MULTI span with batch size (v5 _executeMulti)', async () => { + await driveCommand(CHANNELS.NODE_REDIS_MULTI, { arguments: [queue, 0], self: clientSelf }, { result: [] }); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('MULTI'); + expect(json.op).toBe('db.redis'); + expect(json.data['sentry.origin']).toBe('auto.db.orchestrion.redis'); + expect(json.data['db.system.name']).toBe('redis'); + expect(json.data['db.operation.batch.size']).toBe(2); + expect(json.data['server.address']).toBe('localhost'); + expect(json.data['server.port']).toBe(6380); + }); + + it('creates a single PIPELINE span (v5 _executePipeline)', async () => { + await driveCommand(CHANNELS.NODE_REDIS_PIPELINE, { arguments: [queue, 0], self: clientSelf }, { result: [] }); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('PIPELINE'); + expect(json.data['db.operation.batch.size']).toBe(2); + }); + + it('derives MULTI vs PIPELINE from the chainId arg (v4 multiExecutor)', async () => { + await driveCommand( + CHANNELS.NODE_REDIS_BATCH, + { arguments: [queue, 0, Symbol('chain')], self: clientSelf }, + { result: [] }, + ); + expect(spanToJSON(lastRedisSpan()!).description).toBe('MULTI'); + + await driveCommand(CHANNELS.NODE_REDIS_BATCH, { arguments: [queue, 0], self: clientSelf }, { result: [] }); + expect(spanToJSON(lastRedisSpan()!).description).toBe('PIPELINE'); + }); + }); +});