Skip to content

Commit bffac10

Browse files
Lms24claude
andauthored
feat(core): Emit low-cardinality cache.* span names (#23830)
This PR: - emits low cardinality `cache.*` span names following the cache span name rules proposed in getsentry/sentry-conventions#605 - affected integrations: Redis (cache), dataloader, nitro `captureStorageEvents`, nuxt `createStoragePlugin` - adjusts redis integration tests for static and streamed lifecycles and tests against correct names in each - deliberately NOT adjusts the redis logic to update db spans to cache spans. This will be tracked in a separate issue. closes #23818 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cfaa457 commit bffac10

16 files changed

Lines changed: 436 additions & 26 deletions

File tree

‎MIGRATION.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,7 @@ The following span names were adjusted:
923923
| `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`) |
924924
| `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`) |
925925
| `queue.receive` | The kafkajs operation name (`poll my-topic`) | The messaging operation type and the destination (`receive my-topic`) |
926+
| `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`) |
926927
| `db` (mongoose) | `mongoose.<Model>.<operation>` (`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 |
927928

928929
`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
959960

960961
Messaging span names now read `<operation type> <destination>` 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`.
961962

963+
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.
964+
965+
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.
966+
962967
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/<uuid> send`). The full ARN remains on `messaging.destination.name`.
963968

964969
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.

‎dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ test.describe('Cache Instrumentation', () => {
4747
if (cacheMissSpan) {
4848
expect(cacheMissSpan.attributes).toMatchObject({
4949
'sentry.op': { type: 'string', value: 'cache.get' },
50+
'cache.operation': { type: 'string', value: 'get' },
5051
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
5152
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: false },
5253
'db.operation.name': { type: 'string', value: 'getItem' },
@@ -64,6 +65,7 @@ test.describe('Cache Instrumentation', () => {
6465
if (cacheHitSpan) {
6566
expect(cacheHitSpan.attributes).toMatchObject({
6667
'sentry.op': { type: 'string', value: 'cache.get' },
68+
'cache.operation': { type: 'string', value: 'get' },
6769
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
6870
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true },
6971
'db.operation.name': { type: 'string', value: 'getItem' },
@@ -81,6 +83,7 @@ test.describe('Cache Instrumentation', () => {
8183
if (cacheSetSpan) {
8284
expect(cacheSetSpan.attributes).toMatchObject({
8385
'sentry.op': { type: 'string', value: 'cache.put' },
86+
'cache.operation': { type: 'string', value: 'put' },
8487
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
8588
'db.operation.name': { type: 'string', value: 'setItem' },
8689
'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) },

‎dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,35 +38,38 @@ test.describe('Storage Instrumentation - Aliases', () => {
3838
expect(setSpan).toBeDefined();
3939
expect(setSpan?.attributes).toMatchObject({
4040
'sentry.op': { type: 'string', value: 'cache.put' },
41+
'cache.operation': { type: 'string', value: 'put' },
4142
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
4243
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') },
4344
'db.operation.name': { type: 'string', value: 'setItem' },
4445
'db.collection.name': { type: 'string', value: 'test-storage' },
4546
'db.system.name': { type: 'string', value: 'memory' },
4647
});
47-
expect(setSpan?.name).toBe(prefixKey('alias:user'));
48+
expect(setSpan?.name).toBe('cache.put');
4849

4950
// Test get (alias for getItem)
5051
expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1);
5152
const getSpan = findByKey('getItem', prefixKey('alias:user'));
5253
expect(getSpan).toBeDefined();
5354
expect(getSpan?.attributes).toMatchObject({
5455
'sentry.op': { type: 'string', value: 'cache.get' },
56+
'cache.operation': { type: 'string', value: 'get' },
5557
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
5658
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') },
5759
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true },
5860
'db.operation.name': { type: 'string', value: 'getItem' },
5961
'db.collection.name': { type: 'string', value: 'test-storage' },
6062
'db.system.name': { type: 'string', value: 'memory' },
6163
});
62-
expect(getSpan?.name).toBe(prefixKey('alias:user'));
64+
expect(getSpan?.name).toBe('cache.get');
6365

6466
// Test has (alias for hasItem)
6567
expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1);
6668
const hasSpan = findByKey('hasItem', prefixKey('alias:user'));
6769
expect(hasSpan).toBeDefined();
6870
expect(hasSpan?.attributes).toMatchObject({
6971
'sentry.op': { type: 'string', value: 'cache.get' },
72+
'cache.operation': { type: 'string', value: 'get' },
7073
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
7174
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') },
7275
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true },
@@ -82,25 +85,27 @@ test.describe('Storage Instrumentation - Aliases', () => {
8285
expect(delSpan).toBeDefined();
8386
expect(delSpan?.attributes).toMatchObject({
8487
'sentry.op': { type: 'string', value: 'cache.remove' },
88+
'cache.operation': { type: 'string', value: 'remove' },
8589
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
8690
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp1') },
8791
'db.operation.name': { type: 'string', value: 'removeItem' },
8892
'db.collection.name': { type: 'string', value: 'test-storage' },
8993
'db.system.name': { type: 'string', value: 'memory' },
9094
});
91-
expect(delSpan?.name).toBe(prefixKey('alias:temp1'));
95+
expect(delSpan?.name).toBe('cache.remove');
9296

9397
const removeSpan = findByKey('removeItem', prefixKey('alias:temp2'));
9498
expect(removeSpan).toBeDefined();
9599
expect(removeSpan?.attributes).toMatchObject({
96100
'sentry.op': { type: 'string', value: 'cache.remove' },
101+
'cache.operation': { type: 'string', value: 'remove' },
97102
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
98103
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp2') },
99104
'db.operation.name': { type: 'string', value: 'removeItem' },
100105
'db.collection.name': { type: 'string', value: 'test-storage' },
101106
'db.system.name': { type: 'string', value: 'memory' },
102107
});
103-
expect(removeSpan?.name).toBe(prefixKey('alias:temp2'));
108+
expect(removeSpan?.name).toBe('cache.remove');
104109

105110
// Verify all spans have OK status
106111
expect(allStorageSpans.length).toBeGreaterThan(0);

‎dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ test.describe('Storage Instrumentation', () => {
3838
expect(setItemSpan).toBeDefined();
3939
expect(setItemSpan?.attributes).toMatchObject({
4040
'sentry.op': { type: 'string', value: 'cache.put' },
41+
'cache.operation': { type: 'string', value: 'put' },
4142
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
4243
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') },
4344
'db.operation.name': { type: 'string', value: 'setItem' },
4445
'db.collection.name': { type: 'string', value: 'test-storage' },
4546
'db.system.name': { type: 'string', value: 'memory' },
4647
});
4748

48-
expect(setItemSpan?.name).toBe(prefixKey('user:123'));
49+
expect(setItemSpan?.name).toBe('cache.put');
4950

5051
// Test setItemRaw spans
5152
expect(findSpansByMethod('setItemRaw').length).toBeGreaterThanOrEqual(1);
@@ -54,6 +55,7 @@ test.describe('Storage Instrumentation', () => {
5455
expect(setItemRawSpan).toBeDefined();
5556
expect(setItemRawSpan?.attributes).toMatchObject({
5657
'sentry.op': { type: 'string', value: 'cache.put' },
58+
'cache.operation': { type: 'string', value: 'put' },
5759
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
5860
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') },
5961
'db.operation.name': { type: 'string', value: 'setItemRaw' },
@@ -67,6 +69,7 @@ test.describe('Storage Instrumentation', () => {
6769
expect(hasItemSpan).toBeDefined();
6870
expect(hasItemSpan?.attributes).toMatchObject({
6971
'sentry.op': { type: 'string', value: 'cache.get' },
72+
'cache.operation': { type: 'string', value: 'get' },
7073
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
7174
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') },
7275
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true },
@@ -81,21 +84,23 @@ test.describe('Storage Instrumentation', () => {
8184
expect(getItemSpan).toBeDefined();
8285
expect(getItemSpan?.attributes).toMatchObject({
8386
'sentry.op': { type: 'string', value: 'cache.get' },
87+
'cache.operation': { type: 'string', value: 'get' },
8488
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
8589
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') },
8690
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true },
8791
'db.operation.name': { type: 'string', value: 'getItem' },
8892
'db.collection.name': { type: 'string', value: 'test-storage' },
8993
'db.system.name': { type: 'string', value: 'memory' },
9094
});
91-
expect(getItemSpan?.name).toBe(prefixKey('user:123'));
95+
expect(getItemSpan?.name).toBe('cache.get');
9296

9397
// Test getItemRaw spans - should have cache hit attribute
9498
expect(findSpansByMethod('getItemRaw').length).toBeGreaterThanOrEqual(1);
9599
const getItemRawSpan = findSpanByCacheKey('getItemRaw', prefixKey('raw:data'));
96100
expect(getItemRawSpan).toBeDefined();
97101
expect(getItemRawSpan?.attributes).toMatchObject({
98102
'sentry.op': { type: 'string', value: 'cache.get' },
103+
'cache.operation': { type: 'string', value: 'get' },
99104
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
100105
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') },
101106
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true },
@@ -109,6 +114,7 @@ test.describe('Storage Instrumentation', () => {
109114
expect(getKeysSpans.length).toBeGreaterThanOrEqual(1);
110115
expect(getKeysSpans[0]?.attributes).toMatchObject({
111116
'sentry.op': { type: 'string', value: 'cache.get' },
117+
'cache.operation': { type: 'string', value: 'get' },
112118
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
113119
'db.operation.name': { type: 'string', value: 'getKeys' },
114120
'db.collection.name': { type: 'string', value: 'test-storage' },
@@ -121,6 +127,7 @@ test.describe('Storage Instrumentation', () => {
121127
expect(removeItemSpan).toBeDefined();
122128
expect(removeItemSpan?.attributes).toMatchObject({
123129
'sentry.op': { type: 'string', value: 'cache.remove' },
130+
'cache.operation': { type: 'string', value: 'remove' },
124131
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
125132
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('batch:1') },
126133
'db.operation.name': { type: 'string', value: 'removeItem' },
@@ -133,6 +140,7 @@ test.describe('Storage Instrumentation', () => {
133140
expect(clearSpans.length).toBeGreaterThanOrEqual(1);
134141
expect(clearSpans[0]?.attributes).toMatchObject({
135142
'sentry.op': { type: 'string', value: 'cache.remove' },
143+
'cache.operation': { type: 'string', value: 'remove' },
136144
'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' },
137145
'db.operation.name': { type: 'string', value: 'clear' },
138146
'db.collection.name': { type: 'string', value: 'test-storage' },

‎dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
5+
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
66
dsn: 'https://public@dsn.ingest.sentry.io/1337',
77
release: '1.0',
88
tracesSampleRate: 1.0,

‎dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SerializedStreamedSpanContainer } from '@sentry/core';
12
import { afterAll, describe, expect } from 'vitest';
23
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
34

@@ -103,6 +104,7 @@ describe('dataloader auto-instrumentation', () => {
103104
expect(namedLoadSpan?.op).toBe(CACHE_GET_OP);
104105
expect(namedLoadSpan?.origin).toBe(ORIGIN);
105106
expect(namedLoadSpan?.status).toBe('ok');
107+
expect(namedLoadSpan?.data?.['db.collection.name']).toBe('usersLoader');
106108
},
107109
})
108110
.start();
@@ -113,5 +115,37 @@ describe('dataloader auto-instrumentation', () => {
113115
await runner.makeRequest('get', '/named');
114116
await runner.completed();
115117
}, 30_000);
118+
119+
test('names spans after the cache operation when streamed', async () => {
120+
const runner = createRunner()
121+
.withEnv({ STREAMED: 'true' })
122+
.expect({
123+
span: (container: SerializedStreamedSpanContainer) => {
124+
const namedLoadSpan = container.items.find(
125+
span => span.attributes?.['db.operation.name']?.value === 'load',
126+
);
127+
expect(namedLoadSpan?.name).toBe('cache.get');
128+
expect(namedLoadSpan?.attributes?.['sentry.op']?.value).toBe(CACHE_GET_OP);
129+
expect(namedLoadSpan?.attributes?.['cache.operation']?.value).toBe('get');
130+
// The loader name is no longer part of the span name, it moved to `db.collection.name`.
131+
expect(namedLoadSpan?.attributes?.['db.collection.name']?.value).toBe('usersLoader');
132+
},
133+
})
134+
.expect({
135+
span: (container: SerializedStreamedSpanContainer) => {
136+
for (const [operation, op] of Object.entries(CACHE_MUTATION_OPS)) {
137+
const span = container.items.find(item => item.attributes?.['db.operation.name']?.value === operation);
138+
expect(span, `expected a ${operation} span`).toBeDefined();
139+
expect(span?.name).toBe(op);
140+
expect(span?.attributes?.['sentry.op']?.value).toBe(op);
141+
}
142+
},
143+
})
144+
.start();
145+
146+
await runner.makeRequest('get', '/named');
147+
await runner.makeRequest('get', '/cache-ops');
148+
await runner.completed();
149+
}, 30_000);
116150
});
117151
});

‎dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-ioredis.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
5+
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
66
dsn: 'https://public@dsn.ingest.sentry.io/1337',
77
release: '1.0',
88
tracesSampleRate: 1.0,

‎dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-4.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
5+
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
66
dsn: 'https://public@dsn.ingest.sentry.io/1337',
77
release: '1.0',
88
tracesSampleRate: 1.0,

‎dev-packages/node-integration-tests/suites/tracing/redis-cache/instrument-redis-5.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
5+
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
66
dsn: 'https://public@dsn.ingest.sentry.io/1337',
77
release: '1.0',
88
tracesSampleRate: 1.0,

0 commit comments

Comments
 (0)