Skip to content

Commit 40f04bc

Browse files
mydeaandreiborza
andauthored
feat: Add opt-in vercelAiIntegration to cloudflare & vercel-edge (#16732)
This is on top of #16714. This adds the `vercelAiIntegration` to the cloudflare SDK, as well as to the vercel-edge SDK. I moved the critical code from node to core package, so we can reuse this (the code to process spans). The integration is not added by default, but needs to be added manually. then it will "force" add the event processors etc. We cannot auto-detect the `ai` package, sadly, because this does not work in workers 😢 so for now it needs to be added manually to avoid overhead for users that don't need this. @andreiborza let's verify that this works when deployed to cloudflare, esp. also the auto-enablement via the modules integration etc 🤔 --------- Co-authored-by: Andrei Borza <andrei.borza@sentry.io> Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com>
1 parent ae30471 commit 40f04bc

File tree

14 files changed

+359
-203
lines changed

14 files changed

+359
-203
lines changed

packages/cloudflare/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export { CloudflareClient } from './client';
107107
export { getDefaultIntegrations } from './sdk';
108108

109109
export { fetchIntegration } from './integrations/fetch';
110+
export { vercelAIIntegration } from './integrations/tracing/vercelai';
110111

111112
export { instrumentD1WithSentry } from './d1';
112113

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* This is a copy of the Vercel AI integration from the node SDK.
3+
*
4+
* The only difference is that it does not use `@opentelemetry/instrumentation`
5+
* because Cloudflare Workers do not support it.
6+
*
7+
* Therefore, we cannot automatically patch setting `experimental_telemetry: { isEnabled: true }`
8+
* and users have to manually set this to get spans.
9+
*/
10+
11+
import type { IntegrationFn } from '@sentry/core';
12+
import { addVercelAiProcessors, defineIntegration } from '@sentry/core';
13+
14+
const INTEGRATION_NAME = 'VercelAI';
15+
16+
const _vercelAIIntegration = (() => {
17+
return {
18+
name: INTEGRATION_NAME,
19+
setup(client) {
20+
addVercelAiProcessors(client);
21+
},
22+
};
23+
}) satisfies IntegrationFn;
24+
25+
/**
26+
* Adds Sentry tracing instrumentation for the [ai](https://www.npmjs.com/package/ai) library.
27+
* This integration is not enabled by default, you need to manually add it.
28+
*
29+
* For more information, see the [`ai` documentation](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry).
30+
*
31+
* You need to enable collecting spans for a specific call by setting
32+
* `experimental_telemetry.isEnabled` to `true` in the first argument of the function call.
33+
*
34+
* ```javascript
35+
* const result = await generateText({
36+
* model: openai('gpt-4-turbo'),
37+
* experimental_telemetry: { isEnabled: true },
38+
* });
39+
* ```
40+
*
41+
* If you want to collect inputs and outputs for a specific call, you must specifically opt-in to each
42+
* function call by setting `experimental_telemetry.recordInputs` and `experimental_telemetry.recordOutputs`
43+
* to `true`.
44+
*
45+
* ```javascript
46+
* const result = await generateText({
47+
* model: openai('gpt-4-turbo'),
48+
* experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true },
49+
* });
50+
*/
51+
export const vercelAIIntegration = defineIntegration(_vercelAIIntegration);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { Span } from '@opentelemetry/api';
2+
import type { SpanOrigin } from '@sentry/core';
3+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
4+
5+
/** Adds an origin to an OTEL Span. */
6+
export function addOriginToSpan(span: Span, origin: SpanOrigin): void {
7+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, origin);
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** Detect CommonJS. */
2+
export function isCjs(): boolean {
3+
try {
4+
return typeof module !== 'undefined' && typeof module.exports !== 'undefined';
5+
} catch {
6+
return false;
7+
}
8+
}

packages/core/src/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
498498
): void;
499499

500500
/**
501-
* Register a callback for whenever a span is ended.
501+
* Register a callback for after a span is ended.
502+
* NOTE: The span cannot be mutated anymore in this callback.
502503
* Receives the span as argument.
503504
* @returns {() => void} A function that, when executed, removes the registered callback.
504505
*/

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export { captureFeedback } from './feedback';
123123
export type { ReportDialogOptions } from './report-dialog';
124124
export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports';
125125
export { consoleLoggingIntegration } from './logs/console-integration';
126+
export { addVercelAiProcessors } from './utils/vercel-ai';
126127

127128
export type { FeatureFlag } from './utils/featureFlags';
128129
export {

packages/core/src/utils/vercel-ai.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { Client } from '../client';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
3+
import type { Event } from '../types-hoist/event';
4+
import type { Span, SpanAttributes, SpanJSON, SpanOrigin } from '../types-hoist/span';
5+
import { spanToJSON } from './spanUtils';
6+
import {
7+
AI_MODEL_ID_ATTRIBUTE,
8+
AI_MODEL_PROVIDER_ATTRIBUTE,
9+
AI_PROMPT_ATTRIBUTE,
10+
AI_PROMPT_MESSAGES_ATTRIBUTE,
11+
AI_PROMPT_TOOLS_ATTRIBUTE,
12+
AI_RESPONSE_TEXT_ATTRIBUTE,
13+
AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
14+
AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE,
15+
AI_TOOL_CALL_ID_ATTRIBUTE,
16+
AI_TOOL_CALL_NAME_ATTRIBUTE,
17+
AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE,
18+
AI_USAGE_PROMPT_TOKENS_ATTRIBUTE,
19+
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
20+
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
21+
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
22+
} from './vercel-ai-attributes';
23+
24+
function addOriginToSpan(span: Span, origin: SpanOrigin): void {
25+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, origin);
26+
}
27+
28+
/**
29+
* Post-process spans emitted by the Vercel AI SDK.
30+
* This is supposed to be used in `client.on('spanStart', ...)
31+
*/
32+
function onVercelAiSpanStart(span: Span): void {
33+
const { data: attributes, description: name } = spanToJSON(span);
34+
35+
if (!name) {
36+
return;
37+
}
38+
39+
// Tool call spans
40+
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans
41+
if (attributes[AI_TOOL_CALL_NAME_ATTRIBUTE] && attributes[AI_TOOL_CALL_ID_ATTRIBUTE] && name === 'ai.toolCall') {
42+
processToolCallSpan(span, attributes);
43+
return;
44+
}
45+
46+
// The AI and Provider must be defined for generate, stream, and embed spans.
47+
// The id of the model
48+
const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE];
49+
// the provider of the model
50+
const aiModelProvider = attributes[AI_MODEL_PROVIDER_ATTRIBUTE];
51+
if (typeof aiModelId !== 'string' || typeof aiModelProvider !== 'string' || !aiModelId || !aiModelProvider) {
52+
return;
53+
}
54+
55+
processGenerateSpan(span, name, attributes);
56+
}
57+
58+
const vercelAiEventProcessor = Object.assign(
59+
(event: Event): Event => {
60+
if (event.type === 'transaction' && event.spans) {
61+
for (const span of event.spans) {
62+
// this mutates spans in-place
63+
processEndedVercelAiSpan(span);
64+
}
65+
}
66+
return event;
67+
},
68+
{ id: 'VercelAiEventProcessor' },
69+
);
70+
71+
/**
72+
* Post-process spans emitted by the Vercel AI SDK.
73+
*/
74+
function processEndedVercelAiSpan(span: SpanJSON): void {
75+
const { data: attributes, origin } = span;
76+
77+
if (origin !== 'auto.vercelai.otel') {
78+
return;
79+
}
80+
81+
renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE);
82+
renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE);
83+
84+
if (
85+
typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' &&
86+
typeof attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] === 'number'
87+
) {
88+
attributes['gen_ai.usage.total_tokens'] =
89+
attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE];
90+
}
91+
92+
// Rename AI SDK attributes to standardized gen_ai attributes
93+
renameAttributeKey(attributes, AI_PROMPT_MESSAGES_ATTRIBUTE, 'gen_ai.request.messages');
94+
renameAttributeKey(attributes, AI_RESPONSE_TEXT_ATTRIBUTE, 'gen_ai.response.text');
95+
renameAttributeKey(attributes, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, 'gen_ai.response.tool_calls');
96+
renameAttributeKey(attributes, AI_PROMPT_TOOLS_ATTRIBUTE, 'gen_ai.request.available_tools');
97+
}
98+
99+
/**
100+
* Renames an attribute key in the provided attributes object if the old key exists.
101+
* This function safely handles null and undefined values.
102+
*/
103+
function renameAttributeKey(attributes: Record<string, unknown>, oldKey: string, newKey: string): void {
104+
if (attributes[oldKey] != null) {
105+
attributes[newKey] = attributes[oldKey];
106+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
107+
delete attributes[oldKey];
108+
}
109+
}
110+
111+
function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
112+
addOriginToSpan(span, 'auto.vercelai.otel');
113+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.execute_tool');
114+
span.setAttribute('gen_ai.tool.call.id', attributes[AI_TOOL_CALL_ID_ATTRIBUTE]);
115+
span.setAttribute('gen_ai.tool.name', attributes[AI_TOOL_CALL_NAME_ATTRIBUTE]);
116+
span.updateName(`execute_tool ${attributes[AI_TOOL_CALL_NAME_ATTRIBUTE]}`);
117+
}
118+
119+
function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes): void {
120+
addOriginToSpan(span, 'auto.vercelai.otel');
121+
122+
const nameWthoutAi = name.replace('ai.', '');
123+
span.setAttribute('ai.pipeline.name', nameWthoutAi);
124+
span.updateName(nameWthoutAi);
125+
126+
// If a Telemetry name is set and it is a pipeline span, use that as the operation name
127+
const functionId = attributes[AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE];
128+
if (functionId && typeof functionId === 'string' && name.split('.').length - 1 === 1) {
129+
span.updateName(`${nameWthoutAi} ${functionId}`);
130+
span.setAttribute('ai.pipeline.name', functionId);
131+
}
132+
133+
if (attributes[AI_PROMPT_ATTRIBUTE]) {
134+
span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]);
135+
}
136+
if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) {
137+
span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]);
138+
}
139+
span.setAttribute('ai.streaming', name.includes('stream'));
140+
141+
// Generate Spans
142+
if (name === 'ai.generateText') {
143+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
144+
return;
145+
}
146+
147+
if (name === 'ai.generateText.doGenerate') {
148+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_text');
149+
span.updateName(`generate_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
150+
return;
151+
}
152+
153+
if (name === 'ai.streamText') {
154+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
155+
return;
156+
}
157+
158+
if (name === 'ai.streamText.doStream') {
159+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_text');
160+
span.updateName(`stream_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
161+
return;
162+
}
163+
164+
if (name === 'ai.generateObject') {
165+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
166+
return;
167+
}
168+
169+
if (name === 'ai.generateObject.doGenerate') {
170+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_object');
171+
span.updateName(`generate_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
172+
return;
173+
}
174+
175+
if (name === 'ai.streamObject') {
176+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
177+
return;
178+
}
179+
180+
if (name === 'ai.streamObject.doStream') {
181+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_object');
182+
span.updateName(`stream_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
183+
return;
184+
}
185+
186+
if (name === 'ai.embed') {
187+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
188+
return;
189+
}
190+
191+
if (name === 'ai.embed.doEmbed') {
192+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed');
193+
span.updateName(`embed ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
194+
return;
195+
}
196+
197+
if (name === 'ai.embedMany') {
198+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
199+
return;
200+
}
201+
202+
if (name === 'ai.embedMany.doEmbed') {
203+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed_many');
204+
span.updateName(`embed_many ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
205+
return;
206+
}
207+
208+
if (name.startsWith('ai.stream')) {
209+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'ai.run');
210+
return;
211+
}
212+
}
213+
214+
/**
215+
* Add event processors to the given client to process Vercel AI spans.
216+
*/
217+
export function addVercelAiProcessors(client: Client): void {
218+
client.on('spanStart', onVercelAiSpanStart);
219+
// Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point
220+
client.addEventProcessor(vercelAiEventProcessor);
221+
}

packages/nextjs/src/index.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export declare function init(
2222
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
2323
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
2424

25+
// Different implementation in server and worker
26+
export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration;
27+
2528
export declare const getDefaultIntegrations: (options: Options) => Integration[];
2629
export declare const defaultStackParser: StackParser;
2730

0 commit comments

Comments
 (0)