From 28412784f643a8b35401da5d56b2c33da550d578 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 1 Sep 2026 13:53:26 +0200 Subject: [PATCH 1/5] feat(core): Emit low-cardinality `cache.*` span names --- MIGRATION.md | 5 + .../suites/tracing/dataloader/instrument.mjs | 2 +- .../suites/tracing/dataloader/test.ts | 34 +++ .../redis-cache/instrument-ioredis.mjs | 2 +- .../redis-cache/instrument-redis-4.mjs | 2 +- .../redis-cache/instrument-redis-5.mjs | 2 +- .../suites/tracing/redis-cache/test.ts | 252 ++++++++++++++++++ packages/core/src/tracing/spans/spanNames.ts | 6 + .../src/runtime/hooks/captureStorageEvents.ts | 19 +- .../src/runtime/utils/instrumentStorage.ts | 21 +- .../src/integrations/dataloader.ts | 35 ++- .../src/integrations/redis/redis-cache.ts | 24 +- .../integrations/redis/redis-cache.test.ts | 45 +++- 13 files changed, 434 insertions(+), 15 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 0dee0ad59433..18c9ae5ed12c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -923,6 +923,7 @@ The following span names were adjusted: | `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 | `navigation.redirect` spans are started through the same code path as navigation spans, so they get the same names. @@ -959,6 +960,10 @@ A mongoose span's name is built from `db.collection.name`, so it holds the colle Messaging span names now read ` ` in every integration. The amqplib, kafkajs and NestJS BullMQ integrations used their own word order or verb, so their names change: `my-queue process` became `process my-queue`, amqplib's `publish` became `send`, and the kafkajs batch span's `poll` became `receive`. Cloudflare Queues and the kafkajs producer already matched the conventions, so their names are the same in both trace lifecycles. The operation name an integration reports upstream stays on `messaging.operation.name`. +Cache keys are unbounded, so they are no longer part of a cache span name. They remain available on the `cache.key` attribute, and every cache span now also carries a `cache.operation` attribute (`get`, `put`, `remove`) — the value the name is built from. That attribute is set in both trace lifecycles. This affects the redis/ioredis cache spans (`cachePrefixes`), the Nuxt and Nitro storage spans, and the dataloader spans. + +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. + 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. diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs index 5a6084292bbb..25a9dbb0c127 100644 --- a/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/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/dataloader/test.ts b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts index 1b566f3334b3..b49004cf02a9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts @@ -1,3 +1,4 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -103,6 +104,7 @@ describe('dataloader auto-instrumentation', () => { expect(namedLoadSpan?.op).toBe(CACHE_GET_OP); expect(namedLoadSpan?.origin).toBe(ORIGIN); expect(namedLoadSpan?.status).toBe('ok'); + expect(namedLoadSpan?.data?.['db.collection.name']).toBe('usersLoader'); }, }) .start(); @@ -113,5 +115,37 @@ describe('dataloader auto-instrumentation', () => { await runner.makeRequest('get', '/named'); await runner.completed(); }, 30_000); + + test('names spans after the cache operation when streamed', async () => { + const runner = createRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + const namedLoadSpan = container.items.find( + span => span.attributes?.['db.operation.name']?.value === 'load', + ); + expect(namedLoadSpan?.name).toBe('cache.get'); + expect(namedLoadSpan?.attributes?.['sentry.op']?.value).toBe(CACHE_GET_OP); + expect(namedLoadSpan?.attributes?.['cache.operation']?.value).toBe('get'); + // The loader name is no longer part of the span name, it moved to `db.collection.name`. + expect(namedLoadSpan?.attributes?.['db.collection.name']?.value).toBe('usersLoader'); + }, + }) + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + for (const [operation, op] of Object.entries(CACHE_MUTATION_OPS)) { + const span = container.items.find(item => item.attributes?.['db.operation.name']?.value === operation); + expect(span, `expected a ${operation} span`).toBeDefined(); + expect(span?.name).toBe(op); + expect(span?.attributes?.['sentry.op']?.value).toBe(op); + } + }, + }) + .start(); + + await runner.makeRequest('get', '/named'); + await runner.makeRequest('get', '/cache-ops'); + await runner.completed(); + }, 30_000); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-ioredis.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-ioredis.mjs index 9732fa703714..8b1385e92375 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-ioredis.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-ioredis.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-cache/instrument-redis-4.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-4.mjs index 02e29733ce03..9b24191fd0a1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-4.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-4.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-cache/instrument-redis-5.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-5.mjs index 50b4748a1659..58f4a0875acd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-5.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-5.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-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index 712d7b3852e3..813ddae859b1 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 @@ -59,6 +59,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'set ioredis-cache:test-key [1 other arguments]', + 'cache.operation': 'put', 'cache.key': ['ioredis-cache:test-key'], 'cache.item_size': 2, 'network.peer.address': 'localhost', @@ -73,6 +74,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'set ioredis-cache:test-key-set-EX [3 other arguments]', + 'cache.operation': 'put', 'cache.key': ['ioredis-cache:test-key-set-EX'], 'cache.item_size': 2, 'network.peer.address': 'localhost', @@ -87,6 +89,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'setex ioredis-cache:test-key-setex [2 other arguments]', + 'cache.operation': 'put', 'cache.key': ['ioredis-cache:test-key-setex'], 'cache.item_size': 2, 'network.peer.address': 'localhost', @@ -101,6 +104,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'get ioredis-cache:test-key', + 'cache.operation': 'get', 'cache.hit': true, 'cache.key': ['ioredis-cache:test-key'], 'cache.item_size': 10, @@ -116,6 +120,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'get ioredis-cache:unavailable-data', + 'cache.operation': 'get', 'cache.hit': false, 'cache.key': ['ioredis-cache:unavailable-data'], 'network.peer.address': 'localhost', @@ -130,6 +135,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'mget [3 other arguments]', + 'cache.operation': 'get', 'cache.hit': true, 'cache.key': ['test-key', 'ioredis-cache:test-key', 'ioredis-cache:unavailable-data'], 'network.peer.address': 'localhost', @@ -144,6 +150,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'del ioredis-cache:test-key', + 'cache.operation': 'remove', 'cache.key': ['ioredis-cache:test-key'], 'network.peer.address': 'localhost', 'network.peer.port': 6383, @@ -152,10 +159,87 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]), }; + // Same commands as above, but streamed: the cache key is gone from the span name and only + // `cache.key` still holds it. + const EXPECTED_STREAMED_SPANS = expect.arrayContaining([ + // SET + expect.objectContaining({ + name: 'cache.put', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'db.query.text': { type: 'string', value: 'set ioredis-cache:test-key [1 other arguments]' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'cache.key': { type: 'array', value: ['ioredis-cache:test-key'] }, + 'cache.item_size': { type: 'integer', value: 2 }, + }), + }), + // SETEX + expect.objectContaining({ + name: 'cache.put', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'db.query.text': { type: 'string', value: 'setex ioredis-cache:test-key-setex [2 other arguments]' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'cache.key': { type: 'array', value: ['ioredis-cache:test-key-setex'] }, + }), + }), + // GET + expect.objectContaining({ + name: 'cache.get', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'db.query.text': { type: 'string', value: 'get ioredis-cache:test-key' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'cache.hit': { type: 'boolean', value: true }, + 'cache.key': { type: 'array', value: ['ioredis-cache:test-key'] }, + 'cache.item_size': { type: 'integer', value: 10 }, + }), + }), + // MGET + expect.objectContaining({ + name: 'cache.get', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'db.query.text': { type: 'string', value: 'mget [3 other arguments]' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'cache.key': { + type: 'array', + value: ['test-key', 'ioredis-cache:test-key', 'ioredis-cache:unavailable-data'], + }, + }), + }), + // DEL + expect.objectContaining({ + name: 'cache.remove', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'db.query.text': { type: 'string', value: 'del ioredis-cache:test-key' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'cache.key': { type: 'array', value: ['ioredis-cache:test-key'] }, + }), + }), + ]); + createEsmAndCjsTests(__dirname, 'scenario-ioredis.mjs', 'instrument-ioredis.mjs', (createTestRunner, test) => { test('should create cache spans for prefixed keys (ioredis)', { timeout: 60_000 }, async () => { await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); + + test('should name cache spans after the cache operation when streamed (ioredis)', async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true' }) + .expect({ span: { items: EXPECTED_STREAMED_SPANS } }) + .start() + .completed(); + }); }); }); @@ -188,6 +272,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'SET redis-cache:test-key [1 other arguments]', + 'cache.operation': 'put', 'cache.key': ['redis-cache:test-key'], 'cache.item_size': 2, }), @@ -200,6 +285,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'SET redis-cache:test-key-set-EX [3 other arguments]', + 'cache.operation': 'put', 'cache.key': ['redis-cache:test-key-set-EX'], 'cache.item_size': 2, }), @@ -212,6 +298,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'SETEX redis-cache:test-key-setex [2 other arguments]', + 'cache.operation': 'put', 'cache.key': ['redis-cache:test-key-setex'], 'cache.item_size': 2, }), @@ -224,6 +311,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'GET redis-cache:test-key', + 'cache.operation': 'get', 'cache.hit': true, 'cache.key': ['redis-cache:test-key'], 'cache.item_size': 10, @@ -237,6 +325,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'GET redis-cache:unavailable-data', + 'cache.operation': 'get', 'cache.hit': false, 'cache.key': ['redis-cache:unavailable-data'], }), @@ -249,6 +338,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'MGET [3 other arguments]', + 'cache.operation': 'get', 'cache.hit': true, 'cache.key': ['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data'], }), @@ -261,6 +351,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'DEL redis-cache:test-key', + 'cache.operation': 'remove', 'cache.key': ['redis-cache:test-key'], }), }), @@ -280,6 +371,73 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]), }; + const EXPECTED_STREAMED_SPANS = expect.arrayContaining([ + // SET + expect.objectContaining({ + name: 'cache.put', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'db.query.text': { type: 'string', value: 'SET redis-cache:test-key [1 other arguments]' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'cache.key': { type: 'array', value: ['redis-cache:test-key'] }, + 'cache.item_size': { type: 'integer', value: 2 }, + }), + }), + // SETEX + expect.objectContaining({ + name: 'cache.put', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'db.query.text': { type: 'string', value: 'SETEX redis-cache:test-key-setex [2 other arguments]' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'cache.key': { type: 'array', value: ['redis-cache:test-key-setex'] }, + }), + }), + // GET + expect.objectContaining({ + name: 'cache.get', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'db.query.text': { type: 'string', value: 'GET redis-cache:test-key' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'cache.hit': { type: 'boolean', value: true }, + 'cache.key': { type: 'array', value: ['redis-cache:test-key'] }, + 'cache.item_size': { type: 'integer', value: 10 }, + }), + }), + // MGET + expect.objectContaining({ + name: 'cache.get', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'db.query.text': { type: 'string', value: 'MGET [3 other arguments]' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'cache.key': { + type: 'array', + value: ['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data'], + }, + }), + }), + // DEL + expect.objectContaining({ + name: 'cache.remove', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'db.query.text': { type: 'string', value: 'DEL redis-cache:test-key' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'cache.key': { type: 'array', value: ['redis-cache:test-key'] }, + }), + }), + ]); + createEsmAndCjsTests(__dirname, 'scenario-redis-4.mjs', 'instrument-redis-4.mjs', (createTestRunner, test) => { test('should create cache spans for prefixed keys (redis-4)', async () => { await createTestRunner() @@ -288,6 +446,16 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory .start() .completed(); }); + + 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. + .unordered() + .expect({ span: { items: EXPECTED_STREAMED_SPANS } }) + .start() + .completed(); + }); }); }); @@ -323,6 +491,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'SET redis-5-cache:test-key [1 other arguments]', + 'cache.operation': 'put', 'cache.key': ['redis-5-cache:test-key'], 'cache.item_size': 2, }), @@ -335,6 +504,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'SET redis-5-cache:test-key-set-EX [3 other arguments]', + 'cache.operation': 'put', 'cache.key': ['redis-5-cache:test-key-set-EX'], 'cache.item_size': 2, }), @@ -347,6 +517,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'SETEX redis-5-cache:test-key-setex [2 other arguments]', + 'cache.operation': 'put', 'cache.key': ['redis-5-cache:test-key-setex'], 'cache.item_size': 2, }), @@ -359,6 +530,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'GET redis-5-cache:test-key', + 'cache.operation': 'get', 'cache.hit': true, 'cache.key': ['redis-5-cache:test-key'], 'cache.item_size': 10, @@ -372,6 +544,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'GET redis-5-cache:unavailable-data', + 'cache.operation': 'get', 'cache.hit': false, 'cache.key': ['redis-5-cache:unavailable-data'], }), @@ -384,6 +557,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'MGET [3 other arguments]', + 'cache.operation': 'get', 'cache.hit': true, 'cache.key': ['redis-5-test-key', 'redis-5-cache:test-key', 'redis-5-cache:unavailable-data'], }), @@ -396,6 +570,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory data: expect.objectContaining({ 'sentry.origin': redisOrigin, 'db.query.text': 'DEL redis-5-cache:test-key', + 'cache.operation': 'remove', 'cache.key': ['redis-5-cache:test-key'], }), }), @@ -415,6 +590,73 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory ]), }; + const EXPECTED_STREAMED_SPANS = expect.arrayContaining([ + // SET + expect.objectContaining({ + name: 'cache.put', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'db.query.text': { type: 'string', value: 'SET redis-5-cache:test-key [1 other arguments]' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'cache.key': { type: 'array', value: ['redis-5-cache:test-key'] }, + 'cache.item_size': { type: 'integer', value: 2 }, + }), + }), + // SETEX + expect.objectContaining({ + name: 'cache.put', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'db.query.text': { type: 'string', value: 'SETEX redis-5-cache:test-key-setex [2 other arguments]' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'cache.key': { type: 'array', value: ['redis-5-cache:test-key-setex'] }, + }), + }), + // GET + expect.objectContaining({ + name: 'cache.get', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'db.query.text': { type: 'string', value: 'GET redis-5-cache:test-key' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'cache.hit': { type: 'boolean', value: true }, + 'cache.key': { type: 'array', value: ['redis-5-cache:test-key'] }, + 'cache.item_size': { type: 'integer', value: 10 }, + }), + }), + // MGET + expect.objectContaining({ + name: 'cache.get', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'db.query.text': { type: 'string', value: 'MGET [3 other arguments]' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'cache.key': { + type: 'array', + value: ['redis-5-test-key', 'redis-5-cache:test-key', 'redis-5-cache:unavailable-data'], + }, + }), + }), + // DEL + expect.objectContaining({ + name: 'cache.remove', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: redisOrigin }, + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'db.query.text': { type: 'string', value: 'DEL redis-5-cache:test-key' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'cache.key': { type: 'array', value: ['redis-5-cache:test-key'] }, + }), + }), + ]); + createEsmAndCjsTests(__dirname, 'scenario-redis-5.mjs', 'instrument-redis-5.mjs', (createTestRunner, test) => { test('should create cache spans for prefixed keys (redis-5)', async () => { await createTestRunner() @@ -423,6 +665,16 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory .start() .completed(); }); + + 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. + .unordered() + .expect({ span: { items: EXPECTED_STREAMED_SPANS } }) + .start() + .completed(); + }); }); }); }); diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index f2460a75d9c3..6c6139114497 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -21,6 +21,12 @@ export const NAVIGATION_SPAN_NAME_FALLBACK = 'Navigation'; */ export const DB_SPAN_NAME_FALLBACK = 'Database operation'; +/** + * Fallback name for cache spans when no better-suited span name is available. + * @see https://getsentry.github.io/sentry-conventions/names/#cache-cache-operations + */ +export const CACHE_SPAN_NAME_FALLBACK = 'Cache operation'; + /** * Fallback name for gen_ai agent spans when no better-suited span name is available. * @see https://getsentry.github.io/sentry-conventions/names/#gen_ai-agent diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts index a47ef1a30b6a..643ccb474a03 100644 --- a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -1,8 +1,10 @@ import * as dc from 'node:diagnostics_channel'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { + getClient, GLOBAL_OBJ, + hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_KEY, @@ -54,6 +56,14 @@ const OPERATION_SPAN_OPS = { clear: CACHE_REMOVE, } as const satisfies Record; +// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, +// which makes them identical to the op itself. +const CACHE_OPERATION_NAMES = { + [CACHE_GET]: 'get', + [CACHE_PUT]: 'put', + [CACHE_REMOVE]: 'remove', +} as const; + const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; /** @@ -84,11 +94,16 @@ function setupStorageTracingChannel(operation: TracedOperation): void { dc.tracingChannel(`unstorage.${operation}`), data => { const cacheKeys = keys(data); + const cacheOperationName = CACHE_OPERATION_NAMES[OPERATION_SPAN_OPS[operation]]; + const client = getClient(); return startInactiveSpan({ - name: cacheKeys.join(', ') || operation, + // With span streaming, span names have to be low cardinality, so we can't fall back to the cache keys. + name: + client && hasSpanStreamingEnabled(client) ? `cache.${cacheOperationName}` : cacheKeys.join(', ') || operation, attributes: { [SENTRY_OP]: OPERATION_SPAN_OPS[operation], + [CACHE_OPERATION]: cacheOperationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], 'db.operation.name': operation, diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts index e44f4699b9af..10b4a3d240e8 100644 --- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -1,4 +1,4 @@ -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { isObjectLike, @@ -12,6 +12,8 @@ import { type SpanAttributes, startSpan, type StartSpanOptions, + getClient, + hasSpanStreamingEnabled, } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import type { Driver, Storage } from 'unstorage'; @@ -74,6 +76,14 @@ const METHOD_SPAN_OPS = { clear: CACHE_REMOVE, } as const satisfies Partial>; +// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, +// which makes them identical to the op itself. +const CACHE_OPERATION_NAMES = { + [CACHE_GET]: 'get', + [CACHE_PUT]: 'put', + [CACHE_REMOVE]: 'remove', +} as const; + /** * Creates the Nitro storage plugin setup by instrumenting all relevant storage drivers. * @@ -232,9 +242,12 @@ function createSpanStartOptions( args: unknown[], ): StartSpanOptions { const keys = getCacheKeys(args?.[0], mountBase); + const cacheOperation = METHOD_SPAN_OPS[methodName as keyof typeof METHOD_SPAN_OPS]; + const cacheOperationName = CACHE_OPERATION_NAMES[cacheOperation]; const attributes: SpanAttributes = { - [SENTRY_OP]: METHOD_SPAN_OPS[methodName as keyof typeof METHOD_SPAN_OPS], + [SENTRY_OP]: cacheOperation, + [CACHE_OPERATION]: cacheOperationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], 'db.operation.name': methodName, @@ -242,8 +255,10 @@ function createSpanStartOptions( 'db.system.name': driver.name ?? 'unknown', }; + const client = getClient(); return { - name: keys.join(', '), + // With span streaming, span names have to be low cardinality, so we can't fall back to the cache keys. + name: client && hasSpanStreamingEnabled(client) ? `cache.${cacheOperationName}` : keys.join(', '), attributes, }; } diff --git a/packages/server-utils/src/integrations/dataloader.ts b/packages/server-utils/src/integrations/dataloader.ts index f54112b9eb6e..6c0d2a6a74bd 100644 --- a/packages/server-utils/src/integrations/dataloader.ts +++ b/packages/server-utils/src/integrations/dataloader.ts @@ -1,10 +1,19 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import { CACHE_KEY, DB_OPERATION_NAME, SENTRY_KIND, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + SENTRY_KIND, + SENTRY_OP, +} from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import type { IntegrationFn, Span, StartSpanOptions } from '@sentry/core'; import { debug, defineIntegration, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, startSpan, @@ -39,6 +48,14 @@ const OPERATION_SPAN_OPS = { clearAll: CACHE_REMOVE, } as const satisfies Record; +// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, +// which makes them identical to the op itself. +const CACHE_OPERATION_NAMES = { + [CACHE_GET]: 'get', + [CACHE_PUT]: 'put', + [CACHE_REMOVE]: 'remove', +} as const; + // The link shape shared between a `load` span and the `batch` span it triggers. type DataLoaderSpanLink = { context: ReturnType }; @@ -88,17 +105,29 @@ function makeSpanOptions( operation: Operation, keyArg?: unknown, ): StartSpanOptions { + const cacheOperation = OPERATION_SPAN_OPS[operation]; + const client = getClient(); + return { - name: getSpanName(loader, operation), + // With span streaming, span names have to be low cardinality, so the loader name is dropped from + // the name and reported on `db.collection.name` instead. + name: + client && hasSpanStreamingEnabled(client) + ? `cache.${CACHE_OPERATION_NAMES[cacheOperation]}` + : getSpanName(loader, operation), onlyIfParent: true, attributes: { - [SENTRY_OP]: OPERATION_SPAN_OPS[operation], + [SENTRY_OP]: cacheOperation, + [CACHE_OPERATION]: CACHE_OPERATION_NAMES[cacheOperation], // Every direct operation (`load`/`loadMany`/`prime`/`clear`/`clearAll`) is a client call, matching // the vendored OTel instrumentation. The `batch` runs off a deferred tick with no obvious network // peer, so it gets no kind. [SENTRY_KIND]: operation === 'batch' ? undefined : 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [DB_OPERATION_NAME]: operation, + // A loader batches and caches one entity type, so it is the closest thing dataloader has to a + // collection. Unnamed loaders report nothing. + [DB_COLLECTION_NAME]: loader?.name ?? undefined, [CACHE_KEY]: getCacheKey(keyArg), }, }; diff --git a/packages/server-utils/src/integrations/redis/redis-cache.ts b/packages/server-utils/src/integrations/redis/redis-cache.ts index e79c6bf8f31c..e39f2c5629c9 100644 --- a/packages/server-utils/src/integrations/redis/redis-cache.ts +++ b/packages/server-utils/src/integrations/redis/redis-cache.ts @@ -1,4 +1,5 @@ import { + CACHE_OPERATION, NET_PEER_NAME, NET_PEER_PORT, NETWORK_PEER_ADDRESS, @@ -9,6 +10,8 @@ import { import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, SEMANTIC_ATTRIBUTE_CACHE_KEY, @@ -30,6 +33,14 @@ export const SET_COMMANDS = ['set', 'setex']; export const REMOVE_COMMANDS = ['del', 'unlink']; // todo: expire (no matching cache convention op yet) +// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, +// which makes them identical to the op itself. +const CACHE_OPERATION_NAMES = { + [CACHE_GET]: 'get', + [CACHE_PUT]: 'put', + [CACHE_REMOVE]: 'remove', +} as const; + /** Options controlling which redis commands are captured as cache spans. */ export interface RedisCacheOptions { /** @@ -139,8 +150,9 @@ export function calculateCacheItemSize(response: unknown): number | undefined { /** * Turns a redis command span into a cache span when its key matches one of the configured - * `cachePrefixes`: sets the cache op, key, hit/miss and item-size attributes and renames the span - * to the cache key. A no-op when no `cachePrefixes` are set or the command/key is not cache-relevant. + * `cachePrefixes`: sets the cache op, operation, key, hit/miss and item-size attributes and renames + * the span to the cache key (or, with span streaming, to the low-cardinality cache operation). + * A no-op when no `cachePrefixes` are set or the command/key is not cache-relevant. * * Runs at command response time against the already-started db span, so it can read connection * attributes off the span and derive the item size from the response. @@ -194,8 +206,16 @@ export function applyRedisCacheAttributes( span.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, + [CACHE_OPERATION]: CACHE_OPERATION_NAMES[cacheOperation], }); + const client = getClient(); + if (client && hasSpanStreamingEnabled(client)) { + // With span streaming, span names have to be low cardinality, so we can't fall back to the cache key. + span.updateName(`cache.${CACHE_OPERATION_NAMES[cacheOperation]}`); + return; + } + // todo: change to string[] once EAP supports it const spanDescription = safeKey.join(', '); diff --git a/packages/server-utils/test/integrations/redis/redis-cache.test.ts b/packages/server-utils/test/integrations/redis/redis-cache.test.ts index cf0a62feaef4..326cb813ccc4 100644 --- a/packages/server-utils/test/integrations/redis/redis-cache.test.ts +++ b/packages/server-utils/test/integrations/redis/redis-cache.test.ts @@ -1,4 +1,5 @@ -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { CACHE_KEY, CACHE_OPERATION, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { setCurrentClient } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { applyRedisCacheAttributes, @@ -9,6 +10,13 @@ import { SET_COMMANDS, shouldConsiderForCache, } from '../../../src/integrations/redis/redis-cache'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +function setUpClient(traceLifecycle: 'stream' | 'static'): void { + const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle, tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); +} describe('redis cache', () => { describe('applyRedisCacheAttributes', () => { @@ -82,6 +90,41 @@ describe('redis cache', () => { expect(mockSpan.updateName).toHaveBeenCalledWith('cache:key1, cache:ke...'); }); }); + + describe('span names', () => { + afterEach(() => { + setCurrentClient(undefined as never); + }); + + it.each([ + { cmd: 'get', op: 'cache.get', operation: 'get' }, + { cmd: 'set', op: 'cache.put', operation: 'put' }, + { cmd: 'del', op: 'cache.remove', operation: 'remove' }, + ])('names a streamed $op span after the cache operation', ({ cmd, op, operation }) => { + setUpClient('stream'); + + applyRedisCacheAttributes(mockSpan, cmd, ['cache:user-42'], 'value', { cachePrefixes: ['cache:'] }); + + expect(mockSpan.updateName).toHaveBeenCalledWith(op); + // The key is high cardinality, so it only lives on the attribute. + expect(mockSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SENTRY_OP]: op, + [CACHE_OPERATION]: operation, + [CACHE_KEY]: ['cache:user-42'], + }), + ); + }); + + it('keeps the cache key as the span name when span streaming is off', () => { + setUpClient('static'); + + applyRedisCacheAttributes(mockSpan, 'get', ['cache:user-42'], 'value', { cachePrefixes: ['cache:'] }); + + expect(mockSpan.updateName).toHaveBeenCalledWith('cache:user-42'); + expect(mockSpan.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ [CACHE_OPERATION]: 'get' })); + }); + }); }); describe('getCacheKeySafely (single arg)', () => { From 155579ef12b836fa67834b77a17b0b0c44be2953 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 1 Sep 2026 16:10:48 +0200 Subject: [PATCH 2/5] small jsdoc deslop adjustment --- packages/core/src/tracing/spans/spanNames.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index 6c6139114497..256bbb74f719 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -15,8 +15,7 @@ export const PAGELOAD_SPAN_NAME_FALLBACK = 'Pageload'; export const NAVIGATION_SPAN_NAME_FALLBACK = 'Navigation'; /** - * db0 exposes no db system we could name the span after, so unsummarizable statements fall back to - * this static name. + * Fallback name for db spans when no better-suited span name is available. * @see https://getsentry.github.io/sentry-conventions/names/#db-queries */ export const DB_SPAN_NAME_FALLBACK = 'Database operation'; From 2bf2f30f3f825c964b300f06649ccc5f1c299ecd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 1 Sep 2026 16:32:01 +0200 Subject: [PATCH 3/5] test(e2e): Expect low-cardinality cache span names in nuxt-3 The nuxt-3 test app runs with span streaming enabled, so cache spans now carry the low-cardinality `cache.{operation}` name instead of falling back to the cache keys. Also assert the newly emitted `cache.operation` attribute, since the span name is derived from it. Co-Authored-By: Claude Opus 5 (1M context) --- .../test-applications/nuxt-3/tests/cache.test.ts | 3 +++ .../nuxt-3/tests/storage-aliases.test.ts | 13 +++++++++---- .../test-applications/nuxt-3/tests/storage.test.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts index b1d0cd7c003b..32aa9f71db41 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts @@ -47,6 +47,7 @@ test.describe('Cache Instrumentation', () => { if (cacheMissSpan) { expect(cacheMissSpan.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: false }, 'db.operation.name': { type: 'string', value: 'getItem' }, @@ -64,6 +65,7 @@ test.describe('Cache Instrumentation', () => { if (cacheHitSpan) { expect(cacheHitSpan.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, 'db.operation.name': { type: 'string', value: 'getItem' }, @@ -81,6 +83,7 @@ test.describe('Cache Instrumentation', () => { if (cacheSetSpan) { expect(cacheSetSpan.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, 'db.operation.name': { type: 'string', value: 'setItem' }, 'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts index 562eb3d3c1b5..603f9b6dd66f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -38,13 +38,14 @@ test.describe('Storage Instrumentation - Aliases', () => { expect(setSpan).toBeDefined(); expect(setSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, 'db.operation.name': { type: 'string', value: 'setItem' }, 'db.collection.name': { type: 'string', value: 'test-storage' }, 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(setSpan?.name).toBe(prefixKey('alias:user')); + expect(setSpan?.name).toBe('cache.put'); // Test get (alias for getItem) expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1); @@ -52,6 +53,7 @@ test.describe('Storage Instrumentation - Aliases', () => { expect(getSpan).toBeDefined(); expect(getSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, @@ -59,7 +61,7 @@ test.describe('Storage Instrumentation - Aliases', () => { 'db.collection.name': { type: 'string', value: 'test-storage' }, 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(getSpan?.name).toBe(prefixKey('alias:user')); + expect(getSpan?.name).toBe('cache.get'); // Test has (alias for hasItem) expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1); @@ -67,6 +69,7 @@ test.describe('Storage Instrumentation - Aliases', () => { expect(hasSpan).toBeDefined(); expect(hasSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, @@ -82,25 +85,27 @@ test.describe('Storage Instrumentation - Aliases', () => { expect(delSpan).toBeDefined(); expect(delSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp1') }, 'db.operation.name': { type: 'string', value: 'removeItem' }, 'db.collection.name': { type: 'string', value: 'test-storage' }, 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(delSpan?.name).toBe(prefixKey('alias:temp1')); + expect(delSpan?.name).toBe('cache.remove'); const removeSpan = findByKey('removeItem', prefixKey('alias:temp2')); expect(removeSpan).toBeDefined(); expect(removeSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp2') }, 'db.operation.name': { type: 'string', value: 'removeItem' }, 'db.collection.name': { type: 'string', value: 'test-storage' }, 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(removeSpan?.name).toBe(prefixKey('alias:temp2')); + expect(removeSpan?.name).toBe('cache.remove'); // Verify all spans have OK status expect(allStorageSpans.length).toBeGreaterThan(0); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts index 324069c1430b..4d184661ba39 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -38,6 +38,7 @@ test.describe('Storage Instrumentation', () => { expect(setItemSpan).toBeDefined(); expect(setItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, 'db.operation.name': { type: 'string', value: 'setItem' }, @@ -45,7 +46,7 @@ test.describe('Storage Instrumentation', () => { 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(setItemSpan?.name).toBe(prefixKey('user:123')); + expect(setItemSpan?.name).toBe('cache.put'); // Test setItemRaw spans expect(findSpansByMethod('setItemRaw').length).toBeGreaterThanOrEqual(1); @@ -54,6 +55,7 @@ test.describe('Storage Instrumentation', () => { expect(setItemRawSpan).toBeDefined(); expect(setItemRawSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') }, 'db.operation.name': { type: 'string', value: 'setItemRaw' }, @@ -67,6 +69,7 @@ test.describe('Storage Instrumentation', () => { expect(hasItemSpan).toBeDefined(); expect(hasItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, @@ -81,6 +84,7 @@ test.describe('Storage Instrumentation', () => { expect(getItemSpan).toBeDefined(); expect(getItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, @@ -88,7 +92,7 @@ test.describe('Storage Instrumentation', () => { 'db.collection.name': { type: 'string', value: 'test-storage' }, 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(getItemSpan?.name).toBe(prefixKey('user:123')); + expect(getItemSpan?.name).toBe('cache.get'); // Test getItemRaw spans - should have cache hit attribute expect(findSpansByMethod('getItemRaw').length).toBeGreaterThanOrEqual(1); @@ -96,6 +100,7 @@ test.describe('Storage Instrumentation', () => { expect(getItemRawSpan).toBeDefined(); expect(getItemRawSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, @@ -109,6 +114,7 @@ test.describe('Storage Instrumentation', () => { expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); expect(getKeysSpans[0]?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, 'db.operation.name': { type: 'string', value: 'getKeys' }, 'db.collection.name': { type: 'string', value: 'test-storage' }, @@ -121,6 +127,7 @@ test.describe('Storage Instrumentation', () => { expect(removeItemSpan).toBeDefined(); expect(removeItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('batch:1') }, 'db.operation.name': { type: 'string', value: 'removeItem' }, @@ -133,6 +140,7 @@ test.describe('Storage Instrumentation', () => { expect(clearSpans.length).toBeGreaterThanOrEqual(1); expect(clearSpans[0]?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, 'db.operation.name': { type: 'string', value: 'clear' }, 'db.collection.name': { type: 'string', value: 'test-storage' }, From ce512ded0b8b92c7e5d2b90804ba5593c0ff241d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 09:38:33 +0200 Subject: [PATCH 4/5] remove unused span name fallback --- packages/core/src/tracing/spans/spanNames.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index 256bbb74f719..28cc6b61b743 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -20,12 +20,6 @@ export const NAVIGATION_SPAN_NAME_FALLBACK = 'Navigation'; */ export const DB_SPAN_NAME_FALLBACK = 'Database operation'; -/** - * Fallback name for cache spans when no better-suited span name is available. - * @see https://getsentry.github.io/sentry-conventions/names/#cache-cache-operations - */ -export const CACHE_SPAN_NAME_FALLBACK = 'Cache operation'; - /** * Fallback name for gen_ai agent spans when no better-suited span name is available. * @see https://getsentry.github.io/sentry-conventions/names/#gen_ai-agent From 58b139179f4af6f182f4a13fe8ca406dab176919 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 10:15:25 +0200 Subject: [PATCH 5/5] extract operations mapping --- packages/core/src/tracing/spans/spanNames.ts | 17 +++++++++++++++-- .../src/runtime/hooks/captureStorageEvents.ts | 11 ++--------- .../nuxt/src/runtime/utils/instrumentStorage.ts | 17 +++++------------ .../server-utils/src/integrations/dataloader.ts | 14 ++------------ .../src/integrations/redis/redis-cache.ts | 11 ++--------- 5 files changed, 26 insertions(+), 44 deletions(-) diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index 28cc6b61b743..082baccfd74b 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -1,5 +1,7 @@ -// This file contains constants for fallback span names to be used, when no -// better-suited, low-cardinality span name is available. +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; + +// This file contains constants for low-cardinality span names: fallback names to be used when no +// better-suited span name is available, as well as the building blocks for derived names. // Only relevant when span streaming is enabled. /** @@ -79,3 +81,14 @@ export const ROUTER_SPAN_NAME_FALLBACK = 'Router'; * @see https://getsentry.github.io/sentry-conventions/names/#web_server-request-handler */ export const REQUEST_HANDLER_SPAN_NAME_FALLBACK = 'Request handler'; + +/** + * The `cache.operation` attribute value each cache op carries. Cache span names are + * `cache.{{cache.operation}}`, so the op constant itself doubles as the low-cardinality span name. + * @see https://getsentry.github.io/sentry-conventions/names/#cache + */ +export const CACHE_OPERATION_NAMES = { + [CACHE_GET]: 'get', + [CACHE_PUT]: 'put', + [CACHE_REMOVE]: 'remove', +} as const; diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts index 643ccb474a03..066ec79bad45 100644 --- a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -2,6 +2,7 @@ import * as dc from 'node:diagnostics_channel'; import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { + CACHE_OPERATION_NAMES, getClient, GLOBAL_OBJ, hasSpanStreamingEnabled, @@ -56,14 +57,6 @@ const OPERATION_SPAN_OPS = { clear: CACHE_REMOVE, } as const satisfies Record; -// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, -// which makes them identical to the op itself. -const CACHE_OPERATION_NAMES = { - [CACHE_GET]: 'get', - [CACHE_PUT]: 'put', - [CACHE_REMOVE]: 'remove', -} as const; - const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; /** @@ -100,7 +93,7 @@ function setupStorageTracingChannel(operation: TracedOperation): void { return startInactiveSpan({ // With span streaming, span names have to be low cardinality, so we can't fall back to the cache keys. name: - client && hasSpanStreamingEnabled(client) ? `cache.${cacheOperationName}` : cacheKeys.join(', ') || operation, + client && hasSpanStreamingEnabled(client) ? OPERATION_SPAN_OPS[operation] : cacheKeys.join(', ') || operation, attributes: { [SENTRY_OP]: OPERATION_SPAN_OPS[operation], [CACHE_OPERATION]: cacheOperationName, diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts index 10b4a3d240e8..621dc3024c1f 100644 --- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -1,9 +1,12 @@ import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { - isObjectLike, + CACHE_OPERATION_NAMES, captureException, debug, + getClient, + hasSpanStreamingEnabled, + isObjectLike, SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -12,8 +15,6 @@ import { type SpanAttributes, startSpan, type StartSpanOptions, - getClient, - hasSpanStreamingEnabled, } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import type { Driver, Storage } from 'unstorage'; @@ -76,14 +77,6 @@ const METHOD_SPAN_OPS = { clear: CACHE_REMOVE, } as const satisfies Partial>; -// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, -// which makes them identical to the op itself. -const CACHE_OPERATION_NAMES = { - [CACHE_GET]: 'get', - [CACHE_PUT]: 'put', - [CACHE_REMOVE]: 'remove', -} as const; - /** * Creates the Nitro storage plugin setup by instrumenting all relevant storage drivers. * @@ -258,7 +251,7 @@ function createSpanStartOptions( const client = getClient(); return { // With span streaming, span names have to be low cardinality, so we can't fall back to the cache keys. - name: client && hasSpanStreamingEnabled(client) ? `cache.${cacheOperationName}` : keys.join(', '), + name: client && hasSpanStreamingEnabled(client) ? cacheOperation : keys.join(', '), attributes, }; } diff --git a/packages/server-utils/src/integrations/dataloader.ts b/packages/server-utils/src/integrations/dataloader.ts index 6c0d2a6a74bd..be42e44d3006 100644 --- a/packages/server-utils/src/integrations/dataloader.ts +++ b/packages/server-utils/src/integrations/dataloader.ts @@ -10,6 +10,7 @@ import { import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import type { IntegrationFn, Span, StartSpanOptions } from '@sentry/core'; import { + CACHE_OPERATION_NAMES, debug, defineIntegration, getClient, @@ -48,14 +49,6 @@ const OPERATION_SPAN_OPS = { clearAll: CACHE_REMOVE, } as const satisfies Record; -// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, -// which makes them identical to the op itself. -const CACHE_OPERATION_NAMES = { - [CACHE_GET]: 'get', - [CACHE_PUT]: 'put', - [CACHE_REMOVE]: 'remove', -} as const; - // The link shape shared between a `load` span and the `batch` span it triggers. type DataLoaderSpanLink = { context: ReturnType }; @@ -111,10 +104,7 @@ function makeSpanOptions( return { // With span streaming, span names have to be low cardinality, so the loader name is dropped from // the name and reported on `db.collection.name` instead. - name: - client && hasSpanStreamingEnabled(client) - ? `cache.${CACHE_OPERATION_NAMES[cacheOperation]}` - : getSpanName(loader, operation), + name: client && hasSpanStreamingEnabled(client) ? cacheOperation : getSpanName(loader, operation), onlyIfParent: true, attributes: { [SENTRY_OP]: cacheOperation, diff --git a/packages/server-utils/src/integrations/redis/redis-cache.ts b/packages/server-utils/src/integrations/redis/redis-cache.ts index e39f2c5629c9..633149b9fed7 100644 --- a/packages/server-utils/src/integrations/redis/redis-cache.ts +++ b/packages/server-utils/src/integrations/redis/redis-cache.ts @@ -10,6 +10,7 @@ import { import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { + CACHE_OPERATION_NAMES, getClient, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_CACHE_HIT, @@ -33,14 +34,6 @@ export const SET_COMMANDS = ['set', 'setex']; export const REMOVE_COMMANDS = ['del', 'unlink']; // todo: expire (no matching cache convention op yet) -// The `cache.operation` value each cache op carries. Cache span names are `cache.{{cache.operation}}`, -// which makes them identical to the op itself. -const CACHE_OPERATION_NAMES = { - [CACHE_GET]: 'get', - [CACHE_PUT]: 'put', - [CACHE_REMOVE]: 'remove', -} as const; - /** Options controlling which redis commands are captured as cache spans. */ export interface RedisCacheOptions { /** @@ -212,7 +205,7 @@ export function applyRedisCacheAttributes( const client = getClient(); if (client && hasSpanStreamingEnabled(client)) { // With span streaming, span names have to be low cardinality, so we can't fall back to the cache key. - span.updateName(`cache.${CACHE_OPERATION_NAMES[cacheOperation]}`); + span.updateName(cacheOperation); return; }