spanforge 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/_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">&#x2B21; 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()">&#9728;</button>
173
+ <button class="icon-btn" title="Refresh" onclick="loadData()">&#8635;</button>
174
+ <button class="icon-btn" title="Export as JSONL" onclick="exportEvents()">&#8659;</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">&#128269;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
205
+
206
+ // ─── Syntax highlight ───────────────────────────────────────────────────────
207
+ function synHi(obj){
208
+ return JSON.stringify(obj,null,2).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
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)">&#10007; 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">&#10003; compliance: ${Math.round(avg)}%${extra}</span>`;
243
+ else if(avg>=50)el.innerHTML=`<span class="chain-warn">&#9888; compliance: ${Math.round(avg)}%${extra}</span>`;
244
+ else el.innerHTML=`<span style="color:var(--error)">&#10007; 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">&#128203;</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">&#10007; Chain TAMPERED &mdash; ${tampered} tampered event(s), ${gaps} gap(s)</div>`;
260
+ else if(d.chain_valid)chainHtml=`<div class="comp-chain-status comp-chain-ok">&#10003; Chain Integrity Verified &mdash; ${d.chain_event_count||0} signed events</div>`;
261
+ else chainHtml=`<div class="comp-chain-status comp-chain-warn">&#9888; Chain Not Verified &mdash; ${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()">&#8592; 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">&#10003; chain: 100% signed</span>';
346
+ else el.innerHTML=`<span class="chain-warn">&#9888; 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">&#128235;</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">&#128203;</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)">&#8592; 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">&#128269;</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
+ &nbsp;<span class="sig-badge ${isSig?'sig-yes':'sig-no'}">${isSig?'&#10003; 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