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/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' }, 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..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. /** @@ -15,8 +17,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'; @@ -80,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 a47ef1a30b6a..066ec79bad45 100644 --- a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -1,8 +1,11 @@ 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 { + CACHE_OPERATION_NAMES, + getClient, GLOBAL_OBJ, + hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_KEY, @@ -84,11 +87,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) ? OPERATION_SPAN_OPS[operation] : 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..621dc3024c1f 100644 --- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -1,9 +1,12 @@ -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, + CACHE_OPERATION_NAMES, captureException, debug, + getClient, + hasSpanStreamingEnabled, + isObjectLike, SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -232,9 +235,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 +248,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) ? cacheOperation : keys.join(', '), attributes, }; } diff --git a/packages/server-utils/src/integrations/dataloader.ts b/packages/server-utils/src/integrations/dataloader.ts index f54112b9eb6e..be42e44d3006 100644 --- a/packages/server-utils/src/integrations/dataloader.ts +++ b/packages/server-utils/src/integrations/dataloader.ts @@ -1,10 +1,20 @@ 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 { + CACHE_OPERATION_NAMES, debug, defineIntegration, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, startSpan, @@ -88,17 +98,26 @@ 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) ? 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..633149b9fed7 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,9 @@ 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, SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, SEMANTIC_ATTRIBUTE_CACHE_KEY, @@ -139,8 +143,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 +199,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(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)', () => {