Skip to content

Commit 02870de

Browse files
msonnbclaude
andauthored
test(e2e): Port Deno db E2E test apps to span streaming (#24003)
Ports `deno-mysql`, `deno-pg` and `deno-redis` to span streaming: the `traceLifecycle: 'static'` pin from #22590 is removed and the specs assert on streamed spans instead of transactions. Second of two PRs for #23812, after #24002 which ported `deno` and added `deno-static`. Decisions taken: - `Deno.serve` has no route information, so streamed `http.server` segments are named `GET` only. The specs identify requests via the `url.path` attribute. - Streamed mysql and pg spans are named after the query summary (`SELECT` for both queries), so the specs match queries on `db.query.text` and additionally assert every db span is a direct child of the request segment. - Streamed redis command spans are named `{db.operation.name} {server.address}:{server.port}` since `db.query.text` carries the key. The specs derive the expected name from those attributes rather than hardcoding the host, and match commands on `db.operation.name` instead of the old `redis-<cmd>` names. `MULTI`/`PIPELINE` batch spans keep their names. Fixes #23812 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 6d3aed3 commit 02870de

7 files changed

Lines changed: 211 additions & 165 deletions

File tree

‎dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import '@sentry/deno/import';
66
import * as Sentry from '@sentry/deno';
77

88
Sentry.init({
9-
traceLifecycle: 'static',
109
environment: 'qa',
1110
dsn: Deno.env.get('E2E_TEST_DSN'),
1211
debug: !!Deno.env.get('DEBUG'),
Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,63 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3+
import type { SerializedStreamedSpan } from '@sentry/core';
4+
5+
// `Deno.serve` has no route information, so with span streaming the http.server segment is
6+
// named after the method only; the path lives in `url.path`.
7+
function isTestMysqlSegment(span: SerializedStreamedSpan): boolean {
8+
return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-mysql';
9+
}
310

411
test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => {
5-
// Each incoming request gets a Sentry http.server transaction (via the
12+
// Each incoming request gets a Sentry http.server segment span (via the
613
// default denoServeIntegration); the mysql queries run inside it, so their
7-
// db spans attach to that transaction.
8-
const transactionPromise = waitForTransaction('deno-mysql', event => {
9-
return (
10-
event?.contexts?.trace?.op === 'http.server' &&
11-
(event.request?.url ?? '').includes('/test-mysql') &&
12-
(event.spans?.some(span => span.op === 'db') ?? false)
13-
);
14-
});
14+
// db spans join that trace.
15+
const spansPromise = collectStreamedSpans(
16+
'deno-mysql',
17+
spans => spans.some(isTestMysqlSegment) && spans.some(span => getSpanOp(span) === 'db'),
18+
);
1519

1620
const res = await fetch(`${baseURL}/test-mysql`);
1721
expect(res.status).toBe(200);
1822
await res.json();
1923

20-
const transaction = await transactionPromise;
21-
const dbSpans = transaction.spans!.filter(span => span.op === 'db');
24+
const spans = await spansPromise;
25+
const dbSpans = spans.filter(span => getSpanOp(span) === 'db');
2226

23-
const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
27+
const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution');
2428
expect(firstQuery).toBeDefined();
25-
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql');
26-
expect(firstQuery!.data?.['db.system.name']).toBe('mysql');
27-
expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution');
28-
expect(firstQuery!.data?.['server.port']).toBe(3306);
29-
expect(firstQuery!.data?.['db.user']).toBe('root');
29+
// With span streaming, db span names are the low-cardinality query summary, not the raw SQL
30+
expect(firstQuery!.name).toBe('SELECT');
31+
expect(firstQuery!.attributes).toMatchObject({
32+
'sentry.origin': { value: 'auto.db.mysql', type: 'string' },
33+
'db.system.name': { value: 'mysql', type: 'string' },
34+
'db.query.text': { value: 'SELECT 1 + 1 AS solution', type: 'string' },
35+
'server.port': { value: 3306, type: 'integer' },
36+
'db.user': { value: 'root', type: 'string' },
37+
});
3038
});
3139

32-
test('a nested query lands on the same transaction (AsyncLocalStorage context restored)', async ({ baseURL }) => {
40+
test('a nested query lands on the same trace (AsyncLocalStorage context restored)', async ({ baseURL }) => {
3341
// The second query runs inside the first query's callback — i.e. across
34-
// mysql's async socket-callback dispatch. Both spans appearing on the SAME
35-
// http.server transaction proves denoMysqlIntegration's context strategy
42+
// mysql's async socket-callback dispatch. Both db spans being children of the
43+
// SAME http.server segment proves denoMysqlIntegration's context strategy
3644
// restored the parent span across that async boundary (otherwise the nested
37-
// query would start its own trace and never join this transaction).
38-
const transactionPromise = waitForTransaction('deno-mysql', event => {
39-
return (
40-
event?.contexts?.trace?.op === 'http.server' &&
41-
(event.request?.url ?? '').includes('/test-mysql') &&
42-
(event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
43-
);
44-
});
45+
// query would start its own trace and never join this one).
46+
const spansPromise = collectStreamedSpans(
47+
'deno-mysql',
48+
spans => spans.some(isTestMysqlSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2,
49+
);
4550

4651
const res = await fetch(`${baseURL}/test-mysql`);
4752
expect(res.status).toBe(200);
4853
await res.json();
4954

50-
const transaction = await transactionPromise;
51-
const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description);
52-
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
53-
expect(descriptions).toContain('SELECT NOW()');
55+
const spans = await spansPromise;
56+
const segment = spans.find(isTestMysqlSegment)!;
57+
const dbSpans = spans.filter(span => getSpanOp(span) === 'db');
58+
59+
const queries = dbSpans.map(span => span.attributes['db.query.text']?.value);
60+
expect(queries).toContain('SELECT 1 + 1 AS solution');
61+
expect(queries).toContain('SELECT NOW()');
62+
expect(dbSpans.every(span => span.parent_span_id === segment.span_id)).toBe(true);
5463
});

‎dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import '@sentry/deno/import';
77
import * as Sentry from '@sentry/deno';
88

99
Sentry.init({
10-
traceLifecycle: 'static',
1110
environment: 'qa',
1211
dsn: Deno.env.get('E2E_TEST_DSN'),
1312
debug: !!Deno.env.get('DEBUG'),
Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,64 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3+
import type { SerializedStreamedSpan } from '@sentry/core';
4+
5+
// `Deno.serve` has no route information, so with span streaming the http.server segment is
6+
// named after the method only; the path lives in `url.path`.
7+
function isTestPgSegment(span: SerializedStreamedSpan): boolean {
8+
return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-pg';
9+
}
310

411
test('pg queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => {
5-
// Each incoming request gets a Sentry http.server transaction (via the
12+
// Each incoming request gets a Sentry http.server segment span (via the
613
// default denoServeIntegration); the pg queries run inside it, so their
7-
// db spans attach to that transaction.
8-
const transactionPromise = waitForTransaction('deno-pg', event => {
9-
return (
10-
event?.contexts?.trace?.op === 'http.server' &&
11-
(event.request?.url ?? '').includes('/test-pg') &&
12-
(event.spans?.some(span => span.op === 'db') ?? false)
13-
);
14-
});
14+
// db spans join that trace.
15+
const spansPromise = collectStreamedSpans(
16+
'deno-pg',
17+
spans => spans.some(isTestPgSegment) && spans.some(span => getSpanOp(span) === 'db'),
18+
);
1519

1620
const res = await fetch(`${baseURL}/test-pg`);
1721
expect(res.status).toBe(200);
1822
await res.json();
1923

20-
const transaction = await transactionPromise;
21-
const dbSpans = transaction.spans!.filter(span => span.op === 'db');
24+
const spans = await spansPromise;
25+
const dbSpans = spans.filter(span => getSpanOp(span) === 'db');
2226

23-
const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
27+
const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution');
2428
expect(firstQuery).toBeDefined();
25-
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.postgres');
26-
expect(firstQuery!.data?.['db.system.name']).toBe('postgresql');
27-
expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution');
28-
expect(firstQuery!.data?.['server.port']).toBe(5432);
29-
expect(firstQuery!.data?.['db.user']).toBe('postgres');
29+
// With span streaming, db span names are the low-cardinality query summary, not the raw SQL
30+
expect(firstQuery!.name).toBe('SELECT');
31+
expect(firstQuery!.attributes).toMatchObject({
32+
'sentry.origin': { value: 'auto.db.postgres', type: 'string' },
33+
'db.system.name': { value: 'postgresql', type: 'string' },
34+
'db.query.text': { value: 'SELECT 1 + 1 AS solution', type: 'string' },
35+
'server.port': { value: 5432, type: 'integer' },
36+
'db.user': { value: 'postgres', type: 'string' },
37+
});
3038
});
3139

32-
test('a nested query lands on the same transaction (AsyncLocalStorage context restored)', async ({ baseURL }) => {
40+
test('a nested query lands on the same trace (AsyncLocalStorage context restored)', async ({ baseURL }) => {
3341
// The second query runs inside the first query's callback
34-
// i.e. across pg's async socket-callback dispatch. Both spans appearing
35-
// on the SAME http.server transaction proves denoPostgresIntegration's
42+
// i.e. across pg's async socket-callback dispatch. Both db spans being
43+
// children of the SAME http.server segment proves denoPostgresIntegration's
3644
// context strategy restored the parent span across that async boundary
3745
// (otherwise the nested query would start its own trace and never join
38-
// this transaction).
39-
const transactionPromise = waitForTransaction('deno-pg', event => {
40-
return (
41-
event?.contexts?.trace?.op === 'http.server' &&
42-
(event.request?.url ?? '').includes('/test-pg') &&
43-
(event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
44-
);
45-
});
46+
// this one).
47+
const spansPromise = collectStreamedSpans(
48+
'deno-pg',
49+
spans => spans.some(isTestPgSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2,
50+
);
4651

4752
const res = await fetch(`${baseURL}/test-pg`);
4853
expect(res.status).toBe(200);
4954
await res.json();
5055

51-
const transaction = await transactionPromise;
52-
const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description);
53-
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
54-
expect(descriptions).toContain('SELECT NOW()');
56+
const spans = await spansPromise;
57+
const segment = spans.find(isTestPgSegment)!;
58+
const dbSpans = spans.filter(span => getSpanOp(span) === 'db');
59+
60+
const queries = dbSpans.map(span => span.attributes['db.query.text']?.value);
61+
expect(queries).toContain('SELECT 1 + 1 AS solution');
62+
expect(queries).toContain('SELECT NOW()');
63+
expect(dbSpans.every(span => span.parent_span_id === segment.span_id)).toBe(true);
5564
});

‎dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import IORedis from 'ioredis';
33
import { createClient } from 'redis';
44

55
Sentry.init({
6-
traceLifecycle: 'static',
76
environment: 'qa',
87
dsn: Deno.env.get('E2E_TEST_DSN'),
98
debug: !!Deno.env.get('DEBUG'),
Lines changed: 67 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,107 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3+
import type { SerializedStreamedSpan } from '@sentry/core';
34

4-
test('ioredis GET emits an http.server transaction containing a db.query child span', async ({ baseURL }) => {
5-
// Each incoming request gets a Sentry http.server transaction (via the
5+
// `Deno.serve` has no route information, so with span streaming the http.server segment is
6+
// named after the method only; the path lives in `url.path`.
7+
function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean {
8+
return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path;
9+
}
10+
11+
function isRedisCommand(span: SerializedStreamedSpan): boolean {
12+
return getSpanOp(span) === 'db.query';
13+
}
14+
15+
// `db.query.text` carries the key, so with span streaming a redis command span is named
16+
// `{db.operation.name} {server.address}:{server.port}` instead.
17+
function expectedCommandName(span: SerializedStreamedSpan): string {
18+
const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes;
19+
return `${operation?.value} ${address?.value}:${port?.value}`;
20+
}
21+
22+
test('ioredis GET emits an http.server segment containing a db.query child span', async ({ baseURL }) => {
23+
// Each incoming request gets a Sentry http.server segment span (via the
624
// 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.query') ?? false)
13-
);
14-
});
25+
// child span joins that trace.
26+
const spansPromise = collectStreamedSpans(
27+
'deno-redis',
28+
spans => spans.some(isSegmentFor('/ioredis-get')) && spans.some(isRedisCommand),
29+
);
1530

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

20-
const transaction = await transactionPromise;
21-
const redisSpan = transaction.spans!.find(span => span.op === 'db.query');
35+
const spans = await spansPromise;
36+
const segment = spans.find(isSegmentFor('/ioredis-get'))!;
37+
const redisSpan = spans.find(isRedisCommand);
2238
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.name']).toBe('redis');
26-
expect(redisSpan!.data?.['db.query.text']).toBe('get iocache:user:42');
39+
expect(redisSpan!.parent_span_id).toBe(segment.span_id);
40+
expect(redisSpan!.name).toBe(expectedCommandName(redisSpan!));
41+
expect(redisSpan!.attributes).toMatchObject({
42+
'db.system.name': { value: 'redis', type: 'string' },
43+
// ioredis publishes lowercase command names; node-redis publishes uppercase.
44+
'db.operation.name': { value: 'get', type: 'string' },
45+
'db.query.text': { value: 'get iocache:user:42', type: 'string' },
46+
});
2747
});
2848

29-
test('ioredis SET then GET emit two db.query 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.query').length ?? 0) >= 2
35-
);
36-
});
49+
test('ioredis SET then GET emit two db.query child spans on the same trace', async ({ baseURL }) => {
50+
const spansPromise = collectStreamedSpans(
51+
'deno-redis',
52+
spans => spans.some(isSegmentFor('/ioredis-set-get')) && spans.filter(isRedisCommand).length >= 2,
53+
);
3754

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

42-
const transaction = await transactionPromise;
43-
const redisSpans = transaction.spans!.filter(span => span.op === 'db.query');
59+
const spans = await spansPromise;
60+
const segment = spans.find(isSegmentFor('/ioredis-set-get'))!;
61+
const redisSpans = spans.filter(isRedisCommand);
4462
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');
63+
expect(redisSpans.every(span => span.parent_span_id === segment.span_id)).toBe(true);
64+
const ops = redisSpans.map(span => span.attributes['db.operation.name']?.value);
65+
expect(ops).toContain('set');
66+
expect(ops).toContain('get');
4867
});
4968

5069
test('ioredis MULTI emits one db.query span per command (no batch channel)', async ({ baseURL }) => {
5170
// ioredis does not publish to a batch channel — each command in the
5271
// 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.query').length ?? 0) >= 3
60-
);
61-
});
72+
// own payload. So the trace should contain multiple command child spans,
73+
// but no PIPELINE/MULTI batch span.
74+
const spansPromise = collectStreamedSpans(
75+
'deno-redis',
76+
spans => spans.some(isSegmentFor('/ioredis-multi')) && spans.filter(isRedisCommand).length >= 3,
77+
);
6278

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

67-
const transaction = await transactionPromise;
68-
const redisSpans = transaction.spans!.filter(span => span.op === 'db.query');
83+
const spans = await spansPromise;
84+
const redisSpans = spans.filter(isRedisCommand);
6985
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');
86+
const ops = redisSpans.map(span => span.attributes['db.operation.name']?.value);
87+
expect(ops).toContain('set');
88+
expect(ops).toContain('get');
7389
// 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');
90+
const batchSpan = spans.find(span => span.name === 'MULTI' || span.name === 'PIPELINE');
7591
expect(batchSpan).toBeUndefined();
7692
});
7793

7894
test('ioredis PIPELINE emits one db.query 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.query').length ?? 0) >= 3
84-
);
85-
});
95+
const spansPromise = collectStreamedSpans(
96+
'deno-redis',
97+
spans => spans.some(isSegmentFor('/ioredis-pipeline')) && spans.filter(isRedisCommand).length >= 3,
98+
);
8699

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

91-
const transaction = await transactionPromise;
92-
const redisSpans = transaction.spans!.filter(span => span.op === 'db.query');
104+
const spans = await spansPromise;
105+
const redisSpans = spans.filter(isRedisCommand);
93106
expect(redisSpans.length).toBeGreaterThanOrEqual(3);
94107
});

0 commit comments

Comments
 (0)