Skip to content

Commit 257bcb0

Browse files
s1gr1dmydea
andauthored
feat(redis-cache): Create cache-span with prefixed keys (get/set commands) (#12070)
Populates the OTel span with cache attributes. Currently, `get` and `set` commands are considered. --------- Co-authored-by: Francesco Novy <[email protected]>
1 parent f1d1a16 commit 257bcb0

File tree

7 files changed

+231
-6
lines changed

7 files changed

+231
-6
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: '3.9'
2+
3+
services:
4+
db:
5+
image: redis:latest
6+
restart: always
7+
container_name: integration-tests-redis
8+
ports:
9+
- '6379:6379'
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const { loggingTransport } = require('@sentry-internal/node-integration-tests');
2+
const Sentry = require('@sentry/node');
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
integrations: [Sentry.redisIntegration({ cachePrefixes: ['ioredis-cache:'] })],
10+
});
11+
12+
// Stop the process from exiting before the transaction is sent
13+
setInterval(() => {}, 1000);
14+
15+
const Redis = require('ioredis');
16+
17+
const redis = new Redis({ port: 6379 });
18+
19+
async function run() {
20+
await Sentry.startSpan(
21+
{
22+
name: 'Test Span',
23+
op: 'test-span',
24+
},
25+
async () => {
26+
try {
27+
await redis.set('test-key', 'test-value');
28+
await redis.set('ioredis-cache:test-key', 'test-value');
29+
30+
await redis.get('test-key');
31+
await redis.get('ioredis-cache:test-key');
32+
await redis.get('ioredis-cache:unavailable-data');
33+
} finally {
34+
await redis.disconnect();
35+
}
36+
},
37+
);
38+
}
39+
40+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
41+
run();
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
2+
3+
describe('redis auto instrumentation', () => {
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('should not add cache spans when key is not prefixed', done => {
9+
const EXPECTED_TRANSACTION = {
10+
transaction: 'Test Span',
11+
spans: expect.arrayContaining([
12+
expect.objectContaining({
13+
description: 'set test-key [1 other arguments]',
14+
op: 'db',
15+
data: expect.objectContaining({
16+
'sentry.op': 'db',
17+
'db.system': 'redis',
18+
'net.peer.name': 'localhost',
19+
'net.peer.port': 6379,
20+
'db.statement': 'set test-key [1 other arguments]',
21+
}),
22+
}),
23+
expect.objectContaining({
24+
description: 'get test-key',
25+
op: 'db',
26+
data: expect.objectContaining({
27+
'sentry.op': 'db',
28+
'db.system': 'redis',
29+
'net.peer.name': 'localhost',
30+
'net.peer.port': 6379,
31+
'db.statement': 'get test-key',
32+
}),
33+
}),
34+
]),
35+
};
36+
37+
createRunner(__dirname, 'scenario-ioredis.js')
38+
.withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port=6379'] })
39+
.expect({ transaction: EXPECTED_TRANSACTION })
40+
.start(done);
41+
});
42+
43+
test('should create cache spans for prefixed keys', done => {
44+
const EXPECTED_TRANSACTION = {
45+
transaction: 'Test Span',
46+
spans: expect.arrayContaining([
47+
// SET
48+
expect.objectContaining({
49+
description: 'set ioredis-cache:test-key [1 other arguments]',
50+
op: 'cache.put',
51+
data: expect.objectContaining({
52+
'db.statement': 'set ioredis-cache:test-key [1 other arguments]',
53+
'cache.key': 'ioredis-cache:test-key',
54+
'cache.item_size': 2,
55+
'network.peer.address': 'localhost',
56+
'network.peer.port': 6379,
57+
}),
58+
}),
59+
// GET
60+
expect.objectContaining({
61+
description: 'get ioredis-cache:test-key',
62+
op: 'cache.get_item', // todo: will be changed to cache.get
63+
data: expect.objectContaining({
64+
'db.statement': 'get ioredis-cache:test-key',
65+
'cache.hit': true,
66+
'cache.key': 'ioredis-cache:test-key',
67+
'cache.item_size': 10,
68+
'network.peer.address': 'localhost',
69+
'network.peer.port': 6379,
70+
}),
71+
}),
72+
// GET (unavailable)
73+
expect.objectContaining({
74+
description: 'get ioredis-cache:unavailable-data',
75+
op: 'cache.get_item', // todo: will be changed to cache.get
76+
data: expect.objectContaining({
77+
'db.statement': 'get ioredis-cache:unavailable-data',
78+
'cache.hit': false,
79+
'cache.key': 'ioredis-cache:unavailable-data',
80+
'network.peer.address': 'localhost',
81+
'network.peer.port': 6379,
82+
}),
83+
}),
84+
]),
85+
};
86+
87+
createRunner(__dirname, 'scenario-ioredis.js')
88+
.withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port=6379'] })
89+
.expect({ transaction: EXPECTED_TRANSACTION })
90+
.start(done);
91+
});
92+
});

dev-packages/node-integration-tests/suites/tracing/redis/scenario-ioredis.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const redis = new Redis({ port: 6379 });
1818
async function run() {
1919
await Sentry.startSpan(
2020
{
21-
name: 'Test Transaction',
22-
op: 'transaction',
21+
name: 'Test Span',
22+
op: 'test-span',
2323
},
2424
async () => {
2525
try {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ describe('redis auto instrumentation', () => {
77

88
test('should auto-instrument `ioredis` package when using redis.set() and redis.get()', done => {
99
const EXPECTED_TRANSACTION = {
10-
transaction: 'Test Transaction',
10+
transaction: 'Test Span',
1111
spans: expect.arrayContaining([
1212
expect.objectContaining({
1313
description: 'set test-key [1 other arguments]',
1414
op: 'db',
1515
data: expect.objectContaining({
16+
'sentry.op': 'db',
1617
'db.system': 'redis',
1718
'net.peer.name': 'localhost',
1819
'net.peer.port': 6379,
@@ -23,6 +24,7 @@ describe('redis auto instrumentation', () => {
2324
description: 'get test-key',
2425
op: 'db',
2526
data: expect.objectContaining({
27+
'sentry.op': 'db',
2628
'db.system': 'redis',
2729
'net.peer.name': 'localhost',
2830
'net.peer.port': 6379,

packages/core/src/semanticAttributes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_v
3535
export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'sentry.profile_id';
3636

3737
export const SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME = 'sentry.exclusive_time';
38+
39+
export const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit';
40+
41+
export const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key';
42+
43+
export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size';

packages/node/src/integrations/tracing/redis.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,89 @@
11
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
2-
import { defineIntegration } from '@sentry/core';
2+
import {
3+
SEMANTIC_ATTRIBUTE_CACHE_HIT,
4+
SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE,
5+
SEMANTIC_ATTRIBUTE_CACHE_KEY,
6+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
7+
defineIntegration,
8+
spanToJSON,
9+
} from '@sentry/core';
310
import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry';
411
import type { IntegrationFn } from '@sentry/types';
512

6-
const _redisIntegration = (() => {
13+
function keyHasPrefix(key: string, prefixes: string[]): boolean {
14+
return prefixes.some(prefix => key.startsWith(prefix));
15+
}
16+
17+
/** Currently, caching only supports 'get' and 'set' commands. More commands will be added (setex, mget, del, expire) */
18+
function shouldConsiderForCache(
19+
redisCommand: string,
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
key: string | number | any[] | Buffer,
22+
prefixes: string[],
23+
): boolean {
24+
return (redisCommand === 'get' || redisCommand === 'set') && typeof key === 'string' && keyHasPrefix(key, prefixes);
25+
}
26+
27+
function calculateCacheItemSize(response: unknown): number | undefined {
28+
try {
29+
if (Buffer.isBuffer(response)) return response.byteLength;
30+
else if (typeof response === 'string') return response.length;
31+
else if (typeof response === 'number') return response.toString().length;
32+
else if (response === null || response === undefined) return 0;
33+
return JSON.stringify(response).length;
34+
} catch (e) {
35+
return undefined;
36+
}
37+
}
38+
39+
interface RedisOptions {
40+
cachePrefixes?: string[];
41+
}
42+
43+
const _redisIntegration = ((options?: RedisOptions) => {
744
return {
845
name: 'Redis',
946
setupOnce() {
1047
addOpenTelemetryInstrumentation([
11-
new IORedisInstrumentation({}),
48+
new IORedisInstrumentation({
49+
responseHook: (span, redisCommand, cmdArgs, response) => {
50+
const key = cmdArgs[0];
51+
52+
if (!options?.cachePrefixes || !shouldConsiderForCache(redisCommand, key, options.cachePrefixes)) {
53+
// not relevant for cache
54+
return;
55+
}
56+
57+
// otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199
58+
// We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/
59+
const networkPeerAddress = spanToJSON(span).data?.['net.peer.name'];
60+
const networkPeerPort = spanToJSON(span).data?.['net.peer.port'];
61+
if (networkPeerPort && networkPeerAddress) {
62+
span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort });
63+
}
64+
65+
const cacheItemSize = calculateCacheItemSize(response);
66+
if (cacheItemSize) span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize);
67+
68+
if (typeof key === 'string') {
69+
switch (redisCommand) {
70+
case 'get':
71+
span.setAttributes({
72+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', // todo: will be changed to cache.get
73+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: key,
74+
});
75+
if (cacheItemSize !== undefined) span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0);
76+
break;
77+
case 'set':
78+
span.setAttributes({
79+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put',
80+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: key,
81+
});
82+
break;
83+
}
84+
}
85+
},
86+
}),
1287
// todo: implement them gradually
1388
// new LegacyRedisInstrumentation({}),
1489
// new RedisInstrumentation({}),

0 commit comments

Comments
 (0)