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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- 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
|