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.
@@ -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
+ )