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 @@
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"
@@ -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)