semantic-trace 0.1.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,78 @@
1
+ """agent-trace: Semantic tracing primitive for AI agents.
2
+
3
+ Minimal, composable, and zero-bloat by design.
4
+
5
+ Quick start:
6
+ from agent_trace import Trace, IntentInvariant, InvariantType, semantic_replay
7
+
8
+ invariants = [
9
+ IntentInvariant(
10
+ id="valid-json",
11
+ description="Output must be valid JSON",
12
+ invariant_type=InvariantType.SUBSTRING_CHECK,
13
+ config={"substring": '"summary"'},
14
+ fidelity_threshold=1.0,
15
+ ),
16
+ ]
17
+
18
+ with Trace(name="my-agent", invariants=invariants, output_file="traces/run.jsonl") as trace:
19
+ trace.add_span(span)
20
+
21
+ report = semantic_replay("traces/run.jsonl")
22
+ print(report.summary())
23
+ """
24
+
25
+ from agent_trace.core.schema import (
26
+ ActionType,
27
+ IntentInvariant,
28
+ InvariantResult,
29
+ InvariantType,
30
+ ReplayReport,
31
+ Span,
32
+ Trace,
33
+ TraceMetadata,
34
+ TraceModel,
35
+ )
36
+ from agent_trace.core.serializer import (
37
+ read_trace_from_jsonl,
38
+ write_metadata_to_jsonl,
39
+ write_span_to_jsonl,
40
+ )
41
+ from agent_trace.engine.invariants import (
42
+ BaseInvariantChecker,
43
+ InvariantViolation,
44
+ LLMAsJudgeChecker,
45
+ SchemaInvariantChecker,
46
+ SubstringInvariantChecker,
47
+ )
48
+ from agent_trace.engine.replay import (
49
+ mechanical_replay,
50
+ semantic_replay,
51
+ validate_trace,
52
+ )
53
+
54
+ __version__ = "0.1.0"
55
+
56
+ __all__ = [
57
+ "ActionType",
58
+ "BaseInvariantChecker",
59
+ "IntentInvariant",
60
+ "InvariantResult",
61
+ "InvariantType",
62
+ "InvariantViolation",
63
+ "LLMAsJudgeChecker",
64
+ "ReplayReport",
65
+ "SchemaInvariantChecker",
66
+ "Span",
67
+ "SubstringInvariantChecker",
68
+ "Trace",
69
+ "TraceMetadata",
70
+ "TraceModel",
71
+ "__version__",
72
+ "mechanical_replay",
73
+ "read_trace_from_jsonl",
74
+ "semantic_replay",
75
+ "validate_trace",
76
+ "write_metadata_to_jsonl",
77
+ "write_span_to_jsonl",
78
+ ]
agent_trace/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """CLI entry point for inspecting and validating trace files.
2
+
3
+ Usage:
4
+ trace <command> <trace_file> [options]
5
+
6
+ Commands:
7
+ info Show trace metadata summary
8
+ validate Run mechanical (structural) validation
9
+ replay Run full semantic replay with invariant checks
10
+ spans List all spans with their durations and invariant results
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from agent_trace.core.serializer import read_trace_from_jsonl
21
+ from agent_trace.engine.replay import mechanical_replay, validate_trace
22
+
23
+
24
+ def cmd_info(args: argparse.Namespace) -> None:
25
+ """Print trace metadata summary to stdout."""
26
+ trace = read_trace_from_jsonl(args.trace_file)
27
+ if args.json:
28
+ print(json.dumps(trace.metadata.model_dump(mode="json"), indent=2))
29
+ return
30
+
31
+ print(f"Trace ID: {trace.metadata.trace_id}")
32
+ print(f"Session ID: {trace.metadata.session_id}")
33
+ print(f"Agent: {trace.metadata.agent_name}")
34
+ print(f"Start: {trace.metadata.start_time.isoformat()}")
35
+ end = trace.metadata.end_time.isoformat() if trace.metadata.end_time else "N/A"
36
+ print(f"End: {end}")
37
+ print(f"Spans: {len(trace.spans)}")
38
+
39
+
40
+ def cmd_validate(args: argparse.Namespace) -> None:
41
+ """Run mechanical validation and exit non-zero on errors."""
42
+ errors = mechanical_replay(args.trace_file)
43
+ if args.json:
44
+ print(json.dumps({"valid": len(errors) == 0, "errors": errors}, indent=2))
45
+ if errors:
46
+ sys.exit(1)
47
+ return
48
+
49
+ if errors:
50
+ print("Structural errors:")
51
+ for err in errors:
52
+ print(f" - {err}")
53
+ sys.exit(1)
54
+ print("Structural validation passed.")
55
+
56
+
57
+ def cmd_replay(args: argparse.Namespace) -> None:
58
+ """Run full semantic replay and exit non-zero on violations."""
59
+ report = validate_trace(args.trace_file)
60
+
61
+ if args.json:
62
+ output = {
63
+ "clean": report.is_clean,
64
+ "trace_id": str(report.trace_id),
65
+ "agent_name": report.agent_name,
66
+ "total_spans": report.total_spans,
67
+ "total_invariants": report.total_invariants,
68
+ "pass_rate": report.pass_rate,
69
+ "structural_errors": report.structural_errors,
70
+ "violations": [
71
+ {
72
+ "span_id": v.span_id,
73
+ "invariant_id": v.invariant_id,
74
+ "expected_score": v.expected_score,
75
+ "actual_score": v.actual_score,
76
+ }
77
+ for v in report.violations
78
+ ],
79
+ }
80
+ print(json.dumps(output, indent=2))
81
+ if not report.is_clean:
82
+ sys.exit(1)
83
+ return
84
+
85
+ print(report.summary())
86
+ if not report.is_clean:
87
+ report.print_violations()
88
+ sys.exit(1)
89
+
90
+
91
+ def cmd_spans(args: argparse.Namespace) -> None:
92
+ """List all spans with action type, UUID, duration, and invariant results."""
93
+ trace = read_trace_from_jsonl(args.trace_file)
94
+
95
+ if args.json:
96
+ spans = []
97
+ for span in trace.spans:
98
+ span_data = span.model_dump(mode="json")
99
+ spans.append(span_data)
100
+ print(json.dumps(spans, indent=2))
101
+ return
102
+
103
+ for span in trace.spans:
104
+ if span.duration_ms is not None:
105
+ print(f"[{span.action_type}] {span.span_id} ({span.duration_ms:.0f}ms)")
106
+ else:
107
+ print(f"[{span.action_type}] {span.span_id}")
108
+ if span.invariant_results:
109
+ for inv_id, score in span.invariant_results.items():
110
+ status = "PASS" if score >= 1.0 else "FAIL"
111
+ print(f" {inv_id}: {score:.2f} [{status}]")
112
+
113
+
114
+ def main() -> None:
115
+ """CLI entry point. Parses arguments and dispatches to the appropriate command."""
116
+ parser = argparse.ArgumentParser(
117
+ prog="trace",
118
+ description="Semantic tracing CLI for AI agents. Inspect, validate, and replay trace files.",
119
+ )
120
+ parser.add_argument(
121
+ "--version",
122
+ action="version",
123
+ version="agent-trace 0.1.0",
124
+ )
125
+
126
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
127
+
128
+ for name, help_text, handler in [
129
+ ("info", "Show trace metadata summary", cmd_info),
130
+ ("validate", "Run structural validation", cmd_validate),
131
+ ("replay", "Run full semantic replay", cmd_replay),
132
+ ("spans", "List all spans", cmd_spans),
133
+ ]:
134
+ sub = subparsers.add_parser(name, help=help_text)
135
+ sub.add_argument("trace_file", type=str, help="Path to the JSONL trace file")
136
+ sub.add_argument(
137
+ "--json",
138
+ action="store_true",
139
+ help="Output in JSON format for machine consumption",
140
+ )
141
+ sub.set_defaults(handler=handler)
142
+
143
+ args = parser.parse_args()
144
+
145
+ if args.command is None:
146
+ parser.print_help()
147
+ sys.exit(1)
148
+
149
+ trace_path = Path(args.trace_file)
150
+ if not trace_path.exists():
151
+ print(f"Error: file not found: {args.trace_file}", file=sys.stderr)
152
+ sys.exit(1)
153
+
154
+ args.handler(args)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1,39 @@
1
+ """Core data models and serialization for agent-trace.
2
+
3
+ Provides Pydantic models (TraceModel, Span, IntentInvariant), the Trace
4
+ context manager, and JSONL serialization utilities.
5
+ """
6
+
7
+ from agent_trace.core.schema import (
8
+ ActionType,
9
+ IntentInvariant,
10
+ InvariantResult,
11
+ InvariantType,
12
+ ReplayReport,
13
+ Span,
14
+ Trace,
15
+ TraceMetadata,
16
+ TraceModel,
17
+ )
18
+ from agent_trace.core.serializer import (
19
+ read_trace_from_jsonl,
20
+ write_metadata_to_jsonl,
21
+ write_span_to_jsonl,
22
+ )
23
+
24
+ __all__ = [
25
+ # Models
26
+ "ActionType",
27
+ "IntentInvariant",
28
+ "InvariantResult",
29
+ "InvariantType",
30
+ "ReplayReport",
31
+ "Span",
32
+ "Trace",
33
+ "TraceMetadata",
34
+ "TraceModel",
35
+ # Serialization
36
+ "read_trace_from_jsonl",
37
+ "write_metadata_to_jsonl",
38
+ "write_span_to_jsonl",
39
+ ]
@@ -0,0 +1,401 @@
1
+ """Pydantic v2 data models and Trace context manager for agent-trace.
2
+
3
+ All models use strict typing and Pydantic v2 validation. No mutable defaults.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ if TYPE_CHECKING:
18
+ from agent_trace.engine.invariants import InvariantViolation
19
+
20
+
21
+ class InvariantType(str, Enum):
22
+ """Enumeration of built-in invariant checker types.
23
+
24
+ Attributes:
25
+ SCHEMA_MATCH: Validates output against a Pydantic-compatible schema.
26
+ SUBSTRING_CHECK: Checks for a target substring in the JSON-serialized output.
27
+ LLM_AS_JUDGE: Uses an LLM to semantically evaluate the output.
28
+ CUSTOM: Placeholder for user-defined checkers via the ABC.
29
+ """
30
+
31
+ SCHEMA_MATCH = "SCHEMA_MATCH"
32
+ SUBSTRING_CHECK = "SUBSTRING_CHECK"
33
+ LLM_AS_JUDGE = "LLM_AS_JUDGE"
34
+ CUSTOM = "CUSTOM"
35
+
36
+
37
+ class ActionType(str, Enum):
38
+ """Enumeration of span action types.
39
+
40
+ Attributes:
41
+ LLM_CALL: A call to a language model.
42
+ TOOL_CALL: A call to an external tool.
43
+ AGENT_STEP: A high-level agent reasoning step.
44
+ CUSTOM: A user-defined action type.
45
+ """
46
+
47
+ LLM_CALL = "llm_call"
48
+ TOOL_CALL = "tool_call"
49
+ AGENT_STEP = "agent_step"
50
+ CUSTOM = "custom"
51
+
52
+
53
+ class TraceMetadata(BaseModel):
54
+ """Metadata header for a trace.
55
+
56
+ Written as the first line of every JSONL trace file.
57
+
58
+ Attributes:
59
+ trace_id: Unique identifier for this trace.
60
+ session_id: Identifier for the agent session that produced this trace.
61
+ agent_name: Human-readable name of the agent.
62
+ start_time: UTC timestamp when the trace was created.
63
+ end_time: UTC timestamp when the trace was finalized. None until closed.
64
+ """
65
+
66
+ trace_id: uuid.UUID = Field(default_factory=uuid.uuid4)
67
+ session_id: str
68
+ agent_name: str
69
+ start_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
70
+ end_time: datetime | None = None
71
+
72
+
73
+ class IntentInvariant(BaseModel):
74
+ """A declarative intent assertion attached to a span.
75
+
76
+ Invariants express what the span's output *should* satisfy. They are
77
+ evaluated during semantic replay.
78
+
79
+ Attributes:
80
+ id: Unique identifier for this invariant.
81
+ description: Human-readable description of the intent.
82
+ invariant_type: The checker type to use.
83
+ config: Rule parameters passed to the checker (e.g., ``{"substring": "..."}``).
84
+ fidelity_threshold: Minimum score (0.0-1.0) for the check to pass.
85
+ """
86
+
87
+ id: str
88
+ description: str
89
+ invariant_type: InvariantType
90
+ config: dict[str, Any] = Field(default_factory=dict)
91
+ fidelity_threshold: float = Field(ge=0.0, le=1.0, default=1.0)
92
+
93
+
94
+ class Span(BaseModel):
95
+ """A single unit of execution within a trace.
96
+
97
+ Each span represents one atomic action (LLM call, tool invocation, etc.)
98
+ and may carry attached invariants for later verification.
99
+
100
+ Attributes:
101
+ span_id: Unique identifier for this span.
102
+ parent_id: Optional parent span UUID for nested execution.
103
+ trace_id: The trace this span belongs to.
104
+ timestamp: UTC timestamp when the span started.
105
+ action_type: The kind of action this span represents.
106
+ input_data: The input payload for the action.
107
+ output_data: The output payload produced by the action.
108
+ duration_ms: Execution duration in milliseconds, if available.
109
+ attached_invariants: Intent invariants to verify during replay.
110
+ invariant_results: Post-execution scores, populated by semantic replay.
111
+ """
112
+
113
+ span_id: uuid.UUID = Field(default_factory=uuid.uuid4)
114
+ parent_id: uuid.UUID | None = None
115
+ trace_id: uuid.UUID
116
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
117
+ action_type: ActionType
118
+ input_data: dict[str, Any] = Field(default_factory=dict)
119
+ output_data: dict[str, Any] = Field(default_factory=dict)
120
+ duration_ms: float | None = None
121
+ attached_invariants: list[IntentInvariant] = Field(default_factory=list)
122
+ invariant_results: dict[str, float] | None = None
123
+
124
+
125
+ class TraceModel(BaseModel):
126
+ """A complete trace consisting of metadata and ordered spans.
127
+
128
+ This is the core Pydantic model. For the high-level context manager API,
129
+ use ``Trace`` instead.
130
+
131
+ Attributes:
132
+ metadata: The trace header with session and agent information.
133
+ spans: Ordered list of spans captured during execution.
134
+ """
135
+
136
+ metadata: TraceMetadata
137
+ spans: list[Span] = Field(default_factory=list)
138
+
139
+
140
+ @dataclass
141
+ class InvariantResult:
142
+ """Result of a single invariant check during replay.
143
+
144
+ Attributes:
145
+ invariant_id: Identifier of the invariant that was checked.
146
+ span_id: UUID of the span that was evaluated.
147
+ score: The actual score returned by the checker (0.0-1.0).
148
+ threshold: The minimum score required for the check to pass.
149
+ passed: Whether the score meets or exceeds the threshold.
150
+ """
151
+
152
+ invariant_id: str
153
+ span_id: str
154
+ score: float
155
+ threshold: float
156
+ passed: bool
157
+
158
+
159
+ @dataclass
160
+ class ReplayReport:
161
+ """Comprehensive report from semantic or mechanical replay.
162
+
163
+ Provides summary statistics, violation details, and a human-readable
164
+ summary string. Iterable over violations for backward compatibility.
165
+
166
+ Attributes:
167
+ trace_file: Path to the trace file that was replayed.
168
+ trace_id: UUID of the trace.
169
+ agent_name: Name of the agent that produced the trace.
170
+ total_spans: Number of spans in the trace.
171
+ total_invariants: Total number of invariant checks performed.
172
+ violations: List of invariant violations (score below threshold).
173
+ results: All invariant check results (passing and failing).
174
+ structural_errors: Structural errors from mechanical validation.
175
+ """
176
+
177
+ trace_file: Path
178
+ trace_id: uuid.UUID
179
+ agent_name: str
180
+ total_spans: int
181
+ total_invariants: int
182
+ violations: list[InvariantViolation]
183
+ results: list[InvariantResult]
184
+ structural_errors: list[str] = field(default_factory=list)
185
+
186
+ def __iter__(self):
187
+ return iter(self.violations)
188
+
189
+ def __len__(self) -> int:
190
+ return len(self.violations)
191
+
192
+ def __getitem__(self, index: int):
193
+ return self.violations[index]
194
+
195
+ def __bool__(self) -> bool:
196
+ return bool(self.violations) or bool(self.structural_errors)
197
+
198
+ @property
199
+ def is_clean(self) -> bool:
200
+ """True if no violations and no structural errors."""
201
+ return not self.violations and not self.structural_errors
202
+
203
+ @property
204
+ def pass_rate(self) -> float:
205
+ """Fraction of invariant checks that passed."""
206
+ if self.total_invariants == 0:
207
+ return 1.0
208
+ passed = sum(1 for r in self.results if r.passed)
209
+ return passed / self.total_invariants
210
+
211
+ def summary(self) -> str:
212
+ """Return a human-readable summary string."""
213
+ lines = [
214
+ f"Replay Report: {self.agent_name}",
215
+ f" Trace: {self.trace_id}",
216
+ f" File: {self.trace_file}",
217
+ f" Spans: {self.total_spans}",
218
+ f" Invariants checked: {self.total_invariants}",
219
+ f" Pass rate: {self.pass_rate:.0%}",
220
+ ]
221
+ if self.structural_errors:
222
+ lines.append(f" Structural errors: {len(self.structural_errors)}")
223
+ if self.violations:
224
+ lines.append(f" Violations: {len(self.violations)}")
225
+ else:
226
+ lines.append(" Status: ALL CLEAR")
227
+ return "\n".join(lines)
228
+
229
+ def print_violations(self) -> None:
230
+ """Print all violations and structural errors to stdout."""
231
+ if not self.violations and not self.structural_errors:
232
+ print("No violations found.")
233
+ return
234
+
235
+ if self.structural_errors:
236
+ print("Structural errors:")
237
+ for err in self.structural_errors:
238
+ print(f" - {err}")
239
+
240
+ if self.violations:
241
+ print("Invariant violations:")
242
+ for v in self.violations:
243
+ print(
244
+ f" Span {v.span_id}: invariant {v.invariant_id} "
245
+ f"(expected>={v.expected_score:.2f}, got {v.actual_score:.2f})"
246
+ )
247
+
248
+
249
+ class Trace:
250
+ """Context manager for capturing agent traces.
251
+
252
+ Creates a trace header, collects spans, and writes to a JSONL file
253
+ in real-time. On exit, finalizes the trace with an end timestamp.
254
+
255
+ Usage:
256
+ with Trace(name="my-agent", output_file="traces/run.jsonl") as trace:
257
+ trace.add_span(span)
258
+
259
+ Or with default invariants attached to every span:
260
+ invariants = [IntentInvariant(...)]
261
+ with Trace(name="my-agent", invariants=invariants, output_file="traces/run.jsonl") as trace:
262
+ # spans automatically get the invariants
263
+ trace.add_span(span)
264
+
265
+ For backward compatibility, can also be initialized with a metadata object:
266
+ trace = Trace(metadata=TraceMetadata(...))
267
+ trace.add_span(span)
268
+ trace.save("traces/run.jsonl")
269
+
270
+ Args:
271
+ name: Human-readable name of the agent (required unless metadata is provided).
272
+ metadata: Pre-built TraceMetadata (alternative to name).
273
+ invariants: Default invariants to attach to every span.
274
+ output_file: Path to the JSONL file for real-time streaming.
275
+ session_id: Identifier for the agent session. Auto-generated if omitted.
276
+ trace_id: Explicit trace UUID. Auto-generated if omitted.
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ name: str | None = None,
282
+ *,
283
+ metadata: TraceMetadata | None = None,
284
+ invariants: list[IntentInvariant] | None = None,
285
+ output_file: str | Path | None = None,
286
+ session_id: str | None = None,
287
+ trace_id: uuid.UUID | None = None,
288
+ ) -> None:
289
+ if name is not None and metadata is not None:
290
+ raise ValueError("Provide either 'name' or 'metadata', not both")
291
+
292
+ if metadata is not None:
293
+ self._model = TraceModel(metadata=metadata, spans=[])
294
+ self._output_file: Path | None = None
295
+ self._default_invariants: list[IntentInvariant] = []
296
+ elif name is not None:
297
+ self._model = TraceModel(
298
+ metadata=TraceMetadata(
299
+ trace_id=trace_id or uuid.uuid4(),
300
+ session_id=session_id or f"session-{uuid.uuid4().hex[:8]}",
301
+ agent_name=name,
302
+ ),
303
+ spans=[],
304
+ )
305
+ self._output_file = Path(output_file).resolve() if output_file else None
306
+ self._default_invariants = list(invariants) if invariants else []
307
+ else:
308
+ raise ValueError("Either 'name' or 'metadata' must be provided")
309
+
310
+ self._active = False
311
+
312
+ def __enter__(self) -> Trace:
313
+ if self._active:
314
+ raise RuntimeError("Trace is already active. Create a new instance.")
315
+ self._active = True
316
+ if self._output_file:
317
+ from agent_trace.core.serializer import write_metadata_to_jsonl
318
+
319
+ write_metadata_to_jsonl(self._output_file, self._model)
320
+ return self
321
+
322
+ def __exit__(self, *args: Any) -> bool:
323
+ self._active = False
324
+ self._model.metadata.end_time = datetime.now(timezone.utc)
325
+ if self._output_file:
326
+ from agent_trace.core.serializer import write_metadata_to_jsonl
327
+
328
+ write_metadata_to_jsonl(self._output_file, self._model)
329
+ return False
330
+
331
+ def __repr__(self) -> str:
332
+ status = "active" if self._active else "inactive"
333
+ return (
334
+ f"Trace(name={self._model.metadata.agent_name!r}, "
335
+ f"trace_id={self.trace_id!r}, "
336
+ f"spans={len(self.spans)}, "
337
+ f"status={status!r})"
338
+ )
339
+
340
+ @property
341
+ def model(self) -> TraceModel:
342
+ """The underlying TraceModel. Modifications affect the trace."""
343
+ return self._model
344
+
345
+ @property
346
+ def trace_id(self) -> uuid.UUID:
347
+ return self._model.metadata.trace_id
348
+
349
+ @property
350
+ def session_id(self) -> str:
351
+ return self._model.metadata.session_id
352
+
353
+ @property
354
+ def spans(self) -> list[Span]:
355
+ return list(self._model.spans)
356
+
357
+ def add_span(self, span: Span) -> None:
358
+ """Add a span to the trace.
359
+
360
+ Attaches default invariants and writes to the output file if
361
+ the context manager is active.
362
+
363
+ Args:
364
+ span: The span to add. Must have a matching trace_id.
365
+
366
+ Raises:
367
+ ValueError: If the span's trace_id does not match this trace.
368
+ """
369
+ if span.trace_id != self.trace_id:
370
+ raise ValueError(
371
+ f"Span trace_id {span.trace_id} does not match trace {self.trace_id}"
372
+ )
373
+ span.attached_invariants.extend(self._default_invariants)
374
+ self._model.spans.append(span)
375
+ if self._active and self._output_file:
376
+ from agent_trace.core.serializer import write_span_to_jsonl
377
+
378
+ write_span_to_jsonl(self._output_file, span)
379
+
380
+ def save(self, output_file: str | Path | None = None) -> None:
381
+ """Save the complete trace to a JSONL file.
382
+
383
+ Args:
384
+ output_file: Path to write. Uses the constructor value if omitted.
385
+
386
+ Raises:
387
+ ValueError: If no output file is specified.
388
+ """
389
+ from agent_trace.core.serializer import (
390
+ write_metadata_to_jsonl,
391
+ write_span_to_jsonl,
392
+ )
393
+
394
+ path = Path(output_file).resolve() if output_file else self._output_file
395
+ if not path:
396
+ raise ValueError("No output file specified")
397
+
398
+ self._model.metadata.end_time = datetime.now(timezone.utc)
399
+ write_metadata_to_jsonl(path, self._model)
400
+ for span in self._model.spans:
401
+ write_span_to_jsonl(path, span)