Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import * as Sentry from '@sentry/node';
import { generateText, stepCountIs, streamText, tool } from 'ai';
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
import { z } from 'zod';

const usage = {
inputTokens: { total: 10, noCache: 10, cached: 0 },
outputTokens: { total: 20, noCache: 20, cached: 0 },
totalTokens: { total: 30, noCache: 30, cached: 0 },
};

const tools = {
getWeather: tool({
description: 'Get the current weather for a location',
inputSchema: z.object({ location: z.string() }),
contextSchema: z.object({ units: z.string(), apiKey: z.string() }),
execute: async ({ location }) => `Weather in ${location}: Sunny`,
}),
};

async function run() {
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
let callCount = 0;
await generateText({
model: new MockLanguageModelV3({
doGenerate: async () =>
callCount++ === 0
? {
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage,
content: [
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'getWeather',
input: JSON.stringify({ location: 'Vienna' }),
},
],
warnings: [],
}
: {
finishReason: { unified: 'stop', raw: 'stop' },
usage,
content: [{ type: 'text', text: 'Sunny' }],
warnings: [],
},
}),
tools,
stopWhen: stepCountIs(3),
prompt: 'Weather in Vienna?',
runtimeContext: { requestId: 'req_generate', tenantId: 'acme', apiToken: 'secret-runtime' },
toolsContext: { getWeather: { units: 'metric', apiKey: 'secret-tool' } },
telemetry: {
isEnabled: true,
functionId: 'support-chat',
includeRuntimeContext: { requestId: true, tenantId: true },
includeToolsContext: { getWeather: { units: true } },
},
});

const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start', warnings: [] },
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Hello!' },
{ type: 'text-end', id: '0' },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
],
}),
}),
}),
prompt: 'Hi',
runtimeContext: { requestId: 'req_stream', apiToken: 'secret-runtime' },
telemetry: {
isEnabled: true,
functionId: 'support-stream',
includeRuntimeContext: { requestId: true },
},
});
for await (const _part of result.fullStream) {
void _part;
}
});

await Sentry.flush(2000);
}

run();
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_DECIDE } from '@sentry/conventions/op';
import {
GEN_AI_DECIDE,
GEN_AI_EXECUTE_TOOL,
GEN_AI_GENERATE_CONTENT,
GEN_AI_INVOKE_AGENT,
} from '@sentry/conventions/op';
import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests, createEsmTests } from '../../../../utils/runner';

Expand Down Expand Up @@ -1429,3 +1434,62 @@ describe('Vercel AI telemetry metadata (v6)', () => {
{ additionalDependencies: { ai: '^6.0.0' } },
);
});

describe('Vercel AI runtime and tool context (v7)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmTests(
__dirname,
'scenario-runtime-context.mjs',
'instrument.mjs',
(createRunner, test) => {
test('records the context keys included in telemetry as span attributes', async () => {
await createRunner()
.expect({
span: container => {
const spans = container.items.filter(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai',
);
// Keys left out of `includeRuntimeContext`/`includeToolsContext` must not reach any span.
expect(JSON.stringify(spans)).not.toMatch(/secret-runtime|secret-tool|apiToken|apiKey/);

const generateSpans = spans.filter(
span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_generate',
);
expect(generateSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([
GEN_AI_EXECUTE_TOOL,
GEN_AI_GENERATE_CONTENT,
GEN_AI_GENERATE_CONTENT,
GEN_AI_INVOKE_AGENT,
]);
for (const span of generateSpans) {
expect(span.attributes['vercel.ai.settings.context.tenantId']).toEqual({
type: 'string',
value: 'acme',
});
}

const toolSpan = generateSpans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL);
expect(toolSpan?.attributes['vercel.ai.tool_call.context.units']).toEqual({
type: 'string',
value: 'metric',
});

const streamSpans = spans.filter(
span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_stream',
);
expect(streamSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([
GEN_AI_GENERATE_CONTENT,
GEN_AI_INVOKE_AGENT,
]);
},
})
.start()
.completed();
});
},
{ additionalDependencies: { ai: '^7.0.154' } },
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const VERCEL_AI_OPERATION_ID_ATTRIBUTE = 'vercel.ai.operationId';
const VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE = 'vercel.ai.model.provider';
const VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'vercel.ai.settings.maxRetries';
const VERCEL_AI_TELEMETRY_METADATA_ATTRIBUTE_PREFIX = 'vercel.ai.telemetry.metadata.';
const VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.settings.context.';
const VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.tool_call.context.';

// Tracks the top-level operationId (and whether it streams) per `callId` so a model-call span can
// name its `doGenerate`/`doStream` operation the same way the OTel integration does. `isStream` is
Expand All @@ -108,6 +110,10 @@ const toolDescriptionsByCallId = new Map<string, Map<string, string>>();
// before the operation's own span ends. The running token sum lives on the span's own attributes.
const invokeAgentSpanByCallId = new Map<string, Span>();

// Only an operation's start event carries `runtimeContext`; its model-call and tool events copy the
// resulting attributes from here. Cleared with the operation.
const runtimeContextAttributesByCallId = new Map<string, SpanAttributes>();

// Only top-level operations own the `callId` → operationId mapping; `step`/`languageModelCall`/
// `executeTool` share the parent's `callId`, so they must not clear it.
const ROOT_OPERATION_TYPES = new Set<ChannelEventType>([
Expand Down Expand Up @@ -140,6 +146,7 @@ export function clearOperationCallId(callId: string): void {
operationIdByCallId.delete(callId);
toolDescriptionsByCallId.delete(callId);
invokeAgentSpanByCallId.delete(callId);
runtimeContextAttributesByCallId.delete(callId);
}

/**
Expand Down Expand Up @@ -450,9 +457,17 @@ export function createSpanFromMessage(
getOpenAiConversationId(event.providerOptions) ??
(ROOT_OPERATION_TYPES.has(type) ? undefined : getActiveSpanConversationId());

const runtimeContextAttributes =
(callId ? runtimeContextAttributesByCallId.get(callId) : undefined) ??
contextAttributes(event, event.runtimeContext, VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX);
if (callId && ROOT_OPERATION_TYPES.has(type)) {
runtimeContextAttributesByCallId.set(callId, runtimeContextAttributes);
}

const baseAttributes: SpanAttributes = {
[SENTRY_ORIGIN]: ORIGIN,
...telemetryMetadataAttributes(event.telemetryMetadata),
...runtimeContextAttributes,
[GEN_AI_PROVIDER_NAME]: provider,
[VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE]: provider,
[GEN_AI_REQUEST_MODEL]: modelId,
Expand All @@ -473,7 +488,7 @@ export function createSpanFromMessage(

return buildModelCallSpan(event, baseAttributes, recordInputs, callId, modelId);
case 'executeTool':
return buildToolSpan(event, recordInputs, conversationId);
return buildToolSpan(event, recordInputs, conversationId, runtimeContextAttributes);
case 'embed':
case 'embedMany': {
// `embed` carries a single `value`; `embedMany` a `values` array — both map to the embeddings input.
Expand Down Expand Up @@ -520,6 +535,26 @@ function telemetryMetadataAttributes(metadata: unknown): Record<string, string>
return attributes;
}

/**
* One string attribute per context key, as `<prefix><key>`. `ai` < 7.0.114 published the full context to the
* channel, ignoring `telemetry.includeRuntimeContext`/`includeToolsContext`, and a filtered context
* looks the same as an unfiltered one. `aiVersion` (added in 7.0.154) is the only reliable sign that
* the AI SDK filtered it, so without it we record nothing.
*/
function contextAttributes(event: Record<string, unknown>, context: unknown, prefix: string): SpanAttributes {
const attributes: SpanAttributes = {};
if (typeof event.aiVersion !== 'string' || !isObjectLike(context)) {
return attributes;
}
for (const [key, value] of Object.entries(context)) {
const attributeValue = stringify(value);
if (value !== null && attributeValue !== undefined) {
attributes[`${prefix}${key}`] = attributeValue;
}
}
return attributes;
}

/** Start a `gen_ai.<operation>` span named `<operation> <suffix>` (or just `<operation>` when no suffix). */
function startGenAiSpan(operation: GenAiOperation, suffix: string | undefined, attributes: SpanAttributes): Span {
return startInactiveSpan({
Expand Down Expand Up @@ -581,6 +616,7 @@ function buildToolSpan(
event: Record<string, unknown>,
recordInputs: boolean,
conversationId: string | undefined,
runtimeContextAttributes: SpanAttributes,
): Span {
const toolCall = isObjectLike(event.toolCall) ? event.toolCall : {};
const toolName = asString(toolCall.toolName);
Expand All @@ -592,6 +628,8 @@ function buildToolSpan(
recordInputs && toolName ? resolveToolDescription(asString(event.callId), toolName, event.tools) : undefined;
return startGenAiSpan('execute_tool', toolName, {
[SENTRY_ORIGIN]: ORIGIN,
...runtimeContextAttributes,
...contextAttributes(event, event.toolContext, VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX),
[GEN_AI_TOOL_NAME]: toolName,
[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId,
[GEN_AI_TOOL_DESCRIPTION]: description,
Expand Down
Loading
Loading