Skip to content
Closed
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
Expand Up @@ -951,9 +951,9 @@ function buildInputMessageAttributes(event: Record<string, unknown>): Record<str
// `ai` >= 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
Expand All @@ -965,3 +965,22 @@ function buildInputMessageAttributes(event: Record<string, unknown>): Record<str

return attributes;
}

/**
* Normalize the `system`/`instructions` option into `gen_ai.system_instructions` entries. The AI SDK
* accepts `string | SystemModelMessage | SystemModelMessage[]` there — the message form is how a
* caller attaches `providerOptions` to the system prompt (e.g. Anthropic prompt caching) — so a bare
* string and single message both become one entry, and an array contributes one entry per message.
*/
function systemInstructionEntries(value: unknown): Array<{ type: 'text'; content: string }> | 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof createSpanFromMessage>[0],
{} as Parameters<typeof createSpanFromMessage>[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<typeof createSpanFromMessage>[0],
{} as Parameters<typeof createSpanFromMessage>[1],
);
span?.end();

const data = spanToStaticSpanJSON(endedSpans[0]!).data;
expect(data?.[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"Be brief."}]');
});
});
Loading