Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d0cb9d0
feat(core): Send gen_ai spans as v2 envelope items
andreiborza Apr 16, 2026
69b5cf8
Remove SDK-side enrichment and redundant op/origin backfill
andreiborza Apr 16, 2026
257c9fb
Pre-check wether transactions have gen_ai spans when we construct tra…
andreiborza Apr 16, 2026
eeb7812
Inline span conversion
andreiborza Apr 16, 2026
fd67edd
Merge remote-tracking branch 'origin/develop' into ab/gen-ai-span-v2-poc
andreiborza Apr 20, 2026
dc21b49
Stringify array attributes in Vercel AI integration for v2 serializat…
andreiborza Apr 20, 2026
d97c7f2
Update Vercel AI Node tests
andreiborza Apr 20, 2026
d51f4f2
Update Vercel AI E2E tests
andreiborza Apr 20, 2026
bed75f7
Update Anthropic Node tests
andreiborza Apr 20, 2026
0474787
Update Anthropic Cloudflare tests
andreiborza Apr 20, 2026
2630a24
Update OpenAI Node tests
andreiborza Apr 20, 2026
7eea494
Update OpenAI Cloudflare tests
andreiborza Apr 20, 2026
5d2ec5a
Update LangChain Node tests
andreiborza Apr 20, 2026
9968bc8
Update LangChain Cloudflare tests
andreiborza Apr 20, 2026
9e8767b
Update LangGraph Node tests
andreiborza Apr 20, 2026
6a62f0b
Update LangGraph Cloudflare tests
andreiborza Apr 20, 2026
0667e9d
Update Google GenAI Node tests
andreiborza Apr 20, 2026
26f9157
Update Google GenAI Cloudflare tests
andreiborza Apr 20, 2026
76d835c
Update size limits
andreiborza Apr 20, 2026
5f5d49e
Flip `enableTruncation` default to false
andreiborza Apr 20, 2026
7fc4b17
Merge remote-tracking branch 'origin/develop' into ab/gen-ai-span-v2-poc
andreiborza Apr 21, 2026
e6ecd23
Bump size limit
andreiborza Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
import { extractGenAiSpansFromEvent } from './tracing/spans/extractGenAiSpans';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base';
import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb';
import type { CheckIn, MonitorConfig } from './types-hoist/checkin';
Expand Down Expand Up @@ -522,12 +523,20 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public sendEvent(event: Event, hint: EventHint = {}): void {
this.emit('beforeSendEvent', event, hint);

// Extract gen_ai spans from transaction and convert to span v2 format.
// This mutates event.spans to remove the extracted spans.
const genAiSpanItem = extractGenAiSpansFromEvent(event, this);

let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel);

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

if (genAiSpanItem) {
env = addItemToEnvelope(env, genAiSpanItem);
}

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendEnvelope(env).then(sendResponse => this.emit('afterSendEvent', event, sendResponse));
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/tracing/spans/extractGenAiSpans.ts
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);
Copy link
Copy Markdown
Member

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 splice here instead of creating a new array?

Copy link
Copy Markdown
Member Author

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.

Copy link
Copy Markdown
Member Author

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

} 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 },
];
}
78 changes: 78 additions & 0 deletions packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts
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 || '',
Comment thread
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,
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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,
}
: {}),
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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' {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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';
}
2 changes: 1 addition & 1 deletion packages/core/src/types-hoist/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ type LogEnvelopeHeaders = BaseEnvelopeHeaders;
type MetricEnvelopeHeaders = BaseEnvelopeHeaders;
export type EventEnvelope = BaseEnvelope<
EventEnvelopeHeaders,
EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem
EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem | SpanContainerItem
>;
export type SessionEnvelope = BaseEnvelope<SessionEnvelopeHeaders, SessionItem>;
export type ClientReportEnvelope = BaseEnvelope<ClientReportEnvelopeHeaders, ClientReportItem>;
Expand Down
156 changes: 156 additions & 0 deletions packages/core/test/lib/tracing/spans/extractGenAiSpans.test.ts
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');
});
});
Loading
Loading