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.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console exporter — prints a trace as a colored span tree using rich.
|
|
3
|
+
Falls back to plain text if rich is not installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from agent_trace import Span, Trace
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"StdoutExporter",
|
|
16
|
+
"export",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# Colour map: SpanStatus value -> rich colour name
|
|
20
|
+
_STATUS_COLOUR: dict[str, str] = {
|
|
21
|
+
"OK": "green",
|
|
22
|
+
"ERROR": "red",
|
|
23
|
+
"UNSET": "yellow",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Plain-text symbol map
|
|
27
|
+
_STATUS_SYMBOL: dict[str, str] = {
|
|
28
|
+
"OK": "[OK]",
|
|
29
|
+
"ERROR": "[ERR]",
|
|
30
|
+
"UNSET": "[---]",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Span attributes worth surfacing inline in the tree view — the two numbers
|
|
35
|
+
# needed to check "did latency grow along with prompt size across turns"
|
|
36
|
+
# (#2920) are otherwise only reachable by manually opening trace.json and
|
|
37
|
+
# cross-referencing two separate JSON keys per span.
|
|
38
|
+
_INLINE_ATTRIBUTE_KEYS: tuple[str, ...] = (
|
|
39
|
+
"llm.model",
|
|
40
|
+
"llm.usage.prompt_tokens",
|
|
41
|
+
"llm.usage.completion_tokens",
|
|
42
|
+
"llm.usage.total_tokens",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _inline_attributes_suffix(attributes: Mapping[str, object]) -> str:
|
|
47
|
+
"""Render a compact ``key=value`` suffix for the subset of *attributes*
|
|
48
|
+
worth showing inline, or "" if none of them are present."""
|
|
49
|
+
parts = [
|
|
50
|
+
f"{key}={attributes[key]}"
|
|
51
|
+
for key in _INLINE_ATTRIBUTE_KEYS
|
|
52
|
+
if key in attributes
|
|
53
|
+
]
|
|
54
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _exception_message(span: Span) -> str | None:
|
|
58
|
+
"""Return the exception.message text captured on *span* via
|
|
59
|
+
Span.record_exception(), or None if it has no recorded exception event
|
|
60
|
+
— the data Span.record_exception() (core/span.py) already captures on
|
|
61
|
+
every ERROR span, but which was previously invisible in this tree view
|
|
62
|
+
(a developer saw "[ERR] llm:<model>" with no indication of why)."""
|
|
63
|
+
for event in span.events:
|
|
64
|
+
if event.name == "exception" and "exception.message" in event.attributes:
|
|
65
|
+
return str(event.attributes["exception.message"])
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _unaccounted_ms(span: Span, children: list[Span] | None) -> float | None:
|
|
70
|
+
"""Return *span*'s duration_ms minus the sum of its direct children's
|
|
71
|
+
duration_ms, or None when it can't be computed (span or any child is
|
|
72
|
+
still open, or there are no children — nothing to subtract).
|
|
73
|
+
|
|
74
|
+
Surfaces the gap a developer previously had to manually eyeball —
|
|
75
|
+
"this node took far longer than the LLM call inside it did" — e.g.
|
|
76
|
+
#2920's reporters posting screenshots of exactly this pattern with no
|
|
77
|
+
quantified figure to point at.
|
|
78
|
+
"""
|
|
79
|
+
if span.duration_ms is None or not children:
|
|
80
|
+
return None
|
|
81
|
+
child_total = 0.0
|
|
82
|
+
for child in children:
|
|
83
|
+
if child.duration_ms is None:
|
|
84
|
+
return None # a still-open child makes the subtraction meaningless
|
|
85
|
+
child_total += child.duration_ms
|
|
86
|
+
return span.duration_ms - child_total
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _unaccounted_suffix(span: Span, children: list[Span] | None) -> str:
|
|
90
|
+
unaccounted = _unaccounted_ms(span, children)
|
|
91
|
+
if unaccounted is None:
|
|
92
|
+
return ""
|
|
93
|
+
return f" [{unaccounted:.1f}ms unaccounted of {span.duration_ms:.1f}ms]"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _exception_http_detail(span: Span) -> str | None:
|
|
97
|
+
"""Return "HTTP <status>: <body preview>" when *span*'s recorded
|
|
98
|
+
exception carried an HTTP error response body (exception.http_
|
|
99
|
+
response_body/exception.http_status_code, set by Span.record_exception
|
|
100
|
+
for requests.exceptions.HTTPError/httpx.HTTPStatusError-shaped
|
|
101
|
+
exceptions) — the actual provider/proxy error text (#4940), which
|
|
102
|
+
str(exc) alone (all `_exception_message` surfaces) typically omits."""
|
|
103
|
+
for event in span.events:
|
|
104
|
+
if event.name != "exception":
|
|
105
|
+
continue
|
|
106
|
+
body = event.attributes.get("exception.http_response_body")
|
|
107
|
+
if not body:
|
|
108
|
+
continue
|
|
109
|
+
status = event.attributes.get("exception.http_status_code")
|
|
110
|
+
prefix = f"HTTP {status}: " if status is not None else "HTTP: "
|
|
111
|
+
return f"{prefix}{body}"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _trace_header_info(trace: Trace) -> tuple[str, str]:
|
|
116
|
+
"""Return (duration_string, display_name) for trace header lines."""
|
|
117
|
+
ended = [s.end_time for s in trace.spans if s.end_time is not None]
|
|
118
|
+
started = [s.start_time for s in trace.spans]
|
|
119
|
+
dur_str = ""
|
|
120
|
+
if started and ended:
|
|
121
|
+
total_ms = (max(ended) - min(started)) * 1_000
|
|
122
|
+
dur_str = f" ({total_ms:.1f} ms total)"
|
|
123
|
+
display_name = str(trace.metadata.get("name", trace.run_id))
|
|
124
|
+
return dur_str, display_name
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class StdoutExporter:
|
|
128
|
+
"""Export a :class:`~agent_trace.Trace` as a human-readable span tree.
|
|
129
|
+
|
|
130
|
+
Uses ``rich`` for coloured output when available; otherwise falls back to
|
|
131
|
+
plain indented ASCII.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def export(self, trace: Trace) -> None:
|
|
135
|
+
"""Print the full span tree for *trace* to stdout."""
|
|
136
|
+
try:
|
|
137
|
+
self._export_rich(trace)
|
|
138
|
+
except ImportError:
|
|
139
|
+
self._export_plain(trace)
|
|
140
|
+
|
|
141
|
+
def export_span(
|
|
142
|
+
self, span: Span, depth: int = 0, children: list[Span] | None = None
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Export a single span at the given indent depth (plain-text only).
|
|
145
|
+
|
|
146
|
+
*children*, when supplied, enables the "N ms unaccounted of M ms
|
|
147
|
+
total" suffix (duration_ms minus the sum of direct children's
|
|
148
|
+
duration_ms) — omitted entirely when not supplied, so existing
|
|
149
|
+
single-span callers are unaffected.
|
|
150
|
+
"""
|
|
151
|
+
indent = " " * depth
|
|
152
|
+
sym = _STATUS_SYMBOL.get(span.status.value, "[---]")
|
|
153
|
+
dur = f" ({span.duration_ms:.1f} ms)" if span.duration_ms is not None else ""
|
|
154
|
+
attrs_suffix = _inline_attributes_suffix(span.attributes)
|
|
155
|
+
unaccounted_suffix = _unaccounted_suffix(span, children)
|
|
156
|
+
print(f"{indent}{sym} {span.name}{dur}{attrs_suffix}{unaccounted_suffix}")
|
|
157
|
+
if span.status.value == "ERROR":
|
|
158
|
+
message = _exception_message(span)
|
|
159
|
+
if message:
|
|
160
|
+
print(f"{indent} ! {message}")
|
|
161
|
+
http_detail = _exception_http_detail(span)
|
|
162
|
+
if http_detail:
|
|
163
|
+
print(f"{indent} ! {http_detail}")
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Rich implementation
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _export_rich(self, trace: Trace) -> None:
|
|
170
|
+
from rich.console import Console
|
|
171
|
+
from rich.tree import Tree
|
|
172
|
+
|
|
173
|
+
console = Console()
|
|
174
|
+
dur_str, display_name = _trace_header_info(trace)
|
|
175
|
+
rich_dur = f" [dim]({dur_str.strip()})[/dim]" if dur_str else ""
|
|
176
|
+
|
|
177
|
+
root_label = (
|
|
178
|
+
f"[bold cyan]Trace:[/bold cyan] {display_name}"
|
|
179
|
+
f" [dim]{trace.run_id}[/dim]{rich_dur}"
|
|
180
|
+
)
|
|
181
|
+
tree = Tree(root_label)
|
|
182
|
+
|
|
183
|
+
# Build parent->children map first so tree construction is order-independent.
|
|
184
|
+
children_map: dict[str | None, list[Any]] = {}
|
|
185
|
+
for span in trace.spans:
|
|
186
|
+
children_map.setdefault(span.parent_id, []).append(span)
|
|
187
|
+
|
|
188
|
+
def _add_children(parent_node: Any, parent_id: str | None) -> None:
|
|
189
|
+
for span in children_map.get(parent_id, []):
|
|
190
|
+
colour = _STATUS_COLOUR.get(span.status.value, "yellow")
|
|
191
|
+
dur = (
|
|
192
|
+
f" [dim]({span.duration_ms:.1f} ms)[/dim]"
|
|
193
|
+
if span.duration_ms is not None
|
|
194
|
+
else ""
|
|
195
|
+
)
|
|
196
|
+
attrs_suffix = _inline_attributes_suffix(span.attributes)
|
|
197
|
+
dim_attrs = (
|
|
198
|
+
f" [dim]{attrs_suffix.strip()}[/dim]" if attrs_suffix else ""
|
|
199
|
+
)
|
|
200
|
+
unaccounted_suffix = _unaccounted_suffix(
|
|
201
|
+
span, children_map.get(span.span_id)
|
|
202
|
+
)
|
|
203
|
+
dim_unaccounted = (
|
|
204
|
+
f" [dim]{unaccounted_suffix.strip()}[/dim]"
|
|
205
|
+
if unaccounted_suffix
|
|
206
|
+
else ""
|
|
207
|
+
)
|
|
208
|
+
label = (
|
|
209
|
+
f"[{colour}]{span.name}[/{colour}]"
|
|
210
|
+
f" [{colour}]{span.status.value}[/{colour}]"
|
|
211
|
+
f"{dur}{dim_attrs}{dim_unaccounted}"
|
|
212
|
+
)
|
|
213
|
+
if span.status.value == "ERROR":
|
|
214
|
+
message = _exception_message(span)
|
|
215
|
+
if message:
|
|
216
|
+
label += f"\n [red]! {message}[/red]"
|
|
217
|
+
http_detail = _exception_http_detail(span)
|
|
218
|
+
if http_detail:
|
|
219
|
+
label += f"\n [red]! {http_detail}[/red]"
|
|
220
|
+
node = parent_node.add(label)
|
|
221
|
+
_add_children(node, span.span_id)
|
|
222
|
+
|
|
223
|
+
_add_children(tree, None)
|
|
224
|
+
console.print(tree)
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Plain-text fallback
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _export_plain(self, trace: Trace) -> None:
|
|
231
|
+
dur_str, display_name = _trace_header_info(trace)
|
|
232
|
+
print(f"Trace: {display_name} [{trace.run_id}]{dur_str}")
|
|
233
|
+
|
|
234
|
+
# Build parent->children mapping for tree printing
|
|
235
|
+
children: dict[str | None, list[Span]] = {}
|
|
236
|
+
for span in trace.spans:
|
|
237
|
+
children.setdefault(span.parent_id, []).append(span)
|
|
238
|
+
|
|
239
|
+
def _print_subtree(parent_id: str | None, depth: int) -> None:
|
|
240
|
+
for span in children.get(parent_id, []):
|
|
241
|
+
self.export_span(
|
|
242
|
+
span, depth=depth, children=children.get(span.span_id)
|
|
243
|
+
)
|
|
244
|
+
_print_subtree(span.span_id, depth + 1)
|
|
245
|
+
|
|
246
|
+
_print_subtree(None, depth=1)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Module-level convenience
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
_default_exporter: StdoutExporter = StdoutExporter()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def export(trace: Trace) -> None:
|
|
257
|
+
"""Export *trace* to stdout using the default :class:`StdoutExporter`."""
|
|
258
|
+
_default_exporter.export(trace)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent-trace framework integrations.
|
|
3
|
+
|
|
4
|
+
Each submodule wraps a specific agent framework's callback/event-hook
|
|
5
|
+
surface to emit agent-trace spans (e.g. ``langgraph.py`` for LangGraph,
|
|
6
|
+
``openai_agents.py`` for the OpenAI Agents SDK, ``crewai.py`` for crewAI).
|
|
7
|
+
|
|
8
|
+
Submodules are exported lazily here (PEP 562 module ``__getattr__``) so that
|
|
9
|
+
``import agent_trace.integrations`` never fails regardless of which optional
|
|
10
|
+
framework SDKs happen to be installed — none of the submodules themselves
|
|
11
|
+
import their target SDK at module import time either (see langgraph.py /
|
|
12
|
+
openai_agents.py, which defer the real ``import langgraph`` / ``import
|
|
13
|
+
agents`` to call time inside the class/function that needs it). Accessing a
|
|
14
|
+
specific submodule (e.g. ``agent_trace.integrations.crewai`` or ``from
|
|
15
|
+
agent_trace.integrations import crewai``) triggers that submodule's own
|
|
16
|
+
import; only *calling* something inside it that actually needs the SDK
|
|
17
|
+
raises a helpful ``ImportError`` with an install hint if the SDK is
|
|
18
|
+
missing.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from agent_trace.integrations import (
|
|
28
|
+
agno,
|
|
29
|
+
autogen,
|
|
30
|
+
crewai,
|
|
31
|
+
google_genai,
|
|
32
|
+
haystack,
|
|
33
|
+
langgraph,
|
|
34
|
+
langgraph_checkpoint,
|
|
35
|
+
langgraph_state_diff,
|
|
36
|
+
langgraph_stream_debug,
|
|
37
|
+
llama_index,
|
|
38
|
+
mcp,
|
|
39
|
+
openai_agents,
|
|
40
|
+
pydantic_ai,
|
|
41
|
+
streaming,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"agno",
|
|
46
|
+
"autogen",
|
|
47
|
+
"crewai",
|
|
48
|
+
"google_genai",
|
|
49
|
+
"haystack",
|
|
50
|
+
"langgraph",
|
|
51
|
+
"langgraph_checkpoint",
|
|
52
|
+
"langgraph_state_diff",
|
|
53
|
+
"langgraph_stream_debug",
|
|
54
|
+
"llama_index",
|
|
55
|
+
"mcp",
|
|
56
|
+
"openai_agents",
|
|
57
|
+
"pydantic_ai",
|
|
58
|
+
"streaming",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str) -> Any:
|
|
63
|
+
if name in __all__:
|
|
64
|
+
return importlib.import_module(f"agent_trace.integrations.{name}")
|
|
65
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def __dir__() -> list[str]:
|
|
69
|
+
return sorted(__all__)
|