Skip to content

Commit 3a5576b

Browse files
JPeer264claude
andcommitted
fix(server-utils): Map Flue messages to the gen_ai conventions
Flue records its turn messages in pi-ai's shape, with a `toolResult` role and `toolCall` parts. Sentry renders neither, so tool calls and tool results were missing from the conversation views, and images were recorded as base64. A shared pi-ai mapper now translates them to the conventions, as the Google GenAI integration does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d880cf0 commit 3a5576b

4 files changed

Lines changed: 236 additions & 8 deletions

File tree

‎packages/server-utils/src/ai/flue/utils.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
SERVER_PORT,
4040
} from '@sentry/conventions/attributes';
4141
import { getGenAiSpanOp } from '../core/utils';
42+
import { piAiAssistantMessageToGenAiMessage, piAiMessagesToGenAiMessages } from '../pi-ai/messages';
4243
import { FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants';
4344
import type { FlueErrorInfo, FlueModelRequestInfo, FlueObservation, FlueUsage } from './types';
4445

@@ -173,9 +174,11 @@ export function endTurnSpan(observation: FlueObservation, turnSpans: SpanTracker
173174
span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, stringify([finishReason]));
174175
}
175176

176-
const output = observation.response?.output;
177-
if (recordOutputs && output !== undefined) {
178-
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify(output));
177+
const output = recordOutputs
178+
? piAiAssistantMessageToGenAiMessage(observation.response?.output, finishReason)
179+
: undefined;
180+
if (output) {
181+
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify([output]));
179182
}
180183

181184
setUsageAttributes(span, observation.response?.usage, observation.isError);
@@ -283,8 +286,9 @@ export function recordRequestContent(observation: FlueObservation, turnSpans: Sp
283286
if (input.systemPrompt) {
284287
span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, input.systemPrompt);
285288
}
286-
if (input.messages) {
287-
span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(input.messages));
289+
const messages = piAiMessagesToGenAiMessages(input.messages);
290+
if (messages.length) {
291+
span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(messages));
288292
}
289293
if (input.tools?.length) {
290294
span.setAttribute(GEN_AI_TOOL_DEFINITIONS, stringify(input.tools));
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { isObjectLike, stringify } from '@sentry/core';
2+
3+
/** A message in the gen_ai conventions shape, see https://develop.sentry.dev/sdk/telemetry/traces/modules/ai-agents/. */
4+
export interface GenAiMessage {
5+
role: string;
6+
parts: GenAiMessagePart[];
7+
finish_reason?: string;
8+
}
9+
10+
export type GenAiMessagePart = Record<string, unknown> & { type: string };
11+
12+
/**
13+
* Map pi-ai messages (`@earendil-works/pi-ai`, which Flue and pi-durable send) to the gen_ai
14+
* conventions. Sentry renders neither pi-ai's `toolResult` role nor its `toolCall` parts.
15+
*
16+
* System messages are left out: their prompt belongs in `gen_ai.system_instructions`.
17+
*/
18+
export function piAiMessagesToGenAiMessages(messages: unknown): GenAiMessage[] {
19+
if (!Array.isArray(messages)) {
20+
return [];
21+
}
22+
23+
const mapped: GenAiMessage[] = [];
24+
for (const message of messages) {
25+
if (!isObjectLike(message) || typeof message.role !== 'string' || message.role === 'system') {
26+
continue;
27+
}
28+
29+
if (message.role === 'toolResult') {
30+
mapped.push({
31+
role: 'tool',
32+
parts: [
33+
{
34+
type: 'tool_call_response',
35+
id: message.toolCallId,
36+
name: message.toolName,
37+
result: piAiContentToString(message.content),
38+
},
39+
],
40+
});
41+
continue;
42+
}
43+
44+
const parts = piAiContentToParts(message.content);
45+
if (parts.length) {
46+
mapped.push({ role: message.role, parts });
47+
}
48+
}
49+
return mapped;
50+
}
51+
52+
/** The `gen_ai.output.messages` entry of a pi-ai assistant message, or `undefined` when it has no content. */
53+
export function piAiAssistantMessageToGenAiMessage(message: unknown, finishReason?: string): GenAiMessage | undefined {
54+
const parts = isObjectLike(message) ? piAiContentToParts(message.content) : [];
55+
if (!parts.length) {
56+
return undefined;
57+
}
58+
return { role: 'assistant', parts, ...(finishReason ? { finish_reason: finishReason } : {}) };
59+
}
60+
61+
/** Text-only content as its text, joined the way pi-ai joins it; anything else as its mapped parts. */
62+
export function piAiContentToString(content: unknown): string | undefined {
63+
const parts = piAiContentToParts(content);
64+
return parts.every(part => part.type === 'text') ? parts.map(part => part.content).join('\n') : stringify(parts);
65+
}
66+
67+
function piAiContentToParts(content: unknown): GenAiMessagePart[] {
68+
if (typeof content === 'string') {
69+
return content ? [{ type: 'text', content }] : [];
70+
}
71+
if (!Array.isArray(content)) {
72+
return [];
73+
}
74+
return content.map(piAiPartToGenAiPart).filter((part): part is GenAiMessagePart => part !== undefined);
75+
}
76+
77+
/** An image is reported by its media type only: its `data` is base64, which the conventions require to be dropped. */
78+
function piAiPartToGenAiPart(part: unknown): GenAiMessagePart | undefined {
79+
if (!isObjectLike(part)) {
80+
return undefined;
81+
}
82+
83+
switch (part.type) {
84+
case 'text':
85+
return typeof part.text === 'string' ? { type: 'text', content: part.text } : undefined;
86+
case 'thinking':
87+
// A redacted thinking block carries an encrypted payload, not text.
88+
return typeof part.thinking === 'string' && part.thinking && !part.redacted
89+
? { type: 'reasoning', content: part.thinking }
90+
: undefined;
91+
case 'toolCall':
92+
return { type: 'tool_call', id: part.id, name: part.name, arguments: stringify(part.arguments ?? {}, String) };
93+
case 'image':
94+
return { type: 'blob', mime_type: part.mimeType };
95+
default:
96+
// Part kinds we don't know yet render as JSON rather than disappearing.
97+
return { type: 'object', content: part };
98+
}
99+
}

‎packages/server-utils/test/ai/lib/tracing/flue.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,15 @@ describe('createFlueInstrumentation', () => {
404404
await instr.interceptor(AGENT_OP, AGENT_CTX, async () => {
405405
instr.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {});
406406
instr.observe(requestContent, {});
407-
instr.observe(turn({ response: { ...turn().response, output: { role: 'assistant' } } }), {});
407+
instr.observe(
408+
turn({
409+
response: {
410+
...turn().response,
411+
output: { role: 'assistant', content: [{ type: 'text', text: 'It is sunny.' }] },
412+
},
413+
}),
414+
{},
415+
);
408416
instr.observe(
409417
{
410418
type: 'tool_start',
@@ -427,8 +435,11 @@ describe('createFlueInstrumentation', () => {
427435

428436
const chat = findSpan('chat claude-haiku-4.5');
429437
expect(chat?.data['gen_ai.system_instructions']).toBe('You are helpful.');
430-
expect(chat?.data['gen_ai.input.messages']).toContain('"role":"user"');
431-
expect(chat?.data['gen_ai.output.messages']).toContain('"role":"assistant"');
438+
// Mapped from pi-ai's message shape to the conventions, which is what Sentry renders.
439+
expect(chat?.data['gen_ai.input.messages']).toBe('[{"role":"user","parts":[{"type":"text","content":"hi"}]}]');
440+
expect(chat?.data['gen_ai.output.messages']).toBe(
441+
'[{"role":"assistant","parts":[{"type":"text","content":"It is sunny."}],"finish_reason":"stop"}]',
442+
);
432443
expect(chat?.data['gen_ai.tool.definitions']).toContain('get_weather');
433444

434445
const tool = findSpan('execute_tool get_weather');
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
piAiAssistantMessageToGenAiMessage,
4+
piAiContentToString,
5+
piAiMessagesToGenAiMessages,
6+
} from '../../../../src/ai/pi-ai/messages';
7+
8+
describe('convert pi-ai messages to gen_ai messages', () => {
9+
it('maps user, assistant and tool result messages', () => {
10+
expect(
11+
piAiMessagesToGenAiMessages([
12+
{ role: 'user', content: 'Weather in Vienna?', timestamp: 1 },
13+
{
14+
role: 'assistant',
15+
content: [
16+
{ type: 'thinking', thinking: 'The user wants the weather.' },
17+
{ type: 'text', text: 'Let me check.' },
18+
{ type: 'toolCall', id: 'call_1', name: 'get_weather', arguments: { city: 'Vienna' } },
19+
],
20+
stopReason: 'toolUse',
21+
},
22+
{
23+
role: 'toolResult',
24+
toolCallId: 'call_1',
25+
toolName: 'get_weather',
26+
content: [{ type: 'text', text: 'Sunny in Vienna.' }],
27+
isError: false,
28+
},
29+
]),
30+
).toStrictEqual([
31+
{ role: 'user', parts: [{ type: 'text', content: 'Weather in Vienna?' }] },
32+
{
33+
role: 'assistant',
34+
parts: [
35+
{ type: 'reasoning', content: 'The user wants the weather.' },
36+
{ type: 'text', content: 'Let me check.' },
37+
{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Vienna"}' },
38+
],
39+
},
40+
{
41+
role: 'tool',
42+
parts: [{ type: 'tool_call_response', id: 'call_1', name: 'get_weather', result: 'Sunny in Vienna.' }],
43+
},
44+
]);
45+
});
46+
47+
it('reports images by media type and drops their base64 data', () => {
48+
expect(
49+
piAiMessagesToGenAiMessages([
50+
{
51+
role: 'user',
52+
content: [
53+
{ type: 'text', text: 'What is this?' },
54+
{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' },
55+
],
56+
},
57+
]),
58+
).toStrictEqual([
59+
{
60+
role: 'user',
61+
parts: [
62+
{ type: 'text', content: 'What is this?' },
63+
{ type: 'blob', mime_type: 'image/png' },
64+
],
65+
},
66+
]);
67+
});
68+
69+
it('leaves out system messages, redacted thinking and messages without content', () => {
70+
expect(
71+
piAiMessagesToGenAiMessages([
72+
{ role: 'system', content: '', sections: { preamble: 'You are helpful.' } },
73+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'c2VjcmV0', redacted: true }] },
74+
{ role: 'user', content: '' },
75+
'not a message',
76+
]),
77+
).toStrictEqual([]);
78+
});
79+
80+
it('keeps unknown part kinds as objects', () => {
81+
expect(piAiMessagesToGenAiMessages([{ role: 'user', content: [{ type: 'audio', url: 'a.mp3' }] }])).toStrictEqual([
82+
{ role: 'user', parts: [{ type: 'object', content: { type: 'audio', url: 'a.mp3' } }] },
83+
]);
84+
});
85+
86+
it('maps an assistant message to an output message with its finish reason', () => {
87+
expect(
88+
piAiAssistantMessageToGenAiMessage(
89+
{
90+
role: 'assistant',
91+
content: [{ type: 'toolCall', id: 'call_1', name: 'get_weather', arguments: { city: 'Vienna' } }],
92+
},
93+
'tool_call',
94+
),
95+
).toStrictEqual({
96+
role: 'assistant',
97+
parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Vienna"}' }],
98+
finish_reason: 'tool_call',
99+
});
100+
expect(piAiAssistantMessageToGenAiMessage({ role: 'assistant', content: [] }, 'stop')).toBeUndefined();
101+
});
102+
103+
it('renders text-only content as text and other content as mapped parts', () => {
104+
expect(
105+
piAiContentToString([
106+
{ type: 'text', text: 'line 1' },
107+
{ type: 'text', text: 'line 2' },
108+
]),
109+
).toBe('line 1\nline 2');
110+
expect(piAiContentToString([{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }])).toBe(
111+
'[{"type":"blob","mime_type":"image/png"}]',
112+
);
113+
});
114+
});

0 commit comments

Comments
 (0)