Skip to content

Commit 5c75501

Browse files
nicohrubeccodex
andauthored
test(node): Add streamed span collection helpers (#25131)
Add Node runner equivalents of Cloudflare’s `collectStreamedSpans` and `collectStreamedSpansUntilSegment` so tests can collect across envelopes. Migrate AWS, feature-flag, GrowthBook, and sampler fallback tests using the segment helper. Part of #24141 --------- Co-authored-by: GPT-6 <codex@openai.com>
1 parent fa38591 commit 5c75501

6 files changed

Lines changed: 232 additions & 86 deletions

File tree

‎dev-packages/node-integration-tests/README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,35 @@ Nock interceptors are internally used to capture envelope requests by `getEnvelo
3434
be used inside the test cases to intercept requests but should be removed before the test ends, as not to cause
3535
flakiness.
3636

37+
### Collecting streamed spans
38+
39+
Register collectors before starting the scenario. `collectStreamedSpansUntilSegment` collects spans across envelopes
40+
until it sees a segment matching a name or predicate, then returns the collected spans from that trace:
41+
42+
```ts
43+
const runner = createRunner(__dirname, 'scenario.ts');
44+
const spansPromise = runner.collectStreamedSpansUntilSegment('main');
45+
46+
await runner.start().completed();
47+
48+
const spans = await spansPromise;
49+
expect(spans.filter(span => !span.is_segment)).toHaveLength(2);
50+
```
51+
52+
A segment does not guarantee that all its children have arrived. If a scenario sends children later, use
53+
`collectStreamedSpans` with a predicate describing which spans must be present:
54+
55+
```ts
56+
const spansPromise = runner.collectStreamedSpans(
57+
spans => spans.some(span => span.is_segment && span.name === 'main') && spans.length >= 3,
58+
);
59+
```
60+
61+
The predicate is evaluated separately for each trace. Returning `false` keeps waiting; throwing fails the runner
62+
immediately, including in unordered mode. Keep assertions after collection so incorrect attributes produce assertion
63+
failures. A predicate that never matches still fails on scenario exit or timeout. Neither helper waits for spans
64+
arriving after its condition is satisfied.
65+
3766
## Other Runtimes
3867

3968
`dev-packages/bun-integration-tests` and `dev-packages/deno-integration-tests` run every suite of this package on Bun

‎dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -258,18 +258,12 @@ describe('awsIntegration', () => {
258258
'instrument.mjs',
259259
(createTestRunner, test) => {
260260
test('auto-instruments aws-sdk service operations', { timeout: 90_000 }, async () => {
261-
const spans: SerializedStreamedSpanContainer['items'] = [];
262-
await createTestRunner()
263-
.ignore('event')
264-
.unordered()
265-
.expect({
266-
span: container => {
267-
spans.push(...container.items);
268-
assertAwsServiceSpans(spans);
269-
},
270-
})
271-
.start()
272-
.completed();
261+
const runner = createTestRunner().ignore('event');
262+
const spansPromise = runner.collectStreamedSpansUntilSegment('Test Transaction');
263+
264+
await runner.start().completed();
265+
266+
assertAwsServiceSpans(await spansPromise);
273267
});
274268
},
275269
{ additionalDependencies },

‎dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,12 @@ test('records a client report and no error event when tracesSampler throws', asy
2424
});
2525

2626
test('sends the span when tracesSampler throws but tracesSampleRate is 1', async () => {
27-
await createRunner(__dirname, 'scenario-fallback.ts')
28-
.unordered()
29-
.expect({
30-
span: container => {
31-
const segment = container.items.find(span => span.is_segment);
32-
expect(segment?.name).toBe('sampled via tracesSampleRate fallback');
33-
},
34-
})
35-
.start()
36-
.completed();
27+
const runner = createRunner(__dirname, 'scenario-fallback.ts');
28+
const spansPromise = runner.collectStreamedSpansUntilSegment();
29+
30+
await runner.start().completed();
31+
32+
const spans = await spansPromise;
33+
const segment = spans.find(span => span.is_segment);
34+
expect(segment?.name).toBe('sampled via tracesSampleRate fallback');
3735
});
Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN, type SerializedStreamedSpanContainer } from '@sentry/core';
1+
import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core';
22
import { afterAll, expect, test } from 'vitest';
33
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
44

@@ -12,29 +12,25 @@ test('Flags captured on span attributes with max limit', async () => {
1212
for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) {
1313
expectedFlags[`flag.evaluation.feat${i}`] = { type: 'boolean', value: i === 3 };
1414
}
15-
const spans: SerializedStreamedSpanContainer['items'] = [];
1615

17-
await createRunner(__dirname, 'scenario.ts')
18-
.unordered()
19-
.expect({
20-
span: container => {
21-
spans.push(...container.items);
22-
const children = spans.filter(span => !span.is_segment);
23-
expect(children).toHaveLength(2);
24-
expect(children).toEqual(
25-
expect.arrayContaining([
26-
expect.objectContaining({
27-
name: 'test-span',
28-
attributes: expect.objectContaining({}),
29-
}),
30-
expect.objectContaining({
31-
name: 'test-nested-span',
32-
attributes: expect.objectContaining(expectedFlags),
33-
}),
34-
]),
35-
);
36-
},
37-
})
38-
.start()
39-
.completed();
16+
const runner = createRunner(__dirname, 'scenario.ts');
17+
const spansPromise = runner.collectStreamedSpansUntilSegment('test-root-span');
18+
19+
await runner.start().completed();
20+
21+
const spans = await spansPromise;
22+
const children = spans.filter(span => !span.is_segment);
23+
expect(children).toHaveLength(2);
24+
expect(children).toEqual(
25+
expect.arrayContaining([
26+
expect.objectContaining({
27+
name: 'test-span',
28+
attributes: expect.objectContaining({}),
29+
}),
30+
expect.objectContaining({
31+
name: 'test-nested-span',
32+
attributes: expect.objectContaining(expectedFlags),
33+
}),
34+
]),
35+
);
4036
});

‎dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts‎

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,27 @@ afterAll(() => {
66
});
77

88
test('GrowthBook flags are added to active span attributes on span end', async () => {
9-
await createRunner(__dirname, 'scenario.ts')
10-
.unordered()
11-
.expect({
12-
span: container => {
13-
expect(container.items).toHaveLength(1);
14-
const span = container.items.find(item => item.is_segment);
15-
expect(span).toMatchObject({
16-
name: 'test-span',
17-
status: 'ok',
18-
span_id: expect.stringMatching(/[a-f\d]{16}/),
19-
trace_id: expect.stringMatching(/[a-f\d]{32}/),
20-
attributes: {
21-
'flag.evaluation.feat1': { type: 'boolean', value: true },
22-
'flag.evaluation.feat2': { type: 'boolean', value: false },
23-
'flag.evaluation.bool-feat': { type: 'boolean', value: true },
24-
'sentry.op': { type: 'string', value: 'function' },
25-
'sentry.origin': { type: 'string', value: 'manual' },
26-
},
27-
});
28-
// string-feat should NOT be here since it's not boolean
29-
expect(span?.attributes['flag.evaluation.string-feat']).toBeUndefined();
30-
},
31-
})
32-
.start()
33-
.completed();
9+
const runner = createRunner(__dirname, 'scenario.ts');
10+
const spansPromise = runner.collectStreamedSpansUntilSegment();
11+
12+
await runner.start().completed();
13+
14+
const spans = await spansPromise;
15+
expect(spans).toHaveLength(1);
16+
const span = spans.find(item => item.is_segment);
17+
expect(span).toMatchObject({
18+
name: 'test-span',
19+
status: 'ok',
20+
span_id: expect.stringMatching(/[a-f\d]{16}/),
21+
trace_id: expect.stringMatching(/[a-f\d]{32}/),
22+
attributes: {
23+
'flag.evaluation.feat1': { type: 'boolean', value: true },
24+
'flag.evaluation.feat2': { type: 'boolean', value: false },
25+
'flag.evaluation.bool-feat': { type: 'boolean', value: true },
26+
'sentry.op': { type: 'string', value: 'function' },
27+
'sentry.origin': { type: 'string', value: 'manual' },
28+
},
29+
});
30+
// string-feat should NOT be here since it's not boolean
31+
expect(span?.attributes['flag.evaluation.string-feat']).toBeUndefined();
3432
});

0 commit comments

Comments
 (0)