diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts index aa695958ed70..7c3a0ed13fae 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts @@ -33,11 +33,16 @@ export default defineNuxtConfig({ nitro: { // Nuxt's server is built by Nitro (Rollup), not Vite — so the orchestrion // code transform has to run as a Nitro Rollup plugin to reach `server/api/*` - // routes. Force-bundle ONLY the instrumented deps (`mysql`) via - // `externals.inline`; externalized deps are `require()`d from `node_modules` - // at runtime and never pass through the transform. + // routes. Force-bundle the instrumented deps via `externals.inline`; + // externalized deps are `require()`d from `node_modules` at runtime and never + // pass through the transform. + // + // `standard-as-callback` is ioredis' CJS `export default` helper used by + // `connect()`. Left external, Rollup's interop resolves its `.default` to a + // non-function in the bundle; inlining it alongside ioredis links the + // interop consistently. externals: { - inline: INSTRUMENTED_MODULE_NAMES, + inline: [...INSTRUMENTED_MODULE_NAMES, 'standard-as-callback'], }, rollupConfig: { plugins: [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts index b07c2a81ff0a..4aefc07f7c59 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts @@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Instruments ioredis automatically', async ({ baseURL }) => { - // This test works as well without orchestrion const transactionEventPromise = waitForTransaction('nuxt-4-orchestrion', transactionEvent => { return ( transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-ioredis' @@ -21,7 +20,7 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { expect(spans).toContainEqual( expect.objectContaining({ op: 'db', - origin: 'auto.db.otel.redis', + origin: 'auto.db.orchestrion.redis', description: 'set test-key [1 other arguments]', status: 'ok', data: expect.objectContaining({ @@ -33,7 +32,7 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { expect(spans).toContainEqual( expect.objectContaining({ op: 'db', - origin: 'auto.db.otel.redis', + origin: 'auto.db.orchestrion.redis', description: 'get test-key', status: 'ok', data: expect.objectContaining({ diff --git a/packages/node/src/integrations/tracing/redis/cache.ts b/packages/node/src/integrations/tracing/redis/cache.ts new file mode 100644 index 000000000000..9d9d505fcda4 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/cache.ts @@ -0,0 +1,103 @@ +import type { Span } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + spanToJSON, + truncate, +} from '@sentry/core'; +import type { IORedisCommandArgs } from '../../../utils/redisCache'; +import { + calculateCacheItemSize, + GET_COMMANDS, + getCacheKeySafely, + getCacheOperation, + isInCommands, + shouldConsiderForCache, +} from '../../../utils/redisCache'; +import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; + +// This module deliberately does NOT import the vendored OTel `IORedisInstrumentation`/ +// `RedisInstrumentation`, so the orchestrion opt-in can pull `cacheResponseHook` +// without dragging the OTel redis instrumentation into its module graph. + +export interface RedisOptions { + /** + * Define cache prefixes for cache keys that should be captured as a cache span. + * + * Setting this to, for example, `['user:']` will capture cache keys that start with `user:`. + */ + cachePrefixes?: string[]; + /** + * Maximum length of the cache key added to the span description. If the key exceeds this length, it will be truncated. + * + * Passing `0` will use the full cache key without truncation. + * + * By default, the full cache key is used. + */ + maxCacheKeyLength?: number; +} + +/* Only exported for testing purposes */ +export let _redisOptions: RedisOptions = {}; + +/** Set the options consumed by {@link cacheResponseHook}. */ +export function setRedisOptions(options: RedisOptions): void { + _redisOptions = options; +} + +/* Only exported for testing purposes */ +export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( + span: Span, + redisCommand: string, + cmdArgs: IORedisCommandArgs, + response: unknown, +) => { + const safeKey = getCacheKeySafely(redisCommand, cmdArgs); + const cacheOperation = getCacheOperation(redisCommand); + + if ( + !safeKey || + !cacheOperation || + !_redisOptions.cachePrefixes || + !shouldConsiderForCache(redisCommand, safeKey, _redisOptions.cachePrefixes) + ) { + // not relevant for cache + return; + } + + // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 + // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ + // Fall back to stable semconv attributes (server.address/server.port) when + // old-semconv ones are absent, eg OTEL_SEMCONV_STABILITY_OPT_IN=database + // set for node-redis v4/v5. + const spanData = spanToJSON(span).data; + const networkPeerAddress = spanData['net.peer.name'] ?? spanData['server.address']; + const networkPeerPort = spanData['net.peer.port'] ?? spanData['server.port']; + if (networkPeerPort && networkPeerAddress) { + span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); + } + + const cacheItemSize = calculateCacheItemSize(response); + + if (cacheItemSize) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); + } + + if (isInCommands(GET_COMMANDS, redisCommand) && cacheItemSize !== undefined) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); + } + + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, + }); + + // todo: change to string[] once EAP supports it + const spanDescription = safeKey.join(', '); + + span.updateName( + _redisOptions.maxCacheKeyLength ? truncate(spanDescription, _redisOptions.maxCacheKeyLength) : spanDescription, + ); +}; diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index f1d1d625ba82..7595d252a642 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -1,107 +1,20 @@ -import type { IntegrationFn, Span } from '@sentry/core'; -import { - defineIntegration, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - spanToJSON, - truncate, - waitForTracingChannelBinding, -} from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; import * as dc from 'node:diagnostics_channel'; import { subscribeRedisDiagnosticChannels, type RedisTracingChannelFactory } from '@sentry/server-utils'; import { generateInstrumentOnce } from '@sentry/node-core'; -import type { IORedisCommandArgs } from '../../../utils/redisCache'; -import { - calculateCacheItemSize, - GET_COMMANDS, - getCacheKeySafely, - getCacheOperation, - isInCommands, - shouldConsiderForCache, -} from '../../../utils/redisCache'; -import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; +import { isDiagnosticsChannelInjectionEnabled } from '../../../sdk/diagnosticsChannelInjection'; +import { cacheResponseHook, type RedisOptions, setRedisOptions } from './cache'; import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; import { RedisInstrumentation } from './vendored/redis-instrumentation'; -interface RedisOptions { - /** - * Define cache prefixes for cache keys that should be captured as a cache span. - * - * Setting this to, for example, `['user:']` will capture cache keys that start with `user:`. - */ - cachePrefixes?: string[]; - /** - * Maximum length of the cache key added to the span description. If the key exceeds this length, it will be truncated. - * - * Passing `0` will use the full cache key without truncation. - * - * By default, the full cache key is used. - */ - maxCacheKeyLength?: number; -} +// `cacheResponseHook`/`_redisOptions` live in `./cache` (which has no OTel +// instrumentation imports) so the orchestrion opt-in can pull the hook without +// dragging the OTel redis instrumentation in. Re-exported here for tests. +export { _redisOptions, cacheResponseHook } from './cache'; const INTEGRATION_NAME = 'Redis' as const; -/* Only exported for testing purposes */ -export let _redisOptions: RedisOptions = {}; - -/* Only exported for testing purposes */ -export const cacheResponseHook: IORedisResponseCustomAttributeFunction = ( - span: Span, - redisCommand: string, - cmdArgs: IORedisCommandArgs, - response: unknown, -) => { - const safeKey = getCacheKeySafely(redisCommand, cmdArgs); - const cacheOperation = getCacheOperation(redisCommand); - - if ( - !safeKey || - !cacheOperation || - !_redisOptions.cachePrefixes || - !shouldConsiderForCache(redisCommand, safeKey, _redisOptions.cachePrefixes) - ) { - // not relevant for cache - return; - } - - // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 - // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ - // Fall back to stable semconv attributes (server.address/server.port) when - // old-semconv ones are absent, eg OTEL_SEMCONV_STABILITY_OPT_IN=database - // set for node-redis v4/v5. - const spanData = spanToJSON(span).data; - const networkPeerAddress = spanData['net.peer.name'] ?? spanData['server.address']; - const networkPeerPort = spanData['net.peer.port'] ?? spanData['server.port']; - if (networkPeerPort && networkPeerAddress) { - span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); - } - - const cacheItemSize = calculateCacheItemSize(response); - - if (cacheItemSize) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); - } - - if (isInCommands(GET_COMMANDS, redisCommand) && cacheItemSize !== undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); - } - - span.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, - }); - - // todo: change to string[] once EAP supports it - const spanDescription = safeKey.join(', '); - - span.updateName( - _redisOptions.maxCacheKeyLength ? truncate(spanDescription, _redisOptions.maxCacheKeyLength) : spanDescription, - ); -}; - const instrumentIORedis = generateInstrumentOnce(`${INTEGRATION_NAME}.IORedis`, () => { return new IORedisInstrumentation({ responseHook: cacheResponseHook, @@ -120,7 +33,13 @@ const instrumentRedisModule = generateInstrumentOnce(`${INTEGRATION_NAME}.Redis` */ export const instrumentRedis = Object.assign( (): void => { - instrumentIORedis(); + // 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. + if (!isDiagnosticsChannelInjectionEnabled() || !dc.tracingChannel) { + instrumentIORedis(); + } instrumentRedisModule(); // node-redis >= 5.12.0 and ioredis >= 5.11.0 publish via diagnostics_channel. // `bindTracingChannelToSpan` (inside the subscriber) makes the span the active @@ -144,7 +63,7 @@ const _redisIntegration = ((options: RedisOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { - _redisOptions = options; + setRedisOptions(options); instrumentRedis(); }, }; diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts index 381b7ed2dfb9..44043e81507b 100644 --- a/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts @@ -5,54 +5,10 @@ * NOTICE from the Sentry authors: * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common * - Upstream version: @opentelemetry/redis-common@0.38.2 - * - Minor TypeScript adjustments for this repository's compiler settings - */ -/* eslint-disable -- vendored @opentelemetry/redis-common */ - -/** - * List of regexes and the number of arguments that should be serialized for matching commands. - * For example, HSET should serialize which key and field it's operating on, but not its value. - * Setting the subset to -1 will serialize all arguments. - * Commands without a match will have their first argument serialized. * - * Refer to https://redis.io/commands/ for the full list. + * The implementation lives in `@sentry/server-utils` (shared with the orchestrion + * ioredis subscriber). Re-exported here to keep the import path stable for the + * vendored redis/ioredis instrumentations. */ -const serializationSubsets = [ - { - regex: /^ECHO/i, - args: 0, - }, - { - regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i, - args: 1, - }, - { - regex: /^(HSET|HMSET|LSET|LINSERT)/i, - args: 2, - }, - { - regex: - /^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i, - args: -1, - }, -]; -/** - * Given the redis command name and arguments, return a combination of the - * command name + the allowed arguments according to `serializationSubsets`. - */ -export const defaultDbStatementSerializer = ( - cmdName: string, - cmdArgs: Array, -): string => { - if (Array.isArray(cmdArgs) && cmdArgs.length) { - const nArgsToSerialize = serializationSubsets.find(({ regex }) => regex.test(cmdName))?.args ?? 0; - const argsToSerialize: Array = - nArgsToSerialize >= 0 ? cmdArgs.slice(0, nArgsToSerialize) : cmdArgs.slice(); - if (cmdArgs.length > argsToSerialize.length) { - argsToSerialize.push(`[${cmdArgs.length - nArgsToSerialize} other arguments]`); - } - return `${cmdName} ${argsToSerialize.join(' ')}`; - } - return cmdName; -}; +export { defaultDbStatementSerializer } from '@sentry/server-utils'; diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 6bba862cdfc0..ddcf0a0aed18 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,9 +1,11 @@ import { mysqlChannelIntegration, lruMemoizerChannelIntegration, + ioredisChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; +import { cacheResponseHook } from '../integrations/tracing/redis/cache'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; @@ -41,12 +43,15 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const; - const replacedOtelIntegrationNames = integrations.map(i => i.name); + // These channel integrations 1:1 replace the OTel integration of the same name. + const replacements = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const; return { - integrations, - replacedOtelIntegrationNames, + // ioredis only supersedes the ioredis monkey-patch inside the composite OTel + // `Redis` integration (gated off in `redisIntegration`), so it's added here + // but kept out of `replacedOtelIntegrationNames` — `Redis` must stay. + integrations: [...replacements, ioredisChannelIntegration({ responseHook: cacheResponseHook })], + replacedOtelIntegrationNames: replacements.map(i => i.name), 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 new file mode 100644 index 000000000000..9f2cd3692b15 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis-ioredis-gating.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { instrumentCalls, injection } = vi.hoisted(() => ({ + instrumentCalls: [] as string[], + injection: { enabled: false }, +})); + +// Control the gating flag. +vi.mock('../../../src/sdk/diagnosticsChannelInjection', () => ({ + isDiagnosticsChannelInjectionEnabled: () => injection.enabled, +})); + +// Record which instrumentations actually get generated, without registering real +// OTel module hooks (the creator is never invoked). +vi.mock('@sentry/node-core', async importOriginal => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + generateInstrumentOnce: (name: string) => Object.assign(() => instrumentCalls.push(name), { id: name }), + }; +}); + +// The >=5.11.0 diagnostics_channel subscription is irrelevant here; keep it inert. +vi.mock('@sentry/server-utils', async importOriginal => { + const actual = (await importOriginal()) as Record; + return { ...actual, subscribeRedisDiagnosticChannels: () => undefined }; +}); + +import { instrumentRedis } from '../../../src/integrations/tracing/redis'; + +describe('instrumentRedis ioredis gating', () => { + beforeEach(() => { + instrumentCalls.length = 0; + }); + + it('instruments the OTel ioredis monkey-patch when diagnostics-channel injection is disabled', () => { + injection.enabled = false; + + instrumentRedis(); + + expect(instrumentCalls).toContain('Redis.IORedis'); + expect(instrumentCalls).toContain('Redis.Redis'); + }); + + it('skips the OTel ioredis monkey-patch 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'); + }); +}); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index b66a789f53d3..7cd374e6d874 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -20,6 +20,7 @@ export type { RedisDiagnosticChannelResponseHook, RedisTracingChannelFactory, } from './redis/redis-dc-subscriber'; +export { defaultDbStatementSerializer } from './redis/redis-statement-serializer'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { SentryTracingChannel, diff --git a/packages/server-utils/src/integrations/tracing-channel/ioredis.ts b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts new file mode 100644 index 000000000000..5ea10f29bc6b --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- we intentionally emit the OLD db/net semconv + to match `@opentelemetry/instrumentation-ioredis` (and Sentry's `inferDbSpanData`, which keys off + `db.statement`). 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_STATEMENT, DB_SYSTEM, NET_PEER_NAME, NET_PEER_PORT } from '@sentry/conventions/attributes'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + waitForTracingChannelBinding, +} 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'; + +// Distinct from the OTel `Redis` integration, which is composite (node-redis + +// ioredis + the >=5.11.0 diagnostics_channel subscriber) and stays in the set; +// only its ioredis monkey-patch is gated off in the node SDK when this is active. +const INTEGRATION_NAME = 'IORedis' as const; + +const ORIGIN = 'auto.db.orchestrion.redis'; + +// todo(v11): Let's drop this as this is already covered with host and port +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; + +/** Mirrors `@opentelemetry/instrumentation-ioredis`' response hook. Not called for failed commands. */ +export type IORedisResponseHook = (span: Span, command: string, args: Array, result: unknown) => void; + +export interface IORedisChannelIntegrationOptions { + responseHook?: IORedisResponseHook; +} + +/** Structural type for the command object ioredis passes to `sendCommand`. */ +interface RedisCommand { + name: string; + args: Array; +} + +interface RedisClientLike { + options?: { host?: string; port?: number }; +} + +interface IORedisCommandContext { + arguments?: unknown[]; + self?: RedisClientLike; + result?: unknown; + error?: unknown; +} + +type IORedisConnectContext = Omit; + +function getConnectionOptions(self: RedisClientLike | undefined): { host?: string; port?: number } { + return { host: self?.options?.host, port: self?.options?.port }; +} + +function connectionAttributes(host: string | undefined, port: number | undefined): Record { + return { + [DB_SYSTEM]: 'redis', + [ATTR_DB_CONNECTION_STRING]: `redis://${host}:${port}`, + [NET_PEER_NAME]: host, + [NET_PEER_PORT]: port, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }; +} + +const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions = {}) => { + const responseHook = options.responseHook; + + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && + debug.log(`[orchestrion:ioredis] subscribing to "${CHANNELS.IOREDIS_COMMAND}"/"${CHANNELS.IOREDIS_CONNECT}"`); + + const commandChannel = diagnosticsChannel.tracingChannel( + CHANNELS.IOREDIS_COMMAND, + ); + const connectChannel = diagnosticsChannel.tracingChannel( + CHANNELS.IOREDIS_CONNECT, + ); + + // `bindTracingChannelToSpan` uses `bindStore`, which needs the async-context + // binding that `initOpenTelemetry()` registers after integration `setupOnce` — + // defer until it's available (matches the native redis diagnostics-channel subscriber). + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + commandChannel, + data => { + // ioredis' `requireParentSpan` default: only create a span under an active span. + if (!getActiveSpan()) { + return undefined; + } + const command = data.arguments?.[0] as RedisCommand | undefined; + if (!command || typeof command !== 'object') { + return undefined; + } + const { host, port } = getConnectionOptions(data.self); + const statement = defaultDbStatementSerializer(command.name, command.args ?? []); + return startInactiveSpan({ + name: statement, + op: 'db', + attributes: { ...connectionAttributes(host, port), [DB_STATEMENT]: statement }, + }); + }, + { + captureError: false, + beforeSpanEnd(span, data) { + if ('error' in data || !responseHook) { + return; + } + const command = data.arguments?.[0] as RedisCommand | undefined; + if (command) { + runResponseHook(responseHook, span, command, data.result); + } + }, + }, + ); + + bindTracingChannelToSpan( + connectChannel, + data => { + if (!getActiveSpan()) { + return undefined; + } + const { host, port } = getConnectionOptions(data.self); + return startInactiveSpan({ + name: 'connect', + op: 'db', + attributes: { ...connectionAttributes(host, port), [DB_STATEMENT]: 'connect' }, + }); + }, + { captureError: false }, + ); + }); + }, + }; +}) satisfies IntegrationFn; + +function runResponseHook(hook: IORedisResponseHook, span: Span, command: RedisCommand, result: unknown): void { + try { + hook(span, command.name, command.args, result); + } catch { + // never let a user-provided response hook break instrumentation + } +} + +/** + * EXPERIMENTAL — orchestrion-driven ioredis integration. Subscribes to + * `orchestrion:ioredis:command` / `:connect` (injected into ioredis' `<5.11.0` + * `sendCommand`/`connect`) and creates db spans matching + * `@opentelemetry/instrumentation-ioredis`. Requires the orchestrion runtime hook + * or bundler plugin. + */ +export const ioredisChannelIntegration = defineIntegration(_ioredisChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index ad2d8ccdd4dd..dc7e6886e768 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -14,6 +14,8 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', + IOREDIS_COMMAND: 'orchestrion:ioredis:command', + IOREDIS_CONNECT: 'orchestrion:ioredis:connect', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 104df2185386..6f16541bd2a6 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -38,6 +38,29 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, + // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) + ...['lib/redis.js', 'built/redis.js', 'built/redis/index.js'].flatMap((filePath): InstrumentationConfig[] => [ + { + channelName: 'command', + module: { name: 'ioredis', versionRange: '>=2.0.0 <5.0.0', filePath }, + functionQuery: { expressionName: 'sendCommand', kind: 'Async' }, + }, + { + channelName: 'connect', + module: { name: 'ioredis', versionRange: '>=2.0.0 <5.0.0', filePath }, + functionQuery: { expressionName: 'connect', kind: 'Async' }, + }, + ]), + { + channelName: 'command', + module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, + functionQuery: { className: 'Redis', methodName: 'sendCommand', kind: 'Async' }, + }, + { + channelName: 'connect', + module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, + functionQuery: { className: 'Redis', methodName: 'connect', kind: 'Async' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 4b182e51ec13..fed1367f0ea7 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,5 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +export { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; +export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; diff --git a/packages/server-utils/src/redis/redis-statement-serializer.ts b/packages/server-utils/src/redis/redis-statement-serializer.ts new file mode 100644 index 000000000000..c6670729997c --- /dev/null +++ b/packages/server-utils/src/redis/redis-statement-serializer.ts @@ -0,0 +1,61 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common + * - Upstream version: @opentelemetry/redis-common@0.38.2 + * + * Single canonical copy, shared by the orchestrion ioredis subscriber here and + * the node SDK's vendored redis/ioredis instrumentations (which re-export it via + * `packages/node/src/integrations/tracing/redis/vendored/redis-common.ts`). + */ +/* eslint-disable -- vendored @opentelemetry/redis-common */ + +/** + * List of regexes and the number of arguments that should be serialized for matching commands. + * For example, HSET should serialize which key and field it's operating on, but not its value. + * Setting the subset to -1 will serialize all arguments. + * Commands without a match will have their first argument serialized. + * + * Refer to https://redis.io/commands/ for the full list. + */ +const serializationSubsets = [ + { + regex: /^ECHO/i, + args: 0, + }, + { + regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i, + args: 1, + }, + { + regex: /^(HSET|HMSET|LSET|LINSERT)/i, + args: 2, + }, + { + regex: + /^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i, + args: -1, + }, +]; + +/** + * Given the redis command name and arguments, return a combination of the + * command name + the allowed arguments according to `serializationSubsets`. + */ +export const defaultDbStatementSerializer = ( + cmdName: string, + cmdArgs: Array, +): string => { + if (Array.isArray(cmdArgs) && cmdArgs.length) { + const nArgsToSerialize = serializationSubsets.find(({ regex }) => regex.test(cmdName))?.args ?? 0; + const argsToSerialize: Array = + nArgsToSerialize >= 0 ? cmdArgs.slice(0, nArgsToSerialize) : cmdArgs.slice(); + if (cmdArgs.length > argsToSerialize.length) { + argsToSerialize.push(`[${cmdArgs.length - nArgsToSerialize} other arguments]`); + } + return `${cmdName} ${argsToSerialize.join(' ')}`; + } + return cmdName; +}; diff --git a/packages/server-utils/test/integrations/tracing-channel/ioredis.test.ts b/packages/server-utils/test/integrations/tracing-channel/ioredis.test.ts new file mode 100644 index 000000000000..07d80450ea48 --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/ioredis.test.ts @@ -0,0 +1,255 @@ +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 { ioredisChannelIntegration } from '../../../src/integrations/tracing-channel/ioredis'; +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('ioredisChannelIntegration', () => { + beforeAll(() => { + installTestAsyncContextStrategy(); + initTestClient(); + const integration = ioredisChannelIntegration({ 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('command channel', () => { + it('creates a db span matching the OTel ioredis shape and runs the response hook', async () => { + const command = { name: 'get', args: ['test-key'] }; + const { resolved } = await driveCommand( + CHANNELS.IOREDIS_COMMAND, + { arguments: [command], self: { options: { host: 'localhost', port: 6380 } } }, + { result: 'value' }, + ); + + const span = lastRedisSpan(); + expect(span).toBeDefined(); + const json = spanToJSON(span!); + expect(json.description).toBe('get test-key'); + expect(json.op).toBe('db'); + 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 () => { + const command = { name: 'set', args: ['test-key', 'super-secret-value'] }; + await driveCommand( + CHANNELS.IOREDIS_COMMAND, + { arguments: [command], self: { options: { host: 'localhost', port: 6380 } } }, + { result: 'OK' }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('set test-key [1 other arguments]'); + 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 () => { + const command = { name: 'incr', args: ['test-key'] }; + await driveCommand( + CHANNELS.IOREDIS_COMMAND, + { arguments: [command], self: { options: { host: 'localhost', port: 6380 } } }, + { error: new Error('value is not an integer') }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('incr test-key'); + expect(json.status).toBe('value is not an integer'); + expect(responseHookSpy).not.toHaveBeenCalled(); + }); + + it('does not create a span when there is no active parent span', async () => { + const command = { name: 'get', args: ['test-key'] }; + const { activeInside } = await driveCommand( + CHANNELS.IOREDIS_COMMAND, + { arguments: [command], self: { options: { host: 'localhost', port: 6380 } } }, + { result: 'value' }, + { withParent: false }, + ); + + expect(lastRedisSpan()).toBeUndefined(); + expect(activeInside ? spanToJSON(activeInside).data['sentry.origin'] : undefined).not.toBe( + 'auto.db.orchestrion.redis', + ); + }); + + it('parents the redis span to the surrounding span', async () => { + let outerSpanId: string | undefined; + const command = { name: 'get', args: ['k'] }; + + await startSpan({ name: 'outer' }, async outer => { + outerSpanId = outer.spanContext().spanId; + const channel = tracingChannel(CHANNELS.IOREDIS_COMMAND); + await channel + .tracePromise(async () => 'v', { arguments: [command], self: { options: { host: 'h', port: 1 } } }) + .catch(() => undefined); + }); + + expect(spanToJSON(lastRedisSpan()!).parent_span_id).toBe(outerSpanId); + }); + }); + + describe('connect channel', () => { + it('creates a connect span', async () => { + await driveCommand( + CHANNELS.IOREDIS_CONNECT, + { self: { options: { host: 'localhost', port: 6380 } } }, + { result: undefined }, + ); + + const json = spanToJSON(lastRedisSpan()!); + expect(json.description).toBe('connect'); + expect(json.op).toBe('db'); + expect(json.data['db.statement']).toBe('connect'); + expect(json.data['db.system']).toBe('redis'); + expect(json.data['sentry.origin']).toBe('auto.db.orchestrion.redis'); + }); + }); +});