spanforge 2.0.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.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/debug.py ADDED
@@ -0,0 +1,514 @@
1
+ """spanforge.debug — Developer experience utilities for inspecting traces.
2
+
3
+ Provides three standalone functions (also wired as methods on
4
+ :class:`~spanforge._trace.Trace`):
5
+
6
+ - :func:`print_tree` — pretty-print a hierarchical span tree to stdout.
7
+ - :func:`summary` — return an aggregated statistics dict.
8
+ - :func:`visualize` — generate a self-contained HTML Gantt timeline.
9
+
10
+ All three accept either a list of
11
+ :class:`~spanforge.namespaces.trace.SpanPayload` objects (the *serialised*
12
+ form used for storage/export) or a list of
13
+ :class:`~spanforge._span.Span` objects (the *live* form held by a
14
+ :class:`~spanforge._trace.Trace`). Mixed lists are not supported.
15
+
16
+ Usage::
17
+
18
+ from spanforge import start_trace, print_tree, summary, visualize
19
+
20
+ with start_trace("research-agent") as trace:
21
+ ...
22
+
23
+ # After the trace ends its spans are collected internally:
24
+ trace.print_tree()
25
+ stats = trace.summary()
26
+ html = trace.visualize()
27
+
28
+ # Or pass raw spans from a JSONL file:
29
+ from spanforge.stream import iter_file
30
+ from spanforge.namespaces.trace import SpanPayload
31
+ spans = [SpanPayload.from_dict(e.payload) for e in iter_file("events.jsonl")]
32
+ print_tree(spans)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import html as _html_mod
39
+ from collections import defaultdict
40
+ from typing import TYPE_CHECKING, Any, Sequence, Union
41
+
42
+ if TYPE_CHECKING:
43
+ from spanforge._span import Span
44
+ from spanforge.namespaces.trace import SpanPayload
45
+
46
+ __all__ = ["print_tree", "summary", "visualize"]
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Type helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+ # Union accepted by all public functions.
53
+ _SpanLike = Union["SpanPayload", "Span"]
54
+
55
+
56
+ def _to_payload(span: _SpanLike) -> "SpanPayload":
57
+ """Coerce a live *Span* to a *SpanPayload* so we always work with one type."""
58
+ from spanforge.namespaces.trace import SpanPayload # noqa: PLC0415
59
+
60
+ if isinstance(span, SpanPayload):
61
+ return span
62
+ # Assume Span (live)
63
+ return span.to_span_payload()
64
+
65
+
66
+ def _coerce(spans: Sequence[_SpanLike]) -> list["SpanPayload"]:
67
+ return [_to_payload(s) for s in spans]
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Colour helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ _ANSI_GREEN = "\033[92m"
75
+ _ANSI_YELLOW = "\033[93m"
76
+ _ANSI_RED = "\033[91m"
77
+ _ANSI_RESET = "\033[0m"
78
+ _ANSI_BOLD = "\033[1m"
79
+ _ANSI_DIM = "\033[2m"
80
+
81
+
82
+ def _no_color() -> bool:
83
+ """Return True when colour output should be suppressed."""
84
+ return bool(os.environ.get("NO_COLOR") or os.environ.get("SPANFORGE_NO_COLOR"))
85
+
86
+
87
+ def _color(text: str, code: str) -> str:
88
+ if _no_color():
89
+ return text
90
+ return f"{code}{text}{_ANSI_RESET}"
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # print_tree()
95
+ # ---------------------------------------------------------------------------
96
+
97
+ # Box-drawing characters for the tree lines.
98
+ _BRANCH = "├─ "
99
+ _LAST = "└─ "
100
+ _PIPE = "│ "
101
+ _SPACE = " "
102
+
103
+
104
+ def _status_badge(status: str) -> str:
105
+ if status == "ok":
106
+ return _color("ok", _ANSI_GREEN)
107
+ if status == "error":
108
+ return _color("error", _ANSI_RED)
109
+ if status == "timeout":
110
+ return _color("timeout", _ANSI_YELLOW)
111
+ return status
112
+
113
+
114
+ def _make_model_str(p: "SpanPayload") -> str:
115
+ if p.model is None:
116
+ return ""
117
+ model_name = getattr(p.model, "name", None) or str(p.model)
118
+ return f" [{model_name}]"
119
+
120
+
121
+ def _make_token_str(p: "SpanPayload") -> str:
122
+ if p.token_usage is None:
123
+ return ""
124
+ inp = getattr(p.token_usage, "input_tokens", None) or getattr(p.token_usage, "prompt_tokens", None) or 0
125
+ out = getattr(p.token_usage, "output_tokens", None) or getattr(p.token_usage, "completion_tokens", None) or 0
126
+ return f" in={inp} out={out} tok" if (inp or out) else ""
127
+
128
+
129
+ def _make_cost_str(p: "SpanPayload") -> str:
130
+ if p.cost is None:
131
+ return ""
132
+ total = getattr(p.cost, "total_cost_usd", None) or 0.0
133
+ return f" ${total:.4f}" if total else ""
134
+
135
+
136
+ def _span_label(p: "SpanPayload") -> str:
137
+ """Build the single-line description of a span used in the tree."""
138
+ model_str = _make_model_str(p)
139
+ dur = f" {p.duration_ms:.0f}ms" if p.duration_ms else ""
140
+ token_str = _make_token_str(p)
141
+ cost_str = _make_cost_str(p)
142
+
143
+ error_str = ""
144
+ if p.error:
145
+ err_short = p.error[:40] + ("…" if len(p.error) > 40 else "")
146
+ error_str = f" {_color(err_short, _ANSI_RED)}"
147
+
148
+ events_str = ""
149
+ if p.events:
150
+ events_str = f" [{len(p.events)} event{'s' if len(p.events) != 1 else ''}]"
151
+
152
+ badge = _status_badge(p.status)
153
+ name = _color(p.span_name, _ANSI_BOLD)
154
+ return f"{name}{model_str} {badge}{dur}{token_str}{cost_str}{events_str}{error_str}"
155
+
156
+
157
+ def _dfs_print(
158
+ span_id: str,
159
+ children: dict[str | None, list["SpanPayload"]],
160
+ payloads_by_id: dict[str, "SpanPayload"],
161
+ prefix: str,
162
+ is_last: bool,
163
+ lines: list[str],
164
+ ) -> None:
165
+ p = payloads_by_id[span_id]
166
+ connector = _LAST if is_last else _BRANCH
167
+ lines.append(prefix + connector + _span_label(p))
168
+ child_prefix = prefix + (_SPACE if is_last else _PIPE)
169
+ kids = children.get(span_id, [])
170
+ for i, child in enumerate(kids):
171
+ _dfs_print(
172
+ child.span_id,
173
+ children,
174
+ payloads_by_id,
175
+ child_prefix,
176
+ i == len(kids) - 1,
177
+ lines,
178
+ )
179
+
180
+
181
+ def print_tree(
182
+ spans: Sequence[_SpanLike],
183
+ *,
184
+ trace_id: str | None = None,
185
+ file: Any = None,
186
+ ) -> None:
187
+ """Pretty-print a hierarchical span tree.
188
+
189
+ Example output::
190
+
191
+ Agent Run: research-agent [2.4s]
192
+ ├─ llm_call:gpt-4o [gpt-4o] ok 1100ms in=512 out=200 tok $0.0031
193
+ ├─ tool_call:search ok 400ms
194
+ │ └─ tool_call:fetch_url ok 200ms
195
+ └─ llm_call:gpt-4o [gpt-4o] ok 900ms in=300 out=150 tok $0.0021
196
+
197
+ Args:
198
+ spans: Spans to render. All spans in the same trace are shown;
199
+ use *trace_id* to filter when *spans* contains multiple traces.
200
+ trace_id: Optional filter — show only spans with this trace ID.
201
+ file: Output file (default: ``sys.stdout``).
202
+ """
203
+ import sys # noqa: PLC0415
204
+
205
+ payloads = _coerce(spans)
206
+ if not payloads:
207
+ print("(no spans)", file=file or sys.stdout)
208
+ return
209
+
210
+ if trace_id is not None:
211
+ payloads = [p for p in payloads if p.trace_id == trace_id]
212
+ if not payloads:
213
+ print(f"(no spans for trace_id={trace_id!r})", file=file or sys.stdout)
214
+ return
215
+
216
+ # Sort by start time.
217
+ payloads = sorted(payloads, key=lambda p: p.start_time_unix_nano)
218
+
219
+ payloads_by_id: dict[str, "SpanPayload"] = {p.span_id: p for p in payloads}
220
+ children: dict[str | None, list["SpanPayload"]] = defaultdict(list)
221
+ for p in payloads:
222
+ children[p.parent_span_id].append(p)
223
+
224
+ # Roots are spans whose parent is either None or absent from this set.
225
+ roots = [p for p in payloads if p.parent_span_id not in payloads_by_id]
226
+
227
+ out = file or sys.stdout
228
+ lines: list[str] = []
229
+
230
+ # Print a header for each trace encountered.
231
+ traces_seen: set[str] = set()
232
+ root_groups: dict[str, list["SpanPayload"]] = defaultdict(list)
233
+ for r in roots:
234
+ root_groups[r.trace_id].append(r)
235
+
236
+ for tid, trace_roots in root_groups.items():
237
+ if tid not in traces_seen:
238
+ traces_seen.add(tid)
239
+ # Compute total trace duration from first start to last end.
240
+ trace_spans = [p for p in payloads if p.trace_id == tid]
241
+ total_ms = sum(p.duration_ms for p in trace_spans)
242
+ total_s = total_ms / 1000.0
243
+ header = _color(f"Trace {tid[:8]}… total≈{total_s:.1f}s", _ANSI_BOLD)
244
+ lines.append(header)
245
+ for i, root in enumerate(trace_roots):
246
+ _dfs_print(
247
+ root.span_id,
248
+ children,
249
+ payloads_by_id,
250
+ "",
251
+ i == len(trace_roots) - 1,
252
+ lines,
253
+ )
254
+
255
+ print("\n".join(lines), file=out)
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # summary()
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def _sum_token_usage(payloads: list["SpanPayload"]) -> tuple[int, int]:
264
+ """Sum input and output tokens across all payloads."""
265
+ total_in = total_out = 0
266
+ for p in payloads:
267
+ if p.token_usage is not None:
268
+ inp = getattr(p.token_usage, "input_tokens", None) or getattr(p.token_usage, "prompt_tokens", None) or 0
269
+ out = getattr(p.token_usage, "output_tokens", None) or getattr(p.token_usage, "completion_tokens", None) or 0
270
+ total_in += int(inp)
271
+ total_out += int(out)
272
+ return total_in, total_out
273
+
274
+
275
+ def _sum_costs(payloads: list["SpanPayload"]) -> float:
276
+ """Sum total_cost_usd across all payloads."""
277
+ total = 0.0
278
+ for p in payloads:
279
+ if p.cost is not None:
280
+ total += getattr(p.cost, "total_cost_usd", None) or 0.0
281
+ return total
282
+
283
+
284
+ def summary(spans: Sequence[_SpanLike]) -> dict[str, Any]:
285
+ """Return an aggregated statistics dict for the given spans.
286
+
287
+ Example::
288
+
289
+ {
290
+ "trace_id": "ab12cd34...",
291
+ "span_count": 4,
292
+ "llm_calls": 2,
293
+ "tool_calls": 1,
294
+ "total_duration_ms": 2400.0,
295
+ "total_input_tokens": 812,
296
+ "total_output_tokens": 350,
297
+ "total_cost_usd": 0.0052,
298
+ "error_count": 0,
299
+ "timeout_count": 0,
300
+ }
301
+
302
+ When *spans* cover multiple traces, per-trace values are not returned;
303
+ use :func:`print_tree` or filter before calling.
304
+
305
+ Args:
306
+ spans: Spans to summarise.
307
+
308
+ Returns:
309
+ A plain ``dict``. All numeric fields are 0 / 0.0 when no data
310
+ is available (never ``None``).
311
+ """
312
+ payloads = _coerce(spans)
313
+ if not payloads:
314
+ return {
315
+ "trace_id": None,
316
+ "span_count": 0,
317
+ "llm_calls": 0,
318
+ "tool_calls": 0,
319
+ "total_duration_ms": 0.0,
320
+ "total_input_tokens": 0,
321
+ "total_output_tokens": 0,
322
+ "total_cost_usd": 0.0,
323
+ "error_count": 0,
324
+ "timeout_count": 0,
325
+ }
326
+
327
+ trace_ids = {p.trace_id for p in payloads}
328
+ dominant_trace_id = payloads[0].trace_id if len(trace_ids) == 1 else None
329
+
330
+ llm_ops = {"chat", "text_completion", "embeddings", "image_generation", "invoke_agent", "create_agent", "reasoning"}
331
+ llm_calls = sum(
332
+ 1 for p in payloads
333
+ if (str(p.operation.value if hasattr(p.operation, "value") else p.operation)).lower() in llm_ops
334
+ )
335
+ tool_calls = sum(
336
+ 1 for p in payloads
337
+ if (str(p.operation.value if hasattr(p.operation, "value") else p.operation)).lower() == "execute_tool"
338
+ )
339
+ total_duration_ms = sum(p.duration_ms for p in payloads)
340
+
341
+ total_input_tokens, total_output_tokens = _sum_token_usage(payloads)
342
+ total_cost_usd = _sum_costs(payloads)
343
+
344
+ error_count = sum(1 for p in payloads if p.status == "error")
345
+ timeout_count = sum(1 for p in payloads if p.status == "timeout")
346
+
347
+ return {
348
+ "trace_id": dominant_trace_id,
349
+ "span_count": len(payloads),
350
+ "llm_calls": llm_calls,
351
+ "tool_calls": tool_calls,
352
+ "total_duration_ms": total_duration_ms,
353
+ "total_input_tokens": total_input_tokens,
354
+ "total_output_tokens": total_output_tokens,
355
+ "total_cost_usd": total_cost_usd,
356
+ "error_count": error_count,
357
+ "timeout_count": timeout_count,
358
+ }
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # visualize()
363
+ # ---------------------------------------------------------------------------
364
+
365
+ _HTML_TEMPLATE = """\
366
+ <!DOCTYPE html>
367
+ <html lang="en">
368
+ <head>
369
+ <meta charset="utf-8">
370
+ <title>SpanForge Trace Visualizer</title>
371
+ <style>
372
+ body {{ font-family: system-ui, sans-serif; background: #111; color: #eee;
373
+ margin: 0; padding: 16px; }}
374
+ h1 {{ font-size: 1.1rem; color: #aaa; margin: 0 0 12px; }}
375
+ .chart {{ position: relative; overflow-x: auto; }}
376
+ .row {{ display: flex; align-items: center; min-height: 28px;
377
+ border-bottom: 1px solid #222; }}
378
+ .row:hover {{ background: #1a1a1a; }}
379
+ .label {{ flex: 0 0 220px; font-size: 0.78rem; padding: 2px 8px 2px 0;
380
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
381
+ color: #ccc; }}
382
+ .label .model {{ color: #888; font-size: 0.72rem; }}
383
+ .bar-wrap {{ flex: 1 1 auto; position: relative; height: 18px; }}
384
+ .bar {{ position: absolute; height: 100%; border-radius: 3px;
385
+ display: flex; align-items: center; padding: 0 4px;
386
+ font-size: 0.68rem; white-space: nowrap; overflow: hidden;
387
+ box-sizing: border-box; }}
388
+ .bar.ok {{ background: #1f6f3a; color: #7effa4; }}
389
+ .bar.error {{ background: #7a1e1e; color: #ffaaaa; }}
390
+ .bar.timeout {{ background: #7a5c00; color: #ffe080; }}
391
+ .legend {{ margin-top: 14px; display: flex; gap: 16px; font-size: 0.76rem; }}
392
+ .leg-dot {{ width: 12px; height: 12px; border-radius: 2px; display: inline-block; margin-right: 4px; }}
393
+ .leg-ok {{ background: #1f6f3a; }}
394
+ .leg-err {{ background: #7a1e1e; }}
395
+ .leg-to {{ background: #7a5c00; }}
396
+ .stats {{ margin-top: 14px; font-size: 0.8rem; color: #aaa; }}
397
+ .stats b {{ color: #ddd; }}
398
+ </style>
399
+ </head>
400
+ <body>
401
+ <h1>SpanForge — Trace Visualizer</h1>
402
+ <div class="chart">
403
+ {rows}
404
+ </div>
405
+ <div class="legend">
406
+ <span><span class="leg-dot leg-ok"></span>ok</span>
407
+ <span><span class="leg-dot leg-err"></span>error</span>
408
+ <span><span class="leg-dot leg-to"></span>timeout</span>
409
+ </div>
410
+ <div class="stats">{stats}</div>
411
+ </body>
412
+ </html>
413
+ """
414
+
415
+
416
+ def _build_span_row_html(p: "SpanPayload", t_min: int, total_range: int) -> str:
417
+ """Build the HTML row string for a single span in the Gantt chart."""
418
+ left_pct = (p.start_time_unix_nano - t_min) / total_range * 100
419
+ width_pct = max((p.end_time_unix_nano - p.start_time_unix_nano) / total_range * 100, 0.3)
420
+ css_class = p.status if p.status in {"ok", "error", "timeout"} else "ok"
421
+
422
+ label_text = _html_mod.escape(p.span_name)
423
+ model_part = ""
424
+ if p.model is not None:
425
+ model_name = getattr(p.model, "name", None) or str(p.model)
426
+ model_part = f'<span class="model"> [{_html_mod.escape(str(model_name))}]</span>'
427
+
428
+ bar_label = f"{p.duration_ms:.0f}ms"
429
+ if p.token_usage is not None:
430
+ inp = getattr(p.token_usage, "input_tokens", None) or 0
431
+ out = getattr(p.token_usage, "output_tokens", None) or 0
432
+ if inp or out:
433
+ bar_label += f" in={inp} out={out}"
434
+
435
+ title_attr = _html_mod.escape(
436
+ f"{p.span_name} {p.status} {p.duration_ms:.1f}ms"
437
+ + (f" {p.error}" if p.error else "")
438
+ )
439
+ return (
440
+ f'<div class="row">'
441
+ f'<div class="label">{label_text}{model_part}</div>'
442
+ f'<div class="bar-wrap">'
443
+ f'<div class="bar {css_class}" title="{title_attr}" '
444
+ f'style="left:{left_pct:.3f}%;width:{width_pct:.3f}%">'
445
+ f'{_html_mod.escape(bar_label)}'
446
+ f'</div>'
447
+ f'</div>'
448
+ f'</div>'
449
+ )
450
+
451
+
452
+ def visualize(
453
+ spans: Sequence[_SpanLike],
454
+ *,
455
+ output: str = "html",
456
+ path: str | None = None,
457
+ ) -> str:
458
+ """Generate a self-contained HTML Gantt timeline for *spans*.
459
+
460
+ The output is pure HTML/CSS — no external dependencies, no JavaScript
461
+ required. Spans are rendered as proportionally-sized bars on a shared
462
+ timeline axis.
463
+
464
+ Args:
465
+ spans: Spans to visualise.
466
+ output: Currently only ``"html"`` is supported (reserved for future
467
+ formats such as ``"svg"``).
468
+ path: Optional file path. When provided the HTML is written to
469
+ this file **in addition to** being returned as a string.
470
+
471
+ Returns:
472
+ A self-contained HTML string.
473
+
474
+ Raises:
475
+ ValueError: If *output* is not ``"html"``.
476
+ """
477
+ if output != "html":
478
+ raise ValueError(f"visualize: unsupported output format {output!r}. Only 'html' is supported.")
479
+
480
+ payloads = _coerce(spans)
481
+ if not payloads:
482
+ html_out = _HTML_TEMPLATE.format(rows="<p style='color:#888'>No spans to display.</p>", stats="")
483
+ if path:
484
+ with open(path, "w", encoding="utf-8") as fh:
485
+ fh.write(html_out)
486
+ return html_out
487
+
488
+ payloads = sorted(payloads, key=lambda p: p.start_time_unix_nano)
489
+
490
+ t_min = payloads[0].start_time_unix_nano
491
+ t_max = max(p.end_time_unix_nano for p in payloads)
492
+ total_range = max(t_max - t_min, 1) # avoid divide-by-zero
493
+
494
+ rows_html = [_build_span_row_html(p, t_min, total_range) for p in payloads]
495
+
496
+ stats = summary(payloads)
497
+ stats_html = (
498
+ f"<b>{stats['span_count']}</b> spans "
499
+ f"<b>{stats['llm_calls']}</b> LLM calls "
500
+ f"<b>{stats['tool_calls']}</b> tool calls "
501
+ f"total <b>{stats['total_duration_ms']:.0f}ms</b> "
502
+ f"tokens in=<b>{stats['total_input_tokens']}</b> "
503
+ f"out=<b>{stats['total_output_tokens']}</b> "
504
+ f"cost <b>${stats['total_cost_usd']:.4f}</b> "
505
+ f"errors=<b>{stats['error_count']}</b>"
506
+ )
507
+
508
+ html_out = _HTML_TEMPLATE.format(rows="\n ".join(rows_html), stats=stats_html)
509
+
510
+ if path:
511
+ with open(path, "w", encoding="utf-8") as fh:
512
+ fh.write(html_out)
513
+
514
+ return html_out