Skip to content

Commit 4c8becd

Browse files
feat(instrumentation-llamaindex): migrate to OTel 1.40 GenAI semantic conventions (#925)
1 parent 25ae25f commit 4c8becd

File tree

12 files changed

+1083
-136
lines changed

12 files changed

+1083
-136
lines changed

packages/instrumentation-llamaindex/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@
4040
"@opentelemetry/api": "^1.9.0",
4141
"@opentelemetry/core": "^2.0.1",
4242
"@opentelemetry/instrumentation": "^0.203.0",
43-
"@opentelemetry/semantic-conventions": "^1.38.0",
43+
"@opentelemetry/semantic-conventions": "^1.40.0",
4444
"@traceloop/ai-semantic-conventions": "workspace:*",
45+
"@traceloop/instrumentation-utils": "workspace:*",
4546
"lodash": "^4.17.21",
4647
"tslib": "^2.8.1"
4748
},

packages/instrumentation-llamaindex/src/custom-llm-instrumentation.ts

Lines changed: 162 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import * as lodash from "lodash";
21
import type * as llamaindex from "llamaindex";
32

43
import {
@@ -13,15 +12,31 @@ import {
1312
} from "@opentelemetry/api";
1413
import { safeExecuteInTheMiddle } from "@opentelemetry/instrumentation";
1514

16-
import { SpanAttributes } from "@traceloop/ai-semantic-conventions";
1715
import {
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

2641
import { LlamaIndexInstrumentationConfig } from "./types";
2742
import { 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+
3665
export 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;

packages/instrumentation-llamaindex/src/instrumentation.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,29 @@ import { version } from "../package.json";
3636

3737
export class LlamaIndexInstrumentation extends InstrumentationBase {
3838
declare protected _config: LlamaIndexInstrumentationConfig;
39+
private customLLMInstrumentation!: CustomLLMInstrumentation;
3940

4041
constructor(config: LlamaIndexInstrumentationConfig = {}) {
4142
super("@traceloop/instrumentation-llamaindex", version, config);
43+
this.customLLMInstrumentation = new CustomLLMInstrumentation(
44+
() => this._config,
45+
this._diag,
46+
() => this.tracer,
47+
);
4248
}
4349

4450
public override setConfig(config: LlamaIndexInstrumentationConfig = {}) {
4551
super.setConfig(config);
4652
}
4753

48-
public manuallyInstrument(module: typeof llamaindex) {
54+
public manuallyInstrument(module: typeof llamaindex, openaiModule?: any) {
4955
this._diag.debug("Manually instrumenting llamaindex");
5056

5157
this.patch(module);
58+
59+
if (openaiModule) {
60+
this.patchOpenAI(openaiModule);
61+
}
5262
}
5363

5464
protected init(): InstrumentationModuleDefinition[] {
@@ -94,12 +104,6 @@ export class LlamaIndexInstrumentation extends InstrumentationBase {
94104
private patch(moduleExports: typeof llamaindex, moduleVersion?: string) {
95105
this._diag.debug(`Patching llamaindex@${moduleVersion}`);
96106

97-
const customLLMInstrumentation = new CustomLLMInstrumentation(
98-
this._config,
99-
this._diag,
100-
() => this.tracer, // this is on purpose. Tracer may change
101-
);
102-
103107
this._wrap(
104108
moduleExports.RetrieverQueryEngine.prototype,
105109
"query",
@@ -133,7 +137,7 @@ export class LlamaIndexInstrumentation extends InstrumentationBase {
133137
this._wrap(
134138
cls.prototype,
135139
"chat",
136-
customLLMInstrumentation.chatWrapper({ className: cls.name }),
140+
this.customLLMInstrumentation.chatWrapper({ className: cls.name }),
137141
);
138142
} else if (this.isEmbedding(cls.prototype)) {
139143
this._wrap(
@@ -202,7 +206,16 @@ export class LlamaIndexInstrumentation extends InstrumentationBase {
202206
private patchOpenAI(moduleExports: any, moduleVersion?: string) {
203207
this._diag.debug(`Patching @llamaindex/openai@${moduleVersion}`);
204208

205-
// Instrument OpenAIAgent if it exists
209+
if (moduleExports.OpenAI && this.isLLM(moduleExports.OpenAI.prototype)) {
210+
this._wrap(
211+
moduleExports.OpenAI.prototype,
212+
"chat",
213+
this.customLLMInstrumentation.chatWrapper({
214+
className: moduleExports.OpenAI.name,
215+
}),
216+
);
217+
}
218+
206219
if (moduleExports.OpenAIAgent && moduleExports.OpenAIAgent.prototype) {
207220
this._wrap(
208221
moduleExports.OpenAIAgent.prototype,
@@ -223,7 +236,10 @@ export class LlamaIndexInstrumentation extends InstrumentationBase {
223236
private unpatchOpenAI(moduleExports: any, moduleVersion?: string) {
224237
this._diag.debug(`Unpatching @llamaindex/openai@${moduleVersion}`);
225238

226-
// Unwrap OpenAIAgent if it exists
239+
if (moduleExports.OpenAI && moduleExports.OpenAI.prototype) {
240+
this._unwrap(moduleExports.OpenAI.prototype, "chat");
241+
}
242+
227243
if (moduleExports.OpenAIAgent && moduleExports.OpenAIAgent.prototype) {
228244
this._unwrap(moduleExports.OpenAIAgent.prototype, "chat");
229245
}

0 commit comments

Comments
 (0)