1- import * as lodash from "lodash" ;
21import type * as llamaindex from "llamaindex" ;
32
43import {
@@ -13,15 +12,31 @@ import {
1312} from "@opentelemetry/api" ;
1413import { safeExecuteInTheMiddle } from "@opentelemetry/instrumentation" ;
1514
16- import { SpanAttributes } from "@traceloop/ai-semantic-conventions" ;
1715import {
18- ATTR_GEN_AI_COMPLETION ,
19- ATTR_GEN_AI_PROMPT ,
16+ SpanAttributes ,
17+ FinishReasons ,
18+ } from "@traceloop/ai-semantic-conventions" ;
19+ import {
20+ ATTR_GEN_AI_INPUT_MESSAGES ,
21+ ATTR_GEN_AI_OPERATION_NAME ,
22+ ATTR_GEN_AI_OUTPUT_MESSAGES ,
23+ ATTR_GEN_AI_PROVIDER_NAME ,
2024 ATTR_GEN_AI_REQUEST_MODEL ,
25+ ATTR_GEN_AI_REQUEST_TEMPERATURE ,
2126 ATTR_GEN_AI_REQUEST_TOP_P ,
27+ ATTR_GEN_AI_RESPONSE_FINISH_REASONS ,
28+ ATTR_GEN_AI_RESPONSE_ID ,
2229 ATTR_GEN_AI_RESPONSE_MODEL ,
23- ATTR_GEN_AI_SYSTEM ,
30+ ATTR_GEN_AI_USAGE_INPUT_TOKENS ,
31+ ATTR_GEN_AI_USAGE_OUTPUT_TOKENS ,
32+ GEN_AI_OPERATION_NAME_VALUE_CHAT ,
33+ GEN_AI_PROVIDER_NAME_VALUE_OPENAI ,
2434} from "@opentelemetry/semantic-conventions/incubating" ;
35+ import {
36+ formatInputMessages ,
37+ formatOutputMessage ,
38+ mapOpenAIContentBlock ,
39+ } from "@traceloop/instrumentation-utils" ;
2540
2641import { LlamaIndexInstrumentationConfig } from "./types" ;
2742import { shouldSendPrompts , llmGeneratorWrapper } from "./utils" ;
@@ -33,9 +48,23 @@ type AsyncResponseType =
3348 | AsyncIterable < llamaindex . ChatResponseChunk >
3449 | AsyncIterable < llamaindex . CompletionResponse > ;
3550
51+ const classNameToProviderName : Record < string , string > = {
52+ OpenAI : GEN_AI_PROVIDER_NAME_VALUE_OPENAI ,
53+ // Future providers: Anthropic: "anthropic", Gemini: "gcp.gemini", etc.
54+ // See well-known values: https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-provider-name
55+ } ;
56+
57+ export const openAIFinishReasonMap : Record < string , string > = {
58+ stop : FinishReasons . STOP ,
59+ length : FinishReasons . LENGTH ,
60+ tool_calls : FinishReasons . TOOL_CALL ,
61+ content_filter : FinishReasons . CONTENT_FILTER ,
62+ function_call : FinishReasons . TOOL_CALL ,
63+ } ;
64+
3665export class CustomLLMInstrumentation {
3766 constructor (
38- private config : LlamaIndexInstrumentationConfig ,
67+ private config : ( ) => LlamaIndexInstrumentationConfig ,
3968 private diag : DiagLogger ,
4069 private tracer : ( ) => Tracer ,
4170 ) { }
@@ -50,44 +79,34 @@ export class CustomLLMInstrumentation {
5079 const messages = params ?. messages ;
5180 const streaming = params ?. stream ;
5281
53- const span = plugin
54- . tracer ( )
55- . startSpan ( `llamaindex.${ lodash . snakeCase ( className ) } .chat` , {
56- kind : SpanKind . CLIENT ,
57- } ) ;
82+ const span = plugin . tracer ( ) . startSpan ( `chat ${ this . metadata . model } ` , {
83+ kind : SpanKind . CLIENT ,
84+ } ) ;
5885
5986 try {
60- span . setAttribute ( ATTR_GEN_AI_SYSTEM , className ) ;
87+ span . setAttribute (
88+ ATTR_GEN_AI_PROVIDER_NAME ,
89+ classNameToProviderName [ className ] ?? className . toLowerCase ( ) ,
90+ ) ;
6191 span . setAttribute ( ATTR_GEN_AI_REQUEST_MODEL , this . metadata . model ) ;
62- span . setAttribute ( SpanAttributes . LLM_REQUEST_TYPE , "chat" ) ;
92+ span . setAttribute (
93+ ATTR_GEN_AI_OPERATION_NAME ,
94+ GEN_AI_OPERATION_NAME_VALUE_CHAT ,
95+ ) ;
6396 span . setAttribute ( ATTR_GEN_AI_REQUEST_TOP_P , this . metadata . topP ) ;
64- if ( shouldSendPrompts ( plugin . config ) ) {
65- for ( const messageIdx in messages ) {
66- const content = messages [ messageIdx ] . content ;
67- if ( typeof content === "string" ) {
68- span . setAttribute (
69- `${ ATTR_GEN_AI_PROMPT } .${ messageIdx } .content` ,
70- content as string ,
71- ) ;
72- } else if (
73- ( content as llamaindex . MessageContentDetail [ ] ) [ 0 ] . type ===
74- "text"
75- ) {
76- span . setAttribute (
77- `${ ATTR_GEN_AI_PROMPT } .${ messageIdx } .content` ,
78- ( content as llamaindex . MessageContentTextDetail [ ] ) [ 0 ] . text ,
79- ) ;
80- }
81-
82- span . setAttribute (
83- `${ ATTR_GEN_AI_PROMPT } .${ messageIdx } .role` ,
84- messages [ messageIdx ] . role ,
85- ) ;
86- }
97+ span . setAttribute (
98+ ATTR_GEN_AI_REQUEST_TEMPERATURE ,
99+ this . metadata . temperature ,
100+ ) ;
101+ if ( shouldSendPrompts ( plugin . config ( ) ) && messages ) {
102+ span . setAttribute (
103+ ATTR_GEN_AI_INPUT_MESSAGES ,
104+ formatInputMessages ( messages , mapOpenAIContentBlock ) ,
105+ ) ;
87106 }
88107 } catch ( e ) {
89108 plugin . diag . warn ( e ) ;
90- plugin . config . exceptionLogger ?.( e ) ;
109+ plugin . config ( ) . exceptionLogger ?.( e ) ;
91110 }
92111
93112 const execContext = trace . setSpan ( context . active ( ) , span ) ;
@@ -138,36 +157,62 @@ export class CustomLLMInstrumentation {
138157 ) : T {
139158 span . setAttribute ( ATTR_GEN_AI_RESPONSE_MODEL , metadata . model ) ;
140159
141- if ( ! shouldSendPrompts ( this . config ) ) {
142- span . setStatus ( { code : SpanStatusCode . OK } ) ;
143- span . end ( ) ;
144- return result ;
145- }
146-
147160 try {
148- if ( ( result as llamaindex . ChatResponse ) . message ) {
161+ const raw = ( result as any ) . raw ;
162+ if ( raw ?. id ) {
163+ span . setAttribute ( ATTR_GEN_AI_RESPONSE_ID , raw . id ) ;
164+ }
165+ const finishReason : string | null =
166+ raw ?. choices ?. [ 0 ] ?. finish_reason ?? null ;
167+
168+ // finish_reasons: metadata, not content — always set outside shouldSendPrompts
169+ if ( finishReason != null ) {
170+ span . setAttribute ( ATTR_GEN_AI_RESPONSE_FINISH_REASONS , [
171+ openAIFinishReasonMap [ finishReason ] ?? finishReason ,
172+ ] ) ;
173+ }
174+
175+ // Token usage: always set when available
176+ const usage = raw ?. usage ;
177+ if ( usage ) {
178+ span . setAttribute ( ATTR_GEN_AI_USAGE_INPUT_TOKENS , usage . prompt_tokens ) ;
179+ span . setAttribute (
180+ ATTR_GEN_AI_USAGE_OUTPUT_TOKENS ,
181+ usage . completion_tokens ,
182+ ) ;
149183 span . setAttribute (
150- ` ${ ATTR_GEN_AI_COMPLETION } .0.role` ,
151- ( result as llamaindex . ChatResponse ) . message . role ,
184+ SpanAttributes . GEN_AI_USAGE_TOTAL_TOKENS ,
185+ usage . total_tokens ,
152186 ) ;
187+ }
188+
189+ // output messages: content — always set inside shouldSendPrompts
190+ if (
191+ shouldSendPrompts ( this . config ( ) ) &&
192+ ( result as llamaindex . ChatResponse ) . message
193+ ) {
153194 const content = ( result as llamaindex . ChatResponse ) . message . content ;
154- if ( typeof content === "string" ) {
155- span . setAttribute ( `${ ATTR_GEN_AI_COMPLETION } .0.content` , content ) ;
156- } else if ( content [ 0 ] . type === "text" ) {
157- span . setAttribute (
158- `${ ATTR_GEN_AI_COMPLETION } .0.content` ,
159- content [ 0 ] . text ,
160- ) ;
161- }
162- span . setStatus ( { code : SpanStatusCode . OK } ) ;
195+ // Normalize to array so mapOpenAIContentBlock handles both string and block array
196+ const contentArray = typeof content === "string" ? [ content ] : content ;
197+ span . setAttribute (
198+ ATTR_GEN_AI_OUTPUT_MESSAGES ,
199+ formatOutputMessage (
200+ contentArray ,
201+ finishReason ,
202+ openAIFinishReasonMap ,
203+ GEN_AI_OPERATION_NAME_VALUE_CHAT ,
204+ mapOpenAIContentBlock ,
205+ ) ,
206+ ) ;
163207 }
208+
209+ span . setStatus ( { code : SpanStatusCode . OK } ) ;
164210 } catch ( e ) {
165211 this . diag . warn ( e ) ;
166- this . config . exceptionLogger ?.( e ) ;
212+ this . config ( ) . exceptionLogger ?.( e ) ;
167213 }
168214
169215 span . end ( ) ;
170-
171216 return result ;
172217 }
173218
@@ -178,14 +223,67 @@ export class CustomLLMInstrumentation {
178223 metadata : llamaindex . LLMMetadata ,
179224 ) : T {
180225 span . setAttribute ( ATTR_GEN_AI_RESPONSE_MODEL , metadata . model ) ;
181- if ( ! shouldSendPrompts ( this . config ) ) {
182- span . setStatus ( { code : SpanStatusCode . OK } ) ;
183- span . end ( ) ;
184- return result ;
185- }
186226
187- return llmGeneratorWrapper ( result , execContext , ( message ) => {
188- span . setAttribute ( `${ ATTR_GEN_AI_COMPLETION } .0.content` , message ) ;
227+ return llmGeneratorWrapper ( result , execContext , ( message , lastChunk ) => {
228+ try {
229+ // Extract finish_reason and usage from the last chunk's raw OpenAI
230+ // response — available when stream_options: { include_usage: true }
231+ // is set on the LLM (OpenAI sends usage in the final streaming chunk).
232+ const lastRaw = lastChunk ?. raw as any ;
233+ if ( lastRaw ?. id ) {
234+ span . setAttribute ( ATTR_GEN_AI_RESPONSE_ID , lastRaw . id ) ;
235+ }
236+ const finishReason : string | null =
237+ lastRaw ?. choices ?. [ 0 ] ?. finish_reason ?? null ;
238+ const usage = lastRaw ?. usage ?? null ;
239+
240+ if ( finishReason != null ) {
241+ span . setAttribute ( ATTR_GEN_AI_RESPONSE_FINISH_REASONS , [
242+ openAIFinishReasonMap [ finishReason ] ?? finishReason ,
243+ ] ) ;
244+ }
245+
246+ if ( usage ) {
247+ span . setAttribute (
248+ ATTR_GEN_AI_USAGE_INPUT_TOKENS ,
249+ usage . prompt_tokens ,
250+ ) ;
251+ span . setAttribute (
252+ ATTR_GEN_AI_USAGE_OUTPUT_TOKENS ,
253+ usage . completion_tokens ,
254+ ) ;
255+ span . setAttribute (
256+ SpanAttributes . GEN_AI_USAGE_TOTAL_TOKENS ,
257+ usage . total_tokens ,
258+ ) ;
259+ }
260+
261+ if ( ! finishReason && ! usage ) {
262+ this . diag . debug (
263+ "LlamaIndex streaming: no finish_reason or usage in last chunk. " +
264+ "Set stream_options: { include_usage: true } on the LLM to capture token usage." ,
265+ ) ;
266+ }
267+
268+ // Note: streaming only produces text parts — LlamaIndex's streaming interface
269+ // yields text deltas only, not full content blocks. Tool calls or multi-modal
270+ // content are collapsed into a single text string by llmGeneratorWrapper.
271+ if ( shouldSendPrompts ( this . config ( ) ) ) {
272+ span . setAttribute (
273+ ATTR_GEN_AI_OUTPUT_MESSAGES ,
274+ formatOutputMessage (
275+ [ message ] ,
276+ finishReason ,
277+ openAIFinishReasonMap ,
278+ GEN_AI_OPERATION_NAME_VALUE_CHAT ,
279+ mapOpenAIContentBlock ,
280+ ) ,
281+ ) ;
282+ }
283+ } catch ( e ) {
284+ this . diag . warn ( e ) ;
285+ this . config ( ) . exceptionLogger ?.( e ) ;
286+ }
189287 span . setStatus ( { code : SpanStatusCode . OK } ) ;
190288 span . end ( ) ;
191289 } ) as any ;
0 commit comments