Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ jobs:
- name: Set up Deno
uses: denoland/setup-deno@v2.0.4
with:
deno-version: v2.1.5
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
- name: Restore caches
uses: ./.github/actions/restore-cache
with:
Expand Down Expand Up @@ -996,10 +996,12 @@ jobs:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Deno
if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed'
if:
matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application ==
'deno-redis'
uses: denoland/setup-deno@v2.0.4
with:
deno-version: ${{ matrix.deno-version || 'v2.1.5' }}
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
- name: Restore caches
uses: ./.github/actions/restore-cache
with:
Expand Down Expand Up @@ -1122,7 +1124,7 @@ jobs:
if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed'
uses: denoland/setup-deno@v2.0.4
with:
deno-version: ${{ matrix.deno-version || 'v2.1.5' }}
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
- name: Restore caches
uses: ./.github/actions/restore-cache
with:
Expand Down
8 changes: 8 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno-redis/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"imports": {
"@sentry/deno": "npm:@sentry/deno",
"ioredis": "npm:ioredis@^5.11.0",
"redis": "npm:redis@^5.12.0"
},
"nodeModulesDir": "manual"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
redis:
image: redis:8
restart: always
container_name: e2e-tests-deno-redis
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 1s
timeout: 3s
retries: 30
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalSetup() {
// Start Redis via Docker Compose. `--wait` blocks until the healthcheck
// in docker-compose.yml passes, so the Deno app can connect immediately.
execSync('docker compose up -d --wait', {
cwd: __dirname,
stdio: 'inherit',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalTeardown() {
execSync('docker compose down --volumes', {
cwd: __dirname,
stdio: 'inherit',
});
}
24 changes: 24 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno-redis/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "deno-redis",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts",
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install",
"test:assert": "pnpm test"
},
"dependencies": {
"@sentry/deno": "file:../../packed/sentry-deno-packed.tgz",
"ioredis": "^5.11.0",
"redis": "^5.12.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: `pnpm start`,
port: 3030,
});

export default {
...config,
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
};
113 changes: 113 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as Sentry from '@sentry/deno';
import IORedis from 'ioredis';
import { createClient } from 'redis';

Sentry.init({
environment: 'qa',
dsn: Deno.env.get('E2E_TEST_DSN'),
debug: !!Deno.env.get('DEBUG'),
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1,
});

const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379';

// One shared client per process. node-redis publishes to the
// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every
// operation on this client; denoRedisIntegration is already subscribed to
// those.
const redis = createClient({ url: redisUrl });
function onRedisError(err: unknown) {
// eslint-disable-next-line no-console
console.error('redis client error', err);
}
redis.on('error', onRedisError);
await redis.connect();

// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command`
// and `ioredis:connect` channels, which denoRedisIntegration also subscribes
// to. lazyConnect so we can yield a microtick before connecting and ensure
// the DC subscriber is registered before ioredis creates its tracing channels.
await Promise.resolve();
const ioredisUrl = new URL(redisUrl);
const ioredis = new IORedis({
host: ioredisUrl.hostname,
port: Number(ioredisUrl.port) || 6379,
lazyConnect: true,
});
function onIoredisError(err: unknown) {
// eslint-disable-next-line no-console
console.error('ioredis client error', err);
}
ioredis.on('error', onIoredisError);
await ioredis.connect();

const port = 3030;

Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => {
const url = new URL(req.url);

// node-redis: GET — exercises the command channel, success path.
if (url.pathname === '/redis-get') {
const key = url.searchParams.get('key') ?? 'cache:key';
const value = await redis.get(key);
return Response.json({ key, value });
}

// node-redis: SET then GET — exercises two commands inside a single
// transaction so we can assert the parent has two db.redis children.
if (url.pathname === '/redis-set-get') {
const key = url.searchParams.get('key') ?? 'cache:key';
const value = url.searchParams.get('value') ?? 'hello';
await redis.set(key, value);
const echoed = await redis.get(key);
return Response.json({ key, value: echoed });
}

// node-redis: MULTI — exercises the batch channel.
if (url.pathname === '/redis-multi') {
const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec();
return Response.json({ result });
}

// ioredis: GET — exercises the ioredis:command channel.
if (url.pathname === '/ioredis-get') {
const key = url.searchParams.get('key') ?? 'iocache:key';
const value = await ioredis.get(key);
return Response.json({ key, value });
}

// ioredis: SET then GET — two commands inside a transaction.
if (url.pathname === '/ioredis-set-get') {
const key = url.searchParams.get('key') ?? 'iocache:key';
const value = url.searchParams.get('value') ?? 'hello';
await ioredis.set(key, value);
const echoed = await ioredis.get(key);
return Response.json({ key, value: echoed });
}

// ioredis: MULTI — ioredis has no separate batch channel; per-command
// payloads carry `batchMode`/`batchSize` instead, so we still expect one
// db.redis span per command.
if (url.pathname === '/ioredis-multi') {
const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec();
return Response.json({ result });
}

// ioredis: PIPELINE — same shape as MULTI from the perspective of the
// diagnostics channel.
if (url.pathname === '/ioredis-pipeline') {
const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec();
return Response.json({ result });
}

if (url.pathname === '/redis-disconnect') {
redis.off('error', onRedisError);
redis.close();
ioredis.off('error', onIoredisError);
ioredis.disconnect();
return new Response('ok');
}

return new Response('Not found', { status: 404 });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'deno-redis',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('ioredis GET emits an http.server transaction containing a db.redis child span', async ({ baseURL }) => {
// Each incoming request gets a Sentry http.server transaction (via the
// default denoServeIntegration); the ioredis command runs inside it, so the
// child span attaches to that transaction.
const transactionPromise = waitForTransaction('deno-redis', event => {
return (
event?.contexts?.trace?.op === 'http.server' &&
(event.request?.url ?? '').includes('/ioredis-get') &&
(event.spans?.some(span => span.op === 'db.redis') ?? false)
);
});

const res = await fetch(`${baseURL}/ioredis-get?key=iocache:user:42`);
expect(res.status).toBe(200);
await res.json();

const transaction = await transactionPromise;
const redisSpan = transaction.spans!.find(span => span.op === 'db.redis');
expect(redisSpan).toBeDefined();
// ioredis publishes lowercase command names; node-redis publishes uppercase.
expect(redisSpan!.description).toBe('redis-get');
expect(redisSpan!.data?.['db.system']).toBe('redis');
expect(redisSpan!.data?.['db.statement']).toBe('get iocache:user:42');
});

test('ioredis SET then GET emit two db.redis child spans on the same transaction', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('deno-redis', event => {
return (
event?.contexts?.trace?.op === 'http.server' &&
(event.request?.url ?? '').includes('/ioredis-set-get') &&
(event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 2
);
});

const res = await fetch(`${baseURL}/ioredis-set-get?key=iocache:greeting&value=hello`);
expect(res.status).toBe(200);
await res.json();

const transaction = await transactionPromise;
const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
expect(redisSpans.length).toBeGreaterThanOrEqual(2);
const ops = redisSpans.map(s => s.description);
expect(ops).toContain('redis-set');
expect(ops).toContain('redis-get');
});

test('ioredis MULTI emits one db.redis span per command (no batch channel)', async ({ baseURL }) => {
// ioredis does not publish to a batch channel — each command in the
// transaction publishes individually with batchMode/batchSize set on its
// own payload. So the transaction should contain multiple `redis-<cmd>`
// child spans, but no PIPELINE/MULTI batch span.
const transactionPromise = waitForTransaction('deno-redis', event => {
return (
event?.contexts?.trace?.op === 'http.server' &&
(event.request?.url ?? '').includes('/ioredis-multi') &&
(event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 3
);
});

const res = await fetch(`${baseURL}/ioredis-multi`);
expect(res.status).toBe(200);
await res.json();

const transaction = await transactionPromise;
const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
expect(redisSpans.length).toBeGreaterThanOrEqual(3);
const descriptions = redisSpans.map(s => s.description);
expect(descriptions).toContain('redis-set');
expect(descriptions).toContain('redis-get');
// No PIPELINE/MULTI batch wrapper span — ioredis has no separate batch channel.
const batchSpan = transaction.spans!.find(span => span.description === 'MULTI' || span.description === 'PIPELINE');
expect(batchSpan).toBeUndefined();
});

test('ioredis PIPELINE emits one db.redis span per command', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('deno-redis', event => {
return (
event?.contexts?.trace?.op === 'http.server' &&
(event.request?.url ?? '').includes('/ioredis-pipeline') &&
(event.spans?.filter(span => span.op === 'db.redis').length ?? 0) >= 3
);
});

const res = await fetch(`${baseURL}/ioredis-pipeline`);
expect(res.status).toBe(200);
await res.json();

const transaction = await transactionPromise;
const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
expect(redisSpans.length).toBeGreaterThanOrEqual(3);
});
Loading
Loading