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