otelzero 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otelzero/__init__.py +62 -0
- otelzero/__main__.py +140 -0
- otelzero/context.py +86 -0
- otelzero/detection.py +58 -0
- otelzero/hooks/__init__.py +96 -0
- otelzero/hooks/_content.py +865 -0
- otelzero/hooks/_langsmith.py +504 -0
- otelzero/hooks/_native.py +517 -0
- otelzero/hooks/_usage.py +166 -0
- otelzero/hooks/adk.py +128 -0
- otelzero/hooks/base.py +546 -0
- otelzero/hooks/claude_sdk.py +267 -0
- otelzero/hooks/crewai.py +338 -0
- otelzero/hooks/databricks.py +260 -0
- otelzero/hooks/langchain.py +56 -0
- otelzero/hooks/langgraph.py +57 -0
- otelzero/hooks/llama_index.py +724 -0
- otelzero/hooks/microsoft.py +160 -0
- otelzero/hooks/openai_agents.py +313 -0
- otelzero/hooks/pydantic_ai.py +173 -0
- otelzero/hooks/smolagents.py +331 -0
- otelzero/hooks/snowflake.py +271 -0
- otelzero/hooks/strands.py +173 -0
- otelzero/otel.py +372 -0
- otelzero/semconv.py +327 -0
- otelzero/telemetry.py +238 -0
- otelzero/translate/__init__.py +214 -0
- otelzero/translate/langsmith.py +401 -0
- otelzero-0.2.0.dist-info/METADATA +1345 -0
- otelzero-0.2.0.dist-info/RECORD +33 -0
- otelzero-0.2.0.dist-info/WHEEL +5 -0
- otelzero-0.2.0.dist-info/licenses/LICENSE +202 -0
- otelzero-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"""LlamaIndex instrumentation: wrap the workflow runtime every agent starts through.
|
|
2
|
+
|
|
3
|
+
`LlamaIndex <https://github.com/run-llama/llama_index>`_ builds every modern
|
|
4
|
+
agent -- ``FunctionAgent``, ``ReActAgent``, ``CodeActAgent`` and the multi-agent
|
|
5
|
+
``AgentWorkflow`` -- as a *workflow*. Each of those classes overrides ``run`` only
|
|
6
|
+
to package its arguments into a start event and then calls ``super().run(...)``,
|
|
7
|
+
so one wrap on ``workflows.workflow.Workflow.run`` captures all of them, plus any
|
|
8
|
+
workflow a user writes by hand. ``instance`` is the agent itself.
|
|
9
|
+
|
|
10
|
+
Two things make this hook's shape different from the others:
|
|
11
|
+
|
|
12
|
+
* **``run()`` returns before the run happens.** It is synchronous: it schedules
|
|
13
|
+
an asyncio task and hands back a ``WorkflowHandler``, so a ``with`` block
|
|
14
|
+
around the call would close the span before the agent had done anything. The
|
|
15
|
+
run is opened with :func:`~otelzero.hooks.base.start_agent_run` instead and
|
|
16
|
+
closed from a done-callback on the handler's future -- which is safe from the
|
|
17
|
+
event loop, because ``send_log`` hands the POST to a daemon thread.
|
|
18
|
+
* **The result carries no tokens.** ``AgentOutput`` has no usage and its ``raw``
|
|
19
|
+
holds only the last LLM response, so token counts come from LlamaIndex's own
|
|
20
|
+
instrumentation dispatcher: one root event handler sums the ``LLMChatEndEvent``
|
|
21
|
+
/ ``LLMCompletionEndEvent`` payloads a run produces. Predict and
|
|
22
|
+
structured-predict events are deliberately *not* counted -- each wraps an inner
|
|
23
|
+
chat call that is already counted.
|
|
24
|
+
|
|
25
|
+
With content capture opted in, a run records the user's turn, the agent's system
|
|
26
|
+
prompt and its output. The output comes from the last chat the run made, whose
|
|
27
|
+
history holds each tool call and result, and falls back to the final answer.
|
|
28
|
+
|
|
29
|
+
A workflow started from inside a tool or step folds into the run it is part of
|
|
30
|
+
(as a LangGraph subgraph folds into its parent), because the run state lives in a
|
|
31
|
+
:class:`~contextvars.ContextVar` that the run task inherits.
|
|
32
|
+
|
|
33
|
+
Known limitations: a human-in-the-loop run that is never resumed never closes; an
|
|
34
|
+
OpenAI model streaming without ``stream_options={"include_usage": True}`` reports
|
|
35
|
+
no tokens (``FunctionAgent`` streams by default); LLM events that arrive after a
|
|
36
|
+
run has closed are dropped.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import contextvars
|
|
41
|
+
import importlib
|
|
42
|
+
import inspect
|
|
43
|
+
import sys
|
|
44
|
+
import threading
|
|
45
|
+
from typing import Any, Optional, Tuple
|
|
46
|
+
|
|
47
|
+
import wrapt
|
|
48
|
+
|
|
49
|
+
from otelzero import semconv
|
|
50
|
+
from otelzero.hooks import _content
|
|
51
|
+
from otelzero.hooks._usage import UsageAccumulator, extract_usage
|
|
52
|
+
from otelzero.hooks.base import _ImportInstrumenter, install_hook, start_agent_run
|
|
53
|
+
|
|
54
|
+
# otelzero's own marker so a chokepoint is never wrapped twice -- ``Workflow`` is
|
|
55
|
+
# re-exported from two watched modules, and two installs both see the import.
|
|
56
|
+
_MARKER = "_otelzero_wrapped"
|
|
57
|
+
|
|
58
|
+
# The run in progress on this context. A wrapper that finds a live one passes
|
|
59
|
+
# straight through, which is how a workflow started from inside a tool folds into
|
|
60
|
+
# the run it belongs to. The run task copies this context when it is created, so
|
|
61
|
+
# the LLM events it dispatches find the same state.
|
|
62
|
+
_active_run: contextvars.ContextVar = contextvars.ContextVar(
|
|
63
|
+
"otelzero_llama_index_run", default=None
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Strong references to futures otelzero had to create itself, so the loop does
|
|
67
|
+
# not drop them mid-flight.
|
|
68
|
+
_pending_futures: set = set()
|
|
69
|
+
|
|
70
|
+
# Only these two events are counted. ``LLMPredictEndEvent`` and
|
|
71
|
+
# ``LLMStructuredPredictEndEvent`` wrap an inner chat call that already emits one
|
|
72
|
+
# of these, so counting them too would double every predict-based run.
|
|
73
|
+
_COUNTED_EVENTS = ("LLMChatEndEvent", "LLMCompletionEndEvent")
|
|
74
|
+
|
|
75
|
+
# Name the usage handler registers itself under, so a second install finds it.
|
|
76
|
+
_HANDLER_NAME = "OtelzeroLlamaIndexUsageHandler"
|
|
77
|
+
|
|
78
|
+
# ``run``'s own parameters, which are never the run's intent text.
|
|
79
|
+
_NOT_INTENT = ("run_id", "ctx", "start_event", "stepwise", "checkpoint_callback")
|
|
80
|
+
|
|
81
|
+
# LlamaIndex's placeholder when a model object cannot name itself.
|
|
82
|
+
_UNKNOWN_MODEL = "unknown"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _attr_or_key(obj: Any, key: str) -> Any:
|
|
86
|
+
"""Read ``key`` off a dict or an object attribute; ``None`` when absent."""
|
|
87
|
+
if obj is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(obj, dict):
|
|
90
|
+
return obj.get(key)
|
|
91
|
+
return getattr(obj, key, None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _message_text(value: Any) -> Optional[str]:
|
|
95
|
+
"""The text of a prompt or ``ChatMessage``, whichever shape it arrives in."""
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
return value or None
|
|
98
|
+
content = _attr_or_key(value, "content")
|
|
99
|
+
return content if isinstance(content, str) and content else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- Reading the agent -------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_agent_workflow(instance: Any) -> bool:
|
|
106
|
+
"""Whether this is the multi-agent ``AgentWorkflow``, duck-typed.
|
|
107
|
+
|
|
108
|
+
Importing ``AgentWorkflow`` to check would drag ``llama_index.core`` into the
|
|
109
|
+
import of a module that may only be ``workflows``, so the pair of attributes
|
|
110
|
+
that only it has is the test.
|
|
111
|
+
"""
|
|
112
|
+
return getattr(instance, "agents", None) is not None and (
|
|
113
|
+
getattr(instance, "root_agent", None) is not None
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _root_agent(instance: Any) -> Any:
|
|
118
|
+
"""The agent an ``AgentWorkflow`` starts with; ``root_agent`` names it."""
|
|
119
|
+
root = getattr(instance, "root_agent", None)
|
|
120
|
+
agents = getattr(instance, "agents", None)
|
|
121
|
+
if isinstance(root, str) and isinstance(agents, dict):
|
|
122
|
+
return agents.get(root)
|
|
123
|
+
return root
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _agent_name(instance: Any) -> str:
|
|
127
|
+
"""Human label for the run: the agent's name, its root agent's, or its class."""
|
|
128
|
+
if _is_agent_workflow(instance):
|
|
129
|
+
root = getattr(instance, "root_agent", None)
|
|
130
|
+
if isinstance(root, str) and root:
|
|
131
|
+
return root
|
|
132
|
+
name = getattr(root, "name", None)
|
|
133
|
+
if isinstance(name, str) and name:
|
|
134
|
+
return name
|
|
135
|
+
name = getattr(instance, "name", None)
|
|
136
|
+
if isinstance(name, str) and name:
|
|
137
|
+
return name
|
|
138
|
+
return type(instance).__name__
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _agent_names(instance: Any) -> list:
|
|
142
|
+
"""The members of an ``AgentWorkflow``, for the run's ``details``."""
|
|
143
|
+
agents = getattr(instance, "agents", None)
|
|
144
|
+
if isinstance(agents, dict):
|
|
145
|
+
return sorted(str(name) for name in agents)
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _llama_index_model_id(llm: Any) -> Optional[str]:
|
|
150
|
+
"""Best-effort model identifier for a LlamaIndex ``LLM``.
|
|
151
|
+
|
|
152
|
+
Most integrations carry the id on ``model`` (OpenAI, Anthropic) or
|
|
153
|
+
``model_name`` (Hugging Face, Ollama), and LlamaIndex fills either with the
|
|
154
|
+
literal ``"unknown"`` when the class does not know -- which is not a model
|
|
155
|
+
name, so it is skipped. ``metadata`` is the general fallback but is a
|
|
156
|
+
computed property: OpenAI's raises for a model id it does not recognise, so
|
|
157
|
+
reading it cannot be allowed to break the run.
|
|
158
|
+
"""
|
|
159
|
+
if llm is None:
|
|
160
|
+
return None
|
|
161
|
+
if isinstance(llm, str):
|
|
162
|
+
return llm or None
|
|
163
|
+
for attr in ("model", "model_name"):
|
|
164
|
+
value = getattr(llm, attr, None)
|
|
165
|
+
if isinstance(value, str) and value and value != _UNKNOWN_MODEL:
|
|
166
|
+
return value
|
|
167
|
+
try:
|
|
168
|
+
name = getattr(getattr(llm, "metadata", None), "model_name", None)
|
|
169
|
+
except Exception: # pragma: no cover - defensive: metadata is computed
|
|
170
|
+
return None
|
|
171
|
+
if isinstance(name, str) and name and name != _UNKNOWN_MODEL:
|
|
172
|
+
return name
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _agent_llm(instance: Any) -> Any:
|
|
177
|
+
"""The LLM a run will use: the agent's, or an ``AgentWorkflow``'s root one."""
|
|
178
|
+
llm = getattr(instance, "llm", None)
|
|
179
|
+
if llm is None and _is_agent_workflow(instance):
|
|
180
|
+
return getattr(_root_agent(instance), "llm", None)
|
|
181
|
+
return llm
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tool_names(instance: Any) -> list:
|
|
185
|
+
"""Names of the tools the agent was given.
|
|
186
|
+
|
|
187
|
+
A LlamaIndex tool names itself on ``metadata.name``; a bare callable passed
|
|
188
|
+
to ``FunctionAgent(tools=[...])`` is wrapped into one by the time the agent
|
|
189
|
+
holds it, but an unfamiliar shape falls back to the function's own name
|
|
190
|
+
rather than erroring inside the wrapper.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
names = set()
|
|
194
|
+
for tool in getattr(instance, "tools", None) or []:
|
|
195
|
+
name = (
|
|
196
|
+
getattr(getattr(tool, "metadata", None), "name", None)
|
|
197
|
+
or getattr(tool, "name", None)
|
|
198
|
+
or getattr(tool, "__name__", None)
|
|
199
|
+
)
|
|
200
|
+
if name:
|
|
201
|
+
names.add(str(name))
|
|
202
|
+
return sorted(names)
|
|
203
|
+
except Exception: # pragma: no cover - defensive
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_hint(args, kwargs) -> Optional[str]:
|
|
208
|
+
"""Best-effort intent text for the session title.
|
|
209
|
+
|
|
210
|
+
An agent packs the user's turn into the start event before delegating to
|
|
211
|
+
``Workflow.run``, so that is the first place to look; a hand-written workflow
|
|
212
|
+
is run with its own keyword arguments instead, and the first string among
|
|
213
|
+
them is the closest thing to an intent.
|
|
214
|
+
"""
|
|
215
|
+
# ``run``'s positional parameters have moved between releases -- ``ctx`` and
|
|
216
|
+
# ``start_event`` have swapped places -- so every argument is offered to the
|
|
217
|
+
# same read rather than one position being assumed to be the start event.
|
|
218
|
+
for source in (kwargs.get("start_event"), *args, kwargs):
|
|
219
|
+
text = _message_text(_attr_or_key(source, "user_msg"))
|
|
220
|
+
if text:
|
|
221
|
+
return text
|
|
222
|
+
history = _attr_or_key(source, "chat_history")
|
|
223
|
+
if isinstance(history, (list, tuple)) and history:
|
|
224
|
+
text = _message_text(history[-1])
|
|
225
|
+
if text:
|
|
226
|
+
return text
|
|
227
|
+
for key, value in kwargs.items():
|
|
228
|
+
if key in _NOT_INTENT:
|
|
229
|
+
continue
|
|
230
|
+
if isinstance(value, str) and value.strip():
|
|
231
|
+
return value
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _system_prompt(instance: Any) -> Optional[str]:
|
|
236
|
+
"""The agent's system prompt, or an ``AgentWorkflow`` root agent's."""
|
|
237
|
+
prompt = getattr(instance, "system_prompt", None)
|
|
238
|
+
if prompt is None and _is_agent_workflow(instance):
|
|
239
|
+
prompt = getattr(_root_agent(instance), "system_prompt", None)
|
|
240
|
+
return prompt if isinstance(prompt, str) else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --- Message content ---------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _role_of(message: Any) -> Optional[str]:
|
|
247
|
+
role = _attr_or_key(message, "role")
|
|
248
|
+
return getattr(role, "value", role)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _chat_message(message: Any) -> Optional[dict]:
|
|
252
|
+
"""One LlamaIndex ``ChatMessage`` as a semconv message.
|
|
253
|
+
|
|
254
|
+
Tool traffic is not on the fields ``_content`` reads: results name their
|
|
255
|
+
call in ``additional_kwargs``, and calls are in ``additional_kwargs`` or, on
|
|
256
|
+
newer provider integrations, in ``ToolCallBlock`` blocks.
|
|
257
|
+
"""
|
|
258
|
+
extra = _attr_or_key(message, "additional_kwargs") or {}
|
|
259
|
+
text = _attr_or_key(message, "content")
|
|
260
|
+
role = _role_of(message)
|
|
261
|
+
if role == "tool":
|
|
262
|
+
call_id = extra.get("tool_call_id") or extra.get("tool_use_id")
|
|
263
|
+
return _content.message("tool", [_content.tool_call_response_part(call_id, text)])
|
|
264
|
+
parts = [_content.text_part(text)]
|
|
265
|
+
seen = set()
|
|
266
|
+
blocks = [
|
|
267
|
+
b
|
|
268
|
+
for b in _attr_or_key(message, "blocks") or []
|
|
269
|
+
if _attr_or_key(b, "block_type") == "tool_call"
|
|
270
|
+
]
|
|
271
|
+
for call in [*blocks, *(extra.get("tool_calls") or [])]:
|
|
272
|
+
function = _attr_or_key(call, "function")
|
|
273
|
+
source = function if function is not None else call
|
|
274
|
+
call_id = (
|
|
275
|
+
_attr_or_key(call, "tool_call_id")
|
|
276
|
+
or _attr_or_key(call, "tool_id")
|
|
277
|
+
or _attr_or_key(call, "id")
|
|
278
|
+
)
|
|
279
|
+
if call_id is not None and call_id in seen:
|
|
280
|
+
continue
|
|
281
|
+
seen.add(call_id)
|
|
282
|
+
parts.append(
|
|
283
|
+
_content.tool_call_part(
|
|
284
|
+
call_id,
|
|
285
|
+
_attr_or_key(source, "tool_name") or _attr_or_key(source, "name"),
|
|
286
|
+
_attr_or_key(source, "tool_kwargs") or _attr_or_key(source, "arguments"),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return _content.message(role or "assistant", parts)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _output_messages(state: "_RunState", result: Any) -> Any:
|
|
293
|
+
"""A run's output: everything after the user's last turn, else its answer.
|
|
294
|
+
|
|
295
|
+
``None`` unless content capture is opted in. Reads the run's last chat, the
|
|
296
|
+
only place holding the tool hops; a workflow that never chatted, or an
|
|
297
|
+
unfamiliar message shape, reports the final answer instead.
|
|
298
|
+
"""
|
|
299
|
+
if not _content.enabled():
|
|
300
|
+
return None
|
|
301
|
+
try:
|
|
302
|
+
history, response = state.last_chat
|
|
303
|
+
turns = list(history)
|
|
304
|
+
start = max((i for i, m in enumerate(turns) if _role_of(m) == "user"), default=-1)
|
|
305
|
+
shaped = [_chat_message(m) for m in [*turns[start + 1 :], response.message]]
|
|
306
|
+
messages = _content.cap_messages([m for m in shaped if m])
|
|
307
|
+
except Exception:
|
|
308
|
+
messages = None
|
|
309
|
+
return messages or _content.text_of(_attr_or_key(result, "response") or result)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --- Token usage, off LlamaIndex's instrumentation dispatcher ----------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _response_usage(response: Any) -> Tuple[Any, Optional[dict]]:
|
|
316
|
+
"""``(source, usage)`` for an LLM response -- the first spelling that hits.
|
|
317
|
+
|
|
318
|
+
Providers put usage in four different places and LlamaIndex passes the
|
|
319
|
+
payload through untouched, so all four are tried in order of specificity;
|
|
320
|
+
the last is OpenAI's flat ``prompt_tokens``/``completion_tokens`` sitting
|
|
321
|
+
directly in ``additional_kwargs``. The source object is returned so the
|
|
322
|
+
caller can dedupe on the payload the numbers came from.
|
|
323
|
+
"""
|
|
324
|
+
raw = getattr(response, "raw", None)
|
|
325
|
+
extra = getattr(response, "additional_kwargs", None)
|
|
326
|
+
for source in (
|
|
327
|
+
_attr_or_key(raw, "usage"),
|
|
328
|
+
_attr_or_key(raw, "usage_metadata"),
|
|
329
|
+
_attr_or_key(extra, "usage"),
|
|
330
|
+
extra,
|
|
331
|
+
):
|
|
332
|
+
usage = extract_usage(source)
|
|
333
|
+
if usage:
|
|
334
|
+
return source, usage
|
|
335
|
+
return None, None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _response_model(response: Any) -> Optional[str]:
|
|
339
|
+
"""Which model served a response, as the provider reported it."""
|
|
340
|
+
raw = getattr(response, "raw", None)
|
|
341
|
+
for key in ("model", "model_name"):
|
|
342
|
+
value = _attr_or_key(raw, key)
|
|
343
|
+
if isinstance(value, str) and value:
|
|
344
|
+
return value
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class _RunState:
|
|
349
|
+
"""Everything a run accumulates between its start and its done-callback.
|
|
350
|
+
|
|
351
|
+
Lives in a :class:`~contextvars.ContextVar` rather than on the handler, so
|
|
352
|
+
the LLM events a run's task dispatches find it without the dispatcher having
|
|
353
|
+
to know which run it is serving. The lock is not optional: LlamaIndex runs a
|
|
354
|
+
synchronous LLM call in a worker thread, so its end event can be dispatched
|
|
355
|
+
off the loop thread while the loop is starting the next one.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
__slots__ = (
|
|
359
|
+
"model",
|
|
360
|
+
"fallback_model",
|
|
361
|
+
"closed",
|
|
362
|
+
"last_chat",
|
|
363
|
+
"_acc",
|
|
364
|
+
"_seen",
|
|
365
|
+
"_refs",
|
|
366
|
+
"_lock",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def __init__(self, fallback_model: Optional[str] = None):
|
|
370
|
+
self.model: Optional[str] = None
|
|
371
|
+
self.fallback_model = fallback_model
|
|
372
|
+
self.closed = False
|
|
373
|
+
# (messages, response) of the run's latest chat, when content is opted in.
|
|
374
|
+
self.last_chat = None
|
|
375
|
+
self._acc = UsageAccumulator()
|
|
376
|
+
self._seen = set()
|
|
377
|
+
# Strong references to the payloads already counted: ids are only unique
|
|
378
|
+
# among live objects, so releasing one could let a later payload reuse
|
|
379
|
+
# its id and be skipped.
|
|
380
|
+
self._refs = []
|
|
381
|
+
self._lock = threading.Lock()
|
|
382
|
+
|
|
383
|
+
def record(self, response: Any) -> None:
|
|
384
|
+
"""Fold one LLM response into the run, counting each payload once.
|
|
385
|
+
|
|
386
|
+
A ``CustomLLM`` builds ``chat()`` on top of ``complete()`` and emits an
|
|
387
|
+
end event for each, both carrying the *same* provider payload -- so the
|
|
388
|
+
payload's identity, not the event, is what decides whether the tokens
|
|
389
|
+
have already been counted.
|
|
390
|
+
"""
|
|
391
|
+
source, usage = _response_usage(response)
|
|
392
|
+
raw = getattr(response, "raw", None)
|
|
393
|
+
fallback = source if source is not None else response
|
|
394
|
+
anchor = raw if raw is not None else fallback
|
|
395
|
+
model = _response_model(response)
|
|
396
|
+
with self._lock:
|
|
397
|
+
if self.closed or id(anchor) in self._seen:
|
|
398
|
+
return
|
|
399
|
+
self._seen.add(id(anchor))
|
|
400
|
+
self._refs.append(anchor)
|
|
401
|
+
self._acc.add(usage)
|
|
402
|
+
if self.model is None and model:
|
|
403
|
+
self.model = model
|
|
404
|
+
|
|
405
|
+
def close(self) -> Optional[dict]:
|
|
406
|
+
"""Stop accepting events and hand back the run's total usage."""
|
|
407
|
+
with self._lock:
|
|
408
|
+
self.closed = True
|
|
409
|
+
return self._acc.result()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _record_llm_event(event: Any) -> None:
|
|
413
|
+
"""Dispatcher callback: count an LLM end event against the run in progress.
|
|
414
|
+
|
|
415
|
+
Matched by class name so this module imports nothing from LlamaIndex, and
|
|
416
|
+
silent on anything unexpected -- the dispatcher calls its handlers without a
|
|
417
|
+
guard, so raising here would surface inside the user's agent.
|
|
418
|
+
"""
|
|
419
|
+
try:
|
|
420
|
+
state = _active_run.get()
|
|
421
|
+
if state is None or state.closed:
|
|
422
|
+
return
|
|
423
|
+
if type(event).__name__ not in _COUNTED_EVENTS:
|
|
424
|
+
return
|
|
425
|
+
response = getattr(event, "response", None)
|
|
426
|
+
if response is not None:
|
|
427
|
+
state.record(response)
|
|
428
|
+
messages = getattr(event, "messages", None)
|
|
429
|
+
if messages is not None and _content.enabled():
|
|
430
|
+
state.last_chat = (messages, response)
|
|
431
|
+
except Exception: # pragma: no cover - defensive
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _instrumentation_module():
|
|
436
|
+
"""LlamaIndex's instrumentation package, whichever of the two is in play.
|
|
437
|
+
|
|
438
|
+
It was split out of ``llama_index.core`` into its own distribution, and the
|
|
439
|
+
old path is now a re-export of the new one. Whichever the host application
|
|
440
|
+
already imported is the one holding the live root dispatcher, so it is
|
|
441
|
+
preferred over importing the other.
|
|
442
|
+
"""
|
|
443
|
+
for name in ("llama_index.core.instrumentation", "llama_index_instrumentation"):
|
|
444
|
+
module = sys.modules.get(name)
|
|
445
|
+
if module is not None:
|
|
446
|
+
return module
|
|
447
|
+
return importlib.import_module("llama_index_instrumentation")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _build_usage_handler(module):
|
|
451
|
+
"""Subclass LlamaIndex's ``BaseEventHandler`` with otelzero's counter.
|
|
452
|
+
|
|
453
|
+
Built here rather than at module scope because the base class is a pydantic
|
|
454
|
+
model inside LlamaIndex, which otelzero must not import to be installed. The
|
|
455
|
+
subclass holds no state -- a pydantic model would need it declared as a field,
|
|
456
|
+
and the run state belongs to the run, not to the handler.
|
|
457
|
+
"""
|
|
458
|
+
base = importlib.import_module(f"{module.__name__}.event_handlers").BaseEventHandler
|
|
459
|
+
|
|
460
|
+
class _UsageEventHandler(base): # type: ignore[misc, valid-type]
|
|
461
|
+
@classmethod
|
|
462
|
+
def class_name(cls) -> str:
|
|
463
|
+
return _HANDLER_NAME
|
|
464
|
+
|
|
465
|
+
def handle(self, event, **kwargs) -> None:
|
|
466
|
+
_record_llm_event(event)
|
|
467
|
+
|
|
468
|
+
return _UsageEventHandler
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _ensure_usage_handler() -> None:
|
|
472
|
+
"""Register the usage handler on the root dispatcher, exactly once.
|
|
473
|
+
|
|
474
|
+
Deliberately *not* done at install time: the hook is instrumented from inside
|
|
475
|
+
another module's import, where importing LlamaIndex's instrumentation package
|
|
476
|
+
would re-enter the import machinery. Waiting for the first run also means an
|
|
477
|
+
application that never runs a workflow is never touched.
|
|
478
|
+
"""
|
|
479
|
+
try:
|
|
480
|
+
module = _instrumentation_module()
|
|
481
|
+
dispatcher = module.get_dispatcher()
|
|
482
|
+
for existing in list(getattr(dispatcher, "event_handlers", None) or []):
|
|
483
|
+
namer = getattr(existing, "class_name", None)
|
|
484
|
+
if callable(namer) and namer() == _HANDLER_NAME:
|
|
485
|
+
return
|
|
486
|
+
dispatcher.add_event_handler(_build_usage_handler(module)())
|
|
487
|
+
except Exception: # pragma: no cover - usage is best-effort
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# --- Closing a deferred run --------------------------------------------------
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _run_future(handler: Any):
|
|
495
|
+
"""The future that completes when the run does, or ``None``.
|
|
496
|
+
|
|
497
|
+
``WorkflowHandler`` *is* an ``asyncio.Future`` on older cores and wraps one
|
|
498
|
+
on current releases. Nothing else is expected, but an awaitable is still
|
|
499
|
+
driven rather than dropped -- with a strong reference held, since the loop
|
|
500
|
+
keeps only a weak one.
|
|
501
|
+
"""
|
|
502
|
+
if isinstance(handler, asyncio.Future):
|
|
503
|
+
return handler
|
|
504
|
+
task = getattr(handler, "_result_task", None)
|
|
505
|
+
if isinstance(task, asyncio.Future):
|
|
506
|
+
return task
|
|
507
|
+
if inspect.isawaitable(handler):
|
|
508
|
+
task = asyncio.ensure_future(handler)
|
|
509
|
+
_pending_futures.add(task)
|
|
510
|
+
task.add_done_callback(_pending_futures.discard)
|
|
511
|
+
return task
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _is_user_cancellation(exc: BaseException) -> bool:
|
|
516
|
+
"""Whether a run ended because the caller cancelled it, not because it broke.
|
|
517
|
+
|
|
518
|
+
Matched by class name up the MRO so the exception class does not have to be
|
|
519
|
+
imported (and so a version that moves it still matches).
|
|
520
|
+
"""
|
|
521
|
+
return any(cls.__name__ == "WorkflowCancelledByUser" for cls in type(exc).__mro__)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _stopped_cleanly(handler: Any) -> bool:
|
|
525
|
+
"""Whether a cancelled handler had already produced its stop event.
|
|
526
|
+
|
|
527
|
+
``cancel_run()`` cancels the task, but so does the run finishing while
|
|
528
|
+
something else is still awaiting it; a stop event means the workflow reached
|
|
529
|
+
its end, so the run is reported as finished rather than interrupted.
|
|
530
|
+
"""
|
|
531
|
+
getter = getattr(handler, "get_stop_event", None)
|
|
532
|
+
if not callable(getter):
|
|
533
|
+
return False
|
|
534
|
+
try:
|
|
535
|
+
return getter() is not None
|
|
536
|
+
except Exception: # pragma: no cover - defensive
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _finish(run, state: _RunState, *, exc=None, interrupted: bool = False) -> None:
|
|
541
|
+
"""Attach what the run accumulated, then close it (idempotently)."""
|
|
542
|
+
run.set_usage(state.close())
|
|
543
|
+
# The LLM's class name is a last resort: a model named before the run is what
|
|
544
|
+
# was asked for, and RunContext keeps that one.
|
|
545
|
+
run.set_model(state.model or state.fallback_model)
|
|
546
|
+
run.finish(exc=exc, interrupted=interrupted)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _attach_completion(handler: Any, run, state: _RunState) -> None:
|
|
550
|
+
"""Close the run when its future completes, whichever way it completes.
|
|
551
|
+
|
|
552
|
+
A done-callback, not a watcher task: a task awaiting the handler would be
|
|
553
|
+
cancelled at ``asyncio.run`` teardown, which would both cancel the user's run
|
|
554
|
+
and mislabel a finished-but-undelivered one as interrupted. Registered here,
|
|
555
|
+
inside the wrapper, so it inherits the caller's ``run_context`` -- the
|
|
556
|
+
terminal event reads the user and session at send time.
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
def _done(future) -> None:
|
|
560
|
+
if future.cancelled():
|
|
561
|
+
_finish(run, state, interrupted=not _stopped_cleanly(handler))
|
|
562
|
+
return
|
|
563
|
+
exc = future.exception()
|
|
564
|
+
if exc is None:
|
|
565
|
+
run.set_output(_output_messages(state, future.result()))
|
|
566
|
+
_finish(run, state)
|
|
567
|
+
elif _is_user_cancellation(exc):
|
|
568
|
+
_finish(run, state, interrupted=True)
|
|
569
|
+
else:
|
|
570
|
+
_finish(run, state, exc=exc)
|
|
571
|
+
|
|
572
|
+
future = _run_future(handler)
|
|
573
|
+
if future is None:
|
|
574
|
+
# Nothing to wait on -- close now rather than leave the run dangling.
|
|
575
|
+
_finish(run, state)
|
|
576
|
+
return
|
|
577
|
+
try:
|
|
578
|
+
future.add_done_callback(_done)
|
|
579
|
+
except Exception: # pragma: no cover - loop already gone
|
|
580
|
+
_finish(run, state)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# --- The hook ----------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def wrap_workflow_module(module, telemetry) -> None:
|
|
587
|
+
"""Instrument ``Workflow.run`` on an imported module, if it is LlamaIndex's.
|
|
588
|
+
|
|
589
|
+
``workflows`` is a generic distribution name, so the class is identified by
|
|
590
|
+
the signature of the method being wrapped rather than by module name alone.
|
|
591
|
+
"""
|
|
592
|
+
workflow_cls = getattr(module, "Workflow", None)
|
|
593
|
+
original = getattr(workflow_cls, "run", None)
|
|
594
|
+
if original is None or getattr(original, _MARKER, False):
|
|
595
|
+
return
|
|
596
|
+
try:
|
|
597
|
+
if "start_event" not in inspect.signature(original).parameters:
|
|
598
|
+
return
|
|
599
|
+
except (TypeError, ValueError): # pragma: no cover - unsignable callable
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
@wrapt.decorator
|
|
603
|
+
def run_wrapper(wrapped, instance, args, kwargs):
|
|
604
|
+
active = _active_run.get()
|
|
605
|
+
if active is not None and not active.closed:
|
|
606
|
+
# A workflow started from inside a tool or step is part of the run
|
|
607
|
+
# already in progress, not a second agent run. A *closed* state means
|
|
608
|
+
# the parent is over, so a late background run opens its own.
|
|
609
|
+
return wrapped(*args, **kwargs)
|
|
610
|
+
|
|
611
|
+
_ensure_usage_handler()
|
|
612
|
+
agent_name = _agent_name(instance)
|
|
613
|
+
llm = _agent_llm(instance)
|
|
614
|
+
model = _llama_index_model_id(llm)
|
|
615
|
+
tools = _tool_names(instance)
|
|
616
|
+
details = {"model": model, "tools": tools}
|
|
617
|
+
if _is_agent_workflow(instance):
|
|
618
|
+
details["agents"] = _agent_names(instance)
|
|
619
|
+
run = start_agent_run(
|
|
620
|
+
telemetry,
|
|
621
|
+
semconv.span_name(semconv.OP_INVOKE_AGENT, agent_name),
|
|
622
|
+
{
|
|
623
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_AGENT,
|
|
624
|
+
semconv.GEN_AI_AGENT_NAME: agent_name,
|
|
625
|
+
semconv.GEN_AI_REQUEST_MODEL: model,
|
|
626
|
+
semconv.GEN_AI_TOOL_DEFINITIONS: semconv.tool_definitions(tools),
|
|
627
|
+
},
|
|
628
|
+
start_event="agent_start",
|
|
629
|
+
end_event="agent_end",
|
|
630
|
+
error_event="agent_error",
|
|
631
|
+
log_fields={
|
|
632
|
+
"agent_name": agent_name,
|
|
633
|
+
"agent_role": agent_name,
|
|
634
|
+
"model": model,
|
|
635
|
+
},
|
|
636
|
+
start_details=details,
|
|
637
|
+
session_name_hint=_run_hint(args, kwargs),
|
|
638
|
+
)
|
|
639
|
+
run.set_input(_content.messages_from(_run_hint(args, kwargs)))
|
|
640
|
+
run.set_system(_system_prompt(instance))
|
|
641
|
+
# The LLM's class name identifies a model object that cannot name itself
|
|
642
|
+
# (LlamaIndex's mocks, a hand-written LLM); kept aside as a last resort.
|
|
643
|
+
fallback = None if model or llm is None else type(llm).__name__
|
|
644
|
+
state = _RunState(fallback_model=fallback)
|
|
645
|
+
token = _active_run.set(state)
|
|
646
|
+
try:
|
|
647
|
+
# The span is current only around the call that schedules the run:
|
|
648
|
+
# that is where the task copies the context, which is what makes the
|
|
649
|
+
# framework's own spans -- and any nested workflow -- children of it.
|
|
650
|
+
with run.activate():
|
|
651
|
+
handler = wrapped(*args, **kwargs)
|
|
652
|
+
except BaseException as exc:
|
|
653
|
+
# run() itself failed (no running loop, bad arguments): there will be
|
|
654
|
+
# no run task, so this wrapper is the only place that can report it.
|
|
655
|
+
_finish(run, state, exc=exc)
|
|
656
|
+
raise
|
|
657
|
+
else:
|
|
658
|
+
_attach_completion(handler, run, state)
|
|
659
|
+
return handler
|
|
660
|
+
finally:
|
|
661
|
+
_active_run.reset(token)
|
|
662
|
+
|
|
663
|
+
wrapped = run_wrapper(original)
|
|
664
|
+
setattr(wrapped, _MARKER, True)
|
|
665
|
+
workflow_cls.run = wrapped
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class LlamaIndexImportHook(_ImportInstrumenter):
|
|
669
|
+
"""Intercept LlamaIndex imports and instrument the workflow runtime.
|
|
670
|
+
|
|
671
|
+
Watches both homes of the ``Workflow`` class: ``workflows.workflow`` is where
|
|
672
|
+
it lives now that the engine ships as its own distribution, and
|
|
673
|
+
``llama_index.core.workflow.workflow`` is where a pre-split core kept it. On
|
|
674
|
+
a current release the second is a re-export of the first, so the wrapped-once
|
|
675
|
+
marker makes watching it free.
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
WATCHED = {
|
|
679
|
+
"workflows.workflow": "_wrap_workflow",
|
|
680
|
+
"llama_index.core.workflow.workflow": "_wrap_workflow",
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
def _wrap_workflow(self, module):
|
|
684
|
+
try:
|
|
685
|
+
wrap_workflow_module(module, self.telemetry)
|
|
686
|
+
except Exception as exc: # pragma: no cover - never break a user import
|
|
687
|
+
print(f"[INSTRUMENT] LlamaIndex instrumentation skipped: {exc}")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def install_llama_index_monitoring(
|
|
691
|
+
server_url: str = "http://localhost:8000",
|
|
692
|
+
user: Optional[str] = None,
|
|
693
|
+
api_key: Optional[str] = None,
|
|
694
|
+
session: Optional[str] = None,
|
|
695
|
+
name: Optional[str] = None,
|
|
696
|
+
):
|
|
697
|
+
"""
|
|
698
|
+
Install LlamaIndex monitoring instrumentation.
|
|
699
|
+
|
|
700
|
+
Instruments ``workflows.workflow.Workflow.run`` -- the method every LlamaIndex
|
|
701
|
+
agent (``FunctionAgent``, ``ReActAgent``, ``CodeActAgent``, ``AgentWorkflow``)
|
|
702
|
+
and every hand-written ``Workflow`` starts through -- so each run streams
|
|
703
|
+
start/end/error telemetry with zero changes to the agent code. Token usage is
|
|
704
|
+
summed from LlamaIndex's own instrumentation events. A workflow started from
|
|
705
|
+
inside a tool is folded into the run it belongs to rather than reported as a
|
|
706
|
+
second agent. Call this *before* ``llama_index`` is imported.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
server_url: URL of the monitoring server
|
|
710
|
+
user: Name of the executing user
|
|
711
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
712
|
+
session: Per-run id used to group events in the app
|
|
713
|
+
name: Explicit session title (else derived from the run's first message)
|
|
714
|
+
"""
|
|
715
|
+
return install_hook(
|
|
716
|
+
LlamaIndexImportHook,
|
|
717
|
+
framework="LlamaIndex",
|
|
718
|
+
label="LlamaIndex",
|
|
719
|
+
server_url=server_url,
|
|
720
|
+
user=user,
|
|
721
|
+
api_key=api_key,
|
|
722
|
+
session=session,
|
|
723
|
+
name=name,
|
|
724
|
+
)
|