Skip to content

Commit 72032b5

Browse files
feat: add --stream-gen-ai-spans flag and v2 envelope span parsing (#150)
* feat: add option for JS runners to stream gen_ai spans * feat: support envelpe v2 format so that we can capture streamed spans as well * feat: enhance error status handling in checkErrorCaptured for v1 and v2 protocols * fix(cli): consistent --not-stream-gen-ai-spans naming and conflict check Addresses Cursor Bugbot review on #150: - Rename --not-stream-genai-spans → --not-stream-gen-ai-spans for hyphenation parity with --stream-gen-ai-spans. - Reject passing both flags together instead of silently dropping the enable flag value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1668d0e commit 72032b5

10 files changed

Lines changed: 87 additions & 2 deletions

File tree

src/cli.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Options:
4343
--sentry-javascript <path> Use local Sentry JavaScript SDK (link)
4444
--sentry-php <path> Use local Sentry PHP SDK (core sentry/sentry-php)
4545
--sentry-laravel <path> Use local Sentry Laravel SDK (composer path repository)
46+
--stream-gen-ai-spans Enable streamGenAiSpans in JS Sentry.init() (default: on)
47+
--not-stream-gen-ai-spans Disable streamGenAiSpans in JS Sentry.init()
4648
--help, -h Show this help message
4749
4850
Examples:
@@ -63,6 +65,16 @@ Examples:
6365
npm run test setup -- --framework openai --sync --streaming
6466
`;
6567

68+
function resolveStreamGenAiSpans(enable: boolean, disable: boolean): boolean {
69+
if (enable && disable) {
70+
console.error(
71+
"Error: --stream-gen-ai-spans and --not-stream-gen-ai-spans are mutually exclusive",
72+
);
73+
process.exit(1);
74+
}
75+
return !disable;
76+
}
77+
6678
function parseCliArgs() {
6779
const { values, positionals } = parseArgs({
6880
args: process.argv.slice(2),
@@ -84,6 +96,8 @@ function parseCliArgs() {
8496
"sentry-javascript": { type: "string" },
8597
"sentry-php": { type: "string" },
8698
"sentry-laravel": { type: "string" },
99+
"stream-gen-ai-spans": { type: "boolean", default: false },
100+
"not-stream-gen-ai-spans": { type: "boolean", default: false },
87101
help: { type: "boolean", short: "h", default: false },
88102
},
89103
allowPositionals: true,
@@ -181,6 +195,12 @@ function parseCliArgs() {
181195
sentryJavaScriptPath: values["sentry-javascript"],
182196
sentryPhpPath: values["sentry-php"],
183197
sentryLaravelPath: values["sentry-laravel"],
198+
// Default ON. --not-stream-gen-ai-spans disables it; --stream-gen-ai-spans
199+
// is the explicit form of the default. Passing both is a conflict.
200+
streamGenAiSpans: resolveStreamGenAiSpans(
201+
values["stream-gen-ai-spans"] === true,
202+
values["not-stream-gen-ai-spans"] === true,
203+
),
184204
help: values.help,
185205
};
186206
}
@@ -214,6 +234,7 @@ async function main() {
214234
parallel: options.parallel,
215235
openReport: options.open,
216236
optionFilters: options.optionFilters,
237+
streamGenAiSpans: options.streamGenAiSpans,
217238
});
218239

219240
try {

src/orchestrator.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class Orchestrator {
4949
private streamingFilter?: boolean;
5050
private blockingFilter?: boolean;
5151
private optionFilters?: Record<string, string>;
52+
private streamGenAiSpans: boolean = true;
5253

5354
constructor(
5455
options: {
@@ -61,6 +62,7 @@ export class Orchestrator {
6162
parallel?: number;
6263
openReport?: boolean;
6364
optionFilters?: Record<string, string>;
65+
streamGenAiSpans?: boolean;
6466
} = {},
6567
) {
6668
this.spanCollector = new SpanCollector();
@@ -79,6 +81,7 @@ export class Orchestrator {
7981
this.blockingFilter = options.blocking;
8082
this.optionFilters = options.optionFilters;
8183
this.openReport = options.openReport === true;
84+
this.streamGenAiSpans = options.streamGenAiSpans !== false;
8285

8386
// Set verbose on validator
8487
this.validator.setVerbose(this.verbose);
@@ -235,6 +238,7 @@ export class Orchestrator {
235238
resolvedOptions: firstRun.framework.resolvedOptions,
236239
timeoutMs: firstRun.testDefinition.timeoutMs ?? 60000,
237240
verbose: this.verbose,
241+
streamGenAiSpans: this.streamGenAiSpans,
238242
});
239243
} catch (setupError) {
240244
// Mark all tests for this framework as errors
@@ -280,6 +284,7 @@ export class Orchestrator {
280284
resolvedOptions: testRun.framework.resolvedOptions,
281285
timeoutMs: testRun.testDefinition.timeoutMs ?? 60000,
282286
verbose: false, // Suppress template rendering logs, we're logging above
287+
streamGenAiSpans: this.streamGenAiSpans,
283288
});
284289

285290
renderedTests.set(testRun.id, testPath);
@@ -495,6 +500,7 @@ export class Orchestrator {
495500
resolvedOptions: testRun.framework.resolvedOptions,
496501
timeoutMs: testRun.testDefinition.timeoutMs ?? 60000,
497502
verbose: this.verbose,
503+
streamGenAiSpans: this.streamGenAiSpans,
498504
});
499505

500506
console.log(` ✓ Setup complete`);
@@ -995,6 +1001,7 @@ export class Orchestrator {
9951001
resolvedOptions: testRun.framework.resolvedOptions,
9961002
timeoutMs,
9971003
verbose: this.verbose && !this.useLiveStatus, // Only verbose when flag is set and not live status
1004+
streamGenAiSpans: this.streamGenAiSpans,
9981005
});
9991006

10001007
// Wait for spans to be collected

src/runner/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export class Runner {
271271
...(testDefinition.agent && { agent: testDefinition.agent }),
272272
...(testDefinition.mcpServer && { mcpServer: testDefinition.mcpServer }),
273273
inputs: processedInputs,
274+
streamGenAiSpans: context.streamGenAiSpans !== false, // JS Sentry.init() flag, default true
274275
};
275276

276277
// PHP/Laravel: add computed class names and command signature for templates

src/runner/templates/base.browser.njk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
sendDefaultPii: true,
4545
// Capture 100% of traces for testing
4646
tracesSampleRate: 1.0,
47+
streamGenAiSpans: {{ streamGenAiSpans }},
4748
});
4849
4950
log('Sentry initialized');

src/runner/templates/base.cloudflare.njk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default Sentry.withSentry(
1818
dsn: env.SENTRY_DSN,
1919
sendDefaultPii: true,
2020
tracesSampleRate: 1.0,
21+
streamGenAiSpans: {{ streamGenAiSpans }},
2122
integrations: [
2223
{% block sentry_integrations %}{% endblock %}
2324
],

src/runner/templates/base.nextjs.njk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Sentry.init({
1818
sendDefaultPii: true,
1919
// Capture 100% of traces for testing
2020
tracesSampleRate: 1.0,
21+
streamGenAiSpans: {{ streamGenAiSpans }},
2122
});
2223

2324
// Import framework AFTER Sentry.init() to ensure instrumentation hooks are in place

src/runner/templates/base.node.njk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Sentry.init({
1313
sendDefaultPii: true,
1414
// Capture 100% of traces for testing
1515
tracesSampleRate: 1.0,
16+
streamGenAiSpans: {{ streamGenAiSpans }},
1617
});
1718

1819
// Import framework AFTER Sentry.init() to ensure instrumentation hooks are in place

src/span-collector/server.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,38 @@ import { promisify } from 'util';
1212

1313
const gunzip = promisify(zlib.gunzip);
1414

15+
/**
16+
* Convert a v2 envelope span into the legacy CapturedSpan shape used by the
17+
* rest of the test framework. The typed attribute map `{ key: { type, value } }`
18+
* is flattened to a plain `data: { key: value }` dict, matching what v1
19+
* transaction-embedded spans already expose, so existing checks work unchanged.
20+
*
21+
* Span protocol reference:
22+
* https://develop.sentry.dev/sdk/telemetry/spans/span-protocol
23+
* https://develop.sentry.dev/sdk/telemetry/spans/implementation/#how-to-approach-span-first-in-sdks
24+
*/
25+
function v2SpanToCapturedSpan(v2: any): CapturedSpan {
26+
const data: Record<string, any> = {};
27+
if (v2.attributes && typeof v2.attributes === 'object') {
28+
for (const [key, attr] of Object.entries<any>(v2.attributes)) {
29+
data[key] = attr && typeof attr === 'object' && 'value' in attr ? attr.value : attr;
30+
}
31+
}
32+
const op = typeof data['sentry.op'] === 'string' ? data['sentry.op'] : undefined;
33+
return {
34+
span_id: v2.span_id,
35+
trace_id: v2.trace_id,
36+
parent_span_id: v2.parent_span_id,
37+
op: op as string,
38+
description: v2.name,
39+
start_timestamp: v2.start_timestamp,
40+
timestamp: v2.end_timestamp,
41+
data,
42+
status: v2.status,
43+
is_segment: v2.is_segment,
44+
};
45+
}
46+
1547
export class SpanCollector {
1648
private app: Hono;
1749
private server: ReturnType<typeof serve> | null = null;
@@ -118,7 +150,21 @@ export class SpanCollector {
118150
const itemHeader = JSON.parse(lines[i]);
119151
const itemBody = JSON.parse(lines[i + 1]);
120152

121-
if (itemHeader.type === 'transaction' || itemHeader.type === 'span') {
153+
// v2 span envelope: application/vnd.sentry.items.span.v2+json
154+
// Body shape: { version: 2, items: [<v2span>, ...] }
155+
// Used by Sentry JS SDK 10.53.0+ when streamGenAiSpans is enabled —
156+
// gen_ai spans are stripped from the transaction and shipped here.
157+
// Spec: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol
158+
if (
159+
itemHeader.type === 'span' &&
160+
typeof itemHeader.content_type === 'string' &&
161+
itemHeader.content_type.includes('span.v2') &&
162+
Array.isArray(itemBody?.items)
163+
) {
164+
for (const v2 of itemBody.items) {
165+
spans.push(v2SpanToCapturedSpan(v2));
166+
}
167+
} else if (itemHeader.type === 'transaction' || itemHeader.type === 'span') {
122168
// Transaction contains child spans
123169
if (itemBody.spans) {
124170
spans.push(...itemBody.spans);

src/test-cases/llm/basic-error.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ const checkErrorCaptured: Check = {
2121
throw new CheckError("Should have at least one AI span but found none");
2222
}
2323

24-
// Any non-ok span status from Sentry's SpanStatusType is valid
24+
// Accept both v1 SpanStatusType granular codes and the v2 protocol's
25+
// normalized "error" value. v2 (span-protocol) constrains status to
26+
// {"ok","error"}; v1 transaction-embedded spans use the legacy enum.
2527
// See: sentry-javascript/packages/core/src/types-hoist/spanStatus.ts
28+
// https://develop.sentry.dev/sdk/telemetry/spans/span-protocol
2629
const errorStatuses = new Set([
30+
"error",
2731
"deadline_exceeded",
2832
"unauthenticated",
2933
"permission_denied",

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,6 @@ export interface RunnerContext {
311311
timeoutMs: number;
312312
// Controls whether to print verbose console output (default: true)
313313
verbose?: boolean;
314+
// JS only: value to pass for `streamGenAiSpans` in Sentry.init() (default: true)
315+
streamGenAiSpans?: boolean;
314316
}

0 commit comments

Comments
 (0)