diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ca0b176b992..fd4b6e47bcb4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -537,7 +537,7 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v2.0.4 with: - deno-version: v2.1.5 + deno-version: ${{ matrix.deno-version || 'v2.8.0' }} - name: Restore caches uses: ./.github/actions/restore-cache with: @@ -996,10 +996,12 @@ 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: ${{ matrix.deno-version || 'v2.1.5' }} + deno-version: ${{ matrix.deno-version || 'v2.8.0' }} - name: Restore caches uses: ./.github/actions/restore-cache with: @@ -1122,7 +1124,7 @@ jobs: if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' uses: denoland/setup-deno@v2.0.4 with: - deno-version: ${{ matrix.deno-version || 'v2.1.5' }} + deno-version: ${{ matrix.deno-version || 'v2.8.0' }} - name: Restore caches uses: ./.github/actions/restore-cache with: 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..682591cfa09e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/deno.json @@ -0,0 +1,8 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "ioredis": "npm:ioredis@^5.11.0", + "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..f4878dde0c6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/package.json @@ -0,0 +1,24 @@ +{ + "name": "deno-redis", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "docker compose up -d --wait && 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", + "ioredis": "^5.11.0", + "redis": "^5.12.0" + }, + "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..3e443a4ccc01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts @@ -0,0 +1,113 @@ +import * as Sentry from '@sentry/deno'; +import IORedis from 'ioredis'; +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, +}); + +const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'; + +// 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. +const redis = createClient({ url: redisUrl }); +function onRedisError(err: unknown) { + // eslint-disable-next-line no-console + console.error('redis client error', err); +} +redis.on('error', onRedisError); +await redis.connect(); + +// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command` +// and `ioredis:connect` channels, which denoRedisIntegration also subscribes +// to. lazyConnect so we can yield a microtick before connecting and ensure +// the DC subscriber is registered before ioredis creates its tracing channels. +await Promise.resolve(); +const ioredisUrl = new URL(redisUrl); +const ioredis = new IORedis({ + host: ioredisUrl.hostname, + port: Number(ioredisUrl.port) || 6379, + lazyConnect: true, +}); +function onIoredisError(err: unknown) { + // eslint-disable-next-line no-console + console.error('ioredis client error', err); +} +ioredis.on('error', onIoredisError); +await ioredis.connect(); + +const port = 3030; + +Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { + const url = new URL(req.url); + + // node-redis: 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 }); + } + + // node-redis: 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 }); + } + + // node-redis: 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 }); + } + + // ioredis: GET — exercises the ioredis:command channel. + if (url.pathname === '/ioredis-get') { + const key = url.searchParams.get('key') ?? 'iocache:key'; + const value = await ioredis.get(key); + return Response.json({ key, value }); + } + + // ioredis: SET then GET — two commands inside a transaction. + if (url.pathname === '/ioredis-set-get') { + const key = url.searchParams.get('key') ?? 'iocache:key'; + const value = url.searchParams.get('value') ?? 'hello'; + await ioredis.set(key, value); + const echoed = await ioredis.get(key); + return Response.json({ key, value: echoed }); + } + + // ioredis: MULTI — ioredis has no separate batch channel; per-command + // payloads carry `batchMode`/`batchSize` instead, so we still expect one + // db.redis span per command. + if (url.pathname === '/ioredis-multi') { + const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec(); + return Response.json({ result }); + } + + // ioredis: PIPELINE — same shape as MULTI from the perspective of the + // diagnostics channel. + if (url.pathname === '/ioredis-pipeline') { + const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec(); + return Response.json({ result }); + } + + if (url.pathname === '/redis-disconnect') { + redis.off('error', onRedisError); + redis.close(); + ioredis.off('error', onIoredisError); + ioredis.disconnect(); + return new Response('ok'); + } + + 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/ioredis.test.ts b/dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts new file mode 100644 index 000000000000..63a40d68d960 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('ioredis GET 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 ioredis 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('/ioredis-get') && + (event.spans?.some(span => span.op === 'db.redis') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/ioredis-get?key=iocache: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(); + // ioredis publishes lowercase command names; node-redis publishes uppercase. + expect(redisSpan!.description).toBe('redis-get'); + expect(redisSpan!.data?.['db.system']).toBe('redis'); + expect(redisSpan!.data?.['db.statement']).toBe('get iocache:user:42'); +}); + +test('ioredis 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('/ioredis-set-get') && + (event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/ioredis-set-get?key=iocache: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('ioredis MULTI emits one db.redis span per command (no batch channel)', async ({ baseURL }) => { + // ioredis does not publish to a batch channel — each command in the + // transaction publishes individually with batchMode/batchSize set on its + // own payload. So the transaction should contain multiple `redis-` + // child spans, but no PIPELINE/MULTI batch span. + const transactionPromise = waitForTransaction('deno-redis', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/ioredis-multi') && + (event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 3 + ); + }); + + const res = await fetch(`${baseURL}/ioredis-multi`); + 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(3); + const descriptions = redisSpans.map(s => s.description); + expect(descriptions).toContain('redis-set'); + expect(descriptions).toContain('redis-get'); + // No PIPELINE/MULTI batch wrapper span — ioredis has no separate batch channel. + const batchSpan = transaction.spans!.find(span => span.description === 'MULTI' || span.description === 'PIPELINE'); + expect(batchSpan).toBeUndefined(); +}); + +test('ioredis PIPELINE emits one db.redis span per command', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno-redis', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/ioredis-pipeline') && + (event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 3 + ); + }); + + const res = await fetch(`${baseURL}/ioredis-pipeline`); + 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(3); +}); 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..b6bfa9fc4bd7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts @@ -0,0 +1,74 @@ +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'); +}); + +test('shut down redis client', async ({ baseURL }) => { + const res = await fetch(`${baseURL}/redis-disconnect`); + expect(await res.text()).toBe('ok'); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts index 8f7975944c29..0a6206499c28 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts @@ -16,7 +16,7 @@ describe('ioredis v5.11 diagnostics_channel auto instrumentation', () => { 'sentry.op': 'db.redis', 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', - 'db.statement': 'set dc-test-key [1 other arguments]', + 'db.statement': 'set dc-test-key ?', }), }), expect.objectContaining({ @@ -25,7 +25,7 @@ describe('ioredis v5.11 diagnostics_channel auto instrumentation', () => { origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.origin': 'auto.db.redis.diagnostic_channel', - 'db.statement': 'set dc-cache:test-key [1 other arguments]', + 'db.statement': 'set dc-cache:test-key ?', 'cache.key': ['dc-cache:test-key'], 'cache.item_size': 2, }), @@ -36,7 +36,7 @@ describe('ioredis v5.11 diagnostics_channel auto instrumentation', () => { origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.origin': 'auto.db.redis.diagnostic_channel', - 'db.statement': 'set dc-cache:test-key-ex [3 other arguments]', + 'db.statement': 'set dc-cache:test-key-ex ? ? ?', 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, }), @@ -81,7 +81,7 @@ describe('ioredis v5.11 diagnostics_channel auto instrumentation', () => { 'sentry.op': 'db.redis', 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', - 'db.statement': 'mget [3 other arguments]', + 'db.statement': 'mget ? ? ?', }), }), ]), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index df8c26b23b63..7e2110d25e84 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -16,7 +16,7 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { 'sentry.op': 'db.redis', 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', - 'db.statement': 'SET dc-test-key [1 other arguments]', + 'db.statement': 'SET dc-test-key ?', }), }), // cache SET: span name updated to key by cacheResponseHook @@ -26,7 +26,7 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.origin': 'auto.db.redis.diagnostic_channel', - 'db.statement': 'SET dc-cache:test-key [1 other arguments]', + 'db.statement': 'SET dc-cache:test-key ?', 'cache.key': ['dc-cache:test-key'], 'cache.item_size': 2, }), @@ -38,7 +38,7 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { origin: 'auto.db.redis.diagnostic_channel', data: expect.objectContaining({ 'sentry.origin': 'auto.db.redis.diagnostic_channel', - 'db.statement': 'SET dc-cache:test-key-ex [3 other arguments]', + 'db.statement': 'SET dc-cache:test-key-ex ? ? ?', 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, }), @@ -87,7 +87,7 @@ describe('redis v5 diagnostics_channel auto instrumentation', () => { 'sentry.op': 'db.redis', 'sentry.origin': 'auto.db.redis.diagnostic_channel', 'db.system': 'redis', - 'db.statement': 'MGET [3 other arguments]', + 'db.statement': 'MGET ? ? ?', }), }), ]), 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..2719857d297f --- /dev/null +++ b/packages/core/src/integrations/redis/redis-dc-subscriber.ts @@ -0,0 +1,324 @@ +import { DEBUG_BUILD } from '../../debug-build'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing/spanstatus'; +import { startSpanManual } from '../../tracing/trace'; +import type { Span } from '../../types/span'; +import { debug } from '../../utils/debug-logger'; + +// Channel names published by node-redis >= 5.12.0 and ioredis >= 5.11.0. +// Hardcoded so the subscriber does not have to import either library — 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'; +export const IOREDIS_DC_CHANNEL_COMMAND = 'ioredis:command'; +export const IOREDIS_DC_CHANNEL_CONNECT = 'ioredis: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 `node-redis:command` channel payload published by node-redis. + * + * Both `command` and `args` are already redacted by node-redis itself (see + * `sanitizeArgs` in @redis/client) using the OTel `redis-common` rules. The + * arg array is `[, , ..., '?', ...]` — `?` replaces any + * value the library considers sensitive. Subscribers can emit `args` directly + * as `db.statement` without further serialization. + */ +export interface RedisCommandData { + command: string; + /** First arg is the command name itself; consumers should slice it off. */ + args: string[]; + database?: number; + serverAddress?: string; + serverPort?: number; + result?: unknown; + error?: Error; +} + +/** + * Shape of the `ioredis:command` channel payload published by ioredis >= 5.11.0. + * + * As with node-redis, args are already sanitized by ioredis (`sanitizeArgs` in + * `lib/tracing.ts`) before publishing. Unlike node-redis, the command name is + * NOT prepended to `args`. + */ +export interface IORedisCommandData { + command: string; + args: string[]; + batchMode?: 'MULTI'; + batchSize?: number; + database?: number; + serverAddress?: string; + serverPort?: number; + result?: unknown; + error?: Error; +} + +/** Shape of the `node-redis: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 and ioredis. */ +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: string[], + 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 and ioredis diagnostics-channel + * events: `node-redis:command`/`:batch`/`:connect` (published by node-redis + * >= 5.12.0) and `ioredis:command`/`:connect` (published by ioredis >= 5.11.0). + * + * On older client versions 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; + subscribed = true; + + try { + // node-redis: command name appears as args[0] in the channel payload, so + // strip it before the statement and response hook see it. + setupCommandChannel(tracingChannel, REDIS_DC_CHANNEL_COMMAND, data => data.args.slice(1)); + setupBatchChannel(tracingChannel, REDIS_DC_CHANNEL_BATCH, data => + data.batchMode === 'PIPELINE' ? 'PIPELINE' : 'MULTI', + ); + setupConnectChannel(tracingChannel, REDIS_DC_CHANNEL_CONNECT); + + // ioredis: args already exclude the command name; no slicing needed. And + // ioredis has no separate batch channel — pipeline/MULTI metadata rides + // on the per-command payload via `batchMode`/`batchSize`. + setupCommandChannel(tracingChannel, IOREDIS_DC_CHANNEL_COMMAND, data => data.args); + setupConnectChannel(tracingChannel, IOREDIS_DC_CHANNEL_CONNECT); + } 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. + DEBUG_BUILD && debug.log('Redis node:diagnostics_channel subscription failed.'); + } +} + +function setupCommandChannel( + tracingChannel: RedisTracingChannelFactory, + channelName: string, + getCommandArgs: (data: T) => string[], +): void { + const channel = tracingChannel(channelName, data => { + // `args` is already sanitized by the publishing library (node-redis / + // ioredis call their own `sanitizeArgs` before publishing). Join with + // spaces to mirror the format the libraries themselves intend. + const args = getCommandArgs(data); + const statement = args.length ? `${data.command} ${args.join(' ')}` : data.command; + 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, + [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, getCommandArgs(data), 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, + channelName: string, + getOperationName: (data: RedisBatchData) => string, +): void { + const channel = tracingChannel(channelName, data => { + return startSpanManual( + { + name: getOperationName(data), + 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, channelName: string): void { + const channel = tracingChannel(channelName, 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: string[], result: unknown): void { + const hook = currentResponseHook; + if (!hook) return; + try { + hook(span, command, args, result); + } catch { + // never let user hooks break instrumentation + } +} + +/** Test-only: reset module-local subscribe state. */ +export function _resetRedisDiagnosticChannelsForTesting(): void { + subscribed = false; + currentResponseHook = undefined; +} diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 1a19fa2902cc..ad35985649d3 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -24,6 +24,26 @@ export type { } from './integrations/express/types'; export { instrumentPostgresJsSql } from './integrations/postgresjs'; +export { + IOREDIS_DC_CHANNEL_COMMAND, + IOREDIS_DC_CHANNEL_CONNECT, + REDIS_DC_CHANNEL_BATCH, + REDIS_DC_CHANNEL_COMMAND, + REDIS_DC_CHANNEL_CONNECT, + subscribeRedisDiagnosticChannels, +} from './integrations/redis/redis-dc-subscriber'; +export type { + IORedisCommandData, + RedisBatchData, + RedisCommandData, + RedisConnectData, + RedisDiagnosticChannelResponseHook, + RedisTracingChannel, + RedisTracingChannelContextWithSpan, + RedisTracingChannelFactory, + RedisTracingChannelSubscribers, +} from './integrations/redis/redis-dc-subscriber'; + 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/core/test/lib/integrations/redis/redis-dc-subscriber.test.ts b/packages/core/test/lib/integrations/redis/redis-dc-subscriber.test.ts new file mode 100644 index 000000000000..82a736caed23 --- /dev/null +++ b/packages/core/test/lib/integrations/redis/redis-dc-subscriber.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _resetRedisDiagnosticChannelsForTesting, + IOREDIS_DC_CHANNEL_COMMAND, + IOREDIS_DC_CHANNEL_CONNECT, + REDIS_DC_CHANNEL_BATCH, + REDIS_DC_CHANNEL_COMMAND, + REDIS_DC_CHANNEL_CONNECT, + subscribeRedisDiagnosticChannels, + type RedisTracingChannel, + type RedisTracingChannelFactory, + type RedisTracingChannelSubscribers, +} from '../../../../src/integrations/redis/redis-dc-subscriber'; +import { SPAN_STATUS_ERROR } from '../../../../src/tracing/spanstatus'; + +interface RecordedChannel { + subs: Partial>; +} + +// 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 { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + updateName: vi.fn(), + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id', traceFlags: 1 }), + }; +} + +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(factory, responseHook); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('node-redis command channel', () => { + describe('asyncEnd (success path)', () => { + it('calls the response hook with sliced args and ends the span', () => { + const data = { + command: 'GET', + args: ['GET', 'cache:key'], + result: 'hit-value', + _sentrySpan: mockSpan, + }; + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'GET', ['cache:key'], 'hit-value'); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('strips the command name from args before passing to the response hook', () => { + const data = { + command: 'MGET', + args: ['MGET', 'key1', 'key2', 'key3'], + result: ['v1', 'v2', 'v3'], + _sentrySpan: mockSpan, + }; + 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(REDIS_DC_CHANNEL_COMMAND).asyncEnd({ command: 'GET', args: ['GET', 'k'], result: 'v' }); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + 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(REDIS_DC_CHANNEL_COMMAND).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { + 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(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(REDIS_DC_CHANNEL_COMMAND).error({ command: 'GET', args: ['GET', 'k'], error: new Error('x') }); + + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + }); + + describe('node-redis batch channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { batchMode: 'PIPELINE', batchSize: 3, _sentrySpan: mockSpan }; + subs(REDIS_DC_CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(REDIS_DC_CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + 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(REDIS_DC_CHANNEL_BATCH).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'MULTI aborted' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('MULTI aborted'); + const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; + + subs(REDIS_DC_CHANNEL_BATCH).error(data); + subs(REDIS_DC_CHANNEL_BATCH).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('node-redis connect channel', () => { + describe('asyncEnd (success path)', () => { + it('ends the span', () => { + const data = { serverAddress: '127.0.0.1', serverPort: 6379, _sentrySpan: mockSpan }; + subs(REDIS_DC_CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('bails early when _sentrySpan is absent', () => { + subs(REDIS_DC_CHANNEL_CONNECT).asyncEnd({ serverAddress: '127.0.0.1' }); + + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('error path', () => { + 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(REDIS_DC_CHANNEL_CONNECT).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not end the span a second time in asyncEnd when error is set', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: '127.0.0.1', error, _sentrySpan: mockSpan }; + + subs(REDIS_DC_CHANNEL_CONNECT).error(data); + subs(REDIS_DC_CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('ioredis command channel', () => { + it('calls the response hook with args as published by ioredis (no slicing)', () => { + const data = { + command: 'get', + args: ['cache:key'], + result: 'hit-value', + _sentrySpan: mockSpan, + }; + subs(IOREDIS_DC_CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'get', ['cache:key'], 'hit-value'); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not slice the first arg for ioredis command payloads', () => { + const data = { + command: 'mget', + args: ['key1', 'key2', 'key3'], + result: ['v1', 'v2', 'v3'], + _sentrySpan: mockSpan, + }; + subs(IOREDIS_DC_CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'mget', ['key1', 'key2', 'key3'], ['v1', 'v2', 'v3']); + }); + + it('handles batch metadata on ioredis command payloads without a separate batch channel', () => { + const data = { + command: 'set', + args: ['cache:key', '?'], + batchMode: 'MULTI', + batchSize: 2, + result: 'OK', + _sentrySpan: mockSpan, + }; + subs(IOREDIS_DC_CHANNEL_COMMAND).asyncEnd(data); + + expect(channels['ioredis:batch']).toBeUndefined(); + expect(responseHook).toHaveBeenCalledWith(mockSpan, 'set', ['cache:key', '?'], 'OK'); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('sets error status and ends the span in the error handler', () => { + const error = new Error('WRONGTYPE'); + const data = { command: 'hset', args: ['key', 'field', '?'], error, _sentrySpan: mockSpan }; + subs(IOREDIS_DC_CHANNEL_COMMAND).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'WRONGTYPE' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { + const error = new Error('WRONGTYPE'); + const data = { command: 'hset', args: ['key', 'field', '?'], error, _sentrySpan: mockSpan }; + + subs(IOREDIS_DC_CHANNEL_COMMAND).error(data); + subs(IOREDIS_DC_CHANNEL_COMMAND).asyncEnd(data); + + expect(responseHook).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + + describe('ioredis connect channel', () => { + it('ends the span on success', () => { + const data = { serverAddress: 'localhost', serverPort: 6379, _sentrySpan: mockSpan }; + subs(IOREDIS_DC_CHANNEL_CONNECT).asyncEnd(data); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('sets error status and ends the span in the error handler', () => { + const error = new Error('connect ECONNREFUSED'); + const data = { serverAddress: 'localhost', serverPort: 1, error, _sentrySpan: mockSpan }; + subs(IOREDIS_DC_CHANNEL_CONNECT).error(data); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + }); + + 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(factory, secondHook); + + const data = { command: 'GET', args: ['GET', 'k'], result: 'v', _sentrySpan: mockSpan }; + subs(REDIS_DC_CHANNEL_COMMAND).asyncEnd(data); + + expect(secondHook).toHaveBeenCalledTimes(1); + expect(responseHook).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/deno/scripts/download-deno-types.mjs b/packages/deno/scripts/download-deno-types.mjs index 0d755099c7d4..96cd724fbf99 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.8.0/lib.deno.d.ts'); writeFileSync('lib.deno.d.ts', code); } diff --git a/packages/deno/src/denoVersion.ts b/packages/deno/src/denoVersion.ts index 8d2f0d1790a7..c455e0702fe7 100644 --- a/packages/deno/src/denoVersion.ts +++ b/packages/deno/src/denoVersion.ts @@ -20,3 +20,6 @@ export const HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED = gte(2, 7, 13); /** Whether `http.server.request.start` fires (Deno 2.8.0+). */ export const HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED = gte(2, 8, 0); + +/** Whether `node:diagnostics_channel.tracingChannel` exists (Deno 1.44.3+). */ +export const TRACING_CHANNEL_SUPPORTED = gte(1, 44, 3); 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..7bd57b5eaaf2 --- /dev/null +++ b/packages/deno/src/integrations/redis.ts @@ -0,0 +1,82 @@ +// * import so that loading this module doesn't error on Deno versions +// lacking `tracingChannel` (added in Deno 1.44.3). +// On older runtimes the integration becomes a no-op. +import * as dc 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 = dc.tracingChannel>(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() { + if (!dc.tracingChannel) { + return; + } + setAsyncLocalStorageAsyncContextStrategy(); + subscribeRedisDiagnosticChannels(portableTracingChannel, options.responseHook); + }, + }; +}) satisfies IntegrationFn; + +/** + * Creates spans for redis commands, batches, and connects under Deno via + * `node:diagnostics_channel`. Subscribes to both node-redis (>= 5.12.0) and + * ioredis (>= 5.11.0) channels — both libraries publish to dedicated channels + * once they're new enough; on older 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..b593a08ddad8 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -16,9 +16,14 @@ import { DenoClient } from './client'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; -import { HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED } from './denoVersion'; +import { + HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, + HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED, + TRACING_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 +52,8 @@ export function getDefaultIntegrations(_options: Options): Integration[] { ...(HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED || HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED ? [denoHttpIntegration()] : []), + // node:diagnostics_channel.tracingChannel exists on Deno 1.44.3+. + ...(TRACING_CHANNEL_SUPPORTED ? [denoRedisIntegration()] : []), contextLinesIntegration(), normalizePathsIntegration(), globalHandlersIntegration(), 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", diff --git a/packages/deno/test/deno-redis.test.ts b/packages/deno/test/deno-redis.test.ts new file mode 100644 index 000000000000..8ff9099f879c --- /dev/null +++ b/packages/deno/test/deno-redis.test.ts @@ -0,0 +1,172 @@ +// + +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'); +}); + +Deno.test('denoRedisIntegration: ioredis: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('ioredis:command'); + + // ioredis passes args without the command name prefix, unlike node-redis. + await startSpan({ name: 'parent', op: 'test' }, async () => { + await channel.tracePromise(() => Promise.resolve('hit-value'), { + command: 'get', + args: ['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); +}); diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index 2e5268c14c6f..5abf5f3feb78 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -9,7 +9,9 @@ import { spanToJSON, truncate, } from '@sentry/core'; +import { subscribeRedisDiagnosticChannels } from '@sentry/core/server'; import { generateInstrumentOnce } from '@sentry/node-core'; +import { tracingChannel as otelTracingChannel } from '@sentry/opentelemetry/tracing-channel'; import type { IORedisCommandArgs } from '../../../utils/redisCache'; import { calculateCacheItemSize, @@ -22,7 +24,6 @@ import { import type { IORedisResponseCustomAttributeFunction } from './vendored/types'; import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; import { RedisInstrumentation } from './vendored/redis-instrumentation'; -import { subscribeRedisDiagnosticChannels } from './redis-dc-subscriber'; interface RedisOptions { /** @@ -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 and ioredis >= 5.11.0 publish 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 927a4471b0d0..000000000000 --- a/packages/node/src/integrations/tracing/redis/redis-dc-subscriber.ts +++ /dev/null @@ -1,244 +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 and ioredis >= 5.11.0. -const CHANNEL_REDIS_COMMAND = 'node-redis:command'; -const CHANNEL_REDIS_BATCH = 'node-redis:batch'; -const CHANNEL_REDIS_CONNECT = 'node-redis:connect'; -const CHANNEL_IOREDIS_COMMAND = 'ioredis:command'; -const CHANNEL_IOREDIS_CONNECT = 'ioredis:connect'; - -const ORIGIN = 'auto.db.redis.diagnostic_channel'; - -interface RedisCommandData { - command: string; - args: Array; - database?: number; - serverAddress?: string; - serverPort?: number; - result?: unknown; - error?: Error; -} - -interface IORedisCommandData { - command: string; - args: string[]; - batchMode?: 'MULTI'; - batchSize?: number; - database?: number; - serverAddress?: string; - serverPort?: number; - result?: unknown; - error?: Error; -} - -interface RedisBatchData { - 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(CHANNEL_REDIS_COMMAND, data => data.args.slice(1)); - setupBatchChannel(CHANNEL_REDIS_BATCH, data => (data.batchMode === 'PIPELINE' ? 'PIPELINE' : 'MULTI')); - setupConnectChannel(CHANNEL_REDIS_CONNECT); - setupCommandChannel(CHANNEL_IOREDIS_COMMAND, data => data.args); - setupConnectChannel(CHANNEL_IOREDIS_CONNECT); - subscribed = true; - } catch { - // tracingChannel from @sentry/opentelemetry requires `node:diagnostics_channel`. - // On runtimes where it isn't available, fail closed. - } -} - -function setupCommandChannel( - channelName: string, - getCommandArgs: (data: T) => Array, -): void { - const channel = tracingChannel(channelName, data => { - const args = getCommandArgs(data); - const statement = safeSerialize(data.command, args); - 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; - runResponseHook(span, data.command, getCommandArgs(data), 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(channelName: string, getOperationName: (data: RedisBatchData) => string): void { - const channel = tracingChannel(channelName, data => { - return startSpanManual( - { - name: getOperationName(data), - 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(channelName: string): void { - const channel = tracingChannel(channelName, 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 }; diff --git a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts b/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts deleted file mode 100644 index aa6b5b51def8..000000000000 --- a/packages/node/test/integrations/tracing/redis/redis-dc-subscriber.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -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 { - _resetRedisDiagnosticChannelsForTesting, - subscribeRedisDiagnosticChannels, -} from '../../../../src/integrations/tracing/redis/redis-dc-subscriber'; - -const CHANNEL_COMMAND = 'node-redis:command'; -const CHANNEL_BATCH = 'node-redis:batch'; -const CHANNEL_CONNECT = 'node-redis:connect'; -const CHANNEL_IOREDIS_COMMAND = 'ioredis:command'; -const CHANNEL_IOREDIS_CONNECT = 'ioredis:connect'; - -const subs = (name: string) => - channels[name]?.subs as { - asyncEnd: (data: any) => void; - error: (data: any) => void; - }; - -function makeSpan() { - return { - end: vi.fn(), - setStatus: vi.fn(), - setAttribute: vi.fn(), - setAttributes: vi.fn(), - updateName: vi.fn(), - spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id', traceFlags: 1 }), - }; -} - -describe('redis-dc-subscriber', () => { - let mockSpan: ReturnType; - let responseHook: ReturnType; - - beforeEach(() => { - _resetRedisDiagnosticChannelsForTesting(); - mockSpan = makeSpan(); - responseHook = vi.fn(); - subscribeRedisDiagnosticChannels(responseHook); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('command channel', () => { - describe('asyncEnd (success path)', () => { - it('calls the response hook with sliced args and ends the span', () => { - const data = { - command: 'GET', - args: ['GET', 'cache:key'], - result: 'hit-value', - _sentrySpan: mockSpan, - }; - subs(CHANNEL_COMMAND).asyncEnd(data); - - expect(responseHook).toHaveBeenCalledWith(mockSpan, 'GET', ['cache:key'], 'hit-value'); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('strips the command name from args before passing to the response hook', () => { - const data = { - command: 'MGET', - args: ['MGET', 'key1', 'key2', 'key3'], - result: ['v1', 'v2', 'v3'], - _sentrySpan: mockSpan, - }; - subs(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' }); - - expect(responseHook).not.toHaveBeenCalled(); - expect(mockSpan.end).not.toHaveBeenCalled(); - }); - }); - - describe('error path', () => { - 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); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'ECONNREFUSED' }); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { - 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); - - 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') }); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - expect(mockSpan.end).not.toHaveBeenCalled(); - }); - }); - }); - - describe('batch channel', () => { - describe('asyncEnd (success path)', () => { - it('ends the span', () => { - const data = { batchMode: 'PIPELINE', batchSize: 3, _sentrySpan: mockSpan }; - subs(CHANNEL_BATCH).asyncEnd(data); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('bails early when _sentrySpan is absent', () => { - subs(CHANNEL_BATCH).asyncEnd({ batchMode: 'MULTI' }); - - expect(mockSpan.end).not.toHaveBeenCalled(); - }); - }); - - describe('error path', () => { - 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); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'MULTI aborted' }); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('does not end the span a second time in asyncEnd when error is set', () => { - const error = new Error('MULTI aborted'); - const data = { batchMode: 'MULTI', error, _sentrySpan: mockSpan }; - - subs(CHANNEL_BATCH).error(data); - subs(CHANNEL_BATCH).asyncEnd(data); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('connect channel', () => { - describe('asyncEnd (success path)', () => { - it('ends the span', () => { - const data = { serverAddress: '127.0.0.1', serverPort: 6379, _sentrySpan: mockSpan }; - subs(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' }); - - expect(mockSpan.end).not.toHaveBeenCalled(); - }); - }); - - describe('error path', () => { - 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); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('does not end the span a second time in asyncEnd when error is set', () => { - 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); - - 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. - const secondHook = vi.fn(); - subscribeRedisDiagnosticChannels(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); - - expect(secondHook).toHaveBeenCalledTimes(1); - expect(responseHook).not.toHaveBeenCalled(); - }); - }); - - describe('ioredis channels', () => { - describe('command channel', () => { - it('calls the response hook with args as published by ioredis', () => { - const data = { - command: 'get', - args: ['cache:key'], - result: 'hit-value', - _sentrySpan: mockSpan, - }; - subs(CHANNEL_IOREDIS_COMMAND).asyncEnd(data); - - expect(responseHook).toHaveBeenCalledWith(mockSpan, 'get', ['cache:key'], 'hit-value'); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('does not slice the first arg for ioredis command payloads', () => { - const data = { - command: 'mget', - args: ['key1', 'key2', 'key3'], - result: ['v1', 'v2', 'v3'], - _sentrySpan: mockSpan, - }; - subs(CHANNEL_IOREDIS_COMMAND).asyncEnd(data); - - expect(responseHook).toHaveBeenCalledWith(mockSpan, 'mget', ['key1', 'key2', 'key3'], ['v1', 'v2', 'v3']); - }); - - it('handles batch metadata on ioredis command payloads without a separate batch channel', () => { - const data = { - command: 'set', - args: ['cache:key', '?'], - batchMode: 'MULTI', - batchSize: 2, - result: 'OK', - _sentrySpan: mockSpan, - }; - subs(CHANNEL_IOREDIS_COMMAND).asyncEnd(data); - - expect(channels['ioredis:batch']).toBeUndefined(); - expect(responseHook).toHaveBeenCalledWith(mockSpan, 'set', ['cache:key', '?'], 'OK'); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('sets error status and ends the span in the error handler', () => { - const error = new Error('WRONGTYPE'); - const data = { command: 'hset', args: ['key', 'field', '?'], error, _sentrySpan: mockSpan }; - subs(CHANNEL_IOREDIS_COMMAND).error(data); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'WRONGTYPE' }); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('does not call the response hook or end the span a second time in asyncEnd when error is set', () => { - const error = new Error('WRONGTYPE'); - const data = { command: 'hset', args: ['key', 'field', '?'], error, _sentrySpan: mockSpan }; - - subs(CHANNEL_IOREDIS_COMMAND).error(data); - subs(CHANNEL_IOREDIS_COMMAND).asyncEnd(data); - - expect(responseHook).not.toHaveBeenCalled(); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - }); - - describe('connect channel', () => { - it('ends the span', () => { - const data = { serverAddress: 'localhost', serverPort: 6379, _sentrySpan: mockSpan }; - subs(CHANNEL_IOREDIS_CONNECT).asyncEnd(data); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('sets error status and ends the span in the error handler', () => { - const error = new Error('connect ECONNREFUSED'); - const data = { serverAddress: 'localhost', serverPort: 1, error, _sentrySpan: mockSpan }; - subs(CHANNEL_IOREDIS_CONNECT).error(data); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'connect ECONNREFUSED' }); - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - }); - }); -});