diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index c1daaf73fc84..2e1c173ee08e 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -951,9 +951,9 @@ function buildInputMessageAttributes(event: Record): Record= 7 forbids system messages in `messages`/`prompt` and exposes the system prompt as a // separate `instructions` field. The OTel path lifts the system message out of the prompt into // `gen_ai.system_instructions` as `[{ type: 'text', content }]`; mirror that shape here. - const instructions = asString(event.instructions); + const instructions = systemInstructionEntries(event.instructions); if (instructions) { - attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = stringify([{ type: 'text', content: instructions }]); + attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = stringify(instructions); } // The AI SDK start events extend `StandardizedPrompt`; messages live on `messages`, otherwise the @@ -965,3 +965,22 @@ function buildInputMessageAttributes(event: Record): Record | undefined { + if (typeof value === 'string') { + return [{ type: 'text', content: value }]; + } + const messages = Array.isArray(value) ? value : isObjectLike(value) ? [value] : []; + const entries = messages.filter(isObjectLike).flatMap(message => { + const content = asString(message.content); + return content ? [{ type: 'text' as const, content }] : []; + }); + + return entries.length ? entries : undefined; +} diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-orchestrion-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-orchestrion-subscriber.ts index 2a59de79b4f8..a8b8c770feb1 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-orchestrion-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-orchestrion-subscriber.ts @@ -663,10 +663,12 @@ function buildTextMessage(type: 'generateText' | 'streamText' | 'generateObject' functionId: asString(telemetry.functionId), ...modelFields(options.model), maxRetries: options.maxRetries, - // The `ai` SDK takes the system prompt as a top-level `system` option (all of v4/v5/v6); the - // shared core lifts `event.instructions` into the system-instructions attribute, matching v7's - // native channel (which carries it as a distinct field rather than inside the messages array). - instructions: asString(options.system), + // The `ai` SDK takes the system prompt as a top-level `system` option (all of v4/v5/v6) that can + // be a string, a `SystemModelMessage`, or an array of them; the shared core lifts + // `event.instructions` into the system-instructions attribute, matching v7's native channel + // (which carries it as a distinct field rather than inside the messages array), so pass it + // through unchanged rather than narrowing it to a string here. + instructions: options.system, // Normalize to the message-array shape the shared core (and v7's channel) expects: a bare string // `prompt` becomes a single user message, matching the SDK's own normalization. messages: normalizePromptMessages(options), diff --git a/packages/server-utils/test/integrations/vercel-ai/system-instructions.test.ts b/packages/server-utils/test/integrations/vercel-ai/system-instructions.test.ts new file mode 100644 index 000000000000..cee02db1261a --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/system-instructions.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { GEN_AI_SYSTEM_INSTRUCTIONS } from '@sentry/conventions/attributes'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { createSpanFromMessage } from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('Vercel AI system instructions', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + it('records `gen_ai.system_instructions` when `instructions` is a string', () => { + const endedSpans = setupClient(); + const span = createSpanFromMessage( + { + type: 'generateText', + event: { instructions: 'Be brief.', recordInputs: true }, + } as Parameters[0], + {} as Parameters[1], + ); + span?.end(); + + const data = spanToStaticSpanJSON(endedSpans[0]!).data; + expect(data?.[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"Be brief."}]'); + }); + + // AI SDK v7's `instructions` option (and v4-v6's `system`) accepts a `SystemModelMessage` object, not + // just a plain string -- for example to attach `providerOptions` like Anthropic prompt caching. + it('records `gen_ai.system_instructions` when `instructions` is a message object', () => { + const endedSpans = setupClient(); + const span = createSpanFromMessage( + { + type: 'generateText', + event: { + instructions: { + role: 'system', + content: 'Be brief.', + providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, + }, + recordInputs: true, + }, + } as Parameters[0], + {} as Parameters[1], + ); + span?.end(); + + const data = spanToStaticSpanJSON(endedSpans[0]!).data; + expect(data?.[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"Be brief."}]'); + }); +});