From 866f29c1b92dfa319ed5f00d2bc9b89ec49a0c3e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:39:37 +0200 Subject: [PATCH] feat(server-utils)!: Emit low cardinality redis and mongodb span names Neither driver reports a SQL statement, so there is no query summary to name their spans after. With span streaming they use the next conventions template they can fill instead: redis pairs the command with `{server.address}:{server.port}` (it has no collection or namespace), mongodb pairs the operation with `{db.collection.name}`. Both fall back to `{db.system.name}`. This keeps the serialized redis 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) --- .../src/integrations/mongodb/mongodb-span.ts | 24 +++++++++++++++++-- .../src/integrations/redis/index.ts | 17 ++++++++++++- .../redis/ioredis-channel-subscriber.ts | 15 ++++++++++-- .../redis/ioredis-channel-subscriber.test.ts | 17 +++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/server-utils/src/integrations/mongodb/mongodb-span.ts b/packages/server-utils/src/integrations/mongodb/mongodb-span.ts index b3b8ca98c4a9..119fb530afeb 100644 --- a/packages/server-utils/src/integrations/mongodb/mongodb-span.ts +++ b/packages/server-utils/src/integrations/mongodb/mongodb-span.ts @@ -9,7 +9,13 @@ import { SERVER_PORT, } from '@sentry/conventions/attributes'; import type { Span, SpanAttributes } from '@sentry/core'; -import { isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import { + getClient, + hasSpanStreamingEnabled, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, +} from '@sentry/core'; // `db.connection_string` is not part of `@sentry/conventions`, so it stays inlined to match // what `@opentelemetry/instrumentation-mongodb` emitted. @@ -220,8 +226,22 @@ export function getV3SpanAttributes( * to support platforms that lack it (ie, Deno). */ export function startMongoSpan(attributes: SpanAttributes): Span { + const client = getClient(); + const operation = attributes[DB_OPERATION_NAME] as string | undefined; + const target = (attributes[DB_COLLECTION_NAME] || attributes[DB_NAMESPACE]) as string | undefined; + // With span streaming, span names have to be low cardinality, so `{db.operation.name}` paired with + // `{db.collection.name}` (or `{db.namespace}`) is used instead of the query document, falling back + // to that target alone and then to `{db.system.name}`. + const streamedName = + client && hasSpanStreamingEnabled(client) + ? operation && target + ? `${operation} ${target}` + : target || DB_SYSTEM_VALUE_MONGODB + : undefined; + return startInactiveSpan({ - name: (attributes[DB_QUERY_TEXT] as string) || `mongodb.${attributes[DB_OPERATION_NAME] || 'command'}`, + name: + streamedName || (attributes[DB_QUERY_TEXT] as string) || `mongodb.${attributes[DB_OPERATION_NAME] || 'command'}`, op: 'db', attributes: { [SENTRY_KIND]: 'client', diff --git a/packages/server-utils/src/integrations/redis/index.ts b/packages/server-utils/src/integrations/redis/index.ts index bb237cf5c7c2..4ad9c8e11d8b 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,21 @@ function nodeRedisAttributes(options: NodeRedisClientOptions | undefined): SpanA function startCommandSpan(commandName: string, commandArgs: Array, attributes: SpanAttributes): Span { const dbStatement = defaultDbStatementSerializer(commandName, commandArgs); + const client = getClient(); + const host = attributes[SERVER_ADDRESS]; + const port = attributes[SERVER_PORT]; + // 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 + ? `${commandName} ${host}:${port}` + : DB_SYSTEM_VALUE_REDIS + : undefined; + return startInactiveSpan({ - name: dbStatement || `redis-${commandName}`, + name: streamedName || dbStatement || `redis-${commandName}`, 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'] };