11import type { LRUMap , Span } from '@sentry/core' ;
22import {
33 captureException ,
4+ isObjectLike ,
45 SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ,
56 SPAN_STATUS_ERROR ,
67 startInactiveSpan ,
@@ -9,11 +10,6 @@ import {
910} from '@sentry/core' ;
1011import {
1112 GEN_AI_CONVERSATION_ID ,
12- GEN_AI_COST_CACHE_CREATION_INPUT_TOKENS ,
13- GEN_AI_COST_CACHE_READ_INPUT_TOKENS ,
14- GEN_AI_COST_INPUT_TOKENS ,
15- GEN_AI_COST_OUTPUT_TOKENS ,
16- GEN_AI_COST_TOTAL_TOKENS ,
1713 GEN_AI_INPUT_MESSAGES ,
1814 GEN_AI_OPERATION_NAME ,
1915 GEN_AI_OUTPUT_MESSAGES ,
@@ -30,17 +26,19 @@ import {
3026 GEN_AI_TOOL_CALL_RESULT ,
3127 GEN_AI_TOOL_DEFINITIONS ,
3228 GEN_AI_TOOL_NAME ,
33- GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS ,
34- GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS ,
35- GEN_AI_USAGE_INPUT_TOKENS ,
36- GEN_AI_USAGE_OUTPUT_TOKENS ,
37- GEN_AI_USAGE_TOTAL_TOKENS ,
3829 SERVER_ADDRESS ,
3930 SERVER_PORT ,
4031} from '@sentry/conventions/attributes' ;
4132import { getGenAiSpanOp } from '../core/utils' ;
33+ import {
34+ piAiAssistantMessageToGenAiMessage ,
35+ piAiContentToString ,
36+ piAiFinishReason ,
37+ piAiMessagesToGenAiMessages ,
38+ } from '../pi-ai/messages' ;
39+ import { setPiAiUsageAttributes } from '../pi-ai/usage' ;
4240import { FLUE_ORIGIN , MAX_TRACKED_FLUE_SPANS } from './constants' ;
43- import type { FlueErrorInfo , FlueModelRequestInfo , FlueObservation , FlueUsage } from './types' ;
41+ import type { FlueErrorInfo , FlueModelRequestInfo , FlueObservation } from './types' ;
4442
4543/**
4644 * Flue persists the incoming W3C `traceparent` at admission and replays it on the agent operation.
@@ -163,62 +161,32 @@ export function endTurnSpan(observation: FlueObservation, turnSpans: SpanTracker
163161
164162 setRequestAttributes ( span , observation . request ) ;
165163
166- const { responseId, finishReason } = observation . response ?? { } ;
164+ const responseId = observation . response ?. responseId ;
167165 if ( responseId ) {
168166 span . setAttribute ( GEN_AI_RESPONSE_ID , responseId ) ;
169167 }
168+ const finishReason = piAiFinishReason ( observation . response ?. finishReason ) ;
170169 if ( finishReason ) {
171170 // Serialized, not a raw array: the conventions declare this attribute's value type as `string`,
172171 // and that is what `ai/core`, Mastra, OpenAI and Vercel AI all write.
173172 span . setAttribute ( GEN_AI_RESPONSE_FINISH_REASONS , stringify ( [ finishReason ] ) ) ;
174173 }
175174
176- const output = observation . response ?. output ;
177- if ( recordOutputs && output !== undefined ) {
178- span . setAttribute ( GEN_AI_OUTPUT_MESSAGES , stringify ( output ) ) ;
175+ const output = recordOutputs
176+ ? piAiAssistantMessageToGenAiMessage ( observation . response ?. output , finishReason )
177+ : undefined ;
178+ if ( output ) {
179+ span . setAttribute ( GEN_AI_OUTPUT_MESSAGES , stringify ( [ output ] ) ) ;
179180 }
180181
181- setUsageAttributes ( span , observation . response ?. usage , observation . isError ) ;
182+ setPiAiUsageAttributes ( span , observation . response ?. usage , observation . isError ) ;
182183
183184 if ( observation . isError ) {
184185 span . setStatus ( { code : SPAN_STATUS_ERROR , message : 'internal_error' } ) ;
185186 }
186187 span . end ( ) ;
187188}
188189
189- /**
190- * Flue reports token counts and its own computed costs on the same `usage` object, so both are set
191- * here. The cost figures have no equivalent in the provider SDKs' own instrumentation.
192- */
193- export function setUsageAttributes ( span : Span , usage : FlueUsage | undefined , isError ?: boolean ) : void {
194- // A turn that failed before the provider billed anything reports every counter as 0. Writing
195- // those is noise that reads as a real zero-cost call, so skip the block entirely.
196- if ( ! usage || ( isError && ! usage . totalTokens ) ) {
197- return ;
198- }
199-
200- const attributes : Record < string , number > = { } ;
201- const set = ( key : string , value : number | undefined ) : void => {
202- if ( typeof value === 'number' ) {
203- attributes [ key ] = value ;
204- }
205- } ;
206-
207- set ( GEN_AI_USAGE_INPUT_TOKENS , usage . input ) ;
208- set ( GEN_AI_USAGE_OUTPUT_TOKENS , usage . output ) ;
209- set ( GEN_AI_USAGE_TOTAL_TOKENS , usage . totalTokens ) ;
210- set ( GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS , usage . cacheRead ) ;
211- set ( GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS , usage . cacheWrite ) ;
212-
213- set ( GEN_AI_COST_INPUT_TOKENS , usage . cost ?. input ) ;
214- set ( GEN_AI_COST_OUTPUT_TOKENS , usage . cost ?. output ) ;
215- set ( GEN_AI_COST_TOTAL_TOKENS , usage . cost ?. total ) ;
216- set ( GEN_AI_COST_CACHE_READ_INPUT_TOKENS , usage . cost ?. cacheRead ) ;
217- set ( GEN_AI_COST_CACHE_CREATION_INPUT_TOKENS , usage . cost ?. cacheWrite ) ;
218-
219- span . setAttributes ( attributes ) ;
220- }
221-
222190/**
223191 * Tool spans hang off the agent invocation rather than the turn, matching how Flue's own
224192 * OpenTelemetry adapter projects them: siblings of `chat`, correlated to model output by tool call
@@ -257,8 +225,11 @@ export function endToolSpan(observation: FlueObservation, toolSpans: SpanTracker
257225 }
258226 toolSpans . remove ( toolCallId ) ;
259227
260- if ( recordOutputs && observation . result !== undefined ) {
261- span . setAttribute ( GEN_AI_TOOL_CALL_RESULT , stringify ( observation . result ) ) ;
228+ if ( recordOutputs ) {
229+ const result = toolResultForModel ( observation ) ;
230+ if ( result !== undefined ) {
231+ span . setAttribute ( GEN_AI_TOOL_CALL_RESULT , result ) ;
232+ }
262233 }
263234
264235 if ( observation . isError ) {
@@ -268,6 +239,25 @@ export function endToolSpan(observation: FlueObservation, toolSpans: SpanTracker
268239 span . end ( ) ;
269240}
270241
242+ /**
243+ * The result the model receives: Flue's `effectiveResult` when it has one, else the content blocks
244+ * of its harness-level `result` (`{ content, details }`, whose `details` payload is tool-specific).
245+ */
246+ function toolResultForModel ( observation : FlueObservation ) : string | undefined {
247+ const { effectiveResult } = observation ;
248+ if ( effectiveResult !== undefined ) {
249+ if ( typeof effectiveResult === 'string' ) {
250+ return effectiveResult ;
251+ }
252+ return Array . isArray ( effectiveResult ) ? piAiContentToString ( effectiveResult ) : stringify ( effectiveResult ) ;
253+ }
254+ const { result } = observation ;
255+ if ( isObjectLike ( result ) && Array . isArray ( result . content ) ) {
256+ return piAiContentToString ( result . content ) ;
257+ }
258+ return result === undefined ? undefined : stringify ( result ) ;
259+ }
260+
271261/**
272262 * `turn_request` is the only event carrying the request's content — the settled `turn` reports
273263 * metadata alone — so input messages, system prompt and tool definitions are read from it.
@@ -283,8 +273,9 @@ export function recordRequestContent(observation: FlueObservation, turnSpans: Sp
283273 if ( input . systemPrompt ) {
284274 span . setAttribute ( GEN_AI_SYSTEM_INSTRUCTIONS , input . systemPrompt ) ;
285275 }
286- if ( input . messages ) {
287- span . setAttribute ( GEN_AI_INPUT_MESSAGES , stringify ( input . messages ) ) ;
276+ const messages = piAiMessagesToGenAiMessages ( input . messages ) ;
277+ if ( messages . length ) {
278+ span . setAttribute ( GEN_AI_INPUT_MESSAGES , stringify ( messages ) ) ;
288279 }
289280 if ( input . tools ?. length ) {
290281 span . setAttribute ( GEN_AI_TOOL_DEFINITIONS , stringify ( input . tools ) ) ;
0 commit comments