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/_server.py
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
"""spanforge._server — Local trace viewer HTTP server + embedded SPA.
|
|
2
|
+
|
|
3
|
+
Provides a JSON API and a single-page trace viewer over stdlib ``http.server``
|
|
4
|
+
for browsing traces captured in the in-process
|
|
5
|
+
:class:`~spanforge._store.TraceStore`. Launched via ``spanforge ui``
|
|
6
|
+
(opens browser) or ``spanforge serve`` (API only).
|
|
7
|
+
|
|
8
|
+
API endpoints
|
|
9
|
+
-------------
|
|
10
|
+
|
|
11
|
+
============================== =====================================
|
|
12
|
+
``GET /`` Embedded SPA trace viewer (HTML)
|
|
13
|
+
``GET /health`` Returns ``{"status": "ok"}``
|
|
14
|
+
``GET /ready`` Readiness check — 200 if store and
|
|
15
|
+
exporter are accessible, 503 otherwise.
|
|
16
|
+
Returns ``{"ready": true, "checks": {...}}``
|
|
17
|
+
``GET /traces`` Returns all trace IDs.
|
|
18
|
+
``GET /traces/{trace_id}`` Returns all events for a trace.
|
|
19
|
+
``GET /events`` Returns the most recent 200 events
|
|
20
|
+
across all traces (newest first).
|
|
21
|
+
``GET /compliance/summary`` Returns compliance summary across all
|
|
22
|
+
loaded frameworks (SOC2, HIPAA, etc.)
|
|
23
|
+
``GET /compliance/events`` Returns events filtered by ``?type=``
|
|
24
|
+
query parameter.
|
|
25
|
+
``GET /metrics`` Basic plaintext counters.
|
|
26
|
+
============================== =====================================
|
|
27
|
+
|
|
28
|
+
All responses are UTF-8 JSON. CORS headers are **not** sent by default;
|
|
29
|
+
pass ``cors_origins="*"`` (or a specific origin) to enable cross-origin
|
|
30
|
+
access for a local HTML/JS viewer.
|
|
31
|
+
|
|
32
|
+
Usage
|
|
33
|
+
-----
|
|
34
|
+
::
|
|
35
|
+
|
|
36
|
+
# Programmatic
|
|
37
|
+
from spanforge._server import TraceViewerServer
|
|
38
|
+
server = TraceViewerServer(port=8888)
|
|
39
|
+
server.start() # background daemon thread
|
|
40
|
+
# ...
|
|
41
|
+
server.stop()
|
|
42
|
+
|
|
43
|
+
# CLI
|
|
44
|
+
$ spanforge serve --port 8888
|
|
45
|
+
$ spanforge serve --port 8888 --file my_spans.jsonl
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import http.server
|
|
51
|
+
import json
|
|
52
|
+
import logging
|
|
53
|
+
import re
|
|
54
|
+
import threading
|
|
55
|
+
import urllib.parse
|
|
56
|
+
from typing import Any
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"TraceViewerServer",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
_log = logging.getLogger("spanforge.server")
|
|
63
|
+
|
|
64
|
+
_TRACE_ID_PATH_RE = re.compile(r"^/traces/([0-9a-f]{32})$")
|
|
65
|
+
_MAX_EVENTS_PER_LIST = 200
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Embedded SPA — single-file trace viewer (vanilla JS, zero external deps)
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
_VIEWER_HTML = """<!DOCTYPE html>
|
|
72
|
+
<html lang="en">
|
|
73
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
74
|
+
<title>spanforge — Trace Viewer</title>
|
|
75
|
+
<style>
|
|
76
|
+
:root{--bg:#0f1117;--bg-panel:#1a1d27;--bg-hover:#242736;--border:#2a2d3a;--text:#e2e8f0;--text-muted:#94a3b8;--accent:#7c3aed;--accent-light:#a78bfa;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--radius:6px;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;--mono:'JetBrains Mono','Fira Code','Cascadia Code',monospace}
|
|
77
|
+
[data-theme=light]{--bg:#f8fafc;--bg-panel:#ffffff;--bg-hover:#f1f5f9;--border:#e2e8f0;--text:#1e293b;--text-muted:#64748b}
|
|
78
|
+
*{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;overflow:hidden}
|
|
79
|
+
body{font-family:var(--font);background:var(--bg);color:var(--text);display:flex;flex-direction:column;font-size:13px}
|
|
80
|
+
#header{display:flex;align-items:center;gap:10px;padding:0 16px;height:50px;background:var(--bg-panel);border-bottom:1px solid var(--border);flex-shrink:0}
|
|
81
|
+
.logo{font-size:15px;font-weight:700;color:var(--accent-light);letter-spacing:-0.5px;white-space:nowrap}
|
|
82
|
+
.stat-chip{padding:3px 9px;border-radius:20px;background:var(--bg-hover);color:var(--text-muted);font-size:11px;white-space:nowrap}
|
|
83
|
+
.chain-ok{color:var(--success)}.chain-warn{color:var(--warning)}.chain-none{color:var(--text-muted)}
|
|
84
|
+
#filter-input{margin-left:auto;padding:5px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);font-size:12px;width:200px;outline:none}
|
|
85
|
+
#filter-input:focus{border-color:var(--accent)}
|
|
86
|
+
.icon-btn{cursor:pointer;padding:5px 8px;border-radius:var(--radius);background:none;border:none;color:var(--text-muted);font-size:14px;transition:background 0.15s}
|
|
87
|
+
.icon-btn:hover{background:var(--bg-hover);color:var(--text)}
|
|
88
|
+
#main{display:flex;flex:1;overflow:hidden}
|
|
89
|
+
/* Left panel */
|
|
90
|
+
#traces-panel{width:220px;flex-shrink:0;border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
|
|
91
|
+
.panel-title{padding:9px 12px 6px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);position:sticky;top:0;background:var(--bg-panel);border-bottom:1px solid var(--border)}
|
|
92
|
+
.trace-item{padding:9px 12px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .1s}
|
|
93
|
+
.trace-item:hover{background:var(--bg-hover)}
|
|
94
|
+
.trace-item.active{background:var(--bg-hover);border-left:3px solid var(--accent);padding-left:9px}
|
|
95
|
+
.trace-id{font-family:var(--mono);font-size:11px;color:var(--accent-light)}
|
|
96
|
+
.trace-meta{margin-top:3px;display:flex;align-items:center;gap:5px;color:var(--text-muted);font-size:10px}
|
|
97
|
+
.badge{padding:1px 5px;border-radius:3px;font-size:10px;font-weight:600}
|
|
98
|
+
.badge-ok{background:rgba(16,185,129,.15);color:#10b981}.badge-err{background:rgba(239,68,68,.15);color:#ef4444}
|
|
99
|
+
.badge-n{background:var(--border);color:var(--text-muted)}
|
|
100
|
+
/* Center panel */
|
|
101
|
+
#center-panel{flex:1;overflow-y:auto;display:flex;flex-direction:column}
|
|
102
|
+
.event-row{display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s}
|
|
103
|
+
.event-row:hover{background:var(--bg-hover)}.event-row.active{background:var(--bg-hover);border-left:3px solid var(--accent);padding-left:13px}
|
|
104
|
+
.evt-type{padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;white-space:nowrap;font-family:var(--mono)}
|
|
105
|
+
.evt-id{font-family:var(--mono);font-size:10px;color:var(--text-muted);min-width:100px}
|
|
106
|
+
.evt-source{color:var(--text-muted);font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
107
|
+
.evt-ts{font-size:10px;color:var(--text-muted);white-space:nowrap}
|
|
108
|
+
/* Waterfall */
|
|
109
|
+
#wf-header{padding:8px 16px;background:var(--bg-panel);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;position:sticky;top:0;z-index:1}
|
|
110
|
+
.wf-back{cursor:pointer;color:var(--accent-light);font-size:11px;text-decoration:none}.wf-back:hover{text-decoration:underline}
|
|
111
|
+
.wf-tid{font-family:var(--mono);font-size:10px}.wf-cost{color:var(--success);font-size:11px;margin-left:auto}.wf-dur{color:var(--text-muted);font-size:11px}
|
|
112
|
+
.wf-body{padding:6px 16px 16px}
|
|
113
|
+
.wf-ruler{display:flex;padding:0 0 4px 196px;font-size:9px;color:var(--text-muted);margin-bottom:2px}
|
|
114
|
+
.wf-ruler-mark{flex:1}
|
|
115
|
+
.wf-row{display:flex;align-items:center;gap:8px;padding:4px;border-radius:var(--radius);cursor:pointer;transition:background .1s}
|
|
116
|
+
.wf-row:hover{background:var(--bg-hover)}.wf-row.active{background:var(--bg-hover)}
|
|
117
|
+
.wf-label{width:188px;flex-shrink:0;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center;gap:4px}
|
|
118
|
+
.wf-timeline{flex:1;height:20px;position:relative;background:var(--bg-hover);border-radius:3px}
|
|
119
|
+
.wf-bar{position:absolute;top:2px;height:16px;border-radius:3px;min-width:3px}.wf-bar:hover{opacity:.8}
|
|
120
|
+
.wf-dl{width:56px;flex-shrink:0;font-size:10px;color:var(--text-muted);text-align:right}
|
|
121
|
+
/* Detail panel */
|
|
122
|
+
#detail-panel{width:340px;flex-shrink:0;border-left:1px solid var(--border);overflow-y:auto}
|
|
123
|
+
.det-hdr{padding:10px 12px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg-panel)}
|
|
124
|
+
.det-type{margin-bottom:6px}.det-kv{display:flex;flex-direction:column;gap:3px}
|
|
125
|
+
.kv-row{display:flex;gap:8px;font-size:11px}.kv-k{color:var(--text-muted);min-width:72px;flex-shrink:0}.kv-v{font-family:var(--mono);font-size:10px;word-break:break-all}
|
|
126
|
+
.det-sec{padding:9px 12px;border-bottom:1px solid var(--border)}
|
|
127
|
+
.sec-title{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text-muted);margin-bottom:7px}
|
|
128
|
+
.json-view{font-family:var(--mono);font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-all}
|
|
129
|
+
.jk{color:#7dd3fc}.js{color:#86efac}.jn{color:#fca5a5}.jb{color:#fbbf24}.jl{color:#94a3b8}
|
|
130
|
+
.sig-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:10px;font-size:10px;font-weight:600}
|
|
131
|
+
.sig-yes{background:rgba(16,185,129,.15);color:#10b981}.sig-no{background:rgba(148,163,184,.15);color:#94a3b8}
|
|
132
|
+
/* Empty states */
|
|
133
|
+
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:8px;color:var(--text-muted);padding:48px}
|
|
134
|
+
.empty-icon{font-size:32px}
|
|
135
|
+
/* Scrollbar */
|
|
136
|
+
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
137
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
138
|
+
/* Compliance dashboard */
|
|
139
|
+
.comp-dash{padding:20px;overflow-y:auto;flex:1}
|
|
140
|
+
.comp-card{background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px}
|
|
141
|
+
.comp-card-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text-muted);margin-bottom:10px}
|
|
142
|
+
.comp-chain-status{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600}
|
|
143
|
+
.comp-chain-ok{color:var(--success)}.comp-chain-err{color:var(--error)}.comp-chain-warn{color:var(--warning)}
|
|
144
|
+
.comp-fw-hdr{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
|
145
|
+
.comp-fw-name{font-size:13px;font-weight:700;color:var(--text)}
|
|
146
|
+
.comp-fw-pct{font-size:12px;font-weight:600;padding:2px 8px;border-radius:10px}
|
|
147
|
+
.comp-clause-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px}
|
|
148
|
+
.comp-clause-row:last-child{border-bottom:none}
|
|
149
|
+
.comp-clause-id{font-family:var(--mono);font-size:10px;color:var(--accent-light);min-width:80px}
|
|
150
|
+
.comp-clause-desc{flex:1;color:var(--text)}
|
|
151
|
+
.comp-clause-badge{padding:2px 7px;border-radius:3px;font-size:10px;font-weight:600}
|
|
152
|
+
.comp-pass{background:rgba(16,185,129,.15);color:#10b981}.comp-fail{background:rgba(239,68,68,.15);color:#ef4444}
|
|
153
|
+
.comp-stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px}
|
|
154
|
+
.comp-stat{text-align:center;padding:10px;background:var(--bg-hover);border-radius:var(--radius)}
|
|
155
|
+
.comp-stat-val{font-size:20px;font-weight:700}.comp-stat-lbl{font-size:10px;color:var(--text-muted);margin-top:2px}
|
|
156
|
+
.comp-back{cursor:pointer;color:var(--accent-light);font-size:12px;background:none;border:none;padding:4px 0;margin-bottom:12px;font-family:var(--font)}
|
|
157
|
+
.comp-back:hover{text-decoration:underline}
|
|
158
|
+
.comp-model-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12px}
|
|
159
|
+
.comp-model-row:last-child{border-bottom:none}
|
|
160
|
+
.comp-model-name{font-family:var(--mono);font-size:11px;color:var(--accent-light);min-width:140px}
|
|
161
|
+
.comp-model-meta{color:var(--text-muted);font-size:11px}
|
|
162
|
+
</style></head>
|
|
163
|
+
<body>
|
|
164
|
+
<header id="header">
|
|
165
|
+
<div class="logo">⬡ spanforge</div>
|
|
166
|
+
<div class="stat-chip" id="s-traces">— traces</div>
|
|
167
|
+
<div class="stat-chip" id="s-events">— events</div>
|
|
168
|
+
<div class="stat-chip" id="s-cost">$—</div>
|
|
169
|
+
<div class="stat-chip" id="s-chain">chain</div>
|
|
170
|
+
<div class="stat-chip" id="s-compliance" title="Click to view compliance dashboard" style="cursor:pointer">compliance</div>
|
|
171
|
+
<input id="filter-input" type="text" placeholder="Filter traces, events, IDs…" oninput="applyFilter()">
|
|
172
|
+
<button class="icon-btn" id="theme-btn" title="Toggle light/dark" onclick="toggleTheme()">☀</button>
|
|
173
|
+
<button class="icon-btn" title="Refresh" onclick="loadData()">↻</button>
|
|
174
|
+
<button class="icon-btn" title="Export as JSONL" onclick="exportEvents()">⇓</button>
|
|
175
|
+
</header>
|
|
176
|
+
<div id="main">
|
|
177
|
+
<nav id="traces-panel">
|
|
178
|
+
<div class="panel-title">Traces</div>
|
|
179
|
+
<div id="traces-list"></div>
|
|
180
|
+
</nav>
|
|
181
|
+
<section id="center-panel"><div id="center-content"></div></section>
|
|
182
|
+
<aside id="detail-panel"><div id="detail-content">
|
|
183
|
+
<div class="empty"><div class="empty-icon">🔍</div><div>Select an event to inspect</div></div>
|
|
184
|
+
</div></aside>
|
|
185
|
+
</div>
|
|
186
|
+
<script>
|
|
187
|
+
// ─── State ─────────────────────────────────────────────────────────────────
|
|
188
|
+
const S={events:[],traceMap:{},sortedTraces:[],selTrace:null,selEvent:null,filter:'',dark:true,compData:null,compView:false};
|
|
189
|
+
|
|
190
|
+
// ─── Colors ─────────────────────────────────────────────────────────────────
|
|
191
|
+
const TC={'llm.trace':'#3b82f6','llm.cost':'#10b981','llm.eval':'#8b5cf6','llm.guard':'#f59e0b',
|
|
192
|
+
'llm.redact':'#ef4444','llm.audit':'#eab308','llm.cache':'#06b6d4','llm.drift':'#f97316'};
|
|
193
|
+
function tc(et){const ns=(et||'').split('.').slice(0,2).join('.');return TC[ns]||'#6b7280';}
|
|
194
|
+
|
|
195
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
196
|
+
function fmtTime(ts){if(!ts)return'';try{const d=new Date(ts);return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false});}catch{return ts;}}
|
|
197
|
+
function fmtDur(ms){if(ms>=1000)return(ms/1000).toFixed(2)+'s';if(ms>=1)return ms.toFixed(1)+'ms';return(ms*1000).toFixed(0)+'\\u00b5s';}
|
|
198
|
+
function shortType(et){return(et||'unknown').split('.').slice(-2).join('.');}
|
|
199
|
+
function matches(ev,f){if(!f)return true;f=f.toLowerCase();return[ev.event_type,ev.source,ev.event_id,ev.trace_id].some(x=>String(x||'').toLowerCase().includes(f));}
|
|
200
|
+
function costOf(ev){const p=ev.payload||{};return+(p.cost_usd||p.total_cost?.total_cost_usd||0);}
|
|
201
|
+
function startNs(ev){const p=ev.payload||{};return p.start_time_unix_nano?+p.start_time_unix_nano:new Date(ev.timestamp||0).getTime()*1e6;}
|
|
202
|
+
function endNs(ev){const p=ev.payload||{};if(p.end_time_unix_nano)return+p.end_time_unix_nano;if(p.duration_ms!=null)return startNs(ev)+p.duration_ms*1e6;return startNs(ev)+1e6;}
|
|
203
|
+
function durMs(ev){const p=ev.payload||{};return p.duration_ms!=null?+p.duration_ms:(endNs(ev)-startNs(ev))/1e6;}
|
|
204
|
+
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
|
205
|
+
|
|
206
|
+
// ─── Syntax highlight ───────────────────────────────────────────────────────
|
|
207
|
+
function synHi(obj){
|
|
208
|
+
return JSON.stringify(obj,null,2).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
209
|
+
.replace(/("(?:\\u[0-9a-fA-F]{4}|\\[^u]|[^\\\\"])*"(\\s*:)?|\\b(?:true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g,m=>{
|
|
210
|
+
let c='jn';if(/^"/.test(m)){c=/:$/.test(m)?'jk':'js';}else if(/true|false/.test(m))c='jb';else if(/null/.test(m))c='jl';
|
|
211
|
+
return`<span class="${c}">${m}</span>`;});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Data loading ───────────────────────────────────────────────────────────
|
|
215
|
+
async function loadData(){
|
|
216
|
+
try{
|
|
217
|
+
const r=await fetch('/events?limit=2000');const d=await r.json();const evts=d.events||[];
|
|
218
|
+
const tm={};let cost=0,signed=0;
|
|
219
|
+
for(const e of evts){const tid=e.trace_id||'__none__';if(!tm[tid])tm[tid]=[];tm[tid].push(e);cost+=costOf(e);if(e.signature)signed++;}
|
|
220
|
+
for(const a of Object.values(tm))a.sort((a,b)=>startNs(a)-startNs(b));
|
|
221
|
+
const latest=arr=>arr.reduce((m,e)=>{const t=new Date(e.timestamp||0).getTime();return t>m?t:m;},0);
|
|
222
|
+
const sorted=Object.keys(tm).sort((a,b)=>latest(tm[b])-latest(tm[a]));
|
|
223
|
+
Object.assign(S,{events:evts,traceMap:tm,sortedTraces:sorted});
|
|
224
|
+
renderHeader({traces:sorted.length,events:evts.length,cost,signed});
|
|
225
|
+
renderTraceList();renderCenter();
|
|
226
|
+
loadComplianceSummary();
|
|
227
|
+
}catch(e){console.error('load failed',e);}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function loadComplianceSummary(){
|
|
231
|
+
try{
|
|
232
|
+
const r=await fetch('/compliance/summary');const d=await r.json();
|
|
233
|
+
S.compData=d;
|
|
234
|
+
const el=document.getElementById('s-compliance');
|
|
235
|
+
// GA-02-A: Show chain integrity + PII in compliance banner
|
|
236
|
+
const chainOk=d.chain_valid;const pii=d.pii_hits||0;const tampered=d.chain_tampered||0;
|
|
237
|
+
if(tampered>0){el.innerHTML=`<span style="color:var(--error)">✗ chain: TAMPERED (${tampered})</span>`;el.title='Chain integrity compromised! '+tampered+' tampered event(s)';}
|
|
238
|
+
else if(!d.frameworks||!d.frameworks.length){el.innerHTML='<span class="chain-none">compliance: n/a</span>';}
|
|
239
|
+
else{
|
|
240
|
+
const avg=d.frameworks.reduce((s,f)=>s+f.pct,0)/d.frameworks.length;
|
|
241
|
+
let extra=pii>0?` PII:${pii}`:'';
|
|
242
|
+
if(avg>=90)el.innerHTML=`<span class="chain-ok">✓ compliance: ${Math.round(avg)}%${extra}</span>`;
|
|
243
|
+
else if(avg>=50)el.innerHTML=`<span class="chain-warn">⚠ compliance: ${Math.round(avg)}%${extra}</span>`;
|
|
244
|
+
else el.innerHTML=`<span style="color:var(--error)">✗ compliance: ${Math.round(avg)}%${extra}</span>`;
|
|
245
|
+
}
|
|
246
|
+
el.onclick=()=>showComplianceDashboard();
|
|
247
|
+
}catch(e){document.getElementById('s-compliance').innerHTML='<span class="chain-none">compliance: error</span>';}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function showComplianceDashboard(){
|
|
251
|
+
S.compView=true;
|
|
252
|
+
const el=document.getElementById('center-content');
|
|
253
|
+
const d=S.compData;
|
|
254
|
+
if(!d){el.innerHTML='<div class="empty"><div class="empty-icon">📋</div><div>No compliance data</div></div>';return;}
|
|
255
|
+
|
|
256
|
+
// Chain integrity card
|
|
257
|
+
let chainHtml='';
|
|
258
|
+
const tampered=d.chain_tampered||0;const gaps=d.chain_gaps||0;
|
|
259
|
+
if(tampered>0)chainHtml=`<div class="comp-chain-status comp-chain-err">✗ Chain TAMPERED — ${tampered} tampered event(s), ${gaps} gap(s)</div>`;
|
|
260
|
+
else if(d.chain_valid)chainHtml=`<div class="comp-chain-status comp-chain-ok">✓ Chain Integrity Verified — ${d.chain_event_count||0} signed events</div>`;
|
|
261
|
+
else chainHtml=`<div class="comp-chain-status comp-chain-warn">⚠ Chain Not Verified — ${d.chain_event_count||0}/${d.event_count||0} signed</div>`;
|
|
262
|
+
|
|
263
|
+
// Stats summary
|
|
264
|
+
const pii=d.pii_hits||0;const piiEvts=d.pii_events_with_hits||0;
|
|
265
|
+
const explPct=d.explanation_coverage_pct!=null?d.explanation_coverage_pct+'%':'n/a';
|
|
266
|
+
|
|
267
|
+
// Frameworks
|
|
268
|
+
let fwHtml='';
|
|
269
|
+
if(d.frameworks&&d.frameworks.length){
|
|
270
|
+
for(const fw of d.frameworks){
|
|
271
|
+
const pctColor=fw.pct>=90?'var(--success)':fw.pct>=50?'var(--warning)':'var(--error)';
|
|
272
|
+
const clauses=fw.clauses||[];
|
|
273
|
+
const passed=clauses.filter(c=>c.passed).length;
|
|
274
|
+
fwHtml+=`<div class="comp-card">
|
|
275
|
+
<div class="comp-fw-hdr">
|
|
276
|
+
<span class="comp-fw-name">${esc(fw.framework)}</span>
|
|
277
|
+
<span class="comp-fw-pct" style="background:${pctColor}22;color:${pctColor}">${fw.pct}% (${fw.score}/${fw.max_score})</span>
|
|
278
|
+
<span style="font-size:11px;color:var(--text-muted)">${passed}/${clauses.length} clauses passed</span>
|
|
279
|
+
</div>
|
|
280
|
+
${clauses.map(c=>`<div class="comp-clause-row">
|
|
281
|
+
<span class="comp-clause-id">${esc(c.clause_id)}</span>
|
|
282
|
+
<span class="comp-clause-desc">${esc(c.description)}</span>
|
|
283
|
+
<span class="comp-clause-badge ${c.passed?'comp-pass':'comp-fail'}">${c.passed?'PASS':'FAIL'}</span>
|
|
284
|
+
</div>`).join('')}
|
|
285
|
+
</div>`;
|
|
286
|
+
}
|
|
287
|
+
}else{fwHtml='<div class="comp-card"><div class="comp-card-title">Frameworks</div><div style="color:var(--text-muted);font-size:12px">No compliance frameworks evaluated. Load events with compliance-relevant types (llm.audit, llm.guard, llm.redact).</div></div>';}
|
|
288
|
+
|
|
289
|
+
// Model registry card — extract unique models from events
|
|
290
|
+
let modelHtml='';
|
|
291
|
+
const models=new Map();
|
|
292
|
+
for(const ev of S.events){
|
|
293
|
+
const p=ev.payload||{};
|
|
294
|
+
const model=p.model||p.model_id||p.model_name||(p.model_info&&p.model_info.model_id);
|
|
295
|
+
if(model&&typeof model==='string'){
|
|
296
|
+
if(!models.has(model)){
|
|
297
|
+
models.set(model,{count:0,source:new Set(),lastSeen:null});
|
|
298
|
+
}
|
|
299
|
+
const m=models.get(model);
|
|
300
|
+
m.count++;
|
|
301
|
+
if(ev.source)m.source.add(ev.source);
|
|
302
|
+
if(ev.timestamp)m.lastSeen=ev.timestamp;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if(models.size>0){
|
|
306
|
+
const rows=[...models.entries()].sort((a,b)=>b[1].count-a[1].count);
|
|
307
|
+
modelHtml=`<div class="comp-card"><div class="comp-card-title">Model Registry</div>
|
|
308
|
+
${rows.map(([name,info])=>`<div class="comp-model-row">
|
|
309
|
+
<span class="comp-model-name">${esc(name)}</span>
|
|
310
|
+
<span class="comp-model-meta">${info.count} event${info.count!==1?'s':''}</span>
|
|
311
|
+
<span class="comp-model-meta">${[...info.source].join(', ')}</span>
|
|
312
|
+
<span class="comp-model-meta" style="margin-left:auto">${fmtTime(info.lastSeen)}</span>
|
|
313
|
+
</div>`).join('')}
|
|
314
|
+
</div>`;
|
|
315
|
+
}else{
|
|
316
|
+
modelHtml='<div class="comp-card"><div class="comp-card-title">Model Registry</div><div style="color:var(--text-muted);font-size:12px">No models detected in event payloads.</div></div>';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
el.innerHTML=`<div class="comp-dash">
|
|
320
|
+
<button class="comp-back" onclick="hideComplianceDashboard()">← Back to Traces</button>
|
|
321
|
+
<div class="comp-card"><div class="comp-card-title">Overview</div>
|
|
322
|
+
${chainHtml}
|
|
323
|
+
<div class="comp-stat-grid" style="margin-top:12px">
|
|
324
|
+
<div class="comp-stat"><div class="comp-stat-val">${d.event_count||0}</div><div class="comp-stat-lbl">Total Events</div></div>
|
|
325
|
+
<div class="comp-stat"><div class="comp-stat-val">${d.chain_event_count||0}</div><div class="comp-stat-lbl">Signed Events</div></div>
|
|
326
|
+
<div class="comp-stat"><div class="comp-stat-val" style="color:${pii>0?'var(--error)':'var(--success)'}">${pii}</div><div class="comp-stat-lbl">PII Hits</div></div>
|
|
327
|
+
<div class="comp-stat"><div class="comp-stat-val">${piiEvts}</div><div class="comp-stat-lbl">Events with PII</div></div>
|
|
328
|
+
<div class="comp-stat"><div class="comp-stat-val">${explPct}</div><div class="comp-stat-lbl">Explanation Coverage</div></div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
${fwHtml}
|
|
332
|
+
${modelHtml}
|
|
333
|
+
</div>`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hideComplianceDashboard(){S.compView=false;renderCenter();}
|
|
337
|
+
|
|
338
|
+
// ─── Header ─────────────────────────────────────────────────────────────────
|
|
339
|
+
function renderHeader({traces,events,cost,signed}){
|
|
340
|
+
document.getElementById('s-traces').textContent=`${traces} trace${traces!==1?'s':''}`;
|
|
341
|
+
document.getElementById('s-events').textContent=`${events} event${events!==1?'s':''}`;
|
|
342
|
+
document.getElementById('s-cost').textContent=`$${cost.toFixed(4)}`;
|
|
343
|
+
const el=document.getElementById('s-chain');const pct=events>0?Math.round(signed/events*100):0;
|
|
344
|
+
if(!signed)el.innerHTML='<span class="chain-none">chain: unsigned</span>';
|
|
345
|
+
else if(pct===100)el.innerHTML='<span class="chain-ok">✓ chain: 100% signed</span>';
|
|
346
|
+
else el.innerHTML=`<span class="chain-warn">⚠ chain: ${pct}% signed</span>`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── Trace list ──────────────────────────────────────────────────────────────
|
|
350
|
+
function renderTraceList(){
|
|
351
|
+
const el=document.getElementById('traces-list');
|
|
352
|
+
const f=S.filter.toLowerCase();const ts=S.sortedTraces;
|
|
353
|
+
if(!ts.length){el.innerHTML='<div class="empty"><div class="empty-icon">📫</div><div>No traces yet</div><div style="font-size:11px;margin-top:4px">Instrument your code with spanforge and run it.</div></div>';return;}
|
|
354
|
+
el.innerHTML=ts.filter(tid=>!f||(tm=>tm.some(e=>matches(e,f)))(S.traceMap[tid]||[])||(tid!=='__none__'&&tid.includes(f)))
|
|
355
|
+
.map(tid=>{const evts=S.traceMap[tid]||[];const cost=evts.reduce((s,e)=>s+costOf(e),0);
|
|
356
|
+
const hasErr=evts.some(e=>(e.payload||{}).status==='error');const ts2=evts[evts.length-1]?.timestamp;
|
|
357
|
+
const disp=tid==='__none__'?'(ungrouped)':tid.substring(0,14)+'\\u2026';const active=S.selTrace===tid;
|
|
358
|
+
return`<div class="trace-item${active?' active':''}" data-tid="${esc(tid)}" onclick="selTrace(this.dataset.tid)">
|
|
359
|
+
<div class="trace-id">${esc(disp)}</div>
|
|
360
|
+
<div class="trace-meta">
|
|
361
|
+
<span class="badge ${hasErr?'badge-err':'badge-ok'}">${hasErr?'ERR':'OK'}</span>
|
|
362
|
+
<span class="badge badge-n">${evts.length}</span>
|
|
363
|
+
${cost>0?`<span style="color:var(--success)">$${cost.toFixed(4)}</span>`:''}
|
|
364
|
+
<span style="margin-left:auto">${fmtTime(ts2)}</span>
|
|
365
|
+
</div></div>`;}).join('');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ─── Center panel ────────────────────────────────────────────────────────────
|
|
369
|
+
function renderCenter(){const el=document.getElementById('center-content');if(S.compView)showComplianceDashboard();else if(S.selTrace)renderWaterfall(el);else renderEventList(el);}
|
|
370
|
+
|
|
371
|
+
function renderEventList(el){
|
|
372
|
+
const f=S.filter.toLowerCase();const evts=S.events.filter(e=>matches(e,f));
|
|
373
|
+
if(!evts.length){el.innerHTML='<div class="empty"><div class="empty-icon">📋</div><div>Select a trace from the left panel</div><div style="font-size:11px;margin-top:4px">Events will appear here once loaded.</div></div>';return;}
|
|
374
|
+
el.innerHTML=`<div class="panel-title" style="padding:9px 16px 6px;position:sticky;top:0;background:var(--bg-panel)">All Events — newest first</div>`
|
|
375
|
+
+evts.slice(0,500).map(ev=>{const c=tc(ev.event_type);const act=S.selEvent?.event_id===ev.event_id;
|
|
376
|
+
return`<div class="event-row${act?' active':''}" data-eid="${esc(ev.event_id)}" onclick="selEvt(this.dataset.eid)">
|
|
377
|
+
<span class="evt-type" style="background:${c}22;color:${c}">${esc(shortType(ev.event_type))}</span>
|
|
378
|
+
<span class="evt-id">${esc((ev.event_id||'').substring(0,14))}</span>
|
|
379
|
+
<span class="evt-source">${esc(ev.source||'')}</span>
|
|
380
|
+
<span class="evt-ts">${fmtTime(ev.timestamp)}</span></div>`;}).join('');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderWaterfall(el){
|
|
384
|
+
const tid=S.selTrace;const evts=S.traceMap[tid]||[];if(!evts.length)return;
|
|
385
|
+
const f=S.filter.toLowerCase();const filtered=evts.filter(e=>matches(e,f));
|
|
386
|
+
const minNs=Math.min(...evts.map(startNs));const maxNs=Math.max(...evts.map(endNs));
|
|
387
|
+
const totalMs=(maxNs-minNs)/1e6||1;const cost=evts.reduce((s,e)=>s+costOf(e),0);
|
|
388
|
+
const dispTid=tid==='__none__'?'(ungrouped)':tid;
|
|
389
|
+
const marks=[0,.25,.5,.75,1].map(f=>`<span class="wf-ruler-mark">${(totalMs*f).toFixed(1)}ms</span>`).join('');
|
|
390
|
+
el.innerHTML=`<div id="wf-header">
|
|
391
|
+
<span class="wf-back" onclick="selTrace(null)">← All Traces</span>
|
|
392
|
+
<span class="wf-tid" title="${esc(dispTid)}">${esc(dispTid.substring(0,36))}${dispTid.length>36?'\\u2026':''}</span>
|
|
393
|
+
${cost>0?`<span class="wf-cost">$${cost.toFixed(4)}</span>`:''}
|
|
394
|
+
<span class="wf-dur">${totalMs.toFixed(2)}ms total</span>
|
|
395
|
+
</div>
|
|
396
|
+
<div class="wf-body">
|
|
397
|
+
<div class="wf-ruler" style="padding-left:196px">${marks}</div>
|
|
398
|
+
${filtered.map(ev=>{const c=tc(ev.event_type);const sMs=(startNs(ev)-minNs)/1e6;const dMs=durMs(ev);
|
|
399
|
+
const left=(sMs/totalMs*100).toFixed(2);const width=Math.max(.3,(dMs/totalMs*100)).toFixed(2);
|
|
400
|
+
const name=(ev.payload||{}).span_name||shortType(ev.event_type);const act=S.selEvent?.event_id===ev.event_id;
|
|
401
|
+
return`<div class="wf-row${act?' active':''}" data-eid="${esc(ev.event_id)}" onclick="selEvt(this.dataset.eid)">
|
|
402
|
+
<div class="wf-label">
|
|
403
|
+
<span class="evt-type" style="background:${c}22;color:${c};font-size:9px;padding:1px 4px">${esc(shortType(ev.event_type))}</span>
|
|
404
|
+
<span style="overflow:hidden;text-overflow:ellipsis" title="${esc(name)}">${esc(name)}</span>
|
|
405
|
+
</div>
|
|
406
|
+
<div class="wf-timeline">
|
|
407
|
+
<div class="wf-bar" style="left:${left}%;width:${width}%;background:${c}cc" title="${esc(ev.event_type)} ${dMs.toFixed(3)}ms"></div>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="wf-dl">${fmtDur(dMs)}</div></div>`;}).join('')}
|
|
410
|
+
</div>`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── Detail panel ────────────────────────────────────────────────────────────
|
|
414
|
+
function renderDetail(ev){
|
|
415
|
+
const el=document.getElementById('detail-content');
|
|
416
|
+
if(!ev){el.innerHTML='<div class="empty"><div class="empty-icon">🔍</div><div>Select an event to inspect</div></div>';return;}
|
|
417
|
+
const c=tc(ev.event_type);const isSig=!!ev.signature;
|
|
418
|
+
const kv=(k,v)=>`<div class="kv-row"><span class="kv-k">${k}</span><span class="kv-v">${esc(v||'—')}</span></div>`;
|
|
419
|
+
const tags=ev.tags&&Object.keys(ev.tags).length?`<div class="det-sec"><div class="sec-title">Tags</div><div style="display:flex;flex-wrap:wrap;gap:4px">${Object.entries(ev.tags).map(([k,v])=>`<span style="padding:2px 6px;border-radius:3px;background:var(--border);font-size:10px;font-family:var(--mono)">${esc(k)}: ${esc(v)}</span>`).join('')}</div></div>`:'';
|
|
420
|
+
el.innerHTML=`<div class="det-hdr">
|
|
421
|
+
<div class="det-type">
|
|
422
|
+
<span class="evt-type" style="background:${c}22;color:${c};font-size:12px">${esc(ev.event_type||'unknown')}</span>
|
|
423
|
+
<span class="sig-badge ${isSig?'sig-yes':'sig-no'}">${isSig?'✓ Signed':'Unsigned'}</span>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="det-kv" style="margin-top:8px">
|
|
426
|
+
${kv('event_id',(ev.event_id||'').substring(0,20)+'\\u2026')}
|
|
427
|
+
${kv('source',ev.source)}${kv('timestamp',ev.timestamp)}
|
|
428
|
+
${kv('trace_id',(ev.trace_id||'').substring(0,16)+(ev.trace_id?'\\u2026':''))}
|
|
429
|
+
${kv('span_id',ev.span_id)}${kv('schema',ev.schema_version)}
|
|
430
|
+
</div></div>
|
|
431
|
+
${tags}
|
|
432
|
+
<div class="det-sec"><div class="sec-title">Payload</div><div class="json-view">${synHi(ev.payload||{})}</div></div>
|
|
433
|
+
${isSig?`<div class="det-sec"><div class="sec-title">Chain</div><div class="det-kv">
|
|
434
|
+
${kv('checksum',(ev.checksum||'').substring(0,30)+'\\u2026')}
|
|
435
|
+
${kv('signature',(ev.signature||'').substring(0,30)+'\\u2026')}
|
|
436
|
+
${kv('prev_id',ev.prev_id)}</div></div>`:''}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Actions ─────────────────────────────────────────────────────────────────
|
|
440
|
+
function selTrace(tid){S.selTrace=tid;S.selEvent=null;renderTraceList();renderCenter();renderDetail(null);}
|
|
441
|
+
function selEvt(id){const ev=S.events.find(e=>e.event_id===id);S.selEvent=ev||null;renderDetail(ev);renderCenter();}
|
|
442
|
+
function applyFilter(){S.filter=document.getElementById('filter-input').value;renderTraceList();renderCenter();}
|
|
443
|
+
function toggleTheme(){S.dark=!S.dark;document.documentElement.setAttribute('data-theme',S.dark?'dark':'light');document.getElementById('theme-btn').textContent=S.dark?'\\u2600':'\\uD83C\\uDF19';}
|
|
444
|
+
function exportEvents(){const lines=S.events.map(e=>JSON.stringify(e)).join('\\n');const b=new Blob([lines],{type:'application/x-ndjson'});const u=URL.createObjectURL(b);const a=document.createElement('a');a.href=u;a.download='spanforge-traces.jsonl';a.click();URL.revokeObjectURL(u);}
|
|
445
|
+
|
|
446
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
|
447
|
+
loadData();setInterval(loadData,30000);
|
|
448
|
+
</script>
|
|
449
|
+
</body></html>"""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class _TraceAPIHandler(http.server.BaseHTTPRequestHandler):
|
|
455
|
+
# Injected by TraceViewerServer before binding.
|
|
456
|
+
_get_store: Any # callable: () -> TraceStore
|
|
457
|
+
_cors_origins: str = "" # configurable CORS origin; empty = no CORS header
|
|
458
|
+
|
|
459
|
+
def do_GET(self) -> None: # noqa: N802
|
|
460
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
461
|
+
path = parsed.path.rstrip("/") or "/"
|
|
462
|
+
|
|
463
|
+
if path == "/":
|
|
464
|
+
self._html_response(_VIEWER_HTML)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
if path == "/health":
|
|
468
|
+
self._json_response({"status": "ok"})
|
|
469
|
+
|
|
470
|
+
elif path == "/api/stats":
|
|
471
|
+
self._handle_api_stats()
|
|
472
|
+
|
|
473
|
+
elif path == "/ready":
|
|
474
|
+
self._handle_ready()
|
|
475
|
+
|
|
476
|
+
elif path == "/traces":
|
|
477
|
+
self._handle_list_traces()
|
|
478
|
+
|
|
479
|
+
elif _TRACE_ID_PATH_RE.match(path):
|
|
480
|
+
m = _TRACE_ID_PATH_RE.match(path)
|
|
481
|
+
self._handle_get_trace(m.group(1)) # type: ignore[union-attr]
|
|
482
|
+
|
|
483
|
+
elif path == "/events":
|
|
484
|
+
self._handle_list_events()
|
|
485
|
+
|
|
486
|
+
elif path == "/metrics":
|
|
487
|
+
self._handle_metrics()
|
|
488
|
+
|
|
489
|
+
elif path == "/compliance/summary":
|
|
490
|
+
self._handle_compliance_summary()
|
|
491
|
+
|
|
492
|
+
elif path == "/compliance/events":
|
|
493
|
+
self._handle_compliance_events()
|
|
494
|
+
|
|
495
|
+
else:
|
|
496
|
+
self._error(404, "Not Found")
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
# Handlers
|
|
500
|
+
# ------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def _handle_api_stats(self) -> None:
|
|
503
|
+
"""Return aggregated stats in JSON (for the SPA header chips)."""
|
|
504
|
+
try:
|
|
505
|
+
store = self._get_store()
|
|
506
|
+
with store._lock:
|
|
507
|
+
events = [e for evts in store._traces.values() for e in evts]
|
|
508
|
+
trace_count = len(store._traces)
|
|
509
|
+
cost = sum(
|
|
510
|
+
float(e.payload.get("cost_usd") or e.payload.get("total_cost", {}).get("total_cost_usd") or 0)
|
|
511
|
+
for e in events
|
|
512
|
+
)
|
|
513
|
+
signed = sum(1 for e in events if getattr(e, "signature", None))
|
|
514
|
+
self._json_response({
|
|
515
|
+
"traces": trace_count,
|
|
516
|
+
"events": len(events),
|
|
517
|
+
"total_cost_usd": cost,
|
|
518
|
+
"signed_count": signed,
|
|
519
|
+
"unsigned_count": len(events) - signed,
|
|
520
|
+
})
|
|
521
|
+
except Exception: # NOSONAR
|
|
522
|
+
_log.exception("api_stats error")
|
|
523
|
+
self._error(500, "Internal Server Error")
|
|
524
|
+
|
|
525
|
+
def _handle_list_traces(self) -> None:
|
|
526
|
+
try:
|
|
527
|
+
store = self._get_store()
|
|
528
|
+
with store._lock:
|
|
529
|
+
trace_ids = list(store._traces.keys())
|
|
530
|
+
self._json_response({"trace_ids": trace_ids, "count": len(trace_ids)})
|
|
531
|
+
except Exception: # NOSONAR
|
|
532
|
+
_log.exception("list_traces error")
|
|
533
|
+
self._error(500, "Internal Server Error")
|
|
534
|
+
|
|
535
|
+
def _handle_ready(self) -> None:
|
|
536
|
+
"""Return readiness status — 200 if the store is accessible, 503 otherwise."""
|
|
537
|
+
import time as _time # noqa: PLC0415
|
|
538
|
+
|
|
539
|
+
checks: dict[str, str] = {}
|
|
540
|
+
ready = True
|
|
541
|
+
try:
|
|
542
|
+
store = self._get_store()
|
|
543
|
+
with store._lock:
|
|
544
|
+
_ = len(store._traces)
|
|
545
|
+
checks["store"] = "ok"
|
|
546
|
+
except Exception as exc: # NOSONAR
|
|
547
|
+
checks["store"] = f"error: {exc}"
|
|
548
|
+
ready = False
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
552
|
+
cfg = get_config()
|
|
553
|
+
checks["exporter"] = cfg.exporter
|
|
554
|
+
except Exception as exc: # NOSONAR
|
|
555
|
+
checks["exporter"] = f"error: {exc}"
|
|
556
|
+
ready = False
|
|
557
|
+
|
|
558
|
+
payload = {
|
|
559
|
+
"ready": ready,
|
|
560
|
+
"checks": checks,
|
|
561
|
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
|
562
|
+
}
|
|
563
|
+
self._json_response(payload, status=200 if ready else 503)
|
|
564
|
+
|
|
565
|
+
def _handle_get_trace(self, trace_id: str) -> None:
|
|
566
|
+
try:
|
|
567
|
+
store = self._get_store()
|
|
568
|
+
events = store.get_trace(trace_id)
|
|
569
|
+
if events is None:
|
|
570
|
+
self._error(404, f"Trace {trace_id!r} not found")
|
|
571
|
+
return
|
|
572
|
+
self._json_response({
|
|
573
|
+
"trace_id": trace_id,
|
|
574
|
+
"event_count": len(events),
|
|
575
|
+
"events": [_serialise_event(e) for e in events],
|
|
576
|
+
})
|
|
577
|
+
except Exception: # NOSONAR
|
|
578
|
+
_log.exception("get_trace error")
|
|
579
|
+
self._error(500, "Internal Server Error")
|
|
580
|
+
|
|
581
|
+
def _handle_list_events(self) -> None:
|
|
582
|
+
try:
|
|
583
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
584
|
+
params = urllib.parse.parse_qs(parsed_url.query)
|
|
585
|
+
try:
|
|
586
|
+
offset = max(0, int(params.get("offset", ["0"])[0]))
|
|
587
|
+
except (ValueError, TypeError):
|
|
588
|
+
offset = 0
|
|
589
|
+
try:
|
|
590
|
+
limit = min(
|
|
591
|
+
max(1, int(params.get("limit", [str(_MAX_EVENTS_PER_LIST)])[0])),
|
|
592
|
+
_MAX_EVENTS_PER_LIST,
|
|
593
|
+
)
|
|
594
|
+
except (ValueError, TypeError):
|
|
595
|
+
limit = _MAX_EVENTS_PER_LIST
|
|
596
|
+
|
|
597
|
+
store = self._get_store()
|
|
598
|
+
all_events: list[Any] = []
|
|
599
|
+
with store._lock:
|
|
600
|
+
for evts in store._traces.values():
|
|
601
|
+
all_events.extend(evts)
|
|
602
|
+
# Newest first.
|
|
603
|
+
all_events.sort(
|
|
604
|
+
key=lambda e: getattr(e, "timestamp", 0),
|
|
605
|
+
reverse=True,
|
|
606
|
+
)
|
|
607
|
+
subset = all_events[offset:offset + limit]
|
|
608
|
+
self._json_response({
|
|
609
|
+
"event_count": len(subset),
|
|
610
|
+
"total": len(all_events),
|
|
611
|
+
"offset": offset,
|
|
612
|
+
"limit": limit,
|
|
613
|
+
"events": [_serialise_event(e) for e in subset],
|
|
614
|
+
})
|
|
615
|
+
except Exception: # NOSONAR
|
|
616
|
+
_log.exception("list_events error")
|
|
617
|
+
self._error(500, "Internal Server Error")
|
|
618
|
+
|
|
619
|
+
def _handle_metrics(self) -> None:
|
|
620
|
+
try:
|
|
621
|
+
from spanforge._stream import _export_error_count # noqa: PLC0415
|
|
622
|
+
store = self._get_store()
|
|
623
|
+
with store._lock:
|
|
624
|
+
trace_count = len(store._traces)
|
|
625
|
+
event_count = sum(len(v) for v in store._traces.values())
|
|
626
|
+
body = (
|
|
627
|
+
f"spanforge_traces_in_store {trace_count}\n"
|
|
628
|
+
f"spanforge_events_in_store {event_count}\n"
|
|
629
|
+
f"spanforge_export_errors_total {_export_error_count}\n"
|
|
630
|
+
)
|
|
631
|
+
self._text_response(body)
|
|
632
|
+
except Exception: # NOSONAR
|
|
633
|
+
_log.exception("metrics error")
|
|
634
|
+
self._error(500, "Internal Server Error")
|
|
635
|
+
|
|
636
|
+
def _handle_compliance_summary(self) -> None:
|
|
637
|
+
"""``GET /compliance/summary`` — aggregate compliance posture.
|
|
638
|
+
|
|
639
|
+
GA-02-A: Returns chain integrity status, PII scan summary, and
|
|
640
|
+
framework compliance scores.
|
|
641
|
+
"""
|
|
642
|
+
try:
|
|
643
|
+
from spanforge.core.compliance_mapping import ComplianceMappingEngine # noqa: PLC0415
|
|
644
|
+
from spanforge.redact import scan_payload # noqa: PLC0415
|
|
645
|
+
|
|
646
|
+
store = self._get_store()
|
|
647
|
+
all_events: list[Any] = []
|
|
648
|
+
with store._lock:
|
|
649
|
+
for evts in store._traces.values():
|
|
650
|
+
all_events.extend(evts)
|
|
651
|
+
|
|
652
|
+
# Chain integrity check
|
|
653
|
+
chain_valid = False
|
|
654
|
+
chain_tampered = 0
|
|
655
|
+
chain_gaps = 0
|
|
656
|
+
signed_count = sum(1 for e in all_events if getattr(e, "signature", None))
|
|
657
|
+
try:
|
|
658
|
+
import os as _os # noqa: PLC0415
|
|
659
|
+
org_secret = _os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
660
|
+
if org_secret and all_events:
|
|
661
|
+
from spanforge.signing import verify_chain as _vc # noqa: PLC0415
|
|
662
|
+
chain_result = _vc(all_events, org_secret)
|
|
663
|
+
chain_valid = chain_result.valid
|
|
664
|
+
chain_tampered = chain_result.tampered_count
|
|
665
|
+
chain_gaps = len(chain_result.gaps)
|
|
666
|
+
except Exception: # noqa: BLE001
|
|
667
|
+
pass
|
|
668
|
+
|
|
669
|
+
# PII scan summary
|
|
670
|
+
pii_hits_total = 0
|
|
671
|
+
pii_events_with_hits = 0
|
|
672
|
+
for e in all_events:
|
|
673
|
+
payload = getattr(e, "payload", None)
|
|
674
|
+
if isinstance(payload, dict):
|
|
675
|
+
result = scan_payload(payload)
|
|
676
|
+
if not result.clean:
|
|
677
|
+
pii_hits_total += len(result.hits)
|
|
678
|
+
pii_events_with_hits += 1
|
|
679
|
+
|
|
680
|
+
# Framework compliance
|
|
681
|
+
mapper = ComplianceMappingEngine()
|
|
682
|
+
comp_result = mapper.evaluate(all_events)
|
|
683
|
+
frameworks: list[dict[str, Any]] = []
|
|
684
|
+
for fw in comp_result.frameworks:
|
|
685
|
+
clauses: list[dict[str, Any]] = []
|
|
686
|
+
for c in fw.clauses:
|
|
687
|
+
clauses.append({
|
|
688
|
+
"clause_id": c.clause_id,
|
|
689
|
+
"description": c.description,
|
|
690
|
+
"passed": c.passed,
|
|
691
|
+
"evidence_count": c.evidence_count,
|
|
692
|
+
"required_event_types": c.required_event_types,
|
|
693
|
+
})
|
|
694
|
+
frameworks.append({
|
|
695
|
+
"framework": fw.framework,
|
|
696
|
+
"score": fw.score,
|
|
697
|
+
"max_score": fw.max_score,
|
|
698
|
+
"pct": round(fw.score / fw.max_score * 100, 1) if fw.max_score else 0,
|
|
699
|
+
"clauses": clauses,
|
|
700
|
+
})
|
|
701
|
+
self._json_response({
|
|
702
|
+
"event_count": len(all_events),
|
|
703
|
+
"chain_valid": chain_valid,
|
|
704
|
+
"chain_event_count": signed_count,
|
|
705
|
+
"chain_tampered": chain_tampered,
|
|
706
|
+
"chain_gaps": chain_gaps,
|
|
707
|
+
"pii_hits": pii_hits_total,
|
|
708
|
+
"pii_events_with_hits": pii_events_with_hits,
|
|
709
|
+
"frameworks": frameworks,
|
|
710
|
+
"explanation_coverage_pct": self._compute_explanation_coverage_pct(all_events),
|
|
711
|
+
})
|
|
712
|
+
except Exception: # NOSONAR
|
|
713
|
+
_log.exception("compliance_summary error")
|
|
714
|
+
self._error(500, "Internal Server Error")
|
|
715
|
+
|
|
716
|
+
@staticmethod
|
|
717
|
+
def _compute_explanation_coverage_pct(all_events: list[Any]) -> float | None:
|
|
718
|
+
"""Return the % of trace/HITL decisions that have a matching explanation event."""
|
|
719
|
+
decision_count = sum(
|
|
720
|
+
1 for e in all_events
|
|
721
|
+
if str(getattr(e, "event_type", "")).startswith(("llm.trace.", "hitl."))
|
|
722
|
+
)
|
|
723
|
+
explanation_count = sum(
|
|
724
|
+
1 for e in all_events
|
|
725
|
+
if str(getattr(e, "event_type", "")).startswith("explanation.")
|
|
726
|
+
)
|
|
727
|
+
if decision_count > 0:
|
|
728
|
+
return round(min(explanation_count / decision_count * 100, 100.0), 1)
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
def _handle_compliance_events(self) -> None:
|
|
732
|
+
"""``GET /compliance/events?type=<prefix>&offset=N&limit=N`` — filter events by namespace prefix.
|
|
733
|
+
|
|
734
|
+
GA-02-B: Uses namespace prefix matching (e.g. ``llm.audit`` matches
|
|
735
|
+
``llm.audit.chain.verified``), adds ``hmac_valid`` per event, and
|
|
736
|
+
supports standard pagination via ``offset`` and ``limit``.
|
|
737
|
+
Omitting ``type`` returns all compliance-relevant events (``llm.audit``,
|
|
738
|
+
``llm.guard``, ``llm.redact``, ``llm.compliance``).
|
|
739
|
+
"""
|
|
740
|
+
try:
|
|
741
|
+
import os as _os # noqa: PLC0415
|
|
742
|
+
from spanforge.signing import verify as _verify # noqa: PLC0415
|
|
743
|
+
|
|
744
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
745
|
+
params = urllib.parse.parse_qs(parsed_url.query)
|
|
746
|
+
type_filter = params.get("type", [None])[0]
|
|
747
|
+
try:
|
|
748
|
+
offset = max(0, int(params.get("offset", ["0"])[0]))
|
|
749
|
+
except (ValueError, TypeError):
|
|
750
|
+
offset = 0
|
|
751
|
+
try:
|
|
752
|
+
limit = min(
|
|
753
|
+
max(1, int(params.get("limit", [str(_MAX_EVENTS_PER_LIST)])[0])),
|
|
754
|
+
_MAX_EVENTS_PER_LIST,
|
|
755
|
+
)
|
|
756
|
+
except (ValueError, TypeError):
|
|
757
|
+
limit = _MAX_EVENTS_PER_LIST
|
|
758
|
+
|
|
759
|
+
_COMPLIANCE_PREFIXES = ("llm.audit", "llm.guard", "llm.redact", "llm.compliance")
|
|
760
|
+
|
|
761
|
+
store = self._get_store()
|
|
762
|
+
all_events: list[Any] = []
|
|
763
|
+
with store._lock:
|
|
764
|
+
for evts in store._traces.values():
|
|
765
|
+
all_events.extend(evts)
|
|
766
|
+
|
|
767
|
+
# Namespace prefix matching
|
|
768
|
+
if type_filter:
|
|
769
|
+
prefix = type_filter.lower()
|
|
770
|
+
all_events = [
|
|
771
|
+
e for e in all_events
|
|
772
|
+
if str(getattr(e, "event_type", "")).lower().startswith(prefix)
|
|
773
|
+
]
|
|
774
|
+
else:
|
|
775
|
+
# Return all compliance-relevant events
|
|
776
|
+
all_events = [
|
|
777
|
+
e for e in all_events
|
|
778
|
+
if any(
|
|
779
|
+
str(getattr(e, "event_type", "")).lower().startswith(p)
|
|
780
|
+
for p in _COMPLIANCE_PREFIXES
|
|
781
|
+
)
|
|
782
|
+
]
|
|
783
|
+
|
|
784
|
+
all_events.sort(
|
|
785
|
+
key=lambda e: getattr(e, "timestamp", 0),
|
|
786
|
+
reverse=True,
|
|
787
|
+
)
|
|
788
|
+
total = len(all_events)
|
|
789
|
+
subset = all_events[offset:offset + limit]
|
|
790
|
+
|
|
791
|
+
org_secret = _os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
792
|
+
|
|
793
|
+
serialised: list[dict[str, Any]] = []
|
|
794
|
+
for ev in subset:
|
|
795
|
+
d = _serialise_event(ev)
|
|
796
|
+
# GA-02-B: Add hmac_valid per event
|
|
797
|
+
if org_secret and getattr(ev, "signature", None):
|
|
798
|
+
try:
|
|
799
|
+
d["hmac_valid"] = _verify(ev, org_secret)
|
|
800
|
+
except Exception: # noqa: BLE001
|
|
801
|
+
d["hmac_valid"] = False
|
|
802
|
+
else:
|
|
803
|
+
d["hmac_valid"] = None # unsigned or no key
|
|
804
|
+
serialised.append(d)
|
|
805
|
+
|
|
806
|
+
self._json_response({
|
|
807
|
+
"type_filter": type_filter,
|
|
808
|
+
"event_count": len(serialised),
|
|
809
|
+
"total": total,
|
|
810
|
+
"offset": offset,
|
|
811
|
+
"limit": limit,
|
|
812
|
+
"events": serialised,
|
|
813
|
+
})
|
|
814
|
+
except Exception: # NOSONAR
|
|
815
|
+
_log.exception("compliance_events error")
|
|
816
|
+
self._error(500, "Internal Server Error")
|
|
817
|
+
|
|
818
|
+
# ------------------------------------------------------------------
|
|
819
|
+
# Wire helpers
|
|
820
|
+
# ------------------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
def _html_response(self, html: str, status: int = 200) -> None:
|
|
823
|
+
body = html.encode("utf-8")
|
|
824
|
+
self.send_response(status)
|
|
825
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
826
|
+
self.send_header("Content-Length", str(len(body)))
|
|
827
|
+
self.end_headers()
|
|
828
|
+
self.wfile.write(body)
|
|
829
|
+
|
|
830
|
+
def _json_response(self, data: Any, status: int = 200) -> None:
|
|
831
|
+
body = json.dumps(data, default=str).encode("utf-8")
|
|
832
|
+
cors = getattr(self, "_cors_origins", "")
|
|
833
|
+
self.send_response(status)
|
|
834
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
835
|
+
self.send_header("Content-Length", str(len(body)))
|
|
836
|
+
if cors:
|
|
837
|
+
self.send_header("Access-Control-Allow-Origin", cors)
|
|
838
|
+
self.end_headers()
|
|
839
|
+
self.wfile.write(body)
|
|
840
|
+
|
|
841
|
+
def _text_response(self, text: str, status: int = 200) -> None:
|
|
842
|
+
body = text.encode("utf-8")
|
|
843
|
+
self.send_response(status)
|
|
844
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
845
|
+
self.send_header("Content-Length", str(len(body)))
|
|
846
|
+
self.end_headers()
|
|
847
|
+
self.wfile.write(body)
|
|
848
|
+
|
|
849
|
+
def _error(self, status: int, message: str) -> None:
|
|
850
|
+
self._json_response({"error": message}, status=status)
|
|
851
|
+
|
|
852
|
+
def log_message(self, fmt: str, *args: Any) -> None: # pragma: no cover
|
|
853
|
+
pass # suppress default access log
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _serialise_event(event: Any) -> dict[str, Any]:
|
|
857
|
+
"""Convert an Event to a plain dict (best-effort)."""
|
|
858
|
+
if hasattr(event, "to_dict"):
|
|
859
|
+
try:
|
|
860
|
+
return event.to_dict() # type: ignore[return-value]
|
|
861
|
+
except Exception: # NOSONAR
|
|
862
|
+
pass
|
|
863
|
+
return {
|
|
864
|
+
"event_type": str(getattr(event, "event_type", "unknown")),
|
|
865
|
+
"payload": getattr(event, "payload", {}),
|
|
866
|
+
"timestamp": getattr(event, "timestamp", None),
|
|
867
|
+
"span_id": getattr(event, "span_id", None),
|
|
868
|
+
"trace_id": getattr(event, "trace_id", None),
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# ---------------------------------------------------------------------------
|
|
873
|
+
# TraceViewerServer
|
|
874
|
+
# ---------------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
class TraceViewerServer:
|
|
878
|
+
"""Lightweight HTTP server exposing the in-process trace store as JSON.
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
port: TCP port to bind (default ``8888``).
|
|
882
|
+
host: Interface to bind (default ``"127.0.0.1"``).
|
|
883
|
+
store: Optional explicit :class:`~spanforge._store.TraceStore`
|
|
884
|
+
instance. If ``None``, uses the global singleton.
|
|
885
|
+
|
|
886
|
+
Example::
|
|
887
|
+
|
|
888
|
+
server = TraceViewerServer(port=8888)
|
|
889
|
+
server.start()
|
|
890
|
+
print("Browse traces at http://localhost:8888/traces")
|
|
891
|
+
# ...
|
|
892
|
+
server.stop()
|
|
893
|
+
"""
|
|
894
|
+
|
|
895
|
+
def __init__(
|
|
896
|
+
self,
|
|
897
|
+
*,
|
|
898
|
+
port: int = 8888,
|
|
899
|
+
host: str = "127.0.0.1",
|
|
900
|
+
store: Any = None,
|
|
901
|
+
cors_origins: str = "",
|
|
902
|
+
) -> None:
|
|
903
|
+
self._port = port
|
|
904
|
+
self._host = host
|
|
905
|
+
self._store = store
|
|
906
|
+
self._cors_origins = cors_origins
|
|
907
|
+
self._server: http.server.HTTPServer | None = None
|
|
908
|
+
self._thread: threading.Thread | None = None
|
|
909
|
+
|
|
910
|
+
def _get_store(self) -> Any:
|
|
911
|
+
if self._store is not None:
|
|
912
|
+
return self._store
|
|
913
|
+
from spanforge._store import get_store # noqa: PLC0415
|
|
914
|
+
return get_store()
|
|
915
|
+
|
|
916
|
+
def start(self) -> None:
|
|
917
|
+
"""Start the viewer server in a background daemon thread."""
|
|
918
|
+
if self._thread is not None and self._thread.is_alive():
|
|
919
|
+
return # already running
|
|
920
|
+
|
|
921
|
+
get_store_fn = self._get_store
|
|
922
|
+
cors = self._cors_origins
|
|
923
|
+
|
|
924
|
+
class _Handler(_TraceAPIHandler):
|
|
925
|
+
pass
|
|
926
|
+
|
|
927
|
+
_Handler._get_store = staticmethod(get_store_fn) # type: ignore[attr-defined]
|
|
928
|
+
_Handler._cors_origins = cors # type: ignore[attr-defined]
|
|
929
|
+
|
|
930
|
+
self._server = http.server.HTTPServer((self._host, self._port), _Handler)
|
|
931
|
+
self._thread = threading.Thread(
|
|
932
|
+
target=self._server.serve_forever,
|
|
933
|
+
name=f"spanforge-viewer-{self._port}",
|
|
934
|
+
daemon=True,
|
|
935
|
+
)
|
|
936
|
+
self._thread.start()
|
|
937
|
+
_log.info(
|
|
938
|
+
"spanforge trace viewer running at http://%s:%d",
|
|
939
|
+
self._host,
|
|
940
|
+
self._port,
|
|
941
|
+
)
|
|
942
|
+
print(
|
|
943
|
+
f"[spanforge] Trace viewer: http://{self._host}:{self._port}/traces"
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
def stop(self) -> None:
|
|
947
|
+
"""Shut down the viewer server."""
|
|
948
|
+
if self._server is not None:
|
|
949
|
+
self._server.shutdown()
|
|
950
|
+
self._server = None
|
|
951
|
+
if self._thread is not None:
|
|
952
|
+
self._thread.join(timeout=3.0)
|
|
953
|
+
self._thread = None
|