agent-observability-trace-cli 0.1.2__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.
Files changed (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. agent_trace/py.typed +0 -0
@@ -0,0 +1,472 @@
1
+ """
2
+ llama_index integration — Dispatcher-based span/event capture.
3
+
4
+ llama_index does not accept a per-call ``callbacks=[...]`` list the way
5
+ LangGraph or the OpenAI Agents SDK do. Instead it exposes a global
6
+ instrumentation surface, ``llama_index.core.instrumentation``, built around a
7
+ tree of ``Dispatcher`` objects (one per module) that fan out to attached
8
+ ``BaseSpanHandler``/``BaseEventHandler`` instances. Every method decorated
9
+ with ``@dispatcher.span`` — which covers essentially all public entry points
10
+ on ``BaseQueryEngine``, ``BaseRetriever``, ``BaseChatEngine``, ``BaseLLM``,
11
+ ``BaseTool``, agent workers/workflows, etc., via the ``DispatcherSpanMixin`` —
12
+ fires ``span_enter``/``span_exit``/``span_drop`` on every handler reachable
13
+ from the dispatcher tree's root. Semantic events (``LLMChatStartEvent``,
14
+ ``AgentToolCallEvent``, ``ExceptionEvent``, ...) are fired independently and
15
+ carry the ``span_id`` of whatever span was active when they were dispatched,
16
+ so a ``BaseEventHandler`` can attribute them back to the span that produced
17
+ them.
18
+
19
+ ``LlamaIndexTracer`` implements both interfaces and, once installed on a
20
+ dispatcher (the root dispatcher by default — every leaf dispatcher's parent
21
+ chain terminates there, so this covers the whole library), turns every
22
+ llama_index span into an agent-trace ``Span`` with the correct parent/child
23
+ nesting, and enriches those spans with chat-history / tool-call / agent-step
24
+ data pulled out of the semantic events.
25
+
26
+ Usage (context manager — recommended, scopes install/uninstall to the block):
27
+
28
+ from agent_trace import tracer
29
+ from agent_trace.integrations.llama_index import LlamaIndexTracer
30
+
31
+ with tracer.start_trace("my_query_engine", record=True) as trace:
32
+ with LlamaIndexTracer(tracer=tracer, trace=trace):
33
+ response = query_engine.query("What is agent-trace?")
34
+
35
+ Usage (manual install/uninstall — e.g. a long-lived process):
36
+
37
+ li_tracer = LlamaIndexTracer(tracer=tracer, trace=trace)
38
+ li_tracer.install()
39
+ ...
40
+ li_tracer.uninstall()
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ import re
47
+ import threading
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ from agent_trace.core.span import Span, SpanStatus
51
+ from agent_trace.interceptor.httpx_hook import pop_correlation_id, push_correlation_id
52
+
53
+ if TYPE_CHECKING:
54
+ from agent_trace import Trace, Tracer
55
+
56
+ __all__ = ["LlamaIndexTracer"]
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ _INSTALL_HINT = (
61
+ "LlamaIndexTracer requires llama-index-core.\n"
62
+ "Install it with:\n\n"
63
+ " pip install llama-index-core\n"
64
+ )
65
+
66
+ # ``Dispatcher.span()`` mints ids as "{ClassName}.{method_name}-{uuid4}" for
67
+ # instance methods, or "{func.__qualname__}-{uuid4}" for plain functions
68
+ # (see llama_index_instrumentation.dispatcher.Dispatcher.span). Stripping the
69
+ # trailing uuid4 gives a stable, human-readable span name.
70
+ _UUID_SUFFIX_RE = re.compile(
71
+ r"-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
72
+ re.IGNORECASE,
73
+ )
74
+
75
+ _TRUNCATE_LEN = 2000
76
+
77
+
78
+ def _span_name(id_: str) -> str:
79
+ """Derive a human-readable span name from a dispatcher span id."""
80
+ stripped = _UUID_SUFFIX_RE.sub("", id_)
81
+ return stripped or id_
82
+
83
+
84
+ def _truncate(value: Any) -> str:
85
+ """Stringify and bound *value* so a single field can't blow up a span."""
86
+ text = str(value)
87
+ if len(text) <= _TRUNCATE_LEN:
88
+ return text
89
+ return text[:_TRUNCATE_LEN] + "...<truncated>"
90
+
91
+
92
+ def _require_llama_index() -> Any:
93
+ """Lazy import guard — raises a clear error if llama-index-core is absent."""
94
+ try:
95
+ import llama_index.core
96
+
97
+ return llama_index.core
98
+ except ImportError as exc:
99
+ raise ImportError(_INSTALL_HINT) from exc
100
+
101
+
102
+ def _apply_llm_chat_start(span: Span, event: Any) -> None:
103
+ messages = getattr(event, "messages", None) or []
104
+ span.set_attribute("llm.messages_count", len(messages))
105
+ model_dict = getattr(event, "model_dict", None) or {}
106
+ model = model_dict.get("model") or model_dict.get("model_name")
107
+ if model:
108
+ span.set_attribute("llm.model", str(model))
109
+ if messages:
110
+ last = messages[-1]
111
+ span.set_attribute("llm.last_message_role", str(getattr(last, "role", "")))
112
+ span.set_attribute(
113
+ "llm.last_message_content", _truncate(getattr(last, "content", ""))
114
+ )
115
+
116
+
117
+ def _apply_llm_chat_end(span: Span, event: Any) -> None:
118
+ response = getattr(event, "response", None)
119
+ message = getattr(response, "message", None) if response is not None else None
120
+ if message is None:
121
+ return
122
+ span.set_attribute(
123
+ "llm.response_content", _truncate(getattr(message, "content", ""))
124
+ )
125
+ additional_kwargs = getattr(message, "additional_kwargs", None) or {}
126
+ span.set_attribute("llm.has_tool_calls", bool(additional_kwargs.get("tool_calls")))
127
+
128
+
129
+ def _apply_llm_completion_start(span: Span, event: Any) -> None:
130
+ prompt = getattr(event, "prompt", None)
131
+ if prompt is not None:
132
+ span.set_attribute("llm.prompt", _truncate(prompt))
133
+
134
+
135
+ def _apply_llm_completion_end(span: Span, event: Any) -> None:
136
+ response = getattr(event, "response", None)
137
+ if response is not None:
138
+ span.set_attribute(
139
+ "llm.response_content", _truncate(getattr(response, "text", ""))
140
+ )
141
+
142
+
143
+ def _apply_agent_tool_call(span: Span, event: Any) -> None:
144
+ tool = getattr(event, "tool", None)
145
+ tool_name = getattr(tool, "name", None) if tool is not None else None
146
+ span.add_event(
147
+ "tool_call",
148
+ attributes={
149
+ "tool.name": str(tool_name) if tool_name else "unknown",
150
+ "tool.arguments": _truncate(getattr(event, "arguments", "")),
151
+ },
152
+ )
153
+
154
+
155
+ def _apply_agent_run_step_start(span: Span, event: Any) -> None:
156
+ task_id = getattr(event, "task_id", None)
157
+ step_input = getattr(event, "input", None)
158
+ if task_id is not None:
159
+ span.set_attribute("agent.task_id", str(task_id))
160
+ if step_input is not None:
161
+ span.set_attribute("agent.step_input", _truncate(step_input))
162
+
163
+
164
+ def _apply_agent_run_step_end(span: Span, event: Any) -> None:
165
+ step_output = getattr(event, "step_output", None)
166
+ if step_output is not None:
167
+ span.set_attribute("agent.step_output", _truncate(step_output))
168
+
169
+
170
+ def _apply_exception(span: Span, event: Any) -> None:
171
+ exc = getattr(event, "exception", None)
172
+ if isinstance(exc, BaseException):
173
+ span.record_exception(exc)
174
+
175
+
176
+ # Dispatch on event.class_name() (a plain string every BaseEvent subclass
177
+ # implements) rather than importing each concrete event class — this keeps
178
+ # the handler resilient to llama_index moving event classes between modules
179
+ # across versions, since only the shape (attribute names), not the import
180
+ # path, is relied upon.
181
+ _EVENT_HANDLERS: dict[str, Any] = {
182
+ "LLMChatStartEvent": _apply_llm_chat_start,
183
+ "LLMChatEndEvent": _apply_llm_chat_end,
184
+ "LLMCompletionStartEvent": _apply_llm_completion_start,
185
+ "LLMCompletionEndEvent": _apply_llm_completion_end,
186
+ "AgentToolCallEvent": _apply_agent_tool_call,
187
+ "AgentRunStepStartEvent": _apply_agent_run_step_start,
188
+ "AgentRunStepEndEvent": _apply_agent_run_step_end,
189
+ "ExceptionEvent": _apply_exception,
190
+ }
191
+
192
+
193
+ def _apply_event(span: Span, event: Any) -> None:
194
+ """Enrich *span* with data pulled out of a dispatched llama_index event."""
195
+ handler = _EVENT_HANDLERS.get(event.class_name())
196
+ if handler is not None:
197
+ handler(span, event)
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Lazily-built concrete handler classes
202
+ # ---------------------------------------------------------------------------
203
+ #
204
+ # BaseSpanHandler / BaseEventHandler / BaseSpan are pydantic BaseModel
205
+ # subclasses defined by llama_index_instrumentation. Subclassing them at
206
+ # module import time would require llama-index-core to be installed just to
207
+ # `import agent_trace.integrations.llama_index`. Instead — same pattern as
208
+ # integrations/langgraph.py's _get_tracer_class() — the concrete impl classes
209
+ # are built exactly once, the first time a LlamaIndexTracer is constructed,
210
+ # with the real base classes as genuine bases.
211
+
212
+ _SpanHandlerClass: type | None = None
213
+ _EventHandlerClass: type | None = None
214
+ _classes_lock: threading.Lock = threading.Lock()
215
+
216
+
217
+ def _get_handler_classes() -> tuple[type, type]:
218
+ """Return (and lazily build) the concrete span/event handler classes."""
219
+ global _SpanHandlerClass, _EventHandlerClass # noqa: PLW0603
220
+ if _SpanHandlerClass is not None and _EventHandlerClass is not None:
221
+ return _SpanHandlerClass, _EventHandlerClass
222
+
223
+ with _classes_lock:
224
+ if _SpanHandlerClass is not None and _EventHandlerClass is not None:
225
+ return _SpanHandlerClass, _EventHandlerClass
226
+
227
+ _require_llama_index()
228
+ from llama_index.core.instrumentation.event_handlers import BaseEventHandler
229
+ from llama_index.core.instrumentation.span.base import BaseSpan
230
+ from llama_index.core.instrumentation.span_handlers import BaseSpanHandler
231
+ from pydantic import PrivateAttr
232
+
233
+ class _AgentTraceSpan(BaseSpan): # type: ignore[misc]
234
+ """Minimal BaseSpan — the real agent-trace Span lives in the
235
+ handler's own registry (keyed by the same dispatcher span id),
236
+ not on this pydantic model."""
237
+
238
+ class _AgentTraceSpanHandlerImpl(BaseSpanHandler[_AgentTraceSpan]): # type: ignore[misc]
239
+ """Turns dispatcher span_enter/span_exit/span_drop into agent-trace
240
+ Spans, using the dispatcher-supplied parent_span_id for nesting."""
241
+
242
+ _tracer: Any = PrivateAttr(default=None)
243
+ _registry: dict[str, Span] = PrivateAttr(default_factory=dict)
244
+ _registry_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
245
+ # Per-span correlation-id contextvar tokens (#13449): every
246
+ # span new_span() creates pushes its own span_id as the active
247
+ # httpx_hook correlation id for the duration it's open, so any
248
+ # HTTP exchange made anywhere inside it is recoverable
249
+ # afterwards via Fixture.exchanges_for_correlation_id(span_id)
250
+ # — the same mechanism LangGraphTracer uses for #6037. Same
251
+ # documented scope/limitation: reliable for synchronous
252
+ # dispatch, not guaranteed to propagate across llama_index's
253
+ # own async/concurrent execution paths.
254
+ _correlation_tokens: dict[str, Any] = PrivateAttr(default_factory=dict)
255
+
256
+ @classmethod
257
+ def class_name(cls) -> str:
258
+ return "AgentTraceSpanHandler"
259
+
260
+ def new_span(
261
+ self,
262
+ id_: str,
263
+ bound_args: Any,
264
+ instance: Any | None = None,
265
+ parent_span_id: str | None = None,
266
+ tags: dict[str, Any] | None = None,
267
+ **kwargs: Any,
268
+ ) -> _AgentTraceSpan:
269
+ parent_id: str | None = None
270
+ if parent_span_id is not None:
271
+ with self._registry_lock:
272
+ parent_span = self._registry.get(parent_span_id)
273
+ if parent_span is not None:
274
+ parent_id = parent_span.span_id
275
+
276
+ span = self._tracer.start_span(_span_name(id_), parent_id=parent_id)
277
+ span.set_attribute("llama_index.span_id", id_)
278
+ if instance is not None:
279
+ span.set_attribute("llama_index.class", type(instance).__name__)
280
+ with self._registry_lock:
281
+ self._registry[id_] = span
282
+ try:
283
+ self._correlation_tokens[id_] = push_correlation_id(
284
+ span.span_id
285
+ )
286
+ except Exception:
287
+ logger.debug(
288
+ "agent-trace: failed to push correlation id for "
289
+ "llama_index span %r",
290
+ id_,
291
+ exc_info=True,
292
+ )
293
+ return _AgentTraceSpan(
294
+ id_=id_, parent_id=parent_span_id, tags=tags or {}
295
+ )
296
+
297
+ def _pop_correlation_token(self, id_: str) -> None:
298
+ with self._registry_lock:
299
+ token = self._correlation_tokens.pop(id_, None)
300
+ if token is None:
301
+ return
302
+ try:
303
+ pop_correlation_id(token)
304
+ except Exception:
305
+ logger.debug(
306
+ "agent-trace: failed to pop correlation id for "
307
+ "llama_index span %r",
308
+ id_,
309
+ exc_info=True,
310
+ )
311
+
312
+ def prepare_to_exit_span(
313
+ self,
314
+ id_: str,
315
+ bound_args: Any,
316
+ instance: Any | None = None,
317
+ result: Any | None = None,
318
+ **kwargs: Any,
319
+ ) -> _AgentTraceSpan | None:
320
+ with self._registry_lock:
321
+ span = self._registry.pop(id_, None)
322
+ self._pop_correlation_token(id_)
323
+ if span is not None and span.end_time is None:
324
+ span.end(SpanStatus.OK)
325
+ return self.open_spans.get(id_) # type: ignore[no-any-return]
326
+
327
+ def prepare_to_drop_span(
328
+ self,
329
+ id_: str,
330
+ bound_args: Any,
331
+ instance: Any | None = None,
332
+ err: BaseException | None = None,
333
+ **kwargs: Any,
334
+ ) -> _AgentTraceSpan | None:
335
+ if id_ not in self.open_spans:
336
+ return None
337
+ with self._registry_lock:
338
+ span = self._registry.pop(id_, None)
339
+ self._pop_correlation_token(id_)
340
+ if span is not None:
341
+ try:
342
+ if err is not None:
343
+ span.record_exception(err)
344
+ if span.end_time is None:
345
+ span.end(
346
+ SpanStatus.ERROR if err is not None else SpanStatus.OK
347
+ )
348
+ except Exception:
349
+ logger.debug(
350
+ "agent-trace: failed to close dropped llama_index span %r",
351
+ id_,
352
+ exc_info=True,
353
+ )
354
+ return self.open_spans.get(id_) # type: ignore[no-any-return]
355
+
356
+ class _AgentTraceEventHandlerImpl(BaseEventHandler): # type: ignore[misc]
357
+ """Routes semantic llama_index events onto the currently-open
358
+ agent-trace span they belong to (matched via event.span_id)."""
359
+
360
+ _registry: dict[str, Span] = PrivateAttr(default_factory=dict)
361
+ _registry_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
362
+
363
+ @classmethod
364
+ def class_name(cls) -> str:
365
+ return "AgentTraceEventHandler"
366
+
367
+ def handle(self, event: Any, **kwargs: Any) -> None:
368
+ span_id = getattr(event, "span_id", None)
369
+ if not span_id:
370
+ return
371
+ with self._registry_lock:
372
+ span = self._registry.get(span_id)
373
+ if span is None:
374
+ return
375
+ try:
376
+ _apply_event(span, event)
377
+ except Exception:
378
+ logger.debug(
379
+ "agent-trace: failed to apply llama_index event %r "
380
+ "onto span %r",
381
+ getattr(event, "class_name", lambda: "?")(),
382
+ span_id,
383
+ exc_info=True,
384
+ )
385
+
386
+ _SpanHandlerClass = _AgentTraceSpanHandlerImpl
387
+ _EventHandlerClass = _AgentTraceEventHandlerImpl
388
+ return _SpanHandlerClass, _EventHandlerClass
389
+
390
+
391
+ class LlamaIndexTracer:
392
+ """Installs agent-trace span/event capture onto a llama_index Dispatcher.
393
+
394
+ Unlike ``LangGraphTracer``/``AgentTraceHook``, this is not passed into a
395
+ single call — llama_index's instrumentation is dispatcher-global, so this
396
+ object is *installed* onto a dispatcher (the root dispatcher, by default)
397
+ for the duration of the work you want captured, then *uninstalled*.
398
+
399
+ Parameters
400
+ ----------
401
+ tracer:
402
+ The active :class:`~agent_trace.Tracer` instance.
403
+ trace:
404
+ The :class:`~agent_trace.Trace` that spans will be registered on.
405
+ """
406
+
407
+ def __init__(self, tracer: Tracer, trace: Trace) -> None:
408
+ span_handler_cls, event_handler_cls = _get_handler_classes()
409
+
410
+ self._tracer: Tracer = tracer
411
+ self._trace: Trace = trace
412
+ # Shared registry: llama_index dispatcher span id -> open agent-trace
413
+ # Span. The span handler populates/pops it; the event handler only
414
+ # reads it, so events can be attributed to the span that produced
415
+ # them (event.span_id matches the dispatcher span id verbatim).
416
+ self._registry: dict[str, Span] = {}
417
+ self._registry_lock: threading.Lock = threading.Lock()
418
+
419
+ self._span_handler: Any = span_handler_cls()
420
+ self._span_handler._tracer = tracer
421
+ self._span_handler._registry = self._registry
422
+ self._span_handler._registry_lock = self._registry_lock
423
+
424
+ self._event_handler: Any = event_handler_cls()
425
+ self._event_handler._registry = self._registry
426
+ self._event_handler._registry_lock = self._registry_lock
427
+
428
+ self._installed_on: Any = None
429
+
430
+ # ------------------------------------------------------------------
431
+ # Install / uninstall
432
+ # ------------------------------------------------------------------
433
+
434
+ def install(self, dispatcher: Any = None) -> None:
435
+ """Attach this tracer's handlers to *dispatcher* (root by default).
436
+
437
+ The root dispatcher's parent chain is the terminus for every other
438
+ dispatcher in the process (``Dispatcher.span_enter``/``event`` walk
439
+ up ``c.parent`` until a non-propagating dispatcher is hit, and the
440
+ root dispatcher is created with ``propagate=False``), so handlers
441
+ attached to root see every span/event fired anywhere in llama_index.
442
+ """
443
+ if dispatcher is None:
444
+ _require_llama_index()
445
+ from llama_index.core.instrumentation import get_dispatcher
446
+
447
+ dispatcher = get_dispatcher()
448
+
449
+ dispatcher.add_span_handler(self._span_handler)
450
+ dispatcher.add_event_handler(self._event_handler)
451
+ self._installed_on = dispatcher
452
+
453
+ def uninstall(self) -> None:
454
+ """Detach this tracer's handlers from whatever dispatcher they were
455
+ installed on. Safe to call even if never installed."""
456
+ dispatcher = self._installed_on
457
+ if dispatcher is None:
458
+ return
459
+ dispatcher.span_handlers = [
460
+ h for h in dispatcher.span_handlers if h is not self._span_handler
461
+ ]
462
+ dispatcher.event_handlers = [
463
+ h for h in dispatcher.event_handlers if h is not self._event_handler
464
+ ]
465
+ self._installed_on = None
466
+
467
+ def __enter__(self) -> LlamaIndexTracer:
468
+ self.install()
469
+ return self
470
+
471
+ def __exit__(self, *exc_info: object) -> None:
472
+ self.uninstall()