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 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clock abstraction for deterministic replay.
|
|
3
|
+
|
|
4
|
+
All spans use get_time() from this module — never time.time() directly.
|
|
5
|
+
During replay, set_clock(FixtureClock()) swaps the time source so all
|
|
6
|
+
recorded timestamps are replayed exactly without wall-clock drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from contextvars import ContextVar, Token
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Clock",
|
|
17
|
+
"FixtureClock",
|
|
18
|
+
"WallClock",
|
|
19
|
+
"get_clock",
|
|
20
|
+
"get_time",
|
|
21
|
+
"restore_clock",
|
|
22
|
+
"set_clock",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Clock(ABC):
|
|
27
|
+
"""Abstract base for all time sources used by agent-trace spans."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def now(self) -> float:
|
|
31
|
+
"""Return current time as a Unix timestamp (seconds since epoch)."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WallClock(Clock):
|
|
36
|
+
"""Production clock that delegates to time.time()."""
|
|
37
|
+
|
|
38
|
+
def now(self) -> float:
|
|
39
|
+
return time.time()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FixtureClock(Clock):
|
|
43
|
+
"""Replay clock driven by pre-recorded timestamps.
|
|
44
|
+
|
|
45
|
+
Initialises to ``time.time()`` so replay spans have meaningful timestamps
|
|
46
|
+
by default. Call :meth:`advance` with each recorded timestamp to reproduce
|
|
47
|
+
original execution times exactly.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, initial: float | None = None) -> None:
|
|
51
|
+
self._current: float = initial if initial is not None else time.time()
|
|
52
|
+
|
|
53
|
+
def advance(self, timestamp: float) -> None:
|
|
54
|
+
"""Set the clock to *timestamp* (seconds since epoch)."""
|
|
55
|
+
self._current = timestamp
|
|
56
|
+
|
|
57
|
+
def now(self) -> float:
|
|
58
|
+
return self._current
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# One clock per async context (or thread). Default is the real wall clock so
|
|
62
|
+
# that code works without any setup in production.
|
|
63
|
+
_clock_var: ContextVar[Clock] = ContextVar("_agent_trace_clock", default=WallClock()) # noqa: B039
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_clock() -> Clock:
|
|
67
|
+
"""Return the active Clock for the current context."""
|
|
68
|
+
return _clock_var.get()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def set_clock(clock: Clock) -> Token[Clock]:
|
|
72
|
+
"""Replace the active clock and return a Token for later restoration.
|
|
73
|
+
|
|
74
|
+
Always pair with restore_clock() in a finally block to avoid leaking the
|
|
75
|
+
override into sibling async tasks.
|
|
76
|
+
"""
|
|
77
|
+
return _clock_var.set(clock)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def restore_clock(token: Token[Clock]) -> None:
|
|
81
|
+
"""Undo a previous set_clock() using the Token it returned."""
|
|
82
|
+
_clock_var.reset(token)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_time() -> float:
|
|
86
|
+
"""Return current time from the active clock.
|
|
87
|
+
|
|
88
|
+
This is the only call-site for time inside agent-trace core code. Using
|
|
89
|
+
this instead of time.time() directly is what makes replay deterministic.
|
|
90
|
+
"""
|
|
91
|
+
return _clock_var.get().now()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared exception types for agent-trace.
|
|
3
|
+
|
|
4
|
+
Centralised here so that callers can catch a single NetworkGuardError
|
|
5
|
+
regardless of which HTTP client (httpx or requests) raised it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
__all__ = ["NetworkGuardError", "RunawayToolCallLoopError", "guard_active"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NetworkGuardError(RuntimeError):
|
|
16
|
+
"""Raised when a live network call is attempted during guarded replay.
|
|
17
|
+
|
|
18
|
+
Set ``AGENT_TRACE_NETWORK_GUARD=1`` to activate the guard. The guard
|
|
19
|
+
exists so that test suites relying on fixtures blow up loudly rather than
|
|
20
|
+
silently hitting real endpoints, which would cause non-deterministic
|
|
21
|
+
results and unexpected API costs.
|
|
22
|
+
|
|
23
|
+
Both the httpx and requests interceptors raise this same class, so a
|
|
24
|
+
single ``except NetworkGuardError`` catches either.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RunawayToolCallLoopError(RuntimeError):
|
|
29
|
+
"""Raised by RecordingTransport/AsyncRecordingTransport's optional
|
|
30
|
+
live loop guard (``loop_guard_threshold=``) once a configurable number
|
|
31
|
+
of *consecutive* tool-call-only responses have been recorded for the
|
|
32
|
+
same host during an active recording session.
|
|
33
|
+
|
|
34
|
+
This is the live-recording signal for issue #3097 — a model that never
|
|
35
|
+
stops emitting ``tool_calls`` and never returns a final tool-call-free
|
|
36
|
+
message, silently burning through the full context window (and API
|
|
37
|
+
budget) before anyone notices. Replay-after-the-fact only helps
|
|
38
|
+
diagnose a loop that already happened and already cost the tokens; this
|
|
39
|
+
guard is what actually catches it early, during the run that is still
|
|
40
|
+
burning the budget.
|
|
41
|
+
|
|
42
|
+
Raised (rather than merely warned) only when the transport was
|
|
43
|
+
constructed with ``on_loop_detected=agent_trace.interceptor.httpx_hook.
|
|
44
|
+
raise_on_loop_detected`` (or an equivalent custom callback) — the
|
|
45
|
+
default ``on_loop_detected`` only emits a ``UserWarning``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def guard_active() -> bool:
|
|
50
|
+
"""Return True when AGENT_TRACE_NETWORK_GUARD=1 is set in the environment."""
|
|
51
|
+
return os.environ.get("AGENT_TRACE_NETWORK_GUARD", "0") == "1"
|
agent_trace/core/span.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Span and SpanEvent data models for agent-trace.
|
|
3
|
+
|
|
4
|
+
A Span represents a single unit of work within a trace. Spans are cheap
|
|
5
|
+
value objects — they hold no locks, no background threads, and no file
|
|
6
|
+
handles. All mutation goes through the public methods below so that
|
|
7
|
+
to_dict() remains consistent with the live state.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import traceback
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from agent_trace.core.clock import get_time
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Span",
|
|
22
|
+
"SpanEvent",
|
|
23
|
+
"SpanStatus",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Attribute value types accepted everywhere in this module.
|
|
27
|
+
_AttrValue = str | int | float | bool
|
|
28
|
+
|
|
29
|
+
# Matches the truncation length used for other large captured text
|
|
30
|
+
# attributes (see agent_trace.integrations.langgraph._MAX_ATTR_LEN) so a
|
|
31
|
+
# pathologically large error body can't blow up trace.json.
|
|
32
|
+
_MAX_EXCEPTION_RESPONSE_BODY_LEN = 8_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _http_error_response_body(exc: BaseException) -> tuple[str | None, int | None]:
|
|
36
|
+
"""Best-effort extraction of ``(response_text, status_code)`` from an
|
|
37
|
+
exception exposing a ``.response`` attribute — the shape
|
|
38
|
+
``requests.exceptions.HTTPError``, ``httpx.HTTPStatusError``, and
|
|
39
|
+
similar HTTP-error-carrying exceptions across provider SDKs all follow.
|
|
40
|
+
|
|
41
|
+
Returns ``(None, None)`` for anything that doesn't match — this must
|
|
42
|
+
never raise, since it runs inside exception-handling code itself.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
response = getattr(exc, "response", None)
|
|
46
|
+
except Exception:
|
|
47
|
+
return None, None
|
|
48
|
+
if response is None:
|
|
49
|
+
return None, None
|
|
50
|
+
|
|
51
|
+
status_code: Any = None
|
|
52
|
+
try:
|
|
53
|
+
status_code = getattr(response, "status_code", None)
|
|
54
|
+
if status_code is None:
|
|
55
|
+
# requests uses `.status_code`; some SDKs expose it differently.
|
|
56
|
+
status_code = getattr(response, "status", None)
|
|
57
|
+
except Exception:
|
|
58
|
+
status_code = None
|
|
59
|
+
|
|
60
|
+
text: str | None = None
|
|
61
|
+
try:
|
|
62
|
+
raw_text = getattr(response, "text", None)
|
|
63
|
+
if isinstance(raw_text, str):
|
|
64
|
+
text = raw_text
|
|
65
|
+
elif callable(raw_text):
|
|
66
|
+
# Some response-like objects expose text() as a method.
|
|
67
|
+
maybe = raw_text()
|
|
68
|
+
if isinstance(maybe, str):
|
|
69
|
+
text = maybe
|
|
70
|
+
except Exception:
|
|
71
|
+
text = None
|
|
72
|
+
|
|
73
|
+
if text is None:
|
|
74
|
+
try:
|
|
75
|
+
content = getattr(response, "content", None)
|
|
76
|
+
if isinstance(content, bytes):
|
|
77
|
+
text = content.decode("utf-8", errors="replace")
|
|
78
|
+
elif isinstance(content, str):
|
|
79
|
+
text = content
|
|
80
|
+
except Exception:
|
|
81
|
+
text = None
|
|
82
|
+
|
|
83
|
+
if text is not None and len(text) > _MAX_EXCEPTION_RESPONSE_BODY_LEN:
|
|
84
|
+
text = text[:_MAX_EXCEPTION_RESPONSE_BODY_LEN] + "...<truncated>"
|
|
85
|
+
|
|
86
|
+
return text, status_code if isinstance(status_code, int) else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SpanStatus(str, Enum):
|
|
90
|
+
"""Lifecycle status of a Span, modelled after OpenTelemetry's StatusCode.
|
|
91
|
+
|
|
92
|
+
CANCELLED is deliberately distinct from ERROR: a span ended because the
|
|
93
|
+
run/task it belonged to was cancelled (e.g. ``asyncio.CancelledError``)
|
|
94
|
+
did not fail — it was cut off mid-flight. Collapsing both into ERROR
|
|
95
|
+
makes a genuine application failure indistinguishable from a cancelled
|
|
96
|
+
run when reading a trace, which matters for diagnosing
|
|
97
|
+
cancellation-triggered data-loss bugs (e.g. unpersisted checkpoints).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
UNSET = "UNSET"
|
|
101
|
+
OK = "OK"
|
|
102
|
+
ERROR = "ERROR"
|
|
103
|
+
CANCELLED = "CANCELLED"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class SpanEvent:
|
|
108
|
+
"""A timestamped annotation attached to a Span.
|
|
109
|
+
|
|
110
|
+
Events capture point-in-time occurrences (e.g. "tool call returned") that
|
|
111
|
+
are too fine-grained for their own span.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
timestamp: float
|
|
116
|
+
attributes: dict[str, _AttrValue] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"name": self.name,
|
|
121
|
+
"timestamp": self.timestamp,
|
|
122
|
+
"attributes": dict(self.attributes),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_dict(cls, data: dict[str, Any]) -> SpanEvent:
|
|
127
|
+
return cls(
|
|
128
|
+
name=str(data["name"]),
|
|
129
|
+
timestamp=float(data["timestamp"]),
|
|
130
|
+
attributes=dict(data.get("attributes", {})),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Span:
|
|
136
|
+
"""Immutable-by-convention record of a single traced operation.
|
|
137
|
+
|
|
138
|
+
Spans are created open (end_time is None) and closed via end(). After
|
|
139
|
+
end() is called, the span should be treated as read-only — mutating a
|
|
140
|
+
closed span produces undefined behaviour in exporters.
|
|
141
|
+
|
|
142
|
+
All timestamps come from core.clock.get_time() so the replay engine can
|
|
143
|
+
substitute pre-recorded values without touching any other code.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
span_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
147
|
+
trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
148
|
+
parent_id: str | None = None
|
|
149
|
+
name: str = ""
|
|
150
|
+
start_time: float = field(default_factory=get_time)
|
|
151
|
+
end_time: float | None = None
|
|
152
|
+
status: SpanStatus = SpanStatus.UNSET
|
|
153
|
+
attributes: dict[str, _AttrValue] = field(default_factory=dict)
|
|
154
|
+
events: list[SpanEvent] = field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Mutation helpers
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def end(self, status: SpanStatus = SpanStatus.OK) -> None:
|
|
161
|
+
"""Close the span, recording end_time and setting status."""
|
|
162
|
+
if self.end_time is not None:
|
|
163
|
+
return
|
|
164
|
+
self.end_time = get_time()
|
|
165
|
+
self.status = status
|
|
166
|
+
|
|
167
|
+
def add_event(
|
|
168
|
+
self,
|
|
169
|
+
name: str,
|
|
170
|
+
attributes: dict[str, _AttrValue] | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Append a named event at the current clock time."""
|
|
173
|
+
self.events.append(
|
|
174
|
+
SpanEvent(
|
|
175
|
+
name=name,
|
|
176
|
+
timestamp=get_time(),
|
|
177
|
+
attributes=attributes or {},
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def set_attribute(self, key: str, value: _AttrValue) -> None:
|
|
182
|
+
"""Set or overwrite a single attribute."""
|
|
183
|
+
self.attributes[key] = value
|
|
184
|
+
|
|
185
|
+
def record_exception(
|
|
186
|
+
self, exc: BaseException, status: SpanStatus = SpanStatus.ERROR
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Capture an exception as a SpanEvent and mark the span's status.
|
|
189
|
+
|
|
190
|
+
Follows OpenTelemetry's exception semantic conventions so downstream
|
|
191
|
+
exporters (e.g. the OTLP exporter) can surface the stack trace.
|
|
192
|
+
|
|
193
|
+
``status`` defaults to ``SpanStatus.ERROR`` (the historical
|
|
194
|
+
behavior). Pass ``status=SpanStatus.CANCELLED`` for a span ended by
|
|
195
|
+
run/task cancellation so a reader of the trace can tell "this
|
|
196
|
+
failed" apart from "this was cut off mid-flight".
|
|
197
|
+
"""
|
|
198
|
+
attributes: dict[str, _AttrValue] = {
|
|
199
|
+
"exception.type": type(exc).__qualname__,
|
|
200
|
+
"exception.message": str(exc),
|
|
201
|
+
"exception.stacktrace": "".join(
|
|
202
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
203
|
+
),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# requests.exceptions.HTTPError / httpx.HTTPStatusError / similar
|
|
207
|
+
# HTTP-error-carrying exceptions stringify to a generic one-liner
|
|
208
|
+
# (e.g. "400 Client Error: None for url: ...") that omits the
|
|
209
|
+
# actual provider/proxy error body — attach it explicitly so the
|
|
210
|
+
# span view alone is enough to root-cause the failure, without
|
|
211
|
+
# falling back to manually grepping fixture.db.
|
|
212
|
+
response_text, status_code = _http_error_response_body(exc)
|
|
213
|
+
if response_text is not None:
|
|
214
|
+
attributes["exception.http_response_body"] = response_text
|
|
215
|
+
if status_code is not None:
|
|
216
|
+
attributes["exception.http_status_code"] = status_code
|
|
217
|
+
|
|
218
|
+
self.add_event("exception", attributes=attributes)
|
|
219
|
+
self.status = status
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Computed properties
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def duration_ms(self) -> float | None:
|
|
227
|
+
"""Wall duration in milliseconds, or None if the span is still open."""
|
|
228
|
+
if self.end_time is None:
|
|
229
|
+
return None
|
|
230
|
+
return (self.end_time - self.start_time) * 1_000
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
# Serialisation — all primitives, no datetime objects
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict[str, Any]:
|
|
237
|
+
"""Serialise to a JSON-safe dict.
|
|
238
|
+
|
|
239
|
+
Enums are stored by value so the output is readable without importing
|
|
240
|
+
this module. Timestamps are plain floats (Unix seconds) to avoid
|
|
241
|
+
timezone ambiguity and datetime import overhead.
|
|
242
|
+
"""
|
|
243
|
+
return {
|
|
244
|
+
"span_id": self.span_id,
|
|
245
|
+
"trace_id": self.trace_id,
|
|
246
|
+
"parent_id": self.parent_id,
|
|
247
|
+
"name": self.name,
|
|
248
|
+
"start_time": self.start_time,
|
|
249
|
+
"end_time": self.end_time,
|
|
250
|
+
"status": self.status.value,
|
|
251
|
+
"attributes": dict(self.attributes),
|
|
252
|
+
"events": [e.to_dict() for e in self.events],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def from_dict(cls, data: dict[str, Any]) -> Span:
|
|
257
|
+
"""Deserialise from a dict produced by to_dict()."""
|
|
258
|
+
events = [SpanEvent.from_dict(e) for e in data.get("events", [])]
|
|
259
|
+
return cls(
|
|
260
|
+
span_id=str(data["span_id"]),
|
|
261
|
+
trace_id=str(data["trace_id"]),
|
|
262
|
+
parent_id=data.get("parent_id"),
|
|
263
|
+
name=str(data["name"]),
|
|
264
|
+
start_time=float(data["start_time"]),
|
|
265
|
+
end_time=float(data["end_time"])
|
|
266
|
+
if data.get("end_time") is not None
|
|
267
|
+
else None,
|
|
268
|
+
status=SpanStatus(data.get("status", SpanStatus.UNSET.value)),
|
|
269
|
+
attributes=dict(data.get("attributes", {})),
|
|
270
|
+
events=events,
|
|
271
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trace — a collection of Spans sharing a single trace_id.
|
|
3
|
+
|
|
4
|
+
A Trace is the top-level container returned by the tracer and persisted to
|
|
5
|
+
disk. It intentionally holds no locks or I/O handles; thread-safety is the
|
|
6
|
+
responsibility of the Tracer that creates and mutates it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from agent_trace.core.span import Span, _AttrValue
|
|
16
|
+
|
|
17
|
+
__all__ = ["Trace"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Trace:
|
|
22
|
+
"""Ordered collection of Spans that belong to one logical agent run.
|
|
23
|
+
|
|
24
|
+
``run_id`` identifies the *execution* (re-running the same code produces
|
|
25
|
+
a new run_id) while ``trace_id`` identifies the *trace structure* — the
|
|
26
|
+
same fixture can be replayed many times with different run_ids but the
|
|
27
|
+
same trace_id so that tooling can diff runs.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
31
|
+
run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
32
|
+
spans: list[Span] = field(default_factory=list)
|
|
33
|
+
metadata: dict[str, _AttrValue] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Mutation helpers
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def add_span(self, span: Span) -> None:
|
|
40
|
+
"""Append *span* to this trace, enforcing trace_id consistency.
|
|
41
|
+
|
|
42
|
+
If the span carries a different trace_id it means it was created
|
|
43
|
+
before the trace existed (e.g. during library initialisation). We
|
|
44
|
+
silently correct the mismatch so callers don't need to thread
|
|
45
|
+
trace_id through every call-site.
|
|
46
|
+
"""
|
|
47
|
+
if span.trace_id != self.trace_id:
|
|
48
|
+
span.trace_id = self.trace_id
|
|
49
|
+
self.spans.append(span)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Queries
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def get_span(self, span_id: str) -> Span | None:
|
|
56
|
+
"""Return the first span whose span_id matches, or None."""
|
|
57
|
+
for span in self.spans:
|
|
58
|
+
if span.span_id == span_id:
|
|
59
|
+
return span
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def root_spans(self) -> list[Span]:
|
|
63
|
+
"""Return spans that have no parent (top-level operations)."""
|
|
64
|
+
return [s for s in self.spans if s.parent_id is None]
|
|
65
|
+
|
|
66
|
+
def children_of(self, span_id: str) -> list[Span]:
|
|
67
|
+
"""Return all direct children of *span_id* in insertion order."""
|
|
68
|
+
return [s for s in self.spans if s.parent_id == span_id]
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Serialisation
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
"""Serialise to a JSON-safe dict (no datetime objects, enums by value)."""
|
|
76
|
+
return {
|
|
77
|
+
"trace_id": self.trace_id,
|
|
78
|
+
"run_id": self.run_id,
|
|
79
|
+
"spans": [s.to_dict() for s in self.spans],
|
|
80
|
+
"metadata": dict(self.metadata),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict[str, Any]) -> Trace:
|
|
85
|
+
"""Deserialise from a dict produced by to_dict()."""
|
|
86
|
+
spans = [Span.from_dict(s) for s in data.get("spans", [])]
|
|
87
|
+
return cls(
|
|
88
|
+
trace_id=str(data["trace_id"]),
|
|
89
|
+
run_id=str(data["run_id"]),
|
|
90
|
+
spans=spans,
|
|
91
|
+
metadata={k: v for k, v in data.get("metadata", {}).items()},
|
|
92
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File exporter — writes traces as JSON or JSONL files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from agent_trace import Trace
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"FileExporter",
|
|
16
|
+
"export",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileExporter:
|
|
21
|
+
"""Write a :class:`~agent_trace.Trace` to disk as JSON or JSONL.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
output_dir:
|
|
26
|
+
Directory where trace files are written. Created if it does not exist.
|
|
27
|
+
format:
|
|
28
|
+
``"json"`` writes a single pretty-printed JSON file with the full
|
|
29
|
+
trace structure. ``"jsonl"`` writes one span dict per line.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
output_dir: Path,
|
|
35
|
+
format: Literal["json", "jsonl"] = "json",
|
|
36
|
+
) -> None:
|
|
37
|
+
self.output_dir: Path = Path(output_dir)
|
|
38
|
+
self.format: Literal["json", "jsonl"] = format
|
|
39
|
+
|
|
40
|
+
def export(self, trace: Trace) -> Path:
|
|
41
|
+
"""Write *trace* to ``output_dir/{run_id}.{format}`` and return the path."""
|
|
42
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
base = self.output_dir.resolve()
|
|
44
|
+
out_path = (base / f"{trace.run_id}.{self.format}").resolve()
|
|
45
|
+
try:
|
|
46
|
+
out_path.relative_to(base)
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Invalid run_id {trace.run_id!r}: path traversal detected"
|
|
50
|
+
) from None
|
|
51
|
+
|
|
52
|
+
if self.format == "jsonl":
|
|
53
|
+
lines = [
|
|
54
|
+
json.dumps(span.to_dict(), separators=(",", ":"))
|
|
55
|
+
for span in trace.spans
|
|
56
|
+
]
|
|
57
|
+
out_path.write_text(
|
|
58
|
+
"\n".join(lines) + ("\n" if lines else ""), encoding="utf-8"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
out_path.write_text(json.dumps(trace.to_dict(), indent=2), encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
return out_path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Module-level convenience
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def export(
|
|
72
|
+
trace: Trace,
|
|
73
|
+
output_dir: Path | None = None,
|
|
74
|
+
) -> Path:
|
|
75
|
+
"""Export *trace* as JSON to *output_dir* (default: current directory).
|
|
76
|
+
|
|
77
|
+
Returns the path of the written file.
|
|
78
|
+
"""
|
|
79
|
+
effective_dir = output_dir or Path.cwd()
|
|
80
|
+
return FileExporter(effective_dir).export(trace)
|