Skip to content

Commit ebbbc65

Browse files
committed
feat(deno): redis diagnostics channel based integration for deno (#21087)
Refactor the redis and ioredis diagnostics_channel integration logic into core/src/integrations, and create a Deno integration that uses the same patterns. Instead of the @sentry/opentelemetry/tracing-channel, the Deno integration adds `_sentrySpan` onto the data in a RedisTracingChannelFactory which is passed to the core utility. Add deno-redis e2e test covering both redis clients. fix: #21221 fix: JS-2630
1 parent 40c6e62 commit ebbbc65

28 files changed

Lines changed: 1465 additions & 561 deletions

File tree

.github/workflows/build.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ jobs:
537537
- name: Set up Deno
538538
uses: denoland/setup-deno@v2.0.4
539539
with:
540-
deno-version: v2.1.5
540+
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
541541
- name: Restore caches
542542
uses: ./.github/actions/restore-cache
543543
with:
@@ -996,10 +996,12 @@ jobs:
996996
use-installer: true
997997
token: ${{ secrets.GITHUB_TOKEN }}
998998
- name: Set up Deno
999-
if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed'
999+
if:
1000+
matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application ==
1001+
'deno-redis'
10001002
uses: denoland/setup-deno@v2.0.4
10011003
with:
1002-
deno-version: ${{ matrix.deno-version || 'v2.1.5' }}
1004+
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
10031005
- name: Restore caches
10041006
uses: ./.github/actions/restore-cache
10051007
with:
@@ -1122,7 +1124,7 @@ jobs:
11221124
if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed'
11231125
uses: denoland/setup-deno@v2.0.4
11241126
with:
1125-
deno-version: ${{ matrix.deno-version || 'v2.1.5' }}
1127+
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
11261128
- name: Restore caches
11271129
uses: ./.github/actions/restore-cache
11281130
with:
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"imports": {
3+
"@sentry/deno": "npm:@sentry/deno",
4+
"ioredis": "npm:ioredis@^5.11.0",
5+
"redis": "npm:redis@^5.12.0"
6+
},
7+
"nodeModulesDir": "manual"
8+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
services:
2+
redis:
3+
image: redis:8
4+
restart: always
5+
container_name: e2e-tests-deno-redis
6+
ports:
7+
- '6379:6379'
8+
healthcheck:
9+
test: ['CMD', 'redis-cli', 'ping']
10+
interval: 1s
11+
timeout: 3s
12+
retries: 30
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalSetup() {
8+
// Start Redis via Docker Compose. `--wait` blocks until the healthcheck
9+
// in docker-compose.yml passes, so the Deno app can connect immediately.
10+
execSync('docker compose up -d --wait', {
11+
cwd: __dirname,
12+
stdio: 'inherit',
13+
});
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalTeardown() {
8+
execSync('docker compose down --volumes', {
9+
cwd: __dirname,
10+
stdio: 'inherit',
11+
});
12+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "deno-redis",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts",
7+
"test": "playwright test",
8+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
9+
"test:build": "pnpm install",
10+
"test:assert": "pnpm test"
11+
},
12+
"dependencies": {
13+
"@sentry/deno": "file:../../packed/sentry-deno-packed.tgz",
14+
"ioredis": "^5.11.0",
15+
"redis": "^5.12.0"
16+
},
17+
"devDependencies": {
18+
"@playwright/test": "~1.56.0",
19+
"@sentry-internal/test-utils": "link:../../../test-utils"
20+
},
21+
"volta": {
22+
"extends": "../../package.json"
23+
}
24+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
port: 3030,
6+
});
7+
8+
export default {
9+
...config,
10+
globalSetup: './global-setup.mjs',
11+
globalTeardown: './global-teardown.mjs',
12+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import * as Sentry from '@sentry/deno';
2+
import IORedis from 'ioredis';
3+
import { createClient } from 'redis';
4+
5+
Sentry.init({
6+
environment: 'qa',
7+
dsn: Deno.env.get('E2E_TEST_DSN'),
8+
debug: !!Deno.env.get('DEBUG'),
9+
tunnel: 'http://localhost:3031/',
10+
tracesSampleRate: 1,
11+
});
12+
13+
const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379';
14+
15+
// One shared client per process. node-redis publishes to the
16+
// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every
17+
// operation on this client; denoRedisIntegration is already subscribed to
18+
// those.
19+
const redis = createClient({ url: redisUrl });
20+
function onRedisError(err: unknown) {
21+
// eslint-disable-next-line no-console
22+
console.error('redis client error', err);
23+
}
24+
redis.on('error', onRedisError);
25+
await redis.connect();
26+
27+
// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command`
28+
// and `ioredis:connect` channels, which denoRedisIntegration also subscribes
29+
// to. lazyConnect so we can yield a microtick before connecting and ensure
30+
// the DC subscriber is registered before ioredis creates its tracing channels.
31+
await Promise.resolve();
32+
const ioredisUrl = new URL(redisUrl);
33+
const ioredis = new IORedis({
34+
host: ioredisUrl.hostname,
35+
port: Number(ioredisUrl.port) || 6379,
36+
lazyConnect: true,
37+
});
38+
function onIoredisError(err: unknown) {
39+
// eslint-disable-next-line no-console
40+
console.error('ioredis client error', err);
41+
}
42+
ioredis.on('error', onIoredisError);
43+
await ioredis.connect();
44+
45+
const port = 3030;
46+
47+
Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => {
48+
const url = new URL(req.url);
49+
50+
// node-redis: GET — exercises the command channel, success path.
51+
if (url.pathname === '/redis-get') {
52+
const key = url.searchParams.get('key') ?? 'cache:key';
53+
const value = await redis.get(key);
54+
return Response.json({ key, value });
55+
}
56+
57+
// node-redis: SET then GET — exercises two commands inside a single
58+
// transaction so we can assert the parent has two db.redis children.
59+
if (url.pathname === '/redis-set-get') {
60+
const key = url.searchParams.get('key') ?? 'cache:key';
61+
const value = url.searchParams.get('value') ?? 'hello';
62+
await redis.set(key, value);
63+
const echoed = await redis.get(key);
64+
return Response.json({ key, value: echoed });
65+
}
66+
67+
// node-redis: MULTI — exercises the batch channel.
68+
if (url.pathname === '/redis-multi') {
69+
const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec();
70+
return Response.json({ result });
71+
}
72+
73+
// ioredis: GET — exercises the ioredis:command channel.
74+
if (url.pathname === '/ioredis-get') {
75+
const key = url.searchParams.get('key') ?? 'iocache:key';
76+
const value = await ioredis.get(key);
77+
return Response.json({ key, value });
78+
}
79+
80+
// ioredis: SET then GET — two commands inside a transaction.
81+
if (url.pathname === '/ioredis-set-get') {
82+
const key = url.searchParams.get('key') ?? 'iocache:key';
83+
const value = url.searchParams.get('value') ?? 'hello';
84+
await ioredis.set(key, value);
85+
const echoed = await ioredis.get(key);
86+
return Response.json({ key, value: echoed });
87+
}
88+
89+
// ioredis: MULTI — ioredis has no separate batch channel; per-command
90+
// payloads carry `batchMode`/`batchSize` instead, so we still expect one
91+
// db.redis span per command.
92+
if (url.pathname === '/ioredis-multi') {
93+
const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec();
94+
return Response.json({ result });
95+
}
96+
97+
// ioredis: PIPELINE — same shape as MULTI from the perspective of the
98+
// diagnostics channel.
99+
if (url.pathname === '/ioredis-pipeline') {
100+
const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec();
101+
return Response.json({ result });
102+
}
103+
104+
if (url.pathname === '/redis-disconnect') {
105+
redis.off('error', onRedisError);
106+
redis.close();
107+
ioredis.off('error', onIoredisError);
108+
ioredis.disconnect();
109+
return new Response('ok');
110+
}
111+
112+
return new Response('Not found', { status: 404 });
113+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'deno-redis',
6+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('ioredis GET emits an http.server transaction containing a db.redis child span', async ({ baseURL }) => {
5+
// Each incoming request gets a Sentry http.server transaction (via the
6+
// default denoServeIntegration); the ioredis command runs inside it, so the
7+
// child span attaches to that transaction.
8+
const transactionPromise = waitForTransaction('deno-redis', event => {
9+
return (
10+
event?.contexts?.trace?.op === 'http.server' &&
11+
(event.request?.url ?? '').includes('/ioredis-get') &&
12+
(event.spans?.some(span => span.op === 'db.redis') ?? false)
13+
);
14+
});
15+
16+
const res = await fetch(`${baseURL}/ioredis-get?key=iocache:user:42`);
17+
expect(res.status).toBe(200);
18+
await res.json();
19+
20+
const transaction = await transactionPromise;
21+
const redisSpan = transaction.spans!.find(span => span.op === 'db.redis');
22+
expect(redisSpan).toBeDefined();
23+
// ioredis publishes lowercase command names; node-redis publishes uppercase.
24+
expect(redisSpan!.description).toBe('redis-get');
25+
expect(redisSpan!.data?.['db.system']).toBe('redis');
26+
expect(redisSpan!.data?.['db.statement']).toBe('get iocache:user:42');
27+
});
28+
29+
test('ioredis SET then GET emit two db.redis child spans on the same transaction', async ({ baseURL }) => {
30+
const transactionPromise = waitForTransaction('deno-redis', event => {
31+
return (
32+
event?.contexts?.trace?.op === 'http.server' &&
33+
(event.request?.url ?? '').includes('/ioredis-set-get') &&
34+
(event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 2
35+
);
36+
});
37+
38+
const res = await fetch(`${baseURL}/ioredis-set-get?key=iocache:greeting&value=hello`);
39+
expect(res.status).toBe(200);
40+
await res.json();
41+
42+
const transaction = await transactionPromise;
43+
const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
44+
expect(redisSpans.length).toBeGreaterThanOrEqual(2);
45+
const ops = redisSpans.map(s => s.description);
46+
expect(ops).toContain('redis-set');
47+
expect(ops).toContain('redis-get');
48+
});
49+
50+
test('ioredis MULTI emits one db.redis span per command (no batch channel)', async ({ baseURL }) => {
51+
// ioredis does not publish to a batch channel — each command in the
52+
// transaction publishes individually with batchMode/batchSize set on its
53+
// own payload. So the transaction should contain multiple `redis-<cmd>`
54+
// child spans, but no PIPELINE/MULTI batch span.
55+
const transactionPromise = waitForTransaction('deno-redis', event => {
56+
return (
57+
event?.contexts?.trace?.op === 'http.server' &&
58+
(event.request?.url ?? '').includes('/ioredis-multi') &&
59+
(event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 3
60+
);
61+
});
62+
63+
const res = await fetch(`${baseURL}/ioredis-multi`);
64+
expect(res.status).toBe(200);
65+
await res.json();
66+
67+
const transaction = await transactionPromise;
68+
const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
69+
expect(redisSpans.length).toBeGreaterThanOrEqual(3);
70+
const descriptions = redisSpans.map(s => s.description);
71+
expect(descriptions).toContain('redis-set');
72+
expect(descriptions).toContain('redis-get');
73+
// No PIPELINE/MULTI batch wrapper span — ioredis has no separate batch channel.
74+
const batchSpan = transaction.spans!.find(span => span.description === 'MULTI' || span.description === 'PIPELINE');
75+
expect(batchSpan).toBeUndefined();
76+
});
77+
78+
test('ioredis PIPELINE emits one db.redis span per command', async ({ baseURL }) => {
79+
const transactionPromise = waitForTransaction('deno-redis', event => {
80+
return (
81+
event?.contexts?.trace?.op === 'http.server' &&
82+
(event.request?.url ?? '').includes('/ioredis-pipeline') &&
83+
(event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 3
84+
);
85+
});
86+
87+
const res = await fetch(`${baseURL}/ioredis-pipeline`);
88+
expect(res.status).toBe(200);
89+
await res.json();
90+
91+
const transaction = await transactionPromise;
92+
const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
93+
expect(redisSpans.length).toBeGreaterThanOrEqual(3);
94+
});

0 commit comments

Comments
 (0)