-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(core): Send gen_ai spans as v2 envelope items #20342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 1 commit
d0cb9d0
69b5cf8
257c9fb
eeb7812
fd67edd
dc21b49
d97c7f2
d51f4f2
bed75f7
0474787
2630a24
7eea494
5d2ec5a
9968bc8
9e8767b
6a62f0b
0667e9d
26f9157
76d835c
5f5d49e
7fc4b17
e6ecd23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import type { Client } from '../../client'; | ||
| import type { SpanContainerItem } from '../../types-hoist/envelope'; | ||
| import type { Event } from '../../types-hoist/event'; | ||
| import { hasSpanStreamingEnabled } from './hasSpanStreamingEnabled'; | ||
| import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan'; | ||
|
|
||
| /** | ||
| * Extracts gen_ai spans from a transaction event, converts them to span v2 format, | ||
| * and returns them as a SpanContainerItem. | ||
| * | ||
| * Only applies to static mode (non-streaming) transactions. | ||
| * | ||
| * WARNING: This function mutates `event.spans` by removing the extracted gen_ai spans | ||
| * from the array. Call this before creating the event envelope so the transaction | ||
| * item does not include the extracted spans. | ||
| */ | ||
| export function extractGenAiSpansFromEvent(event: Event, client: Client): SpanContainerItem | undefined { | ||
| if (event.type !== 'transaction' || !event.spans?.length || hasSpanStreamingEnabled(client)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const genAiSpans = []; | ||
| const remainingSpans = []; | ||
|
|
||
| for (const span of event.spans) { | ||
| if (span.op?.startsWith('gen_ai.')) { | ||
| genAiSpans.push(span); | ||
| } else { | ||
| remainingSpans.push(span); | ||
| } | ||
| } | ||
|
|
||
| if (genAiSpans.length === 0) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const serializedSpans = genAiSpans.map(span => spanJsonToSerializedStreamedSpan(span, event, client)); | ||
|
|
||
| // Remove gen_ai spans from the legacy transaction | ||
| event.spans = remainingSpans; | ||
|
|
||
| return [ | ||
| { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, | ||
| { items: serializedSpans }, | ||
| ]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import type { RawAttributes } from '../../attributes'; | ||
| import type { Client } from '../../client'; | ||
| import { | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, | ||
| SEMANTIC_ATTRIBUTE_USER_EMAIL, | ||
| SEMANTIC_ATTRIBUTE_USER_ID, | ||
| SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, | ||
| SEMANTIC_ATTRIBUTE_USER_USERNAME, | ||
| } from '../../semanticAttributes'; | ||
| import type { Event } from '../../types-hoist/event'; | ||
| import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types-hoist/span'; | ||
| import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; | ||
| import { safeSetSpanJSONAttributes } from './captureSpan'; | ||
|
|
||
| /** | ||
| * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. | ||
| */ | ||
| export function spanJsonToSerializedStreamedSpan( | ||
| span: SpanJSON, | ||
| transactionEvent: Event, | ||
| client: Client, | ||
| ): SerializedStreamedSpan { | ||
| const streamedSpan: StreamedSpanJSON = { | ||
| trace_id: span.trace_id, | ||
| span_id: span.span_id, | ||
| parent_span_id: span.parent_span_id, | ||
| name: span.description || '', | ||
|
alexander-alderman-webb marked this conversation as resolved.
|
||
| start_timestamp: span.start_timestamp, | ||
| end_timestamp: span.timestamp || span.start_timestamp, | ||
| status: mapV1StatusToV2(span.status), | ||
| is_segment: false, | ||
| attributes: { ...(span.data as RawAttributes<Record<string, unknown>>) }, | ||
| links: span.links, | ||
| }; | ||
|
|
||
| // Fold op and origin into attributes | ||
| safeSetSpanJSONAttributes(streamedSpan, { | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_OP]: span.op, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: span.origin, | ||
| }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l: do we need this? Shouldn't the op -> sentry.op backfill have happened already? logaf-l though as only minimal hit and we're already defensive.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope, removed. Thanks! |
||
|
|
||
| // Enrich from transaction event context (same pattern as captureSpan.ts applyCommonSpanAttributes) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's not do this, keep it simple for the time being! |
||
| const sdk = client.getSdkMetadata(); | ||
| const { release, environment, sendDefaultPii } = client.getOptions(); | ||
|
|
||
| safeSetSpanJSONAttributes(streamedSpan, { | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: transactionEvent.release || release, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: transactionEvent.environment || environment, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: transactionEvent.transaction, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: transactionEvent.contexts?.trace?.span_id, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, | ||
| ...(sendDefaultPii | ||
| ? { | ||
| [SEMANTIC_ATTRIBUTE_USER_ID]: transactionEvent.user?.id, | ||
| [SEMANTIC_ATTRIBUTE_USER_EMAIL]: transactionEvent.user?.email, | ||
| [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: transactionEvent.user?.ip_address, | ||
| [SEMANTIC_ATTRIBUTE_USER_USERNAME]: transactionEvent.user?.username, | ||
| } | ||
| : {}), | ||
| }); | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. m: I don't think we need any of the common span attribute application logic, correct? We just map whatever is on the gen_ai span to a v2 span. Should save a decent amount of bytes.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, removed. Thanks! |
||
| return streamedSpanJsonToSerializedSpan(streamedSpan); | ||
| } | ||
|
|
||
| function mapV1StatusToV2(status: string | undefined): 'ok' | 'error' { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l: we could make this slightly more size-efficient with an inlined ternary instead of the function. But feel free to disregard.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, inlined it. Thanks! |
||
| if (!status || status === 'ok' || status === 'cancelled') { | ||
| return 'ok'; | ||
| } | ||
| return 'error'; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import type { Event } from '../../../../src/types-hoist/event'; | ||
| import type { SpanJSON } from '../../../../src/types-hoist/span'; | ||
| import { extractGenAiSpansFromEvent } from '../../../../src/tracing/spans/extractGenAiSpans'; | ||
| import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; | ||
|
|
||
| function makeSpanJSON(overrides: Partial<SpanJSON> = {}): SpanJSON { | ||
| return { | ||
| span_id: 'abc123def456789a', | ||
| trace_id: '00112233445566778899aabbccddeeff', | ||
| start_timestamp: 1000, | ||
| data: {}, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function makeTransactionEvent(spans: SpanJSON[]): Event { | ||
| return { | ||
| type: 'transaction', | ||
| transaction: 'GET /api/chat', | ||
| release: '1.0.0', | ||
| environment: 'production', | ||
| contexts: { | ||
| trace: { | ||
| span_id: 'root0000deadbeef', | ||
| trace_id: '00112233445566778899aabbccddeeff', | ||
| }, | ||
| }, | ||
| spans, | ||
| }; | ||
| } | ||
|
|
||
| function makeClient(options: Partial<Parameters<typeof getDefaultTestClientOptions>[0]> = {}): TestClient { | ||
| return new TestClient( | ||
| getDefaultTestClientOptions({ | ||
| dsn: 'https://dsn@ingest.f00.f00/1', | ||
| ...options, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| describe('extractGenAiSpansFromEvent', () => { | ||
| it('extracts gen_ai spans and removes them from the event', () => { | ||
| const genAiSpan = makeSpanJSON({ | ||
| span_id: 'genai001', | ||
| op: 'gen_ai.chat', | ||
| description: 'chat gpt-4', | ||
| timestamp: 1005, | ||
| }); | ||
| const httpSpan = makeSpanJSON({ | ||
| span_id: 'http001', | ||
| op: 'http.client', | ||
| description: 'GET /api', | ||
| timestamp: 1002, | ||
| }); | ||
|
|
||
| const event = makeTransactionEvent([genAiSpan, httpSpan]); | ||
| const client = makeClient(); | ||
|
|
||
| const result = extractGenAiSpansFromEvent(event, client); | ||
|
|
||
| // gen_ai spans should be in the container item | ||
| expect(result).toBeDefined(); | ||
| const [headers, payload] = result!; | ||
| expect(headers.type).toBe('span'); | ||
| expect(headers.item_count).toBe(1); | ||
| expect(headers.content_type).toBe('application/vnd.sentry.items.span.v2+json'); | ||
| expect(payload.items).toHaveLength(1); | ||
| expect(payload.items[0]!.span_id).toBe('genai001'); | ||
| expect(payload.items[0]!.name).toBe('chat gpt-4'); | ||
|
|
||
| // gen_ai spans should be removed from the event | ||
| expect(event.spans).toHaveLength(1); | ||
| expect(event.spans![0]!.span_id).toBe('http001'); | ||
| }); | ||
|
|
||
| it('extracts multiple gen_ai spans', () => { | ||
| const chatSpan = makeSpanJSON({ span_id: 'chat001', op: 'gen_ai.chat', description: 'chat' }); | ||
| const embeddingsSpan = makeSpanJSON({ span_id: 'embed001', op: 'gen_ai.embeddings', description: 'embed' }); | ||
| const agentSpan = makeSpanJSON({ span_id: 'agent001', op: 'gen_ai.invoke_agent', description: 'agent' }); | ||
| const dbSpan = makeSpanJSON({ span_id: 'db001', op: 'db.query', description: 'SELECT *' }); | ||
|
|
||
| const event = makeTransactionEvent([chatSpan, embeddingsSpan, dbSpan, agentSpan]); | ||
| const client = makeClient(); | ||
|
|
||
| const result = extractGenAiSpansFromEvent(event, client); | ||
|
|
||
| expect(result).toBeDefined(); | ||
| expect(result![0].item_count).toBe(3); | ||
| expect(result![1].items).toHaveLength(3); | ||
| expect(result![1].items.map(s => s.span_id)).toEqual(['chat001', 'embed001', 'agent001']); | ||
|
|
||
| // Only the db span should remain | ||
| expect(event.spans).toHaveLength(1); | ||
| expect(event.spans![0]!.span_id).toBe('db001'); | ||
| }); | ||
|
|
||
| it('returns undefined when there are no gen_ai spans', () => { | ||
| const httpSpan = makeSpanJSON({ op: 'http.client' }); | ||
| const dbSpan = makeSpanJSON({ op: 'db.query' }); | ||
|
|
||
| const event = makeTransactionEvent([httpSpan, dbSpan]); | ||
| const client = makeClient(); | ||
|
|
||
| const result = extractGenAiSpansFromEvent(event, client); | ||
|
|
||
| expect(result).toBeUndefined(); | ||
| expect(event.spans).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('returns undefined when event has no spans', () => { | ||
| const event = makeTransactionEvent([]); | ||
| const client = makeClient(); | ||
|
|
||
| expect(extractGenAiSpansFromEvent(event, client)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined when event is not a transaction', () => { | ||
| const event: Event = { type: undefined, spans: [makeSpanJSON({ op: 'gen_ai.chat' })] }; | ||
| const client = makeClient(); | ||
|
|
||
| expect(extractGenAiSpansFromEvent(event, client)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined when span streaming is enabled', () => { | ||
| const event = makeTransactionEvent([makeSpanJSON({ op: 'gen_ai.chat' })]); | ||
| const client = makeClient({ traceLifecycle: 'stream' }); | ||
|
|
||
| expect(extractGenAiSpansFromEvent(event, client)).toBeUndefined(); | ||
| // Spans should not be modified | ||
| expect(event.spans).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('preserves parent_span_id pointing to v1 spans', () => { | ||
| const genAiSpan = makeSpanJSON({ | ||
| span_id: 'genai001', | ||
| parent_span_id: 'http001', | ||
| op: 'gen_ai.chat', | ||
| }); | ||
| const httpSpan = makeSpanJSON({ | ||
| span_id: 'http001', | ||
| op: 'http.client', | ||
| }); | ||
|
|
||
| const event = makeTransactionEvent([httpSpan, genAiSpan]); | ||
| const client = makeClient(); | ||
|
|
||
| const result = extractGenAiSpansFromEvent(event, client); | ||
|
|
||
| // The v2 span should still reference the v1 parent | ||
| expect(result![1].items[0]!.parent_span_id).toBe('http001'); | ||
| // The v1 parent should remain in the transaction | ||
| expect(event.spans).toHaveLength(1); | ||
| expect(event.spans![0]!.span_id).toBe('http001'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
l: not quite sure, but maybe we can just use
splicehere instead of creating a new array?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Splicing becomes awkward, you'd have to backward splice, or do some pre-collecting of indices iteration... not much gain there.
If we splice backwards, we'd need to reverse gen_ai spans array, I think the code complexity and minimal performance gain from that (still have to reverse) is not worth it.
If we can send the ai spans in reverse order, we'd save some time but that's bound to create some confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I at least got rid of the extra
.map