From 0b60ddf03e37690b432f76d9abe79e4424f4b9b5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 16:34:29 +0200 Subject: [PATCH 1/8] feat(server-utils)!: Emit low cardinality redis span names Redis reports no SQL statement, so there is no query summary to name its spans after. With span streaming they use the next conventions template that can be filled instead: the command paired with `{server.address}:{server.port}`, since redis has no collection or namespace to pair with. It falls back to `{db.system.name}` when the client was configured without a host. This keeps the serialized command, which carries the key and its arguments, out of the span name. It stays on `db.query.text`. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/ioredis-dc/instrument.mjs | 2 +- .../suites/tracing/ioredis-dc/test.ts | 130 ++++++- .../suites/tracing/redis-cache/test.ts | 348 ++++++++++++++++++ .../suites/tracing/redis-dc/instrument.mjs | 2 +- .../suites/tracing/redis-dc/test.ts | 129 ++++++- .../suites/tracing/redis/instrument.mjs | 2 +- .../suites/tracing/redis/test.ts | 91 ++++- .../src/integrations/redis/index.ts | 15 +- .../redis/ioredis-channel-subscriber.ts | 15 +- .../redis/ioredis-channel-subscriber.test.ts | 17 + 10 files changed, 742 insertions(+), 9 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/instrument.mjs index fef89b43c532..c0a1998369a5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', + traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, 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 39969e658748..3ec0e4a78840 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 @@ -1,4 +1,6 @@ -import { afterAll, expect } from 'vitest'; +import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose( @@ -103,5 +105,131 @@ describeWithDockerCompose( .completed(); }); }); + + // The same commands as above, asserted on the streamed span container. The native + // diagnostics_channel subscriber already names db spans `redis-{command}`, which is low + // cardinality, so span streaming does not change them. + describe('streamed', () => { + const ORIGIN = 'auto.db.redis.diagnostic_channel'; + const SEGMENT_NAME = 'Test Span IORedis 5.11 DC'; + const HOST = '127.0.0.1'; + const PORT = 6382; + + const streamAttribute = (value: unknown): { type: string; value: unknown } => ({ + type: Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value, + value, + }); + + // Streamed spans carry `{ type, value }` attribute pairs; the expectations below are written + // as plain values and wrapped here. + const streamAttributes = (values: Record): Record => + Object.fromEntries(Object.entries(values).map(([key, value]) => [key, streamAttribute(value)])); + + function streamedSpan(name: string, op: string, attributes: Record): unknown { + return { + name, + attributes: { + ...streamAttributes({ + 'db.system.name': 'redis', + 'sentry.environment': 'production', + 'sentry.op': op, + 'sentry.origin': ORIGIN, + 'sentry.release': '1.0', + 'sentry.sdk.name': 'sentry.javascript.node', + 'sentry.segment.name': SEGMENT_NAME, + 'server.address': HOST, + 'server.port': PORT, + [SENTRY_TRACE_LIFECYCLE]: 'stream', + ...attributes, + }), + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }; + } + + const PEER = { 'network.peer.address': HOST, 'network.peer.port': PORT }; + + createEsmAndCjsTests(__dirname, 'scenario-ioredis-5-11.mjs', 'instrument.mjs', (createTestRunner, test) => { + test( + 'creates streamed spans for ioredis v5.11 commands via diagnostics_channel', + { timeout: 75_000 }, + async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + // The connect span opens its own segment but shares the trace with the test span, + // so both segments arrive in the same container. + expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ + 'redis-connect', + SEGMENT_NAME, + ]); + + // ioredis' own handshake commands (`client SETINFO`, `info`) are emitted on the + // channel too, but belong to the connect segment — the test span's children are the + // commands the scenario issues. + const spans = container.items.filter( + item => !item.is_segment && item.attributes['sentry.segment.name']?.value === SEGMENT_NAME, + ); + + expect(spans).toEqual([ + streamedSpan('redis-set', 'db.query', { + 'db.operation.name': 'set', + 'db.query.text': 'set dc-test-key ?', + }), + streamedSpan('dc-cache:test-key', 'cache.put', { + ...PEER, + 'db.operation.name': 'set', + 'db.query.text': 'set dc-cache:test-key ?', + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 2, + }), + streamedSpan('dc-cache:test-key-ex', 'cache.put', { + ...PEER, + 'db.operation.name': 'set', + 'db.query.text': 'set dc-cache:test-key-ex ? ? ?', + 'cache.key': ['dc-cache:test-key-ex'], + 'cache.item_size': 2, + }), + streamedSpan('redis-get', 'db.query', { + 'db.operation.name': 'get', + 'db.query.text': 'get dc-test-key', + }), + streamedSpan('dc-cache:test-key', 'cache.get', { + ...PEER, + 'db.operation.name': 'get', + 'db.query.text': 'get dc-cache:test-key', + 'cache.key': ['dc-cache:test-key'], + 'cache.hit': true, + 'cache.item_size': 10, + }), + streamedSpan('dc-cache:unavailable-data', 'cache.get', { + ...PEER, + 'db.operation.name': 'get', + 'db.query.text': 'get dc-cache:unavailable-data', + 'cache.key': ['dc-cache:unavailable-data'], + 'cache.hit': false, + }), + streamedSpan('redis-mget', 'db.query', { + 'db.operation.name': 'mget', + 'db.query.text': 'mget ? ? ?', + }), + ]); + }, + }) + .start() + .completed(); + }, + ); + }); + }); }, ); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index 813ddae859b1..58ab440b8310 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -1,3 +1,5 @@ +import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; +import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; @@ -677,4 +679,350 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }); }); }); + + describe('streamed', () => { + // The blocks above assert the same commands as transactions. With span streaming, span names + // have to be low cardinality, so `db.query` spans drop the serialized statement from their + // name — it stays on `db.query.text` — and are named + // `{db.operation.name} {server.address}:{server.port}` instead. Cache spans are still named + // after the cache key by the cache hook, and batch spans keep their `MULTI`/`PIPELINE` name. + const streamAttribute = (value: unknown): { type: string; value: unknown } => ({ + type: Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value, + value, + }); + + // Streamed spans carry `{ type, value }` attribute pairs; the expectations below are written + // as plain values and wrapped here. + const streamAttributes = (values: Record): Record => + Object.fromEntries(Object.entries(values).map(([key, value]) => [key, streamAttribute(value)])); + + const commonAttributes = (segmentName: string): Record => ({ + ...streamAttributes({ + 'db.system.name': 'redis', + 'sentry.environment': 'production', + 'sentry.kind': 'client', + 'sentry.origin': redisOrigin, + 'sentry.release': '1.0', + 'sentry.sdk.name': 'sentry.javascript.node', + 'sentry.segment.name': segmentName, + [SENTRY_TRACE_LIFECYCLE]: 'stream', + }), + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + }); + + function streamedSpan({ + name, + op, + segmentName, + status = 'ok', + attributes, + }: { + name: string; + op: string; + segmentName: string; + status?: string; + attributes: Record; + }): unknown { + return { + name, + attributes: { + ...commonAttributes(segmentName), + ...streamAttributes({ 'sentry.op': op, ...attributes }), + }, + end_timestamp: expect.any(Number), + is_segment: false, + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status, + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }; + } + + const childSpans = (container: SerializedStreamedSpanContainer): SerializedStreamedSpanContainer['items'] => + container.items.filter(item => !item.is_segment); + + describe('ioredis', () => { + const segmentName = 'Test Span'; + const connection = { 'server.address': 'localhost', 'server.port': 6383 }; + const peer = { 'network.peer.address': 'localhost', 'network.peer.port': 6383 }; + + const span = (name: string, op: string, attributes: Record, status?: string): unknown => + streamedSpan({ name, op, segmentName, status, attributes: { ...connection, ...attributes } }); + + createEsmAndCjsTests(__dirname, 'scenario-ioredis.mjs', 'instrument-ioredis.mjs', (createTestRunner, test) => { + test('creates streamed db and cache spans (ioredis)', { timeout: 60_000 }, async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + expect(container.items.find(item => item.is_segment)?.name).toBe(segmentName); + + expect(childSpans(container)).toEqual([ + span('set localhost:6383', redisSpanOp, { + 'db.operation.name': 'set', + 'db.query.text': 'set test-key [1 other arguments]', + }), + span('ioredis-cache:test-key', 'cache.put', { + ...peer, + 'db.operation.name': 'set', + 'db.query.text': 'set ioredis-cache:test-key [1 other arguments]', + 'cache.key': ['ioredis-cache:test-key'], + 'cache.item_size': 2, + }), + span('ioredis-cache:test-key-set-EX', 'cache.put', { + ...peer, + 'db.operation.name': 'set', + 'db.query.text': 'set ioredis-cache:test-key-set-EX [3 other arguments]', + 'cache.key': ['ioredis-cache:test-key-set-EX'], + 'cache.item_size': 2, + }), + span('ioredis-cache:test-key-setex', 'cache.put', { + ...peer, + 'db.operation.name': 'setex', + 'db.query.text': 'setex ioredis-cache:test-key-setex [2 other arguments]', + 'cache.key': ['ioredis-cache:test-key-setex'], + 'cache.item_size': 2, + }), + span('get localhost:6383', redisSpanOp, { + 'db.operation.name': 'get', + 'db.query.text': 'get test-key', + }), + span('ioredis-cache:test-key', 'cache.get', { + ...peer, + 'db.operation.name': 'get', + 'db.query.text': 'get ioredis-cache:test-key', + 'cache.key': ['ioredis-cache:test-key'], + 'cache.hit': true, + 'cache.item_size': 10, + }), + span('ioredis-cache:unavailable-data', 'cache.get', { + ...peer, + 'db.operation.name': 'get', + 'db.query.text': 'get ioredis-cache:unavailable-data', + 'cache.key': ['ioredis-cache:unavailable-data'], + 'cache.hit': false, + }), + span('test-key, ioredis-cache:test-key, ioredis-cache:unavailable-data', 'cache.get', { + ...peer, + 'db.operation.name': 'mget', + 'db.query.text': 'mget [3 other arguments]', + 'cache.key': ['test-key', 'ioredis-cache:test-key', 'ioredis-cache:unavailable-data'], + 'cache.hit': true, + 'cache.item_size': 20, + }), + span('ioredis-cache:test-key', 'cache.remove', { + ...peer, + 'db.operation.name': 'del', + 'db.query.text': 'del ioredis-cache:test-key', + 'cache.key': ['ioredis-cache:test-key'], + }), + ]); + }, + }) + .start() + .completed(); + }); + }); + }); + + // node-redis v4 fills in `socket.host`, so its `db.query` spans get the + // `{db.operation.name} {server.address}:{server.port}` name. + describe('redis-4', () => { + const segmentName = 'Test Span Redis 4'; + const connection = { 'server.address': 'localhost', 'server.port': 6383 }; + const peer = { 'network.peer.address': 'localhost', 'network.peer.port': 6383 }; + + const span = (name: string, op: string, attributes: Record, status?: string): unknown => + streamedSpan({ name, op, segmentName, status, attributes: { ...connection, ...attributes } }); + + createEsmAndCjsTests(__dirname, 'scenario-redis-4.mjs', 'instrument-redis-4.mjs', (createTestRunner, test) => { + test('creates streamed db and cache spans (redis-4)', { timeout: 60_000 }, async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + // The connect span opens its own segment, but shares the trace with the test span, + // so both segments arrive in the same container. + expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ + 'redis-connect', + segmentName, + ]); + + expect(childSpans(container)).toEqual([ + span('SET localhost:6383', redisSpanOp, { + 'db.operation.name': 'SET', + 'db.query.text': 'SET redis-test-key [1 other arguments]', + }), + span('redis-cache:test-key', 'cache.put', { + ...peer, + 'db.operation.name': 'SET', + 'db.query.text': 'SET redis-cache:test-key [1 other arguments]', + 'cache.key': ['redis-cache:test-key'], + 'cache.item_size': 2, + }), + span('redis-cache:test-key-set-EX', 'cache.put', { + ...peer, + 'db.operation.name': 'SET', + 'db.query.text': 'SET redis-cache:test-key-set-EX [3 other arguments]', + 'cache.key': ['redis-cache:test-key-set-EX'], + 'cache.item_size': 2, + }), + span('redis-cache:test-key-setex', 'cache.put', { + ...peer, + 'db.operation.name': 'SETEX', + 'db.query.text': 'SETEX redis-cache:test-key-setex [2 other arguments]', + 'cache.key': ['redis-cache:test-key-setex'], + 'cache.item_size': 2, + }), + span('GET localhost:6383', redisSpanOp, { + 'db.operation.name': 'GET', + 'db.query.text': 'GET redis-test-key', + }), + span('redis-cache:test-key', 'cache.get', { + ...peer, + 'db.operation.name': 'GET', + 'db.query.text': 'GET redis-cache:test-key', + 'cache.key': ['redis-cache:test-key'], + 'cache.hit': true, + 'cache.item_size': 10, + }), + span('redis-cache:unavailable-data', 'cache.get', { + ...peer, + 'db.operation.name': 'GET', + 'db.query.text': 'GET redis-cache:unavailable-data', + 'cache.key': ['redis-cache:unavailable-data'], + 'cache.hit': false, + }), + span('redis-test-key, redis-cache:test-key, redis-cache:unavailable-data', 'cache.get', { + ...peer, + 'db.operation.name': 'MGET', + 'db.query.text': 'MGET [3 other arguments]', + 'cache.key': ['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data'], + 'cache.hit': true, + 'cache.item_size': 20, + }), + span('redis-cache:test-key', 'cache.remove', { + ...peer, + 'db.operation.name': 'DEL', + 'db.query.text': 'DEL redis-cache:test-key', + 'cache.key': ['redis-cache:test-key'], + }), + // Batch spans are named after the batch operation, which is already low cardinality. + span('MULTI', redisSpanOp, { 'db.operation.batch.size': 2 }), + span( + 'INCR localhost:6383', + redisSpanOp, + { + 'db.operation.name': 'INCR', + 'db.query.text': 'INCR redis-test-key', + 'error.type': 'Error', + 'sentry.status.message': 'ERR value is not an integer or out of range', + }, + 'error', + ), + ]); + }, + }) + .start() + .completed(); + }); + }); + }); + + // node-redis v5 leaves `socket.host` unset when only a port is passed, so there is no + // `server.address` to pair the operation with and the span name falls back to + // `{db.system.name}`. + describe('redis-5', () => { + const segmentName = 'Test Span Redis 5'; + const connection = { 'server.port': 6383 }; + + const span = (name: string, op: string, attributes: Record, status?: string): unknown => + streamedSpan({ name, op, segmentName, status, attributes: { ...connection, ...attributes } }); + + createEsmAndCjsTests(__dirname, 'scenario-redis-5.mjs', 'instrument-redis-5.mjs', (createTestRunner, test) => { + test('creates streamed db and cache spans (redis-5)', { timeout: 60_000 }, async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ + 'redis-connect', + segmentName, + ]); + + expect(childSpans(container)).toEqual([ + span('redis', redisSpanOp, { + 'db.operation.name': 'SET', + 'db.query.text': 'SET redis-5-test-key [1 other arguments]', + }), + span('redis-5-cache:test-key', 'cache.put', { + 'db.operation.name': 'SET', + 'db.query.text': 'SET redis-5-cache:test-key [1 other arguments]', + 'cache.key': ['redis-5-cache:test-key'], + 'cache.item_size': 2, + }), + span('redis-5-cache:test-key-set-EX', 'cache.put', { + 'db.operation.name': 'SET', + 'db.query.text': 'SET redis-5-cache:test-key-set-EX [3 other arguments]', + 'cache.key': ['redis-5-cache:test-key-set-EX'], + 'cache.item_size': 2, + }), + span('redis-5-cache:test-key-setex', 'cache.put', { + 'db.operation.name': 'SETEX', + 'db.query.text': 'SETEX redis-5-cache:test-key-setex [2 other arguments]', + 'cache.key': ['redis-5-cache:test-key-setex'], + 'cache.item_size': 2, + }), + span('redis', redisSpanOp, { + 'db.operation.name': 'GET', + 'db.query.text': 'GET redis-5-test-key', + }), + span('redis-5-cache:test-key', 'cache.get', { + 'db.operation.name': 'GET', + 'db.query.text': 'GET redis-5-cache:test-key', + 'cache.key': ['redis-5-cache:test-key'], + 'cache.hit': true, + 'cache.item_size': 10, + }), + span('redis-5-cache:unavailable-data', 'cache.get', { + 'db.operation.name': 'GET', + 'db.query.text': 'GET redis-5-cache:unavailable-data', + 'cache.key': ['redis-5-cache:unavailable-data'], + 'cache.hit': false, + }), + span('redis-5-test-key, redis-5-cache:test-key, redis-5-cache:unavailable-data', 'cache.get', { + 'db.operation.name': 'MGET', + 'db.query.text': 'MGET [3 other arguments]', + 'cache.key': ['redis-5-test-key', 'redis-5-cache:test-key', 'redis-5-cache:unavailable-data'], + 'cache.hit': true, + 'cache.item_size': 20, + }), + span('redis-5-cache:test-key', 'cache.remove', { + 'db.operation.name': 'DEL', + 'db.query.text': 'DEL redis-5-cache:test-key', + 'cache.key': ['redis-5-cache:test-key'], + }), + span('MULTI', redisSpanOp, { 'db.operation.batch.size': 2 }), + span( + 'redis', + redisSpanOp, + { + 'db.operation.name': 'INCR', + 'db.query.text': 'INCR redis-5-test-key', + 'error.type': 'Error', + 'sentry.status.message': 'ERR value is not an integer or out of range', + }, + 'error', + ), + ]); + }, + }) + .start() + .completed(); + }); + }); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs index fef89b43c532..c0a1998369a5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', + traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, 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 d97db9ae92fe..8c576abc69c8 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 @@ -1,4 +1,6 @@ -import { afterAll, expect } from 'vitest'; +import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose( @@ -111,5 +113,130 @@ describeWithDockerCompose( .completed(); }); }); + + // The same commands as above, asserted on the streamed span container. The native + // diagnostics_channel subscriber already names db spans `redis-{command}`, which is low + // cardinality, so span streaming does not change them. + describe('streamed', () => { + const ORIGIN = 'auto.db.redis.diagnostic_channel'; + const SEGMENT_NAME = 'Test Span Redis 5 DC'; + const HOST = '127.0.0.1'; + const PORT = 6381; + + const streamAttribute = (value: unknown): { type: string; value: unknown } => ({ + type: Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value, + value, + }); + + // Streamed spans carry `{ type, value }` attribute pairs; the expectations below are written + // as plain values and wrapped here. + const streamAttributes = (values: Record): Record => + Object.fromEntries(Object.entries(values).map(([key, value]) => [key, streamAttribute(value)])); + + function streamedSpan(name: string, op: string, attributes: Record): unknown { + return { + name, + attributes: { + ...streamAttributes({ + 'db.system.name': 'redis', + 'sentry.environment': 'production', + 'sentry.op': op, + 'sentry.origin': ORIGIN, + 'sentry.release': '1.0', + 'sentry.sdk.name': 'sentry.javascript.node', + 'sentry.segment.name': SEGMENT_NAME, + 'server.address': HOST, + 'server.port': PORT, + [SENTRY_TRACE_LIFECYCLE]: 'stream', + ...attributes, + }), + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }; + } + + const PEER = { 'network.peer.address': HOST, 'network.peer.port': PORT }; + + createEsmAndCjsTests(__dirname, 'scenario-redis-5-tracing.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('creates streamed spans for redis v5 commands via diagnostics_channel', { timeout: 60_000 }, async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + // The connect span opens its own segment but shares the trace with the test span, + // so both segments arrive in the same container. + expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ + 'redis-connect', + SEGMENT_NAME, + ]); + + const spans = container.items.filter( + item => !item.is_segment && item.attributes['sentry.segment.name']?.value === SEGMENT_NAME, + ); + + expect(spans).toEqual([ + streamedSpan('redis-SET', 'db.query', { + 'db.operation.name': 'SET', + 'db.query.text': 'SET dc-test-key ?', + }), + // cache SET: span name updated to the key by the cache hook + streamedSpan('dc-cache:test-key', 'cache.put', { + ...PEER, + 'db.operation.name': 'SET', + 'db.query.text': 'SET dc-cache:test-key ?', + 'cache.key': ['dc-cache:test-key'], + 'cache.item_size': 2, + }), + // cache SET with EX option: redis v5 sends SET key value EX 10 as the command + streamedSpan('dc-cache:test-key-ex', 'cache.put', { + ...PEER, + 'db.operation.name': 'SET', + 'db.query.text': 'SET dc-cache:test-key-ex ? ? ?', + 'cache.key': ['dc-cache:test-key-ex'], + 'cache.item_size': 2, + }), + streamedSpan('redis-GET', 'db.query', { + 'db.operation.name': 'GET', + 'db.query.text': 'GET dc-test-key', + }), + // cache GET (hit) + streamedSpan('dc-cache:test-key', 'cache.get', { + ...PEER, + 'db.operation.name': 'GET', + 'db.query.text': 'GET dc-cache:test-key', + 'cache.key': ['dc-cache:test-key'], + 'cache.hit': true, + 'cache.item_size': 10, + }), + // cache GET (miss) + streamedSpan('dc-cache:unavailable-data', 'cache.get', { + ...PEER, + 'db.operation.name': 'GET', + 'db.query.text': 'GET dc-cache:unavailable-data', + 'cache.key': ['dc-cache:unavailable-data'], + 'cache.hit': false, + }), + // MGET: node-redis sanitizes args for diagnostics_channel (keys become '?'), + // so cache detection cannot match prefixes — remains a plain db.query span. + streamedSpan('redis-MGET', 'db.query', { + 'db.operation.name': 'MGET', + 'db.query.text': 'MGET ? ? ?', + }), + ]); + }, + }) + .start() + .completed(); + }); + }); + }); }, ); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/redis/instrument.mjs index 170ad6f6a702..22bf57f14364 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', + traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, diff --git a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts index 90a625eb8adf..ee0d3cd6c9c3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts @@ -1,4 +1,6 @@ -import { afterAll, expect } from 'vitest'; +import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, type SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__dirname] }, () => { @@ -67,4 +69,91 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d }, ); }); + + describe('streamed', () => { + // The same three commands as above, asserted on the streamed span container. Only the span + // name differs: with span streaming names have to be low cardinality, so the serialized + // statement is reported through `db.query.text` alone and the name becomes + // `{db.operation.name} {server.address}:{server.port}`. + const COMMON_ATTRIBUTES = { + 'db.system.name': { type: 'string', value: 'redis' }, + 'server.address': { type: 'string', value: 'localhost' }, + 'server.port': { type: 'integer', value: 6380 }, + 'sentry.kind': { type: 'string', value: 'client' }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.op': { type: 'string', value: redisSpanOp }, + 'sentry.origin': { type: 'string', value: origin }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + 'sentry.segment.name': { type: 'string', value: 'Test Span' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + }; + + function expectedDbSpan({ + operation, + statement, + status = 'ok', + errorMessage, + }: { + operation: string; + statement: string; + status?: string; + errorMessage?: string; + }): unknown { + return { + attributes: { + ...COMMON_ATTRIBUTES, + 'db.operation.name': { type: 'string', value: operation }, + 'db.query.text': { type: 'string', value: statement }, + ...(errorMessage + ? { + 'error.type': { type: 'string', value: 'ReplyError' }, + 'sentry.status.message': { type: 'string', value: errorMessage }, + } + : {}), + }, + name: `${operation} localhost:6380`, + end_timestamp: expect.any(Number), + is_segment: false, + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status, + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }; + } + + createEsmAndCjsTests(__dirname, 'scenario-ioredis.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('should auto-instrument `ioredis` package with span streaming enabled', { timeout: 75_000 }, async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + const segmentSpan = container.items.find(item => item.is_segment); + expect(segmentSpan?.name).toBe('Test Span'); + + const dbSpans = container.items.filter( + item => item.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === redisSpanOp, + ); + + expect(dbSpans).toEqual([ + expectedDbSpan({ operation: 'set', statement: 'set test-key [1 other arguments]' }), + expectedDbSpan({ operation: 'get', statement: 'get test-key' }), + // a failing command produces a span with an error status + expectedDbSpan({ + operation: 'incr', + statement: 'incr test-key', + status: 'error', + errorMessage: 'ERR value is not an integer or out of range', + }), + ]); + }, + }) + .start() + .completed(); + }); + }); + }); }); diff --git a/packages/server-utils/src/integrations/redis/index.ts b/packages/server-utils/src/integrations/redis/index.ts index f7b5678318a5..e237a47d2574 100644 --- a/packages/server-utils/src/integrations/redis/index.ts +++ b/packages/server-utils/src/integrations/redis/index.ts @@ -15,6 +15,8 @@ import { isObjectLike, defineIntegration, getActiveSpan, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, @@ -101,8 +103,19 @@ function nodeRedisAttributes(options: NodeRedisClientOptions | undefined): SpanA function startCommandSpan(commandName: string, commandArgs: Array, attributes: SpanAttributes): Span { const dbStatement = defaultDbStatementSerializer(commandName, commandArgs); + const host = attributes[SERVER_ADDRESS]; + const port = attributes[SERVER_PORT]; + + const client = getClient(); + const name = + client && hasSpanStreamingEnabled(client) + ? host && port != null + ? `${commandName} ${host}:${port}` + : DB_SYSTEM_VALUE_REDIS + : dbStatement || `redis-${commandName}`; + return startInactiveSpan({ - name: dbStatement || `redis-${commandName}`, + name, attributes: { [SENTRY_KIND]: 'client', ...attributes, diff --git a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts index 2e25ad7c5370..9866514e352a 100644 --- a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts +++ b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts @@ -10,7 +10,7 @@ import { } from '@sentry/conventions/attributes'; import { DB_QUERY, DB } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import { getClient, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; import type { RedisCacheOptions } from './redis-cache'; @@ -76,8 +76,19 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und tracedCommands.add(command); const { host, port } = getConnectionOptions(data.self); const statement = defaultDbStatementSerializer(command.name, command.args ?? []); + const client = getClient(); + // The serialized statement carries command arguments, so with span streaming — where span names have + // to be low cardinality — `{db.operation.name} {server.address}:{server.port}` is used instead. + // Redis has no collection or namespace to pair with, so `{db.system.name}` is next. + const streamedName = + client && hasSpanStreamingEnabled(client) + ? host && port != null + ? `${command.name} ${host}:${port}` + : 'redis' + : undefined; + return startInactiveSpan({ - name: statement, + name: streamedName || statement, attributes: { [SENTRY_KIND]: 'client', ...connectionAttributes(host, port), diff --git a/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts b/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts index b0e21487e95f..e96434afe1a9 100644 --- a/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts +++ b/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts @@ -39,6 +39,23 @@ describe('startIORedisCommandSpan', () => { ); }); + it('names the span from the conventions with span streaming enabled', () => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as ReturnType); + + startIORedisCommandSpan(ctx({ name: 'set', args: ['test-key', 'test-value'] })); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + // `{db.operation.name} {server.address}:{server.port}` — redis has no collection or namespace + name: 'set localhost:6379', + // the serialized statement, which carries the key, is still reported as an attribute + attributes: expect.objectContaining({ 'db.query.text': 'set test-key [1 other arguments]' }), + }), + ); + }); + it('emits a single span when the same command is re-sent from the offline queue', () => { const command = { name: 'set', args: ['test-key', 'test-value'] }; From 46d70fc23c1a72b0d4bebd731033e02c94f329a1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 17:50:57 +0200 Subject: [PATCH 2/8] feat(server-utils): Name streamed redis spans after the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairing the command with `{server.address}:{server.port}` put a host and port in every redis span name, which says nothing about what ran. Redis has nothing low cardinality to pair the operation with, so the name is now the bare command, matching the span name OTel prescribes for redis. The key and its arguments stay on `db.query.text`. `db.namespace` is deliberately left out of the name: for redis it is the numeric database index, which OTel excludes from span names for that reason. `FCALL`/`FCALL_RO` are the exception, since they name a redis function — the one redis construct the conventions model as a stored procedure. Those spans report `db.stored_procedure.name` and pair it with the operation, unless the publishing library redacted the function name. Batch spans now report the `MULTI`/`PIPELINE` operation they were already named after, so their name follows from their attributes too. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/ioredis-dc/test.ts | 12 +++--- .../suites/tracing/redis-cache/test.ts | 31 ++++++------- .../suites/tracing/redis-dc/test.ts | 11 +++-- .../suites/tracing/redis/test.ts | 4 +- .../src/integrations/redis/index.ts | 21 +++------ .../redis/ioredis-channel-subscriber.ts | 15 ++----- .../integrations/redis/redis-dc-subscriber.ts | 9 +++- .../src/integrations/redis/redis-span-name.ts | 43 +++++++++++++++++++ .../redis/ioredis-channel-subscriber.test.ts | 42 ++++++++++++++++-- 9 files changed, 126 insertions(+), 62 deletions(-) create mode 100644 packages/server-utils/src/integrations/redis/redis-span-name.ts 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 3ec0e4a78840..798dd9b555d0 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 @@ -106,9 +106,9 @@ describeWithDockerCompose( }); }); - // The same commands as above, asserted on the streamed span container. The native - // diagnostics_channel subscriber already names db spans `redis-{command}`, which is low - // cardinality, so span streaming does not change them. + // The same commands as above, asserted on the streamed span container. With span streaming the + // db spans are named after `db.operation.name` (the bare command) instead of `redis-{command}`. + // ioredis reports its commands lowercase, so the name follows suit. describe('streamed', () => { const ORIGIN = 'auto.db.redis.diagnostic_channel'; const SEGMENT_NAME = 'Test Span IORedis 5.11 DC'; @@ -181,7 +181,7 @@ describeWithDockerCompose( ); expect(spans).toEqual([ - streamedSpan('redis-set', 'db.query', { + streamedSpan('set', 'db.query', { 'db.operation.name': 'set', 'db.query.text': 'set dc-test-key ?', }), @@ -199,7 +199,7 @@ describeWithDockerCompose( 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, }), - streamedSpan('redis-get', 'db.query', { + streamedSpan('get', 'db.query', { 'db.operation.name': 'get', 'db.query.text': 'get dc-test-key', }), @@ -218,7 +218,7 @@ describeWithDockerCompose( 'cache.key': ['dc-cache:unavailable-data'], 'cache.hit': false, }), - streamedSpan('redis-mget', 'db.query', { + streamedSpan('mget', 'db.query', { 'db.operation.name': 'mget', 'db.query.text': 'mget ? ? ?', }), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index 58ab440b8310..e796b01dd985 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -683,9 +683,9 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory describe('streamed', () => { // The blocks above assert the same commands as transactions. With span streaming, span names // have to be low cardinality, so `db.query` spans drop the serialized statement from their - // name — it stays on `db.query.text` — and are named - // `{db.operation.name} {server.address}:{server.port}` instead. Cache spans are still named - // after the cache key by the cache hook, and batch spans keep their `MULTI`/`PIPELINE` name. + // name — it stays on `db.query.text` — and are named after `db.operation.name` instead, the + // bare command. Cache spans are still named after the cache key by the cache hook, and batch + // spans keep their `MULTI`/`PIPELINE` name. const streamAttribute = (value: unknown): { type: string; value: unknown } => ({ type: Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value, value, @@ -760,7 +760,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory expect(container.items.find(item => item.is_segment)?.name).toBe(segmentName); expect(childSpans(container)).toEqual([ - span('set localhost:6383', redisSpanOp, { + span('set', redisSpanOp, { 'db.operation.name': 'set', 'db.query.text': 'set test-key [1 other arguments]', }), @@ -785,7 +785,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['ioredis-cache:test-key-setex'], 'cache.item_size': 2, }), - span('get localhost:6383', redisSpanOp, { + span('get', redisSpanOp, { 'db.operation.name': 'get', 'db.query.text': 'get test-key', }), @@ -827,8 +827,6 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }); }); - // node-redis v4 fills in `socket.host`, so its `db.query` spans get the - // `{db.operation.name} {server.address}:{server.port}` name. describe('redis-4', () => { const segmentName = 'Test Span Redis 4'; const connection = { 'server.address': 'localhost', 'server.port': 6383 }; @@ -851,7 +849,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]); expect(childSpans(container)).toEqual([ - span('SET localhost:6383', redisSpanOp, { + span('SET', redisSpanOp, { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-test-key [1 other arguments]', }), @@ -876,7 +874,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['redis-cache:test-key-setex'], 'cache.item_size': 2, }), - span('GET localhost:6383', redisSpanOp, { + span('GET', redisSpanOp, { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-test-key', }), @@ -910,9 +908,9 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['redis-cache:test-key'], }), // Batch spans are named after the batch operation, which is already low cardinality. - span('MULTI', redisSpanOp, { 'db.operation.batch.size': 2 }), + span('MULTI', redisSpanOp, { 'db.operation.name': 'MULTI', 'db.operation.batch.size': 2 }), span( - 'INCR localhost:6383', + 'INCR', redisSpanOp, { 'db.operation.name': 'INCR', @@ -931,9 +929,6 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }); }); - // node-redis v5 leaves `socket.host` unset when only a port is passed, so there is no - // `server.address` to pair the operation with and the span name falls back to - // `{db.system.name}`. describe('redis-5', () => { const segmentName = 'Test Span Redis 5'; const connection = { 'server.port': 6383 }; @@ -953,7 +948,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]); expect(childSpans(container)).toEqual([ - span('redis', redisSpanOp, { + span('SET', redisSpanOp, { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-5-test-key [1 other arguments]', }), @@ -975,7 +970,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['redis-5-cache:test-key-setex'], 'cache.item_size': 2, }), - span('redis', redisSpanOp, { + span('GET', redisSpanOp, { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-5-test-key', }), @@ -1004,9 +999,9 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'db.query.text': 'DEL redis-5-cache:test-key', 'cache.key': ['redis-5-cache:test-key'], }), - span('MULTI', redisSpanOp, { 'db.operation.batch.size': 2 }), + span('MULTI', redisSpanOp, { 'db.operation.name': 'MULTI', 'db.operation.batch.size': 2 }), span( - 'redis', + 'INCR', redisSpanOp, { 'db.operation.name': 'INCR', 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 8c576abc69c8..1e8ef9d87140 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 @@ -114,9 +114,8 @@ describeWithDockerCompose( }); }); - // The same commands as above, asserted on the streamed span container. The native - // diagnostics_channel subscriber already names db spans `redis-{command}`, which is low - // cardinality, so span streaming does not change them. + // The same commands as above, asserted on the streamed span container. With span streaming the + // db spans are named after `db.operation.name` (the bare command) instead of `redis-{command}`. describe('streamed', () => { const ORIGIN = 'auto.db.redis.diagnostic_channel'; const SEGMENT_NAME = 'Test Span Redis 5 DC'; @@ -183,7 +182,7 @@ describeWithDockerCompose( ); expect(spans).toEqual([ - streamedSpan('redis-SET', 'db.query', { + streamedSpan('SET', 'db.query', { 'db.operation.name': 'SET', 'db.query.text': 'SET dc-test-key ?', }), @@ -203,7 +202,7 @@ describeWithDockerCompose( 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, }), - streamedSpan('redis-GET', 'db.query', { + streamedSpan('GET', 'db.query', { 'db.operation.name': 'GET', 'db.query.text': 'GET dc-test-key', }), @@ -226,7 +225,7 @@ describeWithDockerCompose( }), // MGET: node-redis sanitizes args for diagnostics_channel (keys become '?'), // so cache detection cannot match prefixes — remains a plain db.query span. - streamedSpan('redis-MGET', 'db.query', { + streamedSpan('MGET', 'db.query', { 'db.operation.name': 'MGET', 'db.query.text': 'MGET ? ? ?', }), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts index ee0d3cd6c9c3..62082a525726 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts @@ -74,7 +74,7 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d // The same three commands as above, asserted on the streamed span container. Only the span // name differs: with span streaming names have to be low cardinality, so the serialized // statement is reported through `db.query.text` alone and the name becomes - // `{db.operation.name} {server.address}:{server.port}`. + // `{db.operation.name}` — for redis, the bare command. const COMMON_ATTRIBUTES = { 'db.system.name': { type: 'string', value: 'redis' }, 'server.address': { type: 'string', value: 'localhost' }, @@ -114,7 +114,7 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d } : {}), }, - name: `${operation} localhost:6380`, + name: operation, end_timestamp: expect.any(Number), is_segment: false, parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), diff --git a/packages/server-utils/src/integrations/redis/index.ts b/packages/server-utils/src/integrations/redis/index.ts index e237a47d2574..6ec5731d2dd4 100644 --- a/packages/server-utils/src/integrations/redis/index.ts +++ b/packages/server-utils/src/integrations/redis/index.ts @@ -15,8 +15,6 @@ import { isObjectLike, defineIntegration, getActiveSpan, - getClient, - hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, @@ -24,6 +22,7 @@ import { waitForTracingChannelBinding, } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; +import { getRedisQueryNaming } from './redis-span-name'; import { defaultDbStatementSerializer } from './redis-statement-serializer'; import type { RedisCacheOptions } from './redis-cache'; import { applyRedisCacheAttributes } from './redis-cache'; @@ -103,24 +102,16 @@ function nodeRedisAttributes(options: NodeRedisClientOptions | undefined): SpanA function startCommandSpan(commandName: string, commandArgs: Array, attributes: SpanAttributes): Span { const dbStatement = defaultDbStatementSerializer(commandName, commandArgs); - const host = attributes[SERVER_ADDRESS]; - const port = attributes[SERVER_PORT]; - - const client = getClient(); - const name = - client && hasSpanStreamingEnabled(client) - ? host && port != null - ? `${commandName} ${host}:${port}` - : DB_SYSTEM_VALUE_REDIS - : dbStatement || `redis-${commandName}`; + const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(commandName, commandArgs); return startInactiveSpan({ - name, + name: streamedName || dbStatement || `redis-${commandName}`, attributes: { [SENTRY_KIND]: 'client', ...attributes, [SENTRY_OP]: DB_QUERY, [DB_OPERATION_NAME]: commandName, + ...namingAttributes, [DB_QUERY_TEXT]: dbStatement, }, }); @@ -262,13 +253,15 @@ function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: Com const commands = data.arguments?.[0]; const size = Array.isArray(commands) ? commands.length : undefined; const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; + const operation = getOperation(data); return startInactiveSpan({ - name: getOperation(data), + name: operation, attributes: { [SENTRY_KIND]: 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SENTRY_OP]: DB_QUERY, [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, + [DB_OPERATION_NAME]: operation, ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), diff --git a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts index 9866514e352a..778c5142fd8e 100644 --- a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts +++ b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts @@ -10,11 +10,12 @@ import { } from '@sentry/conventions/attributes'; import { DB_QUERY, DB } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; -import { getClient, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; import type { RedisCacheOptions } from './redis-cache'; import { applyRedisCacheAttributes } from './redis-cache'; +import { getRedisQueryNaming } from './redis-span-name'; import { defaultDbStatementSerializer } from './redis-statement-serializer'; const ORIGIN = 'auto.db.redis'; @@ -76,16 +77,7 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und tracedCommands.add(command); const { host, port } = getConnectionOptions(data.self); const statement = defaultDbStatementSerializer(command.name, command.args ?? []); - const client = getClient(); - // The serialized statement carries command arguments, so with span streaming — where span names have - // to be low cardinality — `{db.operation.name} {server.address}:{server.port}` is used instead. - // Redis has no collection or namespace to pair with, so `{db.system.name}` is next. - const streamedName = - client && hasSpanStreamingEnabled(client) - ? host && port != null - ? `${command.name} ${host}:${port}` - : 'redis' - : undefined; + const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(command.name, command.args ?? []); return startInactiveSpan({ name: streamedName || statement, @@ -94,6 +86,7 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und ...connectionAttributes(host, port), [SENTRY_OP]: DB_QUERY, [DB_OPERATION_NAME]: command.name, + ...namingAttributes, [DB_QUERY_TEXT]: statement, }, }); diff --git a/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts b/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts index 830b95aa5e81..e182c61dcbc0 100644 --- a/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts @@ -13,6 +13,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/cor import { bindTracingChannelToSpan } from '../../tracing-channel'; import type { RedisCacheOptions } from './redis-cache'; import { applyRedisCacheAttributes } from './redis-cache'; +import { getRedisQueryNaming } from './redis-span-name'; // 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 @@ -141,13 +142,15 @@ function setupCommandChannel( // spaces to mirror the format the libraries themselves intend. const args = getCommandArgs(data); const statement = args.length ? `${data.command} ${args.join(' ')}` : data.command; + const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(data.command, args); return startInactiveSpan({ - name: `redis-${data.command}`, + name: streamedName || `redis-${data.command}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SENTRY_OP]: DB_QUERY, [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, [DB_OPERATION_NAME]: data.command, + ...namingAttributes, [DB_QUERY_TEXT]: statement, ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), @@ -169,12 +172,14 @@ function setupBatchChannel( getOperationName: (data: RedisBatchData) => string, ): void { bindTracingChannelToSpan(tracingChannel(channelName), data => { + const operation = getOperationName(data); return startInactiveSpan({ - name: getOperationName(data), + name: operation, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SENTRY_OP]: DB_QUERY, [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [DB_OPERATION_NAME]: operation, // should only include batch size greater than 1, // or else it isn't properly considered a "batch" ...(Number(data.batchSize) > 1 ? { [DB_OPERATION_BATCH_SIZE]: data.batchSize } : {}), diff --git a/packages/server-utils/src/integrations/redis/redis-span-name.ts b/packages/server-utils/src/integrations/redis/redis-span-name.ts new file mode 100644 index 000000000000..6f886079a6b3 --- /dev/null +++ b/packages/server-utils/src/integrations/redis/redis-span-name.ts @@ -0,0 +1,43 @@ +import { DB_STORED_PROCEDURE_NAME } from '@sentry/conventions/attributes'; +import type { SpanAttributes } from '@sentry/core'; +import { getClient, hasSpanStreamingEnabled } from '@sentry/core'; + +const DB_SYSTEM_VALUE_REDIS = 'redis'; + +// `FCALL`/`FCALL_RO` invoke a redis function by name as their first argument. Redis functions are +// the one construct the conventions' db naming templates can model for redis — everything else a +// command touches is a key, which is exactly the high cardinality the streamed name has to avoid. +const STORED_PROCEDURE_COMMANDS = ['fcall', 'fcall_ro']; + +function getStoredProcedureName(command: string, args: ReadonlyArray): string | undefined { + if (!STORED_PROCEDURE_COMMANDS.includes(command.toLowerCase())) { + return undefined; + } + const raw = args[0]; + const name = typeof raw === 'string' ? raw : Buffer.isBuffer(raw) ? raw.toString() : undefined; + // node-redis and ioredis redact arguments before publishing them on their channels. A redacted + // function name says nothing, so leave the attribute unset rather than emit `FCALL ?`. + return name && name !== '?' ? name : undefined; +} + +/** + * The conventions attributes that name a redis command span, and the name itself when span + * streaming is enabled (`undefined` otherwise, leaving the caller's existing name in place). + * + * `db.query.text` carries the key and its arguments, so it cannot name a streamed span. Redis has + * nothing to pair the operation with, so the name is the bare `{db.operation.name}` — matching the + * span name OTel prescribes for redis. `FCALL` additionally names the function it calls. + */ +export function getRedisQueryNaming( + command: string, + args: ReadonlyArray, +): { streamedName: string | undefined; attributes: SpanAttributes } { + const storedProcedure = getStoredProcedureName(command, args); + const name = storedProcedure ? `${command} ${storedProcedure}` : command; + const client = getClient(); + + return { + streamedName: client && hasSpanStreamingEnabled(client) ? name || DB_SYSTEM_VALUE_REDIS : undefined, + attributes: storedProcedure ? { [DB_STORED_PROCEDURE_NAME]: storedProcedure } : {}, + }; +} diff --git a/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts b/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts index e96434afe1a9..f2fc3a40d7ce 100644 --- a/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts +++ b/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts @@ -48,10 +48,46 @@ describe('startIORedisCommandSpan', () => { expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ - // `{db.operation.name} {server.address}:{server.port}` — redis has no collection or namespace - name: 'set localhost:6379', + // `{db.operation.name}` — redis has nothing low cardinality to pair the operation with + name: 'set', // the serialized statement, which carries the key, is still reported as an attribute - attributes: expect.objectContaining({ 'db.query.text': 'set test-key [1 other arguments]' }), + attributes: expect.objectContaining({ + 'db.query.text': 'set test-key [1 other arguments]', + }), + }), + ); + }); + + it('names the span after the redis function it calls with span streaming enabled', () => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as ReturnType); + + startIORedisCommandSpan(ctx({ name: 'fcall', args: ['my_func', '1', 'test-key'] })); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + // `{db.operation.name} {db.stored_procedure.name}` — a redis function is named, so unlike + // an ordinary command it has a low cardinality second token to pair with + name: 'fcall my_func', + attributes: expect.objectContaining({ + 'db.stored_procedure.name': 'my_func', + }), + }), + ); + }); + + it('leaves the stored procedure unset when the function name was redacted', () => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as ReturnType); + + startIORedisCommandSpan(ctx({ name: 'fcall', args: ['?', '1', 'test-key'] })); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'fcall', + attributes: expect.not.objectContaining({ 'db.stored_procedure.name': expect.anything() }), }), ); }); From 87ef8925323459262570024b412633b67b4a9f9e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 31 Aug 2026 15:52:40 +0200 Subject: [PATCH 3/8] feat(server-utils): Pair streamed redis span names with the connection Naming streamed redis spans after the bare command dropped the only target the conventions can fill for redis, leaving `SET` to say nothing about where the command went. Pair the operation with `{server.address}:{server.port}` again, falling back to `{db.system.name}` when the client was configured without a host. The native diagnostics_channel subscriber gets the same name, built from the `serverAddress`/`serverPort` its payload already carries. `FCALL`/`FCALL_RO` keep naming the redis function they call: the conventions rank `db.stored_procedure.name` ahead of the connection. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/ioredis-dc/test.ts | 10 +++---- .../suites/tracing/redis-cache/test.ts | 27 ++++++++++-------- .../suites/tracing/redis-dc/test.ts | 9 +++--- .../suites/tracing/redis/test.ts | 4 +-- .../src/integrations/redis/index.ts | 5 +++- .../redis/ioredis-channel-subscriber.ts | 5 +++- .../integrations/redis/redis-dc-subscriber.ts | 5 +++- .../src/integrations/redis/redis-span-name.ts | 28 ++++++++++++++++--- .../redis/ioredis-channel-subscriber.test.ts | 28 ++++++++++++++----- 9 files changed, 85 insertions(+), 36 deletions(-) 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 798dd9b555d0..603639438674 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 @@ -107,8 +107,8 @@ describeWithDockerCompose( }); // The same commands as above, asserted on the streamed span container. With span streaming the - // db spans are named after `db.operation.name` (the bare command) instead of `redis-{command}`. - // ioredis reports its commands lowercase, so the name follows suit. + // db spans are named `{db.operation.name} {server.address}:{server.port}` instead of + // `redis-{command}`. ioredis reports its commands lowercase, so the name follows suit. describe('streamed', () => { const ORIGIN = 'auto.db.redis.diagnostic_channel'; const SEGMENT_NAME = 'Test Span IORedis 5.11 DC'; @@ -181,7 +181,7 @@ describeWithDockerCompose( ); expect(spans).toEqual([ - streamedSpan('set', 'db.query', { + streamedSpan(`set ${HOST}:${PORT}`, 'db.query', { 'db.operation.name': 'set', 'db.query.text': 'set dc-test-key ?', }), @@ -199,7 +199,7 @@ describeWithDockerCompose( 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, }), - streamedSpan('get', 'db.query', { + streamedSpan(`get ${HOST}:${PORT}`, 'db.query', { 'db.operation.name': 'get', 'db.query.text': 'get dc-test-key', }), @@ -218,7 +218,7 @@ describeWithDockerCompose( 'cache.key': ['dc-cache:unavailable-data'], 'cache.hit': false, }), - streamedSpan('mget', 'db.query', { + streamedSpan(`mget ${HOST}:${PORT}`, 'db.query', { 'db.operation.name': 'mget', 'db.query.text': 'mget ? ? ?', }), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index e796b01dd985..061ca7b7acb5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -683,9 +683,9 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory describe('streamed', () => { // The blocks above assert the same commands as transactions. With span streaming, span names // have to be low cardinality, so `db.query` spans drop the serialized statement from their - // name — it stays on `db.query.text` — and are named after `db.operation.name` instead, the - // bare command. Cache spans are still named after the cache key by the cache hook, and batch - // spans keep their `MULTI`/`PIPELINE` name. + // name — it stays on `db.query.text` — and are named + // `{db.operation.name} {server.address}:{server.port}` instead. Cache spans are still named + // after the cache key by the cache hook, and batch spans keep their `MULTI`/`PIPELINE` name. const streamAttribute = (value: unknown): { type: string; value: unknown } => ({ type: Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value, value, @@ -760,7 +760,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory expect(container.items.find(item => item.is_segment)?.name).toBe(segmentName); expect(childSpans(container)).toEqual([ - span('set', redisSpanOp, { + span('set localhost:6383', redisSpanOp, { 'db.operation.name': 'set', 'db.query.text': 'set test-key [1 other arguments]', }), @@ -785,7 +785,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['ioredis-cache:test-key-setex'], 'cache.item_size': 2, }), - span('get', redisSpanOp, { + span('get localhost:6383', redisSpanOp, { 'db.operation.name': 'get', 'db.query.text': 'get test-key', }), @@ -827,6 +827,8 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }); }); + // node-redis v4 fills in `socket.host`, so its `db.query` spans get the + // `{db.operation.name} {server.address}:{server.port}` name. describe('redis-4', () => { const segmentName = 'Test Span Redis 4'; const connection = { 'server.address': 'localhost', 'server.port': 6383 }; @@ -849,7 +851,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]); expect(childSpans(container)).toEqual([ - span('SET', redisSpanOp, { + span('SET localhost:6383', redisSpanOp, { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-test-key [1 other arguments]', }), @@ -874,7 +876,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['redis-cache:test-key-setex'], 'cache.item_size': 2, }), - span('GET', redisSpanOp, { + span('GET localhost:6383', redisSpanOp, { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-test-key', }), @@ -910,7 +912,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory // Batch spans are named after the batch operation, which is already low cardinality. span('MULTI', redisSpanOp, { 'db.operation.name': 'MULTI', 'db.operation.batch.size': 2 }), span( - 'INCR', + 'INCR localhost:6383', redisSpanOp, { 'db.operation.name': 'INCR', @@ -929,6 +931,9 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }); }); + // node-redis v5 leaves `socket.host` unset when only a port is passed, so there is no + // `server.address` to pair the operation with and the span name falls back to + // `{db.system.name}`. describe('redis-5', () => { const segmentName = 'Test Span Redis 5'; const connection = { 'server.port': 6383 }; @@ -948,7 +953,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]); expect(childSpans(container)).toEqual([ - span('SET', redisSpanOp, { + span('redis', redisSpanOp, { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-5-test-key [1 other arguments]', }), @@ -970,7 +975,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'cache.key': ['redis-5-cache:test-key-setex'], 'cache.item_size': 2, }), - span('GET', redisSpanOp, { + span('redis', redisSpanOp, { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-5-test-key', }), @@ -1001,7 +1006,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }), span('MULTI', redisSpanOp, { 'db.operation.name': 'MULTI', 'db.operation.batch.size': 2 }), span( - 'INCR', + 'redis', redisSpanOp, { 'db.operation.name': 'INCR', 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 1e8ef9d87140..97ba086cfd14 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 @@ -115,7 +115,8 @@ describeWithDockerCompose( }); // The same commands as above, asserted on the streamed span container. With span streaming the - // db spans are named after `db.operation.name` (the bare command) instead of `redis-{command}`. + // db spans are named `{db.operation.name} {server.address}:{server.port}` instead of + // `redis-{command}`. describe('streamed', () => { const ORIGIN = 'auto.db.redis.diagnostic_channel'; const SEGMENT_NAME = 'Test Span Redis 5 DC'; @@ -182,7 +183,7 @@ describeWithDockerCompose( ); expect(spans).toEqual([ - streamedSpan('SET', 'db.query', { + streamedSpan(`SET ${HOST}:${PORT}`, 'db.query', { 'db.operation.name': 'SET', 'db.query.text': 'SET dc-test-key ?', }), @@ -202,7 +203,7 @@ describeWithDockerCompose( 'cache.key': ['dc-cache:test-key-ex'], 'cache.item_size': 2, }), - streamedSpan('GET', 'db.query', { + streamedSpan(`GET ${HOST}:${PORT}`, 'db.query', { 'db.operation.name': 'GET', 'db.query.text': 'GET dc-test-key', }), @@ -225,7 +226,7 @@ describeWithDockerCompose( }), // MGET: node-redis sanitizes args for diagnostics_channel (keys become '?'), // so cache detection cannot match prefixes — remains a plain db.query span. - streamedSpan('MGET', 'db.query', { + streamedSpan(`MGET ${HOST}:${PORT}`, 'db.query', { 'db.operation.name': 'MGET', 'db.query.text': 'MGET ? ? ?', }), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts index 62082a525726..ee0d3cd6c9c3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts @@ -74,7 +74,7 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d // The same three commands as above, asserted on the streamed span container. Only the span // name differs: with span streaming names have to be low cardinality, so the serialized // statement is reported through `db.query.text` alone and the name becomes - // `{db.operation.name}` — for redis, the bare command. + // `{db.operation.name} {server.address}:{server.port}`. const COMMON_ATTRIBUTES = { 'db.system.name': { type: 'string', value: 'redis' }, 'server.address': { type: 'string', value: 'localhost' }, @@ -114,7 +114,7 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d } : {}), }, - name: operation, + name: `${operation} localhost:6380`, end_timestamp: expect.any(Number), is_segment: false, parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), diff --git a/packages/server-utils/src/integrations/redis/index.ts b/packages/server-utils/src/integrations/redis/index.ts index 6ec5731d2dd4..de2ed100d6f7 100644 --- a/packages/server-utils/src/integrations/redis/index.ts +++ b/packages/server-utils/src/integrations/redis/index.ts @@ -102,7 +102,10 @@ function nodeRedisAttributes(options: NodeRedisClientOptions | undefined): SpanA function startCommandSpan(commandName: string, commandArgs: Array, attributes: SpanAttributes): Span { const dbStatement = defaultDbStatementSerializer(commandName, commandArgs); - const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(commandName, commandArgs); + const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(commandName, commandArgs, { + host: attributes[SERVER_ADDRESS], + port: attributes[SERVER_PORT], + }); return startInactiveSpan({ name: streamedName || dbStatement || `redis-${commandName}`, diff --git a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts index 778c5142fd8e..4f1b08889b75 100644 --- a/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts +++ b/packages/server-utils/src/integrations/redis/ioredis-channel-subscriber.ts @@ -77,7 +77,10 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und tracedCommands.add(command); const { host, port } = getConnectionOptions(data.self); const statement = defaultDbStatementSerializer(command.name, command.args ?? []); - const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(command.name, command.args ?? []); + const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(command.name, command.args ?? [], { + host, + port, + }); return startInactiveSpan({ name: streamedName || statement, diff --git a/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts b/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts index e182c61dcbc0..8ff655078f3c 100644 --- a/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/redis/redis-dc-subscriber.ts @@ -142,7 +142,10 @@ function setupCommandChannel( // spaces to mirror the format the libraries themselves intend. const args = getCommandArgs(data); const statement = args.length ? `${data.command} ${args.join(' ')}` : data.command; - const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(data.command, args); + const { streamedName, attributes: namingAttributes } = getRedisQueryNaming(data.command, args, { + host: data.serverAddress, + port: data.serverPort, + }); return startInactiveSpan({ name: streamedName || `redis-${data.command}`, attributes: { diff --git a/packages/server-utils/src/integrations/redis/redis-span-name.ts b/packages/server-utils/src/integrations/redis/redis-span-name.ts index 6f886079a6b3..ec3fbdaae747 100644 --- a/packages/server-utils/src/integrations/redis/redis-span-name.ts +++ b/packages/server-utils/src/integrations/redis/redis-span-name.ts @@ -9,6 +9,13 @@ const DB_SYSTEM_VALUE_REDIS = 'redis'; // command touches is a key, which is exactly the high cardinality the streamed name has to avoid. const STORED_PROCEDURE_COMMANDS = ['fcall', 'fcall_ro']; +// The connection a command was sent over. Untyped values because call sites read them off a client, +// a diagnostics_channel payload or an already-built span attribute bag. +interface RedisConnection { + host?: unknown; + port?: unknown; +} + function getStoredProcedureName(command: string, args: ReadonlyArray): string | undefined { if (!STORED_PROCEDURE_COMMANDS.includes(command.toLowerCase())) { return undefined; @@ -20,24 +27,37 @@ function getStoredProcedureName(command: string, args: ReadonlyArray): return name && name !== '?' ? name : undefined; } +// The template needs both halves, so a client configured with only a port has no target. +function getServerTarget({ host, port }: RedisConnection): string | undefined { + const hasPort = typeof port === 'number' || (typeof port === 'string' && !!port); + return typeof host === 'string' && host && hasPort ? `${host}:${port}` : undefined; +} + /** * The conventions attributes that name a redis command span, and the name itself when span * streaming is enabled (`undefined` otherwise, leaving the caller's existing name in place). * * `db.query.text` carries the key and its arguments, so it cannot name a streamed span. Redis has - * nothing to pair the operation with, so the name is the bare `{db.operation.name}` — matching the - * span name OTel prescribes for redis. `FCALL` additionally names the function it calls. + * no collection to pair the operation with, so the name is + * `{db.operation.name} {server.address}:{server.port}`, falling back to `{db.system.name}` when the + * client was configured without a host. `FCALL` names a redis function, which the conventions model + * as a stored procedure and rank ahead of the connection. + * + * `db.namespace` is deliberately not a target: for redis it is the numeric database index, which + * says nothing about what the command did. */ export function getRedisQueryNaming( command: string, args: ReadonlyArray, + connection: RedisConnection, ): { streamedName: string | undefined; attributes: SpanAttributes } { const storedProcedure = getStoredProcedureName(command, args); - const name = storedProcedure ? `${command} ${storedProcedure}` : command; + const target = storedProcedure || getServerTarget(connection); + const name = command && target ? `${command} ${target}` : target || DB_SYSTEM_VALUE_REDIS; const client = getClient(); return { - streamedName: client && hasSpanStreamingEnabled(client) ? name || DB_SYSTEM_VALUE_REDIS : undefined, + streamedName: client && hasSpanStreamingEnabled(client) ? name : undefined, attributes: storedProcedure ? { [DB_STORED_PROCEDURE_NAME]: storedProcedure } : {}, }; } diff --git a/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts b/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts index f2fc3a40d7ce..4ed2fb440ec4 100644 --- a/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts +++ b/packages/server-utils/test/integrations/redis/ioredis-channel-subscriber.test.ts @@ -5,8 +5,11 @@ import { startIORedisCommandSpan } from '../../../src/integrations/redis/ioredis const CONNECTION = { host: 'localhost', port: 6379 }; -function ctx(command: unknown): { arguments: unknown[]; self: { options: typeof CONNECTION } } { - return { arguments: [command], self: { options: CONNECTION } }; +function ctx( + command: unknown, + connection: { host?: string; port?: number } = CONNECTION, +): { arguments: unknown[]; self: { options: { host?: string; port?: number } } } { + return { arguments: [command], self: { options: connection } }; } describe('startIORedisCommandSpan', () => { @@ -48,8 +51,8 @@ describe('startIORedisCommandSpan', () => { expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ - // `{db.operation.name}` — redis has nothing low cardinality to pair the operation with - name: 'set', + // `{db.operation.name} {server.address}:{server.port}` — redis has no collection or namespace + name: 'set localhost:6379', // the serialized statement, which carries the key, is still reported as an attribute attributes: expect.objectContaining({ 'db.query.text': 'set test-key [1 other arguments]', @@ -67,8 +70,8 @@ describe('startIORedisCommandSpan', () => { expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ - // `{db.operation.name} {db.stored_procedure.name}` — a redis function is named, so unlike - // an ordinary command it has a low cardinality second token to pair with + // `{db.operation.name} {db.stored_procedure.name}` — the conventions rank the stored + // procedure ahead of the connection, so it wins over `{server.address}:{server.port}` name: 'fcall my_func', attributes: expect.objectContaining({ 'db.stored_procedure.name': 'my_func', @@ -86,12 +89,23 @@ describe('startIORedisCommandSpan', () => { expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ - name: 'fcall', + name: 'fcall localhost:6379', attributes: expect.not.objectContaining({ 'db.stored_procedure.name': expect.anything() }), }), ); }); + it('falls back to the db system name when the client has no host', () => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as ReturnType); + + startIORedisCommandSpan(ctx({ name: 'set', args: ['test-key', 'test-value'] }, { port: 6379 })); + + // `{db.system.name}` — the address/port template needs both halves + expect(startInactiveSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'redis' })); + }); + it('emits a single span when the same command is re-sent from the offline queue', () => { const command = { name: 'set', args: ['test-key', 'test-value'] }; From a91f403b40321500d2b10835470e1c714a3e79e0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 13:27:55 +0200 Subject: [PATCH 4/8] test: Follow the low-cardinality `cache.*` span names in the redis DC suites The streamed expectations these suites gained were written before #23830 landed, so they still expected cache spans to be named after the cache key and carried no `cache.operation` attribute. Name them after the cache operation the hook now uses, and fold the repeated peer/operation attributes into a `cacheSpan` helper so the two cannot drift apart again. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/ioredis-dc/test.ts | 19 ++++++++++------- .../suites/tracing/redis-dc/test.ts | 21 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) 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 603639438674..3bdcaea303be 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 @@ -157,6 +157,13 @@ describeWithDockerCompose( const PEER = { 'network.peer.address': HOST, 'network.peer.port': PORT }; + // A cache span is a db span the cache hook took over: it is renamed to its cache operation + // and reports the connection it inherited as peer attributes too. + const cacheSpan = ( + op: 'cache.get' | 'cache.put' | 'cache.remove', + attributes: Record, + ): unknown => streamedSpan(op, op, { ...PEER, 'cache.operation': op.slice('cache.'.length), ...attributes }); + createEsmAndCjsTests(__dirname, 'scenario-ioredis-5-11.mjs', 'instrument.mjs', (createTestRunner, test) => { test( 'creates streamed spans for ioredis v5.11 commands via diagnostics_channel', @@ -185,15 +192,13 @@ describeWithDockerCompose( 'db.operation.name': 'set', 'db.query.text': 'set dc-test-key ?', }), - streamedSpan('dc-cache:test-key', 'cache.put', { - ...PEER, + cacheSpan('cache.put', { 'db.operation.name': 'set', 'db.query.text': 'set dc-cache:test-key ?', 'cache.key': ['dc-cache:test-key'], 'cache.item_size': 2, }), - streamedSpan('dc-cache:test-key-ex', 'cache.put', { - ...PEER, + cacheSpan('cache.put', { 'db.operation.name': 'set', 'db.query.text': 'set dc-cache:test-key-ex ? ? ?', 'cache.key': ['dc-cache:test-key-ex'], @@ -203,16 +208,14 @@ describeWithDockerCompose( 'db.operation.name': 'get', 'db.query.text': 'get dc-test-key', }), - streamedSpan('dc-cache:test-key', 'cache.get', { - ...PEER, + cacheSpan('cache.get', { 'db.operation.name': 'get', 'db.query.text': 'get dc-cache:test-key', 'cache.key': ['dc-cache:test-key'], 'cache.hit': true, 'cache.item_size': 10, }), - streamedSpan('dc-cache:unavailable-data', 'cache.get', { - ...PEER, + cacheSpan('cache.get', { 'db.operation.name': 'get', 'db.query.text': 'get dc-cache:unavailable-data', 'cache.key': ['dc-cache:unavailable-data'], 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 97ba086cfd14..a02326b0a1cf 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 @@ -165,6 +165,13 @@ describeWithDockerCompose( const PEER = { 'network.peer.address': HOST, 'network.peer.port': PORT }; + // A cache span is a db span the cache hook took over: it is renamed to its cache operation + // and reports the connection it inherited as peer attributes too. + const cacheSpan = ( + op: 'cache.get' | 'cache.put' | 'cache.remove', + attributes: Record, + ): unknown => streamedSpan(op, op, { ...PEER, 'cache.operation': op.slice('cache.'.length), ...attributes }); + createEsmAndCjsTests(__dirname, 'scenario-redis-5-tracing.mjs', 'instrument.mjs', (createTestRunner, test) => { test('creates streamed spans for redis v5 commands via diagnostics_channel', { timeout: 60_000 }, async () => { await createTestRunner() @@ -187,17 +194,15 @@ describeWithDockerCompose( 'db.operation.name': 'SET', 'db.query.text': 'SET dc-test-key ?', }), - // cache SET: span name updated to the key by the cache hook - streamedSpan('dc-cache:test-key', 'cache.put', { - ...PEER, + // cache SET: turned into a cache span, and renamed by the cache hook + cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET dc-cache:test-key ?', 'cache.key': ['dc-cache:test-key'], 'cache.item_size': 2, }), // cache SET with EX option: redis v5 sends SET key value EX 10 as the command - streamedSpan('dc-cache:test-key-ex', 'cache.put', { - ...PEER, + cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET dc-cache:test-key-ex ? ? ?', 'cache.key': ['dc-cache:test-key-ex'], @@ -208,8 +213,7 @@ describeWithDockerCompose( 'db.query.text': 'GET dc-test-key', }), // cache GET (hit) - streamedSpan('dc-cache:test-key', 'cache.get', { - ...PEER, + cacheSpan('cache.get', { 'db.operation.name': 'GET', 'db.query.text': 'GET dc-cache:test-key', 'cache.key': ['dc-cache:test-key'], @@ -217,8 +221,7 @@ describeWithDockerCompose( 'cache.item_size': 10, }), // cache GET (miss) - streamedSpan('dc-cache:unavailable-data', 'cache.get', { - ...PEER, + cacheSpan('cache.get', { 'db.operation.name': 'GET', 'db.query.text': 'GET dc-cache:unavailable-data', 'cache.key': ['dc-cache:unavailable-data'], From bd92171fa23d2528cbb95d4d8735a27e432a3f4c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 13:28:07 +0200 Subject: [PATCH 5/8] feat(server-utils): Report node-redis' own connection defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-redis v4 writes its `localhost:6379` socket defaults back into `client.options`, v5 does not. Reading the options as-is therefore reported `server.address`/`server.port` for a v4 client and neither for the identically configured v5 one — so the same client got `SET localhost:6383` on v4 and the bare `redis` fallback on v5 once span names became low cardinality. Resolve the connection the way node-redis >= 5.12 reports it on its own diagnostics channel instead: a TCP client falls back to `localhost:6379`, and a unix socket reports its path as the address and no port. Batch spans go through the same helper rather than repeating the option lookup. The redis-cache suite picks up the low-cardinality cache names from #23830 in the same pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/redis-cache/test.ts | 96 ++++++++++--------- .../src/integrations/redis/index.ts | 21 ++-- 2 files changed, 65 insertions(+), 52 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index 061ca7b7acb5..8415898360ee 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -684,8 +684,8 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory // The blocks above assert the same commands as transactions. With span streaming, span names // have to be low cardinality, so `db.query` spans drop the serialized statement from their // name — it stays on `db.query.text` — and are named - // `{db.operation.name} {server.address}:{server.port}` instead. Cache spans are still named - // after the cache key by the cache hook, and batch spans keep their `MULTI`/`PIPELINE` name. + // `{db.operation.name} {server.address}:{server.port}` instead. Cache spans are named after + // their cache operation, and batch spans keep their `MULTI`/`PIPELINE` name. const streamAttribute = (value: unknown): { type: string; value: unknown } => ({ type: Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value, value, @@ -751,6 +751,13 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory const span = (name: string, op: string, attributes: Record, status?: string): unknown => streamedSpan({ name, op, segmentName, status, attributes: { ...connection, ...attributes } }); + // A cache span is a db span the cache hook took over: it is renamed to its cache operation + // and reports the connection it inherited as peer attributes too. + const cacheSpan = ( + op: 'cache.get' | 'cache.put' | 'cache.remove', + attributes: Record, + ): unknown => span(op, op, { ...peer, 'cache.operation': op.slice('cache.'.length), ...attributes }); + createEsmAndCjsTests(__dirname, 'scenario-ioredis.mjs', 'instrument-ioredis.mjs', (createTestRunner, test) => { test('creates streamed db and cache spans (ioredis)', { timeout: 60_000 }, async () => { await createTestRunner() @@ -764,22 +771,19 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'db.operation.name': 'set', 'db.query.text': 'set test-key [1 other arguments]', }), - span('ioredis-cache:test-key', 'cache.put', { - ...peer, + cacheSpan('cache.put', { 'db.operation.name': 'set', 'db.query.text': 'set ioredis-cache:test-key [1 other arguments]', 'cache.key': ['ioredis-cache:test-key'], 'cache.item_size': 2, }), - span('ioredis-cache:test-key-set-EX', 'cache.put', { - ...peer, + cacheSpan('cache.put', { 'db.operation.name': 'set', 'db.query.text': 'set ioredis-cache:test-key-set-EX [3 other arguments]', 'cache.key': ['ioredis-cache:test-key-set-EX'], 'cache.item_size': 2, }), - span('ioredis-cache:test-key-setex', 'cache.put', { - ...peer, + cacheSpan('cache.put', { 'db.operation.name': 'setex', 'db.query.text': 'setex ioredis-cache:test-key-setex [2 other arguments]', 'cache.key': ['ioredis-cache:test-key-setex'], @@ -789,31 +793,27 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'db.operation.name': 'get', 'db.query.text': 'get test-key', }), - span('ioredis-cache:test-key', 'cache.get', { - ...peer, + cacheSpan('cache.get', { 'db.operation.name': 'get', 'db.query.text': 'get ioredis-cache:test-key', 'cache.key': ['ioredis-cache:test-key'], 'cache.hit': true, 'cache.item_size': 10, }), - span('ioredis-cache:unavailable-data', 'cache.get', { - ...peer, + cacheSpan('cache.get', { 'db.operation.name': 'get', 'db.query.text': 'get ioredis-cache:unavailable-data', 'cache.key': ['ioredis-cache:unavailable-data'], 'cache.hit': false, }), - span('test-key, ioredis-cache:test-key, ioredis-cache:unavailable-data', 'cache.get', { - ...peer, + cacheSpan('cache.get', { 'db.operation.name': 'mget', 'db.query.text': 'mget [3 other arguments]', 'cache.key': ['test-key', 'ioredis-cache:test-key', 'ioredis-cache:unavailable-data'], 'cache.hit': true, 'cache.item_size': 20, }), - span('ioredis-cache:test-key', 'cache.remove', { - ...peer, + cacheSpan('cache.remove', { 'db.operation.name': 'del', 'db.query.text': 'del ioredis-cache:test-key', 'cache.key': ['ioredis-cache:test-key'], @@ -837,6 +837,13 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory const span = (name: string, op: string, attributes: Record, status?: string): unknown => streamedSpan({ name, op, segmentName, status, attributes: { ...connection, ...attributes } }); + // A cache span is a db span the cache hook took over: it is renamed to its cache operation + // and reports the connection it inherited as peer attributes too. + const cacheSpan = ( + op: 'cache.get' | 'cache.put' | 'cache.remove', + attributes: Record, + ): unknown => span(op, op, { ...peer, 'cache.operation': op.slice('cache.'.length), ...attributes }); + createEsmAndCjsTests(__dirname, 'scenario-redis-4.mjs', 'instrument-redis-4.mjs', (createTestRunner, test) => { test('creates streamed db and cache spans (redis-4)', { timeout: 60_000 }, async () => { await createTestRunner() @@ -855,22 +862,19 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'db.operation.name': 'SET', 'db.query.text': 'SET redis-test-key [1 other arguments]', }), - span('redis-cache:test-key', 'cache.put', { - ...peer, + cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-cache:test-key [1 other arguments]', 'cache.key': ['redis-cache:test-key'], 'cache.item_size': 2, }), - span('redis-cache:test-key-set-EX', 'cache.put', { - ...peer, + cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-cache:test-key-set-EX [3 other arguments]', 'cache.key': ['redis-cache:test-key-set-EX'], 'cache.item_size': 2, }), - span('redis-cache:test-key-setex', 'cache.put', { - ...peer, + cacheSpan('cache.put', { 'db.operation.name': 'SETEX', 'db.query.text': 'SETEX redis-cache:test-key-setex [2 other arguments]', 'cache.key': ['redis-cache:test-key-setex'], @@ -880,31 +884,27 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory 'db.operation.name': 'GET', 'db.query.text': 'GET redis-test-key', }), - span('redis-cache:test-key', 'cache.get', { - ...peer, + cacheSpan('cache.get', { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-cache:test-key', 'cache.key': ['redis-cache:test-key'], 'cache.hit': true, 'cache.item_size': 10, }), - span('redis-cache:unavailable-data', 'cache.get', { - ...peer, + cacheSpan('cache.get', { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-cache:unavailable-data', 'cache.key': ['redis-cache:unavailable-data'], 'cache.hit': false, }), - span('redis-test-key, redis-cache:test-key, redis-cache:unavailable-data', 'cache.get', { - ...peer, + cacheSpan('cache.get', { 'db.operation.name': 'MGET', 'db.query.text': 'MGET [3 other arguments]', 'cache.key': ['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data'], 'cache.hit': true, 'cache.item_size': 20, }), - span('redis-cache:test-key', 'cache.remove', { - ...peer, + cacheSpan('cache.remove', { 'db.operation.name': 'DEL', 'db.query.text': 'DEL redis-cache:test-key', 'cache.key': ['redis-cache:test-key'], @@ -931,16 +931,24 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory }); }); - // node-redis v5 leaves `socket.host` unset when only a port is passed, so there is no - // `server.address` to pair the operation with and the span name falls back to - // `{db.system.name}`. + // node-redis v5 leaves `socket.host` unset when only a port is passed, unlike v4. The + // integration fills in the library's own `localhost` default, so both report the same + // connection and get the same span name. describe('redis-5', () => { const segmentName = 'Test Span Redis 5'; - const connection = { 'server.port': 6383 }; + const connection = { 'server.address': 'localhost', 'server.port': 6383 }; + const peer = { 'network.peer.address': 'localhost', 'network.peer.port': 6383 }; const span = (name: string, op: string, attributes: Record, status?: string): unknown => streamedSpan({ name, op, segmentName, status, attributes: { ...connection, ...attributes } }); + // A cache span is a db span the cache hook took over: it is renamed to its cache operation + // and reports the connection it inherited as peer attributes too. + const cacheSpan = ( + op: 'cache.get' | 'cache.put' | 'cache.remove', + attributes: Record, + ): unknown => span(op, op, { ...peer, 'cache.operation': op.slice('cache.'.length), ...attributes }); + createEsmAndCjsTests(__dirname, 'scenario-redis-5.mjs', 'instrument-redis-5.mjs', (createTestRunner, test) => { test('creates streamed db and cache spans (redis-5)', { timeout: 60_000 }, async () => { await createTestRunner() @@ -953,60 +961,60 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]); expect(childSpans(container)).toEqual([ - span('redis', redisSpanOp, { + span('SET localhost:6383', redisSpanOp, { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-5-test-key [1 other arguments]', }), - span('redis-5-cache:test-key', 'cache.put', { + cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-5-cache:test-key [1 other arguments]', 'cache.key': ['redis-5-cache:test-key'], 'cache.item_size': 2, }), - span('redis-5-cache:test-key-set-EX', 'cache.put', { + cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET redis-5-cache:test-key-set-EX [3 other arguments]', 'cache.key': ['redis-5-cache:test-key-set-EX'], 'cache.item_size': 2, }), - span('redis-5-cache:test-key-setex', 'cache.put', { + cacheSpan('cache.put', { 'db.operation.name': 'SETEX', 'db.query.text': 'SETEX redis-5-cache:test-key-setex [2 other arguments]', 'cache.key': ['redis-5-cache:test-key-setex'], 'cache.item_size': 2, }), - span('redis', redisSpanOp, { + span('GET localhost:6383', redisSpanOp, { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-5-test-key', }), - span('redis-5-cache:test-key', 'cache.get', { + cacheSpan('cache.get', { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-5-cache:test-key', 'cache.key': ['redis-5-cache:test-key'], 'cache.hit': true, 'cache.item_size': 10, }), - span('redis-5-cache:unavailable-data', 'cache.get', { + cacheSpan('cache.get', { 'db.operation.name': 'GET', 'db.query.text': 'GET redis-5-cache:unavailable-data', 'cache.key': ['redis-5-cache:unavailable-data'], 'cache.hit': false, }), - span('redis-5-test-key, redis-5-cache:test-key, redis-5-cache:unavailable-data', 'cache.get', { + cacheSpan('cache.get', { 'db.operation.name': 'MGET', 'db.query.text': 'MGET [3 other arguments]', 'cache.key': ['redis-5-test-key', 'redis-5-cache:test-key', 'redis-5-cache:unavailable-data'], 'cache.hit': true, 'cache.item_size': 20, }), - span('redis-5-cache:test-key', 'cache.remove', { + cacheSpan('cache.remove', { 'db.operation.name': 'DEL', 'db.query.text': 'DEL redis-5-cache:test-key', 'cache.key': ['redis-5-cache:test-key'], }), span('MULTI', redisSpanOp, { 'db.operation.name': 'MULTI', 'db.operation.batch.size': 2 }), span( - 'redis', + 'INCR localhost:6383', redisSpanOp, { 'db.operation.name': 'INCR', diff --git a/packages/server-utils/src/integrations/redis/index.ts b/packages/server-utils/src/integrations/redis/index.ts index de2ed100d6f7..9241f4936e80 100644 --- a/packages/server-utils/src/integrations/redis/index.ts +++ b/packages/server-utils/src/integrations/redis/index.ts @@ -59,7 +59,7 @@ interface LegacyRedisClient { } interface NodeRedisClientOptions { - socket?: { host?: string; port?: number }; + socket?: { host?: string; port?: number; path?: string }; url?: string; } @@ -91,11 +91,19 @@ function stripCommandOptions(args: unknown[]): unknown[] { return args; } +// Resolves the connection the way node-redis >= 5.12 reports it on its own diagnostics channel: a +// unix socket reports its path and no port, a TCP client the `localhost:6379` it defaults to. +// Only v4 writes those defaults back into `client.options`, so reading the options as-is would +// report a connection for a v4 client and none for the identically configured v5 one. function nodeRedisAttributes(options: NodeRedisClientOptions | undefined): SpanAttributes { + const socket = options?.socket; + const host = socket?.path ?? socket?.host ?? 'localhost'; + const port = socket?.path != null ? undefined : (socket?.port ?? 6379); + return { [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, - ...(options?.socket?.host != null ? { [SERVER_ADDRESS]: options.socket.host } : {}), - ...(options?.socket?.port != null ? { [SERVER_PORT]: options.socket.port } : {}), + [SERVER_ADDRESS]: host, + ...(port != null ? { [SERVER_PORT]: port } : {}), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, }; } @@ -255,19 +263,16 @@ function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: Com bindTracingChannelToSpan(channel, data => { const commands = data.arguments?.[0]; const size = Array.isArray(commands) ? commands.length : undefined; - const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; + const options = (data.self as NodeRedisClient | undefined)?.options; const operation = getOperation(data); return startInactiveSpan({ name: operation, attributes: { [SENTRY_KIND]: 'client', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + ...nodeRedisAttributes(options), [SENTRY_OP]: DB_QUERY, - [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, [DB_OPERATION_NAME]: operation, ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), - ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), - ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), }, }); }); From 3bb8e194a7353333c36cdb9e8e662e1c2c7f35e4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 13:28:14 +0200 Subject: [PATCH 6/8] docs(migration): Document the redis span name changes Add the `db.query` (redis, ioredis) row to the span name table and the prose covering why the connection replaces the serialized command, the new `db.stored_procedure.name` attribute on `FCALL`, and the node-redis connection defaults that now apply in both trace lifecycles. Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATION.md | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 5972f04f6ff8..ed436764d627 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -923,25 +923,26 @@ If you [opt out of span streaming](#opting-out-of-span-streaming), span names re The following span names were adjusted: -| Span op | Before | After | -| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | -| `navigation` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Navigation` if the SDK has none | -| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) | -| `http.client`, `http.client.stream` | The request method and sanitized URL (`GET https://api.example.com/users/123`) | The request method and the domain (`GET api.example.com`), or just the method if there is no domain (`GET`) | -| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none | -| `handler` | Framework-specific, often carrying the request method (`GET /users/:id`, `route-handler`, `getUser`) | The span's `http.route`, or `Request handler` if the SDK has none | -| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | -| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) | -| `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation (`chain format_prompt`) | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order (`invoke_agent format_prompt`), or `{operation}` if the span carries none | -| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none | -| `mcp.server` | The method and its target, including the resource URI (`resources/read file:///docs/api.md`) | The method alone for resource methods (`resources/read`). Tool and prompt names are unchanged (`tools/call get-weather`) | -| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name (`notifications/tools/list_changed`) | The notification method name, or `MCP notification` if the message carries none | -| `queue.publish` | Integration-specific (`publish my-exchange`, `send my-topic`) | The messaging operation type and the destination (`send my-exchange`), or just the operation type when the destination has no name (`send`) | -| `queue.process` | Integration-specific, sometimes containing per-message data (`my-queue process`, `order.created.12345 process`) | The messaging operation type and the destination (`process my-exchange`), or just the operation type when the destination has no name (`process`) | -| `queue.receive` | The kafkajs operation name (`poll my-topic`) | The messaging operation type and the destination (`receive my-topic`) | -| `cache.get`, `cache.put`, `cache.remove` | The cache key(s) (`user:123`), or for dataloader the operation and loader name (`dataloader.load usersLoader`) | The cache operation (`cache.get`, `cache.put`, `cache.remove`) | -| `db` (mongoose) | `mongoose..` (`mongoose.BlogPost.findOne`) | The operation and the collection (`findOne blogposts`), the database namespace when there is no collection, or `mongodb` when the SDK has neither | +| Span op | Before | After | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | +| `navigation` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Navigation` if the SDK has none | +| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) | +| `http.client`, `http.client.stream` | The request method and sanitized URL (`GET https://api.example.com/users/123`) | The request method and the domain (`GET api.example.com`), or just the method if there is no domain (`GET`) | +| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none | +| `handler` | Framework-specific, often carrying the request method (`GET /users/:id`, `route-handler`, `getUser`) | The span's `http.route`, or `Request handler` if the SDK has none | +| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | +| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) | +| `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation (`chain format_prompt`) | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order (`invoke_agent format_prompt`), or `{operation}` if the span carries none | +| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none | +| `mcp.server` | The method and its target, including the resource URI (`resources/read file:///docs/api.md`) | The method alone for resource methods (`resources/read`). Tool and prompt names are unchanged (`tools/call get-weather`) | +| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name (`notifications/tools/list_changed`) | The notification method name, or `MCP notification` if the message carries none | +| `queue.publish` | Integration-specific (`publish my-exchange`, `send my-topic`) | The messaging operation type and the destination (`send my-exchange`), or just the operation type when the destination has no name (`send`) | +| `queue.process` | Integration-specific, sometimes containing per-message data (`my-queue process`, `order.created.12345 process`) | The messaging operation type and the destination (`process my-exchange`), or just the operation type when the destination has no name (`process`) | +| `queue.receive` | The kafkajs operation name (`poll my-topic`) | The messaging operation type and the destination (`receive my-topic`) | +| `cache.get`, `cache.put`, `cache.remove` | The cache key(s) (`user:123`), or for dataloader the operation and loader name (`dataloader.load usersLoader`) | The cache operation (`cache.get`, `cache.put`, `cache.remove`) | +| `db.query` (redis, ioredis) | The serialized command, with its arguments redacted (`set test-key [1 other arguments]`), or `redis-` on the diagnostics-channel path (`redis-SET`) | The operation and the connection (`SET localhost:6379`), the operation and the redis function for `FCALL`/`FCALL_RO` (`fcall my_func`), or `redis` when the SDK knows neither | +| `db` (mongoose) | `mongoose..` (`mongoose.BlogPost.findOne`) | The operation and the collection (`findOne blogposts`), the database namespace when there is no collection, or `mongodb` when the SDK has neither | `navigation.redirect` spans are started through the same code path as navigation spans, so they get the same names. @@ -981,6 +982,12 @@ Cache keys are unbounded, so they are no longer part of a cache span name. They A dataloader span no longer carries the loader's `name` either (`dataloader.load usersLoader` becomes `cache.get`), because the cache conventions have no slot for it in the name. It is reported on the `db.collection.name` attribute instead — a loader batches one entity type, so it is the closest thing dataloader has to a collection — and that attribute is set in both trace lifecycles. Unnamed loaders do not set it. +Redis has no SQL statement to summarize and no collection to pair a command with, so redis and ioredis `db.query` spans are named after the operation and the connection instead of the command that was sent. The command and its arguments remain available on `db.query.text`, redacted as before. `MULTI`/`PIPELINE` batch spans are unchanged — they were already named after their operation, which they now also report on `db.operation.name`. `db.namespace` is deliberately not used in the name: for redis it is the numeric database index, which says nothing about what the command did. + +`FCALL` and `FCALL_RO` call a redis function, the one redis construct the conventions model as a stored procedure, so those spans are named after the function and report it on a new `db.stored_procedure.name` attribute, set in both trace lifecycles. node-redis and ioredis redact arguments before publishing them on their diagnostics channels, so a redacted function name is left off both the name and the attribute. + +Relatedly, and in **both** trace lifecycles: node-redis clients now always report the connection they use. node-redis v4 wrote its `localhost:6379` defaults back into `client.options` and v5 does not, so an identically configured client used to report `server.address`/`server.port` on v4 and neither on v5. The SDK now fills in the same defaults node-redis itself publishes on its diagnostics channel, and a client connected over a unix socket reports its path as `server.address` and no port. + AWS SQS `SendMessage`, `SendMessageBatch` and `ReceiveMessage`, and SNS `Publish`, are messaging spans (e.g. `queue.publish`) rather than `rpc` ones now. Every other command on those clients, such as `DeleteMessage`, stays `rpc`. Their names follow the messaging conventions too, so the operation comes first (`my-queue receive` becomes `receive my-queue`, `my-topic send` becomes `send my-topic`). A streamed SNS `Publish` to a platform endpoint is named `send`, because the endpoint ARN it used to carry ends in a per-device id (`endpoint/GCM/myapp/ send`). The full ARN remains on `messaging.destination.name`. An amqplib span's destination is the exchange it uses, or the routing key when it uses the default exchange. RabbitMQ binds every queue to the default exchange under a key equal to the queue's own name, so `sendToQueue` spans are named after their queue (`send my-queue`) instead of dropping the destination. `messaging.destination.name` reports the same value, and the routing key remains on `messaging.rabbitmq.destination.routing_key` in full. From 3029b69d22ecf7515a16911f7a8db9ee70a7e388 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 14:58:05 +0200 Subject: [PATCH 7/8] docs: Clarify why unix sockets have no span name target Both comments misled a review bot. The connect span shares its trace with the command spans and lands in the same container, so say what `unordered` is actually guarding against, and spell out that the conventions have no address-only template, which is why a unix socket keeps the `redis` fallback even though its address is known. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/redis-cache/test.ts | 6 ++++-- .../server-utils/src/integrations/redis/redis-span-name.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index 8415898360ee..87416e6893c4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -452,7 +452,8 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory test('should name cache spans after the cache operation when streamed (redis-4)', async () => { await createTestRunner() .withEnv({ STREAMED: 'true' }) - // The connect span is streamed in its own envelope, ahead of the command spans. + // The connect span opens its own segment. `unordered` keeps this expectation from + // depending on which segment the buffer happens to emit first. .unordered() .expect({ span: { items: EXPECTED_STREAMED_SPANS } }) .start() @@ -671,7 +672,8 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory test('should name cache spans after the cache operation when streamed (redis-5)', async () => { await createTestRunner() .withEnv({ STREAMED: 'true' }) - // The connect span is streamed in its own envelope, ahead of the command spans. + // The connect span opens its own segment. `unordered` keeps this expectation from + // depending on which segment the buffer happens to emit first. .unordered() .expect({ span: { items: EXPECTED_STREAMED_SPANS } }) .start() diff --git a/packages/server-utils/src/integrations/redis/redis-span-name.ts b/packages/server-utils/src/integrations/redis/redis-span-name.ts index ec3fbdaae747..84af8ce48079 100644 --- a/packages/server-utils/src/integrations/redis/redis-span-name.ts +++ b/packages/server-utils/src/integrations/redis/redis-span-name.ts @@ -27,7 +27,9 @@ function getStoredProcedureName(command: string, args: ReadonlyArray): return name && name !== '?' ? name : undefined; } -// The template needs both halves, so a client configured with only a port has no target. +// The conventions have no address-only template, so both halves are required: a client +// configured with only a port has no target, and neither does a unix socket, whose +// `server.address` is a path with no port to pair it with. function getServerTarget({ host, port }: RedisConnection): string | undefined { const hasPort = typeof port === 'number' || (typeof port === 'string' && !!port); return typeof host === 'string' && host && hasPort ? `${host}:${port}` : undefined; From 6a59114bf72cd9256235ea3f17cd3da45b7672b0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 15:08:15 +0200 Subject: [PATCH 8/8] test(e2e): Expect low cardinality redis span names in react-router-7 #23844 ported this app to span streaming while this branch was open, so its ioredis assertions still expected the serialized command as the span name. The route builds its client without a host or port, so the name reports ioredis' own `localhost:6379` defaults, which the expectations now pin as `server.address`/`server.port` too. The duplicate-span check keys off `db.query.text`, which still tells the two commands apart. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/performance/db.server.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts index 7ce9ef5bda26..92806fd517e7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts @@ -22,9 +22,12 @@ test.describe('server - orchestrion db instrumentation', () => { const childSpans = spans.filter(span => !span.is_segment); + // Under span streaming a redis span is named after the operation and the connection — the key + // it acts on is unbounded, so it stays on `db.query.text`. The route builds its client without + // a host or port, so ioredis' own `localhost:6379` defaults are what the name reports. expect(childSpans).toContainEqual( expect.objectContaining({ - name: 'set test-key [1 other arguments]', + name: 'set localhost:6379', status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { value: 'db.query', type: 'string' }, @@ -32,12 +35,14 @@ test.describe('server - orchestrion db instrumentation', () => { 'db.system.name': { value: 'redis', type: 'string' }, 'db.operation.name': { value: 'set', type: 'string' }, 'db.query.text': { value: 'set test-key [1 other arguments]', type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'server.port': { value: 6379, type: 'integer' }, }), }), ); expect(childSpans).toContainEqual( expect.objectContaining({ - name: 'get test-key', + name: 'get localhost:6379', status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { value: 'db.query', type: 'string' }, @@ -45,12 +50,16 @@ test.describe('server - orchestrion db instrumentation', () => { 'db.system.name': { value: 'redis', type: 'string' }, 'db.operation.name': { value: 'get', type: 'string' }, 'db.query.text': { value: 'get test-key', type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'server.port': { value: 6379, type: 'integer' }, }), }), ); // Each command maps to exactly one span (no offline-queue duplicate). - const setSpans = spans.filter(span => span.name === 'set test-key [1 other arguments]'); + const setSpans = spans.filter( + span => span.attributes['db.query.text']?.value === 'set test-key [1 other arguments]', + ); expect(setSpans).toHaveLength(1); // Every db span nests under the native instrumentation-API http.server segment.