From 0fc320c9b198d8dfdd009f45d26d036c11e03643 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 20 May 2026 14:03:09 -0700 Subject: [PATCH 1/2] feat(deno): redis diagnostics channel based integration for deno (#21087) Refactor the redis-dc integration logic into core/src/integrations, and create a Deno integration that uses the same patterns. Instead of the @sentry/opentelemetry/tracing-channel, the Deno integration just adds `_sentrySpan` onto the data in a RedisTracingChannelFactory which is passed to the core utility. Add deno-redis e2e test. --- .github/workflows/build.yml | 4 +- .../test-applications/deno-redis/deno.json | 7 + .../deno-redis/docker-compose.yml | 12 + .../deno-redis/global-setup.mjs | 14 + .../deno-redis/global-teardown.mjs | 12 + .../test-applications/deno-redis/package.json | 22 ++ .../deno-redis/playwright.config.mjs | 12 + .../test-applications/deno-redis/src/app.ts | 54 ++++ .../deno-redis/start-event-proxy.mjs | 6 + .../deno-redis/tests/redis.test.ts | 69 +++++ .../integrations/redis/redis-dc-subscriber.ts | 282 ++++++++++++++++++ .../redis/redis-statement-serializer.ts | 50 ++++ packages/core/src/server-exports.ts | 18 ++ .../redis/redis-dc-subscriber.test.ts | 111 ++++--- packages/deno/src/index.ts | 2 + packages/deno/src/integrations/redis.ts | 76 +++++ packages/deno/src/sdk.ts | 2 + packages/deno/test/deno-redis.test.ts | 136 +++++++++ .../src/integrations/tracing/redis/index.ts | 19 +- .../tracing/redis/redis-dc-subscriber.ts | 231 -------------- 20 files changed, 851 insertions(+), 288 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/package.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts create mode 100644 packages/core/src/integrations/redis/redis-dc-subscriber.ts create mode 100644 packages/core/src/integrations/redis/redis-statement-serializer.ts rename packages/{node/test/integrations/tracing => core/test/lib/integrations}/redis/redis-dc-subscriber.test.ts (65%) create mode 100644 packages/deno/src/integrations/redis.ts create mode 100644 packages/deno/test/deno-redis.test.ts delete mode 100644 packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21c43b6e61f2..e58e9a7e097a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -986,7 +986,9 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' + if: + matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application == + 'deno-redis' uses: denoland/setup-deno@v2.0.4 with: deno-version: v2.1.5 diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/deno.json b/dev-packages/e2e-tests/test-applications/deno-redis/deno.json new file mode 100644 index 000000000000..582482be659f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "redis": "npm:redis@^5.12.0" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml new file mode 100644 index 000000000000..b695ab5a7308 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml @@ -0,0 +1,12 @@ +services: + redis: + image: redis:8 + restart: always + container_name: e2e-tests-deno-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs new file mode 100644 index 000000000000..ba03e561eb6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start Redis via Docker Compose. `--wait` blocks until the healthcheck + // in docker-compose.yml passes, so the Deno app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/package.json b/dev-packages/e2e-tests/test-applications/deno-redis/package.json new file mode 100644 index 000000000000..ee3d40c0dac3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/package.json @@ -0,0 +1,22 @@ +{ + "name": "deno-redis", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs new file mode 100644 index 000000000000..d525dd371bc9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs @@ -0,0 +1,12 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts new file mode 100644 index 000000000000..885c21d874d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/deno'; +import { createClient } from 'redis'; + +Sentry.init({ + environment: 'qa', + dsn: Deno.env.get('E2E_TEST_DSN'), + debug: !!Deno.env.get('DEBUG'), + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, +}); + +// One shared client per process. node-redis publishes to the +// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every +// operation on this client; denoRedisIntegration is already subscribed to +// those (it's part of the default integrations on Deno 2.7.13+). +const redis = createClient({ + url: Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379', +}); +redis.on('error', err => { + // eslint-disable-next-line no-console + console.error('redis client error', err); +}); +await redis.connect(); + +const port = 3030; + +Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { + const url = new URL(req.url); + + // GET — exercises the command channel, success path. + if (url.pathname === '/redis-get') { + const key = url.searchParams.get('key') ?? 'cache:key'; + const value = await redis.get(key); + return Response.json({ key, value }); + } + + // SET then GET — exercises two commands inside a single transaction so we + // can assert the parent has two db.redis children. + if (url.pathname === '/redis-set-get') { + const key = url.searchParams.get('key') ?? 'cache:key'; + const value = url.searchParams.get('value') ?? 'hello'; + await redis.set(key, value); + const echoed = await redis.get(key); + return Response.json({ key, value: echoed }); + } + + // MULTI — exercises the batch channel. + if (url.pathname === '/redis-multi') { + const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec(); + return Response.json({ result }); + } + + return new Response('Not found', { status: 404 }); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs new file mode 100644 index 000000000000..0c77d1f6d4f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno-redis', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts b/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts new file mode 100644 index 000000000000..0bb2431a5da3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('GET command emits an http.server transaction containing a db.redis child span', async ({ baseURL }) => { + // Each incoming request gets a Sentry http.server transaction (via the + // default denoServeIntegration); the redis command runs inside it, so the + // child span attaches to that transaction. + const transactionPromise = waitForTransaction('deno-redis', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/redis-get') && + (event.spans?.some(span => span.op === 'db.redis') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/redis-get?key=cache:user:42`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const redisSpan = transaction.spans!.find(span => span.op === 'db.redis'); + expect(redisSpan).toBeDefined(); + expect(redisSpan!.description).toBe('redis-GET'); + expect(redisSpan!.data?.['db.system']).toBe('redis'); + // Statement omits the value; for GET the only allowed arg is the key. + expect(redisSpan!.data?.['db.statement']).toBe('GET cache:user:42'); + expect(redisSpan!.data?.['net.peer.port']).toBe(6379); +}); + +test('SET then GET emit two db.redis child spans on the same transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno-redis', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/redis-set-get') && + (event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/redis-set-get?key=cache:greeting&value=hello`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis'); + expect(redisSpans.length).toBeGreaterThanOrEqual(2); + const ops = redisSpans.map(s => s.description); + expect(ops).toContain('redis-SET'); + expect(ops).toContain('redis-GET'); +}); + +test('MULTI batch emits a PIPELINE/MULTI batch span', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno-redis', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/redis-multi') && + (event.spans?.some(span => span.description === 'MULTI' || span.description === 'PIPELINE') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/redis-multi`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const batchSpan = transaction.spans!.find(span => span.description === 'MULTI' || span.description === 'PIPELINE'); + expect(batchSpan).toBeDefined(); + expect(batchSpan!.op).toBe('db.redis'); + expect(batchSpan!.data?.['db.system']).toBe('redis'); +}); diff --git a/packages/core/src/integrations/redis/redis-dc-subscriber.ts b/packages/core/src/integrations/redis/redis-dc-subscriber.ts new file mode 100644 index 000000000000..a7f7105a967e --- /dev/null +++ b/packages/core/src/integrations/redis/redis-dc-subscriber.ts @@ -0,0 +1,282 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { startSpanManual } from '../../tracing/trace'; +import { SPAN_STATUS_ERROR } from '../../tracing/spanstatus'; +import type { Span } from '../../types/span'; +import { defaultDbStatementSerializer } from './redis-statement-serializer'; + +// Channel names published by node-redis >= 5.12.0. Hardcoded so the subscriber +// does not have to import `redis` (which it shouldn't anyway — the channels +// just have to be subscribed to before the user's redis code publishes). +export const REDIS_DC_CHANNEL_COMMAND = 'node-redis:command'; +export const REDIS_DC_CHANNEL_BATCH = 'node-redis:batch'; +export const REDIS_DC_CHANNEL_CONNECT = 'node-redis:connect'; + +const ORIGIN = 'auto.db.redis.diagnostic_channel'; + +// Inlined semconv attribute keys — these are plain strings, no need to depend +// on @opentelemetry/semantic-conventions for them. +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const DB_SYSTEM_VALUE_REDIS = 'redis'; + +const NOOP = (): void => {}; + +/** Shape of the `command` channel payload published by node-redis. */ +export interface RedisCommandData { + command: string; + /** First arg is the command name itself in node-redis >= 5.12.0; consumers should slice it off. */ + args: Array; + database?: number; + serverAddress?: string; + serverPort?: number; + result?: unknown; + error?: Error; +} + +/** Shape of the `batch` channel payload published by node-redis. */ +export interface RedisBatchData { + batchMode?: 'MULTI' | 'PIPELINE'; + batchSize?: number; + database?: number; + clientId?: string | number; + serverAddress?: string; + serverPort?: number; + result?: unknown[]; + error?: Error; +} + +/** Shape of the `connect` channel payload published by node-redis. */ +export interface RedisConnectData { + serverAddress?: string; + serverPort?: number; + url?: string; + error?: Error; +} + +/** + * Optional callback invoked once the redis command response arrives. Useful + * for attaching response-derived attributes (e.g. cache hit/miss, payload size). + * + * Mirrors `@opentelemetry/instrumentation-ioredis`' response hook so existing + * Sentry node code (`cacheResponseHook`) can be reused unchanged. + */ +export type RedisDiagnosticChannelResponseHook = ( + span: Span, + cmdName: string, + cmdArgs: Array, + result: unknown, +) => void; + +/** + * Payload type observed by tracing-channel subscribers — the channel payload + * with `_sentrySpan` stamped on it by the start handler so async/error + * handlers downstream can read it back. + */ +export type RedisTracingChannelContextWithSpan = T & { _sentrySpan?: Span }; + +/** Subscriber object accepted by {@link RedisTracingChannel.subscribe}. */ +export interface RedisTracingChannelSubscribers { + start: (data: RedisTracingChannelContextWithSpan) => void; + asyncStart: (data: RedisTracingChannelContextWithSpan) => void; + asyncEnd: (data: RedisTracingChannelContextWithSpan) => void; + end: (data: RedisTracingChannelContextWithSpan) => void; + error: (data: RedisTracingChannelContextWithSpan) => void; +} + +/** Minimal tracing-channel surface the subscriber depends on. */ +export interface RedisTracingChannel { + subscribe(subs: Partial>): void; +} + +/** + * Platform-provided factory that returns a tracing channel for the given + * channel name. Implementations are responsible for ensuring that, when the + * channel's `start` event fires, the span returned by `transformStart(data)` + * ends up stored on `data._sentrySpan` so the subscriber's `asyncEnd`/`error` + * handlers can read it. + * + * - Node passes `@sentry/opentelemetry/tracing-channel` which uses + * `bindStore` to also propagate the span as the active OTel context. + * - Deno (and other non-OTel runtimes) pass a portable wrapper around + * `node:diagnostics_channel.tracingChannel` that just stamps + * `data._sentrySpan` in `start` without `bindStore`. + */ +export type RedisTracingChannelFactory = ( + name: string, + transformStart: (data: T) => Span, +) => RedisTracingChannel; + +let subscribed = false; +let currentResponseHook: RedisDiagnosticChannelResponseHook | undefined; + +/** + * Subscribe Sentry span handlers to node-redis diagnostics-channel events + * (`node-redis:command`, `node-redis:batch`, `node-redis:connect`) published + * by node-redis >= 5.12.0. + * + * On node-redis < 5.12.0 the channels are never published to, so subscribers + * are inert — there is no double-instrumentation against any IITM-based + * patcher gated to those older versions. + * + * Idempotent: subsequent calls update the response hook but do not + * re-subscribe. + */ +export function subscribeRedisDiagnosticChannels( + tracingChannel: RedisTracingChannelFactory, + responseHook?: RedisDiagnosticChannelResponseHook, +): void { + currentResponseHook = responseHook; + if (subscribed) return; + + try { + setupCommandChannel(tracingChannel); + setupBatchChannel(tracingChannel); + setupConnectChannel(tracingChannel); + subscribed = true; + } catch { + // The factory may rely on `node:diagnostics_channel`, which isn't always + // available. Fail closed — the SDK simply won't emit redis spans here. + } +} + +function setupCommandChannel(tracingChannel: RedisTracingChannelFactory): void { + const channel = tracingChannel(REDIS_DC_CHANNEL_COMMAND, data => { + // node-redis >= 5.12.0 includes the command name as args[0]; strip it so + // serialization and downstream hooks 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, + ); + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + const span = data._sentrySpan; + // Only end here if the error handler isn't going to. + if (!span || data.error) return; + 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(tracingChannel: RedisTracingChannelFactory): void { + const channel = tracingChannel(REDIS_DC_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, + ); + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + if (!data.error) 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(tracingChannel: RedisTracingChannelFactory): void { + const channel = tracingChannel(REDIS_DC_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, + ); + }); + + channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + if (!data.error) 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, 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: reset module-local subscribe state. */ +export function _resetRedisDiagnosticChannelsForTesting(): void { + subscribed = false; + currentResponseHook = undefined; +} diff --git a/packages/core/src/integrations/redis/redis-statement-serializer.ts b/packages/core/src/integrations/redis/redis-statement-serializer.ts new file mode 100644 index 000000000000..c08a567317ea --- /dev/null +++ b/packages/core/src/integrations/redis/redis-statement-serializer.ts @@ -0,0 +1,50 @@ +/* + * Adapted from @opentelemetry/redis-common (Apache-2.0): + * https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common + * + * Sentry note: the upstream package targets the OTel ecosystem; we own this + * portable, dependency-free copy so the redis diagnostics-channel subscriber + * in core can serialize `db.statement` attributes without importing any + * runtime-specific code. + * + * @module + */ + +/** + * Per-command argument-serialization budgets. `args: -1` means "serialize + * everything"; any other value caps how many leading args appear in the + * `db.statement` attribute. Commands without a match serialize just the + * command name. + * + * See https://redis.io/commands/ for the full surface. + */ +const SERIALIZATION_SUBSETS: Array<{ regex: RegExp; args: number }> = [ + { 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, + }, +]; + +/** + * Returns a redis `db.statement` string composed of the command name and the + * allow-listed prefix of arguments. Values that exceed the budget are elided + * with a "[N other arguments]" placeholder so secrets don't leak into spans. + */ +export function defaultDbStatementSerializer( + cmdName: string, + cmdArgs: Array, +): string { + if (!Array.isArray(cmdArgs) || cmdArgs.length === 0) return cmdName; + + const budget = SERIALIZATION_SUBSETS.find(({ regex }) => regex.test(cmdName))?.args ?? 0; + const argsToSerialize: Array = + budget >= 0 ? cmdArgs.slice(0, budget) : cmdArgs.slice(); + if (cmdArgs.length > argsToSerialize.length) { + argsToSerialize.push(`[${cmdArgs.length - budget} other arguments]`); + } + return `${cmdName} ${argsToSerialize.join(' ')}`; +} diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 1a19fa2902cc..e904fd1c6cfa 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -24,6 +24,24 @@ export type { } from './integrations/express/types'; export { instrumentPostgresJsSql } from './integrations/postgresjs'; +export { + REDIS_DC_CHANNEL_BATCH, + REDIS_DC_CHANNEL_COMMAND, + REDIS_DC_CHANNEL_CONNECT, + subscribeRedisDiagnosticChannels, +} from './integrations/redis/redis-dc-subscriber'; +export type { + RedisBatchData, + RedisCommandData, + RedisConnectData, + RedisDiagnosticChannelResponseHook, + RedisTracingChannel, + RedisTracingChannelContextWithSpan, + RedisTracingChannelFactory, + RedisTracingChannelSubscribers, +} from './integrations/redis/redis-dc-subscriber'; +export { defaultDbStatementSerializer } from './integrations/redis/redis-statement-serializer'; + export { patchHttpModuleClient } from './integrations/http/client-patch'; export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions'; export { getHttpServerSubscriptions, isStaticAssetRequest } from './integrations/http/server-subscription'; diff --git a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts b/packages/core/test/lib/integrations/redis/redis-dc-subscriber.test.ts similarity index 65% rename from packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts rename to packages/core/test/lib/integrations/redis/redis-dc-subscriber.test.ts index 852298b3370c..c91b28f2b79a 100644 --- a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts +++ b/packages/core/test/lib/integrations/redis/redis-dc-subscriber.test.ts @@ -1,31 +1,37 @@ -import { SPAN_STATUS_ERROR } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// channels registry must be created before the vi.mock factory runs -const channels = vi.hoisted(() => ({}) as Record void> }>); - -vi.mock('@sentry/opentelemetry/tracing-channel', () => ({ - tracingChannel: (name: string, _transform: unknown) => { - const subs: Record void> = {}; - channels[name] = { subs }; - return { subscribe: (s: Record void>) => Object.assign(subs, s) }; - }, -})); - +import { SPAN_STATUS_ERROR } from '../../../../src/tracing/spanstatus'; import { _resetRedisDiagnosticChannelsForTesting, + REDIS_DC_CHANNEL_BATCH, + REDIS_DC_CHANNEL_COMMAND, + REDIS_DC_CHANNEL_CONNECT, subscribeRedisDiagnosticChannels, -} from '../../../../src/integrations/tracing/redis/redis-dc-subscriber'; + type RedisTracingChannel, + type RedisTracingChannelFactory, + type RedisTracingChannelSubscribers, +} from '../../../../src/integrations/redis/redis-dc-subscriber'; -const CHANNEL_COMMAND = 'node-redis:command'; -const CHANNEL_BATCH = 'node-redis:batch'; -const CHANNEL_CONNECT = 'node-redis:connect'; +interface RecordedChannel { + subs: Partial>; +} -const subs = (name: string) => - channels[name]?.subs as { - asyncEnd: (data: any) => void; - error: (data: any) => void; +// fake tracing-channel factory that stores subscribers in channels by name +function makeFakeFactory(): { + factory: RedisTracingChannelFactory; + channels: Record; +} { + const channels: Record = {}; + const factory: RedisTracingChannelFactory = (name, _transform) => { + const recorded: RecordedChannel = { subs: {} }; + channels[name] = recorded; + return { + subscribe(subs: Partial>) { + Object.assign(recorded.subs, subs); + }, + } as unknown as RedisTracingChannel; }; + return { factory, channels }; +} function makeSpan() { return { @@ -38,15 +44,24 @@ function makeSpan() { }; } -describe('redis-dc-subscriber', () => { +describe('subscribeRedisDiagnosticChannels', () => { + let factory: RedisTracingChannelFactory; + let channels: Record; let mockSpan: ReturnType; let responseHook: ReturnType; + const subs = (name: string) => + channels[name]!.subs as { + asyncEnd: (data: any) => void; + error: (data: any) => void; + }; + beforeEach(() => { _resetRedisDiagnosticChannelsForTesting(); + ({ factory, channels } = makeFakeFactory()); mockSpan = makeSpan(); responseHook = vi.fn(); - subscribeRedisDiagnosticChannels(responseHook); + subscribeRedisDiagnosticChannels(factory, responseHook); }); afterEach(() => { @@ -62,7 +77,7 @@ describe('redis-dc-subscriber', () => { result: 'hit-value', _sentrySpan: mockSpan, }; - subs(CHANNEL_COMMAND).asyncEnd(data); + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd(data); expect(responseHook).toHaveBeenCalledWith(mockSpan, 'GET', ['cache:key'], 'hit-value'); expect(mockSpan.end).toHaveBeenCalledTimes(1); @@ -75,13 +90,13 @@ describe('redis-dc-subscriber', () => { result: ['v1', 'v2', 'v3'], _sentrySpan: mockSpan, }; - subs(CHANNEL_COMMAND).asyncEnd(data); + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd(data); expect(responseHook).toHaveBeenCalledWith(mockSpan, 'MGET', ['key1', 'key2', 'key3'], ['v1', 'v2', 'v3']); }); it('bails early when _sentrySpan is absent', () => { - subs(CHANNEL_COMMAND).asyncEnd({ command: 'GET', args: ['GET', 'k'], result: 'v' }); + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd({ command: 'GET', args: ['GET', 'k'], result: 'v' }); expect(responseHook).not.toHaveBeenCalled(); expect(mockSpan.end).not.toHaveBeenCalled(); @@ -92,7 +107,7 @@ describe('redis-dc-subscriber', () => { it('sets error status and ends the span in the error handler', () => { const error = new Error('ECONNREFUSED'); const data = { command: 'SET', args: ['SET', 'k', 'v'], error, _sentrySpan: mockSpan }; - subs(CHANNEL_COMMAND).error(data); + subs(REDIS_DC_CHANNEL_COMMAND).error(data); expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'ECONNREFUSED' }); expect(mockSpan.end).toHaveBeenCalledTimes(1); @@ -102,16 +117,16 @@ describe('redis-dc-subscriber', () => { const error = new Error('ECONNREFUSED'); const data = { command: 'GET', args: ['GET', 'k'], error, _sentrySpan: mockSpan }; - // TracingChannel fires error first, then asyncEnd, on the same data object - subs(CHANNEL_COMMAND).error(data); - subs(CHANNEL_COMMAND).asyncEnd(data); + // TracingChannel fires error first, then asyncEnd, on the same data object. + subs(REDIS_DC_CHANNEL_COMMAND).error(data); + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd(data); expect(responseHook).not.toHaveBeenCalled(); expect(mockSpan.end).toHaveBeenCalledTimes(1); }); it('bails early in error handler when _sentrySpan is absent', () => { - subs(CHANNEL_COMMAND).error({ command: 'GET', args: ['GET', 'k'], error: new Error('x') }); + subs(REDIS_DC_CHANNEL_COMMAND).error({ command: 'GET', args: ['GET', 'k'], error: new Error('x') }); expect(mockSpan.setStatus).not.toHaveBeenCalled(); expect(mockSpan.end).not.toHaveBeenCalled(); @@ -123,13 +138,13 @@ describe('redis-dc-subscriber', () => { describe('asyncEnd (success path)', () => { it('ends the span', () => { const data = { batchMode: 'PIPELINE', batchSize: 3, _sentrySpan: mockSpan }; - subs(CHANNEL_BATCH).asyncEnd(data); + subs(REDIS_DC_CHANNEL_BATCH).asyncEnd(data); expect(mockSpan.end).toHaveBeenCalledTimes(1); }); it('bails early when _sentrySpan is absent', () => { - subs(CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); + subs(REDIS_DC_CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); expect(mockSpan.end).not.toHaveBeenCalled(); }); @@ -139,7 +154,7 @@ describe('redis-dc-subscriber', () => { it('sets error status and ends the span in the error handler', () => { const error = new Error('MULTI aborted'); const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; - subs(CHANNEL_BATCH).error(data); + subs(REDIS_DC_CHANNEL_BATCH).error(data); expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'MULTI aborted' }); expect(mockSpan.end).toHaveBeenCalledTimes(1); @@ -149,8 +164,8 @@ describe('redis-dc-subscriber', () => { const error = new Error('MULTI aborted'); const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; - subs(CHANNEL_BATCH).error(data); - subs(CHANNEL_BATCH).asyncEnd(data); + subs(REDIS_DC_CHANNEL_BATCH).error(data); + subs(REDIS_DC_CHANNEL_BATCH).asyncEnd(data); expect(mockSpan.end).toHaveBeenCalledTimes(1); }); @@ -161,13 +176,13 @@ describe('redis-dc-subscriber', () => { describe('asyncEnd (success path)', () => { it('ends the span', () => { const data = { serverAddress: '127.0.0.1', serverPort: 6379, _sentrySpan: mockSpan }; - subs(CHANNEL_CONNECT).asyncEnd(data); + subs(REDIS_DC_CHANNEL_CONNECT).asyncEnd(data); expect(mockSpan.end).toHaveBeenCalledTimes(1); }); it('bails early when _sentrySpan is absent', () => { - subs(CHANNEL_CONNECT).asyncEnd({ serverAddress: '127.0.0.1' }); + subs(REDIS_DC_CHANNEL_CONNECT).asyncEnd({ serverAddress: '127.0.0.1' }); expect(mockSpan.end).not.toHaveBeenCalled(); }); @@ -177,7 +192,7 @@ describe('redis-dc-subscriber', () => { it('sets error status and ends the span in the error handler', () => { const error = new Error('connect ECONNREFUSED'); const data = { serverAddress: '127.0.0.1', serverPort: 6379, error, _sentrySpan: mockSpan }; - subs(CHANNEL_CONNECT).error(data); + subs(REDIS_DC_CHANNEL_CONNECT).error(data); expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); expect(mockSpan.end).toHaveBeenCalledTimes(1); @@ -187,25 +202,23 @@ describe('redis-dc-subscriber', () => { const error = new Error('connect ECONNREFUSED'); const data = { serverAddress: '127.0.0.1', error, _sentrySpan: mockSpan }; - subs(CHANNEL_CONNECT).error(data); - subs(CHANNEL_CONNECT).asyncEnd(data); + subs(REDIS_DC_CHANNEL_CONNECT).error(data); + subs(REDIS_DC_CHANNEL_CONNECT).asyncEnd(data); expect(mockSpan.end).toHaveBeenCalledTimes(1); }); }); }); - describe('subscribeRedisDiagnosticChannels', () => { - it('is idempotent — does not re-subscribe if called again', () => { - // subscribeRedisDiagnosticChannels was already called in beforeEach. - // Calling again should not throw or overwrite subscribers. + describe('idempotency', () => { + it('does not re-subscribe on a second call, but updates the response hook', () => { + // First subscription happened in beforeEach. Replay with a new hook — + // the same channel subscribers stay in place, but the new hook fires. const secondHook = vi.fn(); - subscribeRedisDiagnosticChannels(secondHook); + subscribeRedisDiagnosticChannels(factory, secondHook); - // The second hook should still be active (currentResponseHook is updated regardless) - // but no new channel setup should occur. const data = { command: 'GET', args: ['GET', 'k'], result: 'v', _sentrySpan: mockSpan }; - subs(CHANNEL_COMMAND).asyncEnd(data); + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd(data); expect(secondHook).toHaveBeenCalledTimes(1); expect(responseHook).not.toHaveBeenCalled(); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index d06f05f8e4f1..95890bdc54b9 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -106,6 +106,8 @@ export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export { denoHttpIntegration } from './integrations/http'; export type { DenoHttpIntegrationOptions } from './integrations/http'; +export { denoRedisIntegration } from './integrations/redis'; +export type { DenoRedisIntegrationOptions } from './integrations/redis'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/redis.ts b/packages/deno/src/integrations/redis.ts new file mode 100644 index 000000000000..5a6e5b67a288 --- /dev/null +++ b/packages/deno/src/integrations/redis.ts @@ -0,0 +1,76 @@ +import { tracingChannel as nativeTracingChannel } from 'node:diagnostics_channel'; +import type { + Integration, + IntegrationFn, + RedisDiagnosticChannelResponseHook, + RedisTracingChannel, + RedisTracingChannelFactory, + RedisTracingChannelSubscribers, + Span, +} from '@sentry/core'; +import { defineIntegration, subscribeRedisDiagnosticChannels } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoRedis'; + +export interface DenoRedisIntegrationOptions { + /** + * Optional hook invoked once the redis command response arrives. Useful for + * attaching response-derived attributes (e.g. cache hit/miss, payload size). + */ + responseHook?: RedisDiagnosticChannelResponseHook; +} + +/** + * Portable tracing-channel factory: wraps `node:diagnostics_channel.tracingChannel` + * and stamps `data._sentrySpan` from `transformStart` in the `start` subscriber. + * + * Unlike `@sentry/opentelemetry/tracing-channel`, this does not call `bindStore` + */ +type DataWithSpan = T & { _sentrySpan?: Span }; +type SubscriberFn = (data: DataWithSpan) => void; + +const portableTracingChannel: RedisTracingChannelFactory = ( + name: string, + transformStart: (data: T) => Span, +): RedisTracingChannel => { + const channel = nativeTracingChannel>(name); + return { + subscribe(subs: Partial>): void { + const userStart = subs.start as SubscriberFn | undefined; + const composed: Record> = { + start(data) { + data._sentrySpan = transformStart(data); + userStart?.(data); + }, + }; + for (const event of ['asyncStart', 'asyncEnd', 'end', 'error'] as const) { + const fn = subs[event] as SubscriberFn | undefined; + if (fn) composed[event] = fn; + } + // Native subscribe is typed for the full subscriber set, but only the + // handlers actually present are invoked at runtime. + channel.subscribe(composed as unknown as Parameters[0]); + }, + }; +}; + +const _denoRedisIntegration = ((options: DenoRedisIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + subscribeRedisDiagnosticChannels(portableTracingChannel, options.responseHook); + }, + }; +}) satisfies IntegrationFn; + +/** + * Creates spans for node-redis commands, batches, and connects under Deno via + * `node:diagnostics_channel`. Requires node-redis >= 5.12.0 (the version that + * began publishing to these channels). On older node-redis releases the + * subscribers are inert. + */ +export const denoRedisIntegration = defineIntegration(_denoRedisIntegration) as ( + options?: DenoRedisIntegrationOptions, +) => Integration & { name: 'DenoRedis'; setupOnce: () => void }; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 62e7dc6a2e70..87e241cc3ef1 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -19,6 +19,7 @@ import { contextLinesIntegration } from './integrations/contextlines'; import { HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED } from './denoVersion'; import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; +import { denoRedisIntegration } from './integrations/redis'; import { globalHandlersIntegration } from './integrations/globalhandlers'; import { normalizePathsIntegration } from './integrations/normalizepaths'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; @@ -47,6 +48,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { ...(HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED || HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED ? [denoHttpIntegration()] : []), + denoRedisIntegration(), contextLinesIntegration(), normalizePathsIntegration(), globalHandlersIntegration(), diff --git a/packages/deno/test/deno-redis.test.ts b/packages/deno/test/deno-redis.test.ts new file mode 100644 index 000000000000..99f858519dc3 --- /dev/null +++ b/packages/deno/test/deno-redis.test.ts @@ -0,0 +1,136 @@ +// + +import { tracingChannel } from 'node:diagnostics_channel'; +import type { TransactionEvent } from '@sentry/core'; +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; +import type { DenoClient } from '../build/esm/index.js'; +import { getCurrentScope, getGlobalScope, getIsolationScope, init, startSpan } from '../build/esm/index.js'; + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +/** See deno-http.test.ts — same sink shape, deduped for clarity. */ +function transactionSink(): { + beforeSendTransaction: (event: TransactionEvent) => null; + waitFor: (predicate: (event: TransactionEvent) => boolean) => Promise; +} { + const transactions: TransactionEvent[] = []; + const waiters: { predicate: (e: TransactionEvent) => boolean; resolve: (e: TransactionEvent) => void }[] = []; + return { + beforeSendTransaction(event) { + transactions.push(event); + for (let i = waiters.length - 1; i >= 0; i--) { + const w = waiters[i]!; + if (w.predicate(event)) { + waiters.splice(i, 1); + w.resolve(event); + } + } + return null; + }, + waitFor(predicate) { + const already = transactions.find(predicate); + if (already) return Promise.resolve(already); + return new Promise(resolve => { + waiters.push({ predicate, resolve }); + }); + }, + }; +} + +function withTimeout(p: Promise, ms: number, what: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${what} after ${ms}ms`)), ms); + }); + return Promise.race([p, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +Deno.test('denoRedisIntegration: included in default integrations', () => { + resetGlobals(); + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; + const names = client.getOptions().integrations.map(i => i.name); + assert(names.includes('DenoRedis'), `DenoRedis should be in defaults, got ${names.join(', ')}`); +}); + +Deno.test('denoRedisIntegration: node-redis:command channel produces a db.redis child span', async () => { + resetGlobals(); + const sink = transactionSink(); + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const channel = tracingChannel('node-redis:command'); + + // Simulate node-redis publishing a successful GET command around an async op. + // tracePromise fires start → asyncStart → asyncEnd in sequence and stores + // any thrown/rejected error on the data object for our error subscriber. + await startSpan({ name: 'parent', op: 'test' }, async () => { + await channel.tracePromise(() => Promise.resolve('hit-value'), { + command: 'GET', + args: ['GET', 'cache:key'], + serverAddress: '127.0.0.1', + serverPort: 6379, + }); + }); + + const parent = await withTimeout( + sink.waitFor(t => t.transaction === 'parent'), + 5000, + "'parent' transaction", + ); + + const redisSpan = parent.spans?.find(s => s.op === 'db.redis'); + assertExists(redisSpan, `expected a db.redis child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); + assertEquals(redisSpan!.description, 'redis-GET'); + assertEquals(redisSpan!.data?.['db.system'], 'redis'); + assertEquals(redisSpan!.data?.['db.statement'], 'GET cache:key'); + assertEquals(redisSpan!.data?.['net.peer.name'], '127.0.0.1'); + assertEquals(redisSpan!.data?.['net.peer.port'], 6379); +}); + +Deno.test('denoRedisIntegration: errors on the command channel set span status', async () => { + resetGlobals(); + const sink = transactionSink(); + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const channel = tracingChannel('node-redis:command'); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + try { + await channel.tracePromise(() => Promise.reject(new Error('ECONNREFUSED')), { + command: 'SET', + args: ['SET', 'k', 'v'], + }); + } catch { + // swallow — we are observing via Sentry, not via control flow + } + }); + + const parent = await withTimeout( + sink.waitFor(t => t.transaction === 'parent'), + 5000, + "'parent' transaction", + ); + const redisSpan = parent.spans?.find(s => s.op === 'db.redis'); + assertExists(redisSpan, `expected a db.redis child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); + // Sentry serializes a span with `setStatus({ code: SPAN_STATUS_ERROR, message: 'X' })` + // as `status: 'X'` (the message takes the slot). Both "not ok" and the + // forwarded message confirm the error path fired. + assert(redisSpan!.status && redisSpan!.status !== 'ok', `expected error-shaped status, got ${redisSpan!.status}`); + assertEquals(redisSpan!.status, 'ECONNREFUSED'); +}); diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index 2e5268c14c6f..18a5047f624d 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -19,10 +19,11 @@ import { isInCommands, shouldConsiderForCache, } from '../../../utils/redisCache'; +import { subscribeRedisDiagnosticChannels } from '@sentry/core/server'; +import { tracingChannel as otelTracingChannel } from '@sentry/opentelemetry/tracing-channel'; 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 { /** @@ -113,16 +114,20 @@ const instrumentRedisModule = generateInstrumentOnce(`${INTEGRATION_NAME}.Redis` }); }); -/** To be able to preload all Redis OTel instrumentations with just one ID ("Redis"), all the instrumentations are generated in this one function */ +/** + * To be able to preload all Redis OTel instrumentations with just one ID + * ("Redis"), all the instrumentations are generated in this one function + */ 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)); + // node-redis >= 5.12.0 publishes via diagnostics_channel. We pass + // `@sentry/opentelemetry/tracing-channel` as the factory so the span + // becomes the active OTel context via `bindStore`. That factory needs the + // Sentry OTel context manager to be registered, which `initOpenTelemetry()` + // does after integration `setupOnce`, so defer to the next tick. + void Promise.resolve().then(() => subscribeRedisDiagnosticChannels(otelTracingChannel, 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 deleted file mode 100644 index 4a2ddaf8a9b2..000000000000 --- a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts +++ /dev/null @@ -1,231 +0,0 @@ -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; - // only end if error handler isn't going to - if (!span || data.error) 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 => { - // only end if the error handler isn't going to - if (!data.error) 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 => { - // only end if the error handler isn't going to - if (!data.error) 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 }; From 72be1fab4a3f9358a53f2cbc5f71f36c95b416a0 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 20 May 2026 15:17:33 -0700 Subject: [PATCH 2/2] fixup! feat(deno): redis diagnostics channel based integration for deno (#21087) --- .github/workflows/build.yml | 2 +- packages/deno/scripts/download-deno-types.mjs | 2 +- packages/deno/test/__snapshots__/mod.test.ts.snap | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e58e9a7e097a..15e2556786f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -527,7 +527,7 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v2.0.4 with: - deno-version: v2.1.5 + deno-version: v2.7.14 - name: Restore caches uses: ./.github/actions/restore-cache with: diff --git a/packages/deno/scripts/download-deno-types.mjs b/packages/deno/scripts/download-deno-types.mjs index 0d755099c7d4..f64409dd938c 100644 --- a/packages/deno/scripts/download-deno-types.mjs +++ b/packages/deno/scripts/download-deno-types.mjs @@ -2,6 +2,6 @@ import { existsSync, writeFileSync } from 'fs'; import { download } from './download.mjs'; if (!existsSync('lib.deno.d.ts')) { - const code = await download('https://github.com/denoland/deno/releases/download/v2.6.6/lib.deno.d.ts'); + const code = await download('https://github.com/denoland/deno/releases/download/v2.7.14/lib.deno.d.ts'); writeFileSync('lib.deno.d.ts', code); } diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index b173fe0ed13a..21921a088941 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -113,6 +113,8 @@ snapshot[`captureException 1`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "DenoHttp", + "DenoRedis", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -186,6 +188,8 @@ snapshot[`captureMessage 1`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "DenoHttp", + "DenoRedis", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -266,6 +270,8 @@ snapshot[`captureMessage twice 1`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "DenoHttp", + "DenoRedis", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -353,6 +359,8 @@ snapshot[`captureMessage twice 2`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "DenoHttp", + "DenoRedis", "ContextLines", "NormalizePaths", "GlobalHandlers",