Skip to content

Commit d0cb9d0

Browse files
committed
feat(core): Send gen_ai spans as v2 envelope items
1 parent 3332fec commit d0cb9d0

6 files changed

Lines changed: 530 additions & 1 deletion

File tree

packages/core/src/client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Scope } from './scope';
1212
import { updateSession } from './session';
1313
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
1414
import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
15+
import { extractGenAiSpansFromEvent } from './tracing/spans/extractGenAiSpans';
1516
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base';
1617
import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb';
1718
import type { CheckIn, MonitorConfig } from './types-hoist/checkin';
@@ -522,12 +523,20 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
522523
public sendEvent(event: Event, hint: EventHint = {}): void {
523524
this.emit('beforeSendEvent', event, hint);
524525

526+
// Extract gen_ai spans from transaction and convert to span v2 format.
527+
// This mutates event.spans to remove the extracted spans.
528+
const genAiSpanItem = extractGenAiSpansFromEvent(event, this);
529+
525530
let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel);
526531

527532
for (const attachment of hint.attachments || []) {
528533
env = addItemToEnvelope(env, createAttachmentEnvelopeItem(attachment));
529534
}
530535

536+
if (genAiSpanItem) {
537+
env = addItemToEnvelope(env, genAiSpanItem);
538+
}
539+
531540
// sendEnvelope should not throw
532541
// eslint-disable-next-line @typescript-eslint/no-floating-promises
533542
this.sendEnvelope(env).then(sendResponse => this.emit('afterSendEvent', event, sendResponse));
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Client } from '../../client';
2+
import type { SpanContainerItem } from '../../types-hoist/envelope';
3+
import type { Event } from '../../types-hoist/event';
4+
import { hasSpanStreamingEnabled } from './hasSpanStreamingEnabled';
5+
import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan';
6+
7+
/**
8+
* Extracts gen_ai spans from a transaction event, converts them to span v2 format,
9+
* and returns them as a SpanContainerItem.
10+
*
11+
* Only applies to static mode (non-streaming) transactions.
12+
*
13+
* WARNING: This function mutates `event.spans` by removing the extracted gen_ai spans
14+
* from the array. Call this before creating the event envelope so the transaction
15+
* item does not include the extracted spans.
16+
*/
17+
export function extractGenAiSpansFromEvent(event: Event, client: Client): SpanContainerItem | undefined {
18+
if (event.type !== 'transaction' || !event.spans?.length || hasSpanStreamingEnabled(client)) {
19+
return undefined;
20+
}
21+
22+
const genAiSpans = [];
23+
const remainingSpans = [];
24+
25+
for (const span of event.spans) {
26+
if (span.op?.startsWith('gen_ai.')) {
27+
genAiSpans.push(span);
28+
} else {
29+
remainingSpans.push(span);
30+
}
31+
}
32+
33+
if (genAiSpans.length === 0) {
34+
return undefined;
35+
}
36+
37+
const serializedSpans = genAiSpans.map(span => spanJsonToSerializedStreamedSpan(span, event, client));
38+
39+
// Remove gen_ai spans from the legacy transaction
40+
event.spans = remainingSpans;
41+
42+
return [
43+
{ type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' },
44+
{ items: serializedSpans },
45+
];
46+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { RawAttributes } from '../../attributes';
2+
import type { Client } from '../../client';
3+
import {
4+
SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
5+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
6+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7+
SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
8+
SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
9+
SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
10+
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
11+
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
12+
SEMANTIC_ATTRIBUTE_USER_EMAIL,
13+
SEMANTIC_ATTRIBUTE_USER_ID,
14+
SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
15+
SEMANTIC_ATTRIBUTE_USER_USERNAME,
16+
} from '../../semanticAttributes';
17+
import type { Event } from '../../types-hoist/event';
18+
import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types-hoist/span';
19+
import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils';
20+
import { safeSetSpanJSONAttributes } from './captureSpan';
21+
22+
/**
23+
* Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan.
24+
*/
25+
export function spanJsonToSerializedStreamedSpan(
26+
span: SpanJSON,
27+
transactionEvent: Event,
28+
client: Client,
29+
): SerializedStreamedSpan {
30+
const streamedSpan: StreamedSpanJSON = {
31+
trace_id: span.trace_id,
32+
span_id: span.span_id,
33+
parent_span_id: span.parent_span_id,
34+
name: span.description || '',
35+
start_timestamp: span.start_timestamp,
36+
end_timestamp: span.timestamp || span.start_timestamp,
37+
status: mapV1StatusToV2(span.status),
38+
is_segment: false,
39+
attributes: { ...(span.data as RawAttributes<Record<string, unknown>>) },
40+
links: span.links,
41+
};
42+
43+
// Fold op and origin into attributes
44+
safeSetSpanJSONAttributes(streamedSpan, {
45+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: span.op,
46+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: span.origin,
47+
});
48+
49+
// Enrich from transaction event context (same pattern as captureSpan.ts applyCommonSpanAttributes)
50+
const sdk = client.getSdkMetadata();
51+
const { release, environment, sendDefaultPii } = client.getOptions();
52+
53+
safeSetSpanJSONAttributes(streamedSpan, {
54+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: transactionEvent.release || release,
55+
[SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: transactionEvent.environment || environment,
56+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: transactionEvent.transaction,
57+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: transactionEvent.contexts?.trace?.span_id,
58+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name,
59+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version,
60+
...(sendDefaultPii
61+
? {
62+
[SEMANTIC_ATTRIBUTE_USER_ID]: transactionEvent.user?.id,
63+
[SEMANTIC_ATTRIBUTE_USER_EMAIL]: transactionEvent.user?.email,
64+
[SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: transactionEvent.user?.ip_address,
65+
[SEMANTIC_ATTRIBUTE_USER_USERNAME]: transactionEvent.user?.username,
66+
}
67+
: {}),
68+
});
69+
70+
return streamedSpanJsonToSerializedSpan(streamedSpan);
71+
}
72+
73+
function mapV1StatusToV2(status: string | undefined): 'ok' | 'error' {
74+
if (!status || status === 'ok' || status === 'cancelled') {
75+
return 'ok';
76+
}
77+
return 'error';
78+
}

packages/core/src/types-hoist/envelope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ type LogEnvelopeHeaders = BaseEnvelopeHeaders;
154154
type MetricEnvelopeHeaders = BaseEnvelopeHeaders;
155155
export type EventEnvelope = BaseEnvelope<
156156
EventEnvelopeHeaders,
157-
EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem
157+
EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem | SpanContainerItem
158158
>;
159159
export type SessionEnvelope = BaseEnvelope<SessionEnvelopeHeaders, SessionItem>;
160160
export type ClientReportEnvelope = BaseEnvelope<ClientReportEnvelopeHeaders, ClientReportItem>;
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Event } from '../../../../src/types-hoist/event';
3+
import type { SpanJSON } from '../../../../src/types-hoist/span';
4+
import { extractGenAiSpansFromEvent } from '../../../../src/tracing/spans/extractGenAiSpans';
5+
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
6+
7+
function makeSpanJSON(overrides: Partial<SpanJSON> = {}): SpanJSON {
8+
return {
9+
span_id: 'abc123def456789a',
10+
trace_id: '00112233445566778899aabbccddeeff',
11+
start_timestamp: 1000,
12+
data: {},
13+
...overrides,
14+
};
15+
}
16+
17+
function makeTransactionEvent(spans: SpanJSON[]): Event {
18+
return {
19+
type: 'transaction',
20+
transaction: 'GET /api/chat',
21+
release: '1.0.0',
22+
environment: 'production',
23+
contexts: {
24+
trace: {
25+
span_id: 'root0000deadbeef',
26+
trace_id: '00112233445566778899aabbccddeeff',
27+
},
28+
},
29+
spans,
30+
};
31+
}
32+
33+
function makeClient(options: Partial<Parameters<typeof getDefaultTestClientOptions>[0]> = {}): TestClient {
34+
return new TestClient(
35+
getDefaultTestClientOptions({
36+
dsn: 'https://dsn@ingest.f00.f00/1',
37+
...options,
38+
}),
39+
);
40+
}
41+
42+
describe('extractGenAiSpansFromEvent', () => {
43+
it('extracts gen_ai spans and removes them from the event', () => {
44+
const genAiSpan = makeSpanJSON({
45+
span_id: 'genai001',
46+
op: 'gen_ai.chat',
47+
description: 'chat gpt-4',
48+
timestamp: 1005,
49+
});
50+
const httpSpan = makeSpanJSON({
51+
span_id: 'http001',
52+
op: 'http.client',
53+
description: 'GET /api',
54+
timestamp: 1002,
55+
});
56+
57+
const event = makeTransactionEvent([genAiSpan, httpSpan]);
58+
const client = makeClient();
59+
60+
const result = extractGenAiSpansFromEvent(event, client);
61+
62+
// gen_ai spans should be in the container item
63+
expect(result).toBeDefined();
64+
const [headers, payload] = result!;
65+
expect(headers.type).toBe('span');
66+
expect(headers.item_count).toBe(1);
67+
expect(headers.content_type).toBe('application/vnd.sentry.items.span.v2+json');
68+
expect(payload.items).toHaveLength(1);
69+
expect(payload.items[0]!.span_id).toBe('genai001');
70+
expect(payload.items[0]!.name).toBe('chat gpt-4');
71+
72+
// gen_ai spans should be removed from the event
73+
expect(event.spans).toHaveLength(1);
74+
expect(event.spans![0]!.span_id).toBe('http001');
75+
});
76+
77+
it('extracts multiple gen_ai spans', () => {
78+
const chatSpan = makeSpanJSON({ span_id: 'chat001', op: 'gen_ai.chat', description: 'chat' });
79+
const embeddingsSpan = makeSpanJSON({ span_id: 'embed001', op: 'gen_ai.embeddings', description: 'embed' });
80+
const agentSpan = makeSpanJSON({ span_id: 'agent001', op: 'gen_ai.invoke_agent', description: 'agent' });
81+
const dbSpan = makeSpanJSON({ span_id: 'db001', op: 'db.query', description: 'SELECT *' });
82+
83+
const event = makeTransactionEvent([chatSpan, embeddingsSpan, dbSpan, agentSpan]);
84+
const client = makeClient();
85+
86+
const result = extractGenAiSpansFromEvent(event, client);
87+
88+
expect(result).toBeDefined();
89+
expect(result![0].item_count).toBe(3);
90+
expect(result![1].items).toHaveLength(3);
91+
expect(result![1].items.map(s => s.span_id)).toEqual(['chat001', 'embed001', 'agent001']);
92+
93+
// Only the db span should remain
94+
expect(event.spans).toHaveLength(1);
95+
expect(event.spans![0]!.span_id).toBe('db001');
96+
});
97+
98+
it('returns undefined when there are no gen_ai spans', () => {
99+
const httpSpan = makeSpanJSON({ op: 'http.client' });
100+
const dbSpan = makeSpanJSON({ op: 'db.query' });
101+
102+
const event = makeTransactionEvent([httpSpan, dbSpan]);
103+
const client = makeClient();
104+
105+
const result = extractGenAiSpansFromEvent(event, client);
106+
107+
expect(result).toBeUndefined();
108+
expect(event.spans).toHaveLength(2);
109+
});
110+
111+
it('returns undefined when event has no spans', () => {
112+
const event = makeTransactionEvent([]);
113+
const client = makeClient();
114+
115+
expect(extractGenAiSpansFromEvent(event, client)).toBeUndefined();
116+
});
117+
118+
it('returns undefined when event is not a transaction', () => {
119+
const event: Event = { type: undefined, spans: [makeSpanJSON({ op: 'gen_ai.chat' })] };
120+
const client = makeClient();
121+
122+
expect(extractGenAiSpansFromEvent(event, client)).toBeUndefined();
123+
});
124+
125+
it('returns undefined when span streaming is enabled', () => {
126+
const event = makeTransactionEvent([makeSpanJSON({ op: 'gen_ai.chat' })]);
127+
const client = makeClient({ traceLifecycle: 'stream' });
128+
129+
expect(extractGenAiSpansFromEvent(event, client)).toBeUndefined();
130+
// Spans should not be modified
131+
expect(event.spans).toHaveLength(1);
132+
});
133+
134+
it('preserves parent_span_id pointing to v1 spans', () => {
135+
const genAiSpan = makeSpanJSON({
136+
span_id: 'genai001',
137+
parent_span_id: 'http001',
138+
op: 'gen_ai.chat',
139+
});
140+
const httpSpan = makeSpanJSON({
141+
span_id: 'http001',
142+
op: 'http.client',
143+
});
144+
145+
const event = makeTransactionEvent([httpSpan, genAiSpan]);
146+
const client = makeClient();
147+
148+
const result = extractGenAiSpansFromEvent(event, client);
149+
150+
// The v2 span should still reference the v1 parent
151+
expect(result![1].items[0]!.parent_span_id).toBe('http001');
152+
// The v1 parent should remain in the transaction
153+
expect(event.spans).toHaveLength(1);
154+
expect(event.spans![0]!.span_id).toBe('http001');
155+
});
156+
});

0 commit comments

Comments
 (0)