spanforge 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/_server.py
ADDED
|
@@ -0,0 +1,1708 @@
|
|
|
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
|
+
``GET /v1/spanforge/status`` Platform status JSON.
|
|
27
|
+
``GET /v1/trust/scorecard`` T.R.U.S.T. scorecard JSON.
|
|
28
|
+
``GET /v1/trust/badge/{id}.svg``SVG trust badge for a project.
|
|
29
|
+
``GET /v1/audit/{type}`` Audit records by schema key.
|
|
30
|
+
``GET /v1/privacy/dsar/{id}`` GDPR/CCPA data subject access.
|
|
31
|
+
``GET /healthz`` Kubernetes liveness probe (Phase 11).
|
|
32
|
+
``GET /readyz`` Kubernetes readiness probe (Phase 11).
|
|
33
|
+
``GET /v1/enterprise/status`` Enterprise subsystem status (Phase 11).
|
|
34
|
+
``GET /v1/enterprise/health`` Enterprise health check (Phase 11).
|
|
35
|
+
``GET /v1/security/owasp`` OWASP audit result (Phase 11).
|
|
36
|
+
``GET /v1/security/threat-model``STRIDE threat model (Phase 11).
|
|
37
|
+
``GET /v1/security/scan`` Security scan result (Phase 11).
|
|
38
|
+
``POST /v1/scan/pii`` PII scan endpoint.
|
|
39
|
+
``POST /v1/scan/secrets`` Secrets scan endpoint.
|
|
40
|
+
``POST /v1/trust-gate`` Trust gate evaluation.
|
|
41
|
+
============================== =====================================
|
|
42
|
+
|
|
43
|
+
All responses are UTF-8 JSON. CORS headers are **not** sent by default;
|
|
44
|
+
pass ``cors_origins="*"`` (or a specific origin) to enable cross-origin
|
|
45
|
+
access for a local HTML/JS viewer.
|
|
46
|
+
|
|
47
|
+
Usage
|
|
48
|
+
-----
|
|
49
|
+
::
|
|
50
|
+
|
|
51
|
+
# Programmatic
|
|
52
|
+
from spanforge._server import TraceViewerServer
|
|
53
|
+
server = TraceViewerServer(port=8888)
|
|
54
|
+
server.start() # background daemon thread
|
|
55
|
+
# ...
|
|
56
|
+
server.stop()
|
|
57
|
+
|
|
58
|
+
# CLI
|
|
59
|
+
$ spanforge serve --port 8888
|
|
60
|
+
$ spanforge serve --port 8888 --file my_spans.jsonl
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import http.server
|
|
66
|
+
import json
|
|
67
|
+
import logging
|
|
68
|
+
import re
|
|
69
|
+
import threading
|
|
70
|
+
import urllib.parse
|
|
71
|
+
from typing import Any
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"TraceViewerServer",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
_log = logging.getLogger("spanforge.server")
|
|
78
|
+
|
|
79
|
+
_TRACE_ID_PATH_RE = re.compile(r"^/traces/([0-9a-f]{32})$")
|
|
80
|
+
_MAX_EVENTS_PER_LIST = 200
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Embedded SPA — single-file trace viewer (vanilla JS, zero external deps)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
_VIEWER_HTML = """<!DOCTYPE html>
|
|
87
|
+
<html lang="en">
|
|
88
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
89
|
+
<title>spanforge — Trace Viewer</title>
|
|
90
|
+
<style>
|
|
91
|
+
: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}
|
|
92
|
+
[data-theme=light]{--bg:#f8fafc;--bg-panel:#ffffff;--bg-hover:#f1f5f9;--border:#e2e8f0;--text:#1e293b;--text-muted:#64748b}
|
|
93
|
+
*{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;overflow:hidden}
|
|
94
|
+
body{font-family:var(--font);background:var(--bg);color:var(--text);display:flex;flex-direction:column;font-size:13px}
|
|
95
|
+
#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}
|
|
96
|
+
.logo{font-size:15px;font-weight:700;color:var(--accent-light);letter-spacing:-0.5px;white-space:nowrap}
|
|
97
|
+
.stat-chip{padding:3px 9px;border-radius:20px;background:var(--bg-hover);color:var(--text-muted);font-size:11px;white-space:nowrap}
|
|
98
|
+
.chain-ok{color:var(--success)}.chain-warn{color:var(--warning)}.chain-none{color:var(--text-muted)}
|
|
99
|
+
#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}
|
|
100
|
+
#filter-input:focus{border-color:var(--accent)}
|
|
101
|
+
.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}
|
|
102
|
+
.icon-btn:hover{background:var(--bg-hover);color:var(--text)}
|
|
103
|
+
#main{display:flex;flex:1;overflow:hidden}
|
|
104
|
+
/* Left panel */
|
|
105
|
+
#traces-panel{width:220px;flex-shrink:0;border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
|
|
106
|
+
.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)}
|
|
107
|
+
.trace-item{padding:9px 12px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .1s}
|
|
108
|
+
.trace-item:hover{background:var(--bg-hover)}
|
|
109
|
+
.trace-item.active{background:var(--bg-hover);border-left:3px solid var(--accent);padding-left:9px}
|
|
110
|
+
.trace-id{font-family:var(--mono);font-size:11px;color:var(--accent-light)}
|
|
111
|
+
.trace-meta{margin-top:3px;display:flex;align-items:center;gap:5px;color:var(--text-muted);font-size:10px}
|
|
112
|
+
.badge{padding:1px 5px;border-radius:3px;font-size:10px;font-weight:600}
|
|
113
|
+
.badge-ok{background:rgba(16,185,129,.15);color:#10b981}.badge-err{background:rgba(239,68,68,.15);color:#ef4444}
|
|
114
|
+
.badge-n{background:var(--border);color:var(--text-muted)}
|
|
115
|
+
/* Center panel */
|
|
116
|
+
#center-panel{flex:1;overflow-y:auto;display:flex;flex-direction:column}
|
|
117
|
+
.event-row{display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s}
|
|
118
|
+
.event-row:hover{background:var(--bg-hover)}.event-row.active{background:var(--bg-hover);border-left:3px solid var(--accent);padding-left:13px}
|
|
119
|
+
.evt-type{padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;white-space:nowrap;font-family:var(--mono)}
|
|
120
|
+
.evt-id{font-family:var(--mono);font-size:10px;color:var(--text-muted);min-width:100px}
|
|
121
|
+
.evt-source{color:var(--text-muted);font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
122
|
+
.evt-ts{font-size:10px;color:var(--text-muted);white-space:nowrap}
|
|
123
|
+
/* Waterfall */
|
|
124
|
+
#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}
|
|
125
|
+
.wf-back{cursor:pointer;color:var(--accent-light);font-size:11px;text-decoration:none}.wf-back:hover{text-decoration:underline}
|
|
126
|
+
.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}
|
|
127
|
+
.wf-body{padding:6px 16px 16px}
|
|
128
|
+
.wf-ruler{display:flex;padding:0 0 4px 196px;font-size:9px;color:var(--text-muted);margin-bottom:2px}
|
|
129
|
+
.wf-ruler-mark{flex:1}
|
|
130
|
+
.wf-row{display:flex;align-items:center;gap:8px;padding:4px;border-radius:var(--radius);cursor:pointer;transition:background .1s}
|
|
131
|
+
.wf-row:hover{background:var(--bg-hover)}.wf-row.active{background:var(--bg-hover)}
|
|
132
|
+
.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}
|
|
133
|
+
.wf-timeline{flex:1;height:20px;position:relative;background:var(--bg-hover);border-radius:3px}
|
|
134
|
+
.wf-bar{position:absolute;top:2px;height:16px;border-radius:3px;min-width:3px}.wf-bar:hover{opacity:.8}
|
|
135
|
+
.wf-dl{width:56px;flex-shrink:0;font-size:10px;color:var(--text-muted);text-align:right}
|
|
136
|
+
/* Detail panel */
|
|
137
|
+
#detail-panel{width:340px;flex-shrink:0;border-left:1px solid var(--border);overflow-y:auto}
|
|
138
|
+
.det-hdr{padding:10px 12px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg-panel)}
|
|
139
|
+
.det-type{margin-bottom:6px}.det-kv{display:flex;flex-direction:column;gap:3px}
|
|
140
|
+
.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}
|
|
141
|
+
.det-sec{padding:9px 12px;border-bottom:1px solid var(--border)}
|
|
142
|
+
.sec-title{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text-muted);margin-bottom:7px}
|
|
143
|
+
.json-view{font-family:var(--mono);font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-all}
|
|
144
|
+
.jk{color:#7dd3fc}.js{color:#86efac}.jn{color:#fca5a5}.jb{color:#fbbf24}.jl{color:#94a3b8}
|
|
145
|
+
.sig-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:10px;font-size:10px;font-weight:600}
|
|
146
|
+
.sig-yes{background:rgba(16,185,129,.15);color:#10b981}.sig-no{background:rgba(148,163,184,.15);color:#94a3b8}
|
|
147
|
+
/* Empty states */
|
|
148
|
+
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:8px;color:var(--text-muted);padding:48px}
|
|
149
|
+
.empty-icon{font-size:32px}
|
|
150
|
+
/* Scrollbar */
|
|
151
|
+
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
152
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
153
|
+
/* Compliance dashboard */
|
|
154
|
+
.comp-dash{padding:20px;overflow-y:auto;flex:1}
|
|
155
|
+
.comp-card{background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px}
|
|
156
|
+
.comp-card-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text-muted);margin-bottom:10px}
|
|
157
|
+
.comp-chain-status{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600}
|
|
158
|
+
.comp-chain-ok{color:var(--success)}.comp-chain-err{color:var(--error)}.comp-chain-warn{color:var(--warning)}
|
|
159
|
+
.comp-fw-hdr{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
|
160
|
+
.comp-fw-name{font-size:13px;font-weight:700;color:var(--text)}
|
|
161
|
+
.comp-fw-pct{font-size:12px;font-weight:600;padding:2px 8px;border-radius:10px}
|
|
162
|
+
.comp-clause-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px}
|
|
163
|
+
.comp-clause-row:last-child{border-bottom:none}
|
|
164
|
+
.comp-clause-id{font-family:var(--mono);font-size:10px;color:var(--accent-light);min-width:80px}
|
|
165
|
+
.comp-clause-desc{flex:1;color:var(--text)}
|
|
166
|
+
.comp-clause-badge{padding:2px 7px;border-radius:3px;font-size:10px;font-weight:600}
|
|
167
|
+
.comp-pass{background:rgba(16,185,129,.15);color:#10b981}.comp-fail{background:rgba(239,68,68,.15);color:#ef4444}
|
|
168
|
+
.comp-stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px}
|
|
169
|
+
.comp-stat{text-align:center;padding:10px;background:var(--bg-hover);border-radius:var(--radius)}
|
|
170
|
+
.comp-stat-val{font-size:20px;font-weight:700}.comp-stat-lbl{font-size:10px;color:var(--text-muted);margin-top:2px}
|
|
171
|
+
.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)}
|
|
172
|
+
.comp-back:hover{text-decoration:underline}
|
|
173
|
+
.comp-model-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12px}
|
|
174
|
+
.comp-model-row:last-child{border-bottom:none}
|
|
175
|
+
.comp-model-name{font-family:var(--mono);font-size:11px;color:var(--accent-light);min-width:140px}
|
|
176
|
+
.comp-model-meta{color:var(--text-muted);font-size:11px}
|
|
177
|
+
</style></head>
|
|
178
|
+
<body>
|
|
179
|
+
<header id="header">
|
|
180
|
+
<div class="logo">⬡ spanforge</div>
|
|
181
|
+
<div class="stat-chip" id="s-traces">— traces</div>
|
|
182
|
+
<div class="stat-chip" id="s-events">— events</div>
|
|
183
|
+
<div class="stat-chip" id="s-cost">$—</div>
|
|
184
|
+
<div class="stat-chip" id="s-chain">chain</div>
|
|
185
|
+
<div class="stat-chip" id="s-compliance" title="Click to view compliance dashboard" style="cursor:pointer">compliance</div>
|
|
186
|
+
<input id="filter-input" type="text" placeholder="Filter traces, events, IDs…" oninput="applyFilter()">
|
|
187
|
+
<button class="icon-btn" id="theme-btn" title="Toggle light/dark" onclick="toggleTheme()">☀</button>
|
|
188
|
+
<button class="icon-btn" title="Refresh" onclick="loadData()">↻</button>
|
|
189
|
+
<button class="icon-btn" title="Export as JSONL" onclick="exportEvents()">⇓</button>
|
|
190
|
+
</header>
|
|
191
|
+
<div id="main">
|
|
192
|
+
<nav id="traces-panel">
|
|
193
|
+
<div class="panel-title">Traces</div>
|
|
194
|
+
<div id="traces-list"></div>
|
|
195
|
+
</nav>
|
|
196
|
+
<section id="center-panel"><div id="center-content"></div></section>
|
|
197
|
+
<aside id="detail-panel"><div id="detail-content">
|
|
198
|
+
<div class="empty"><div class="empty-icon">🔍</div><div>Select an event to inspect</div></div>
|
|
199
|
+
</div></aside>
|
|
200
|
+
</div>
|
|
201
|
+
<script>
|
|
202
|
+
// ─── State ─────────────────────────────────────────────────────────────────
|
|
203
|
+
const S={events:[],traceMap:{},sortedTraces:[],selTrace:null,selEvent:null,filter:'',dark:true,compData:null,compView:false};
|
|
204
|
+
|
|
205
|
+
// ─── Colors ─────────────────────────────────────────────────────────────────
|
|
206
|
+
const TC={'llm.trace':'#3b82f6','llm.cost':'#10b981','llm.eval':'#8b5cf6','llm.guard':'#f59e0b',
|
|
207
|
+
'llm.redact':'#ef4444','llm.audit':'#eab308','llm.cache':'#06b6d4','llm.drift':'#f97316'};
|
|
208
|
+
function tc(et){const ns=(et||'').split('.').slice(0,2).join('.');return TC[ns]||'#6b7280';}
|
|
209
|
+
|
|
210
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
211
|
+
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;}}
|
|
212
|
+
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';}
|
|
213
|
+
function shortType(et){return(et||'unknown').split('.').slice(-2).join('.');}
|
|
214
|
+
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));}
|
|
215
|
+
function costOf(ev){const p=ev.payload||{};return+(p.cost_usd||p.total_cost?.total_cost_usd||0);}
|
|
216
|
+
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;}
|
|
217
|
+
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;}
|
|
218
|
+
function durMs(ev){const p=ev.payload||{};return p.duration_ms!=null?+p.duration_ms:(endNs(ev)-startNs(ev))/1e6;}
|
|
219
|
+
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
|
220
|
+
|
|
221
|
+
// ─── Syntax highlight ───────────────────────────────────────────────────────
|
|
222
|
+
function synHi(obj){
|
|
223
|
+
return JSON.stringify(obj,null,2).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
224
|
+
.replace(/("(?:\\u[0-9a-fA-F]{4}|\\[^u]|[^\\\\"])*"(\\s*:)?|\\b(?:true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g,m=>{
|
|
225
|
+
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';
|
|
226
|
+
return`<span class="${c}">${m}</span>`;});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Data loading ───────────────────────────────────────────────────────────
|
|
230
|
+
async function loadData(){
|
|
231
|
+
try{
|
|
232
|
+
const r=await fetch('/events?limit=2000');const d=await r.json();const evts=d.events||[];
|
|
233
|
+
const tm={};let cost=0,signed=0;
|
|
234
|
+
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++;}
|
|
235
|
+
for(const a of Object.values(tm))a.sort((a,b)=>startNs(a)-startNs(b));
|
|
236
|
+
const latest=arr=>arr.reduce((m,e)=>{const t=new Date(e.timestamp||0).getTime();return t>m?t:m;},0);
|
|
237
|
+
const sorted=Object.keys(tm).sort((a,b)=>latest(tm[b])-latest(tm[a]));
|
|
238
|
+
Object.assign(S,{events:evts,traceMap:tm,sortedTraces:sorted});
|
|
239
|
+
renderHeader({traces:sorted.length,events:evts.length,cost,signed});
|
|
240
|
+
renderTraceList();renderCenter();
|
|
241
|
+
loadComplianceSummary();
|
|
242
|
+
}catch(e){console.error('load failed',e);}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function loadComplianceSummary(){
|
|
246
|
+
try{
|
|
247
|
+
const r=await fetch('/compliance/summary');const d=await r.json();
|
|
248
|
+
S.compData=d;
|
|
249
|
+
const el=document.getElementById('s-compliance');
|
|
250
|
+
// GA-02-A: Show chain integrity + PII in compliance banner
|
|
251
|
+
const chainOk=d.chain_valid;const pii=d.pii_hits||0;const tampered=d.chain_tampered||0;
|
|
252
|
+
if(tampered>0){el.innerHTML=`<span style="color:var(--error)">✗ chain: TAMPERED (${tampered})</span>`;el.title='Chain integrity compromised! '+tampered+' tampered event(s)';}
|
|
253
|
+
else if(!d.frameworks||!d.frameworks.length){el.innerHTML='<span class="chain-none">compliance: n/a</span>';}
|
|
254
|
+
else{
|
|
255
|
+
const avg=d.frameworks.reduce((s,f)=>s+f.pct,0)/d.frameworks.length;
|
|
256
|
+
let extra=pii>0?` PII:${pii}`:'';
|
|
257
|
+
if(avg>=90)el.innerHTML=`<span class="chain-ok">✓ compliance: ${Math.round(avg)}%${extra}</span>`;
|
|
258
|
+
else if(avg>=50)el.innerHTML=`<span class="chain-warn">⚠ compliance: ${Math.round(avg)}%${extra}</span>`;
|
|
259
|
+
else el.innerHTML=`<span style="color:var(--error)">✗ compliance: ${Math.round(avg)}%${extra}</span>`;
|
|
260
|
+
}
|
|
261
|
+
el.onclick=()=>showComplianceDashboard();
|
|
262
|
+
}catch(e){document.getElementById('s-compliance').innerHTML='<span class="chain-none">compliance: error</span>';}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function showComplianceDashboard(){
|
|
266
|
+
S.compView=true;
|
|
267
|
+
const el=document.getElementById('center-content');
|
|
268
|
+
const d=S.compData;
|
|
269
|
+
if(!d){el.innerHTML='<div class="empty"><div class="empty-icon">📋</div><div>No compliance data</div></div>';return;}
|
|
270
|
+
|
|
271
|
+
// Chain integrity card
|
|
272
|
+
let chainHtml='';
|
|
273
|
+
const tampered=d.chain_tampered||0;const gaps=d.chain_gaps||0;
|
|
274
|
+
if(tampered>0)chainHtml=`<div class="comp-chain-status comp-chain-err">✗ Chain TAMPERED — ${tampered} tampered event(s), ${gaps} gap(s)</div>`;
|
|
275
|
+
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>`;
|
|
276
|
+
else chainHtml=`<div class="comp-chain-status comp-chain-warn">⚠ Chain Not Verified — ${d.chain_event_count||0}/${d.event_count||0} signed</div>`;
|
|
277
|
+
|
|
278
|
+
// Stats summary
|
|
279
|
+
const pii=d.pii_hits||0;const piiEvts=d.pii_events_with_hits||0;
|
|
280
|
+
const explPct=d.explanation_coverage_pct!=null?d.explanation_coverage_pct+'%':'n/a';
|
|
281
|
+
|
|
282
|
+
// Frameworks
|
|
283
|
+
let fwHtml='';
|
|
284
|
+
if(d.frameworks&&d.frameworks.length){
|
|
285
|
+
for(const fw of d.frameworks){
|
|
286
|
+
const pctColor=fw.pct>=90?'var(--success)':fw.pct>=50?'var(--warning)':'var(--error)';
|
|
287
|
+
const clauses=fw.clauses||[];
|
|
288
|
+
const passed=clauses.filter(c=>c.passed).length;
|
|
289
|
+
fwHtml+=`<div class="comp-card">
|
|
290
|
+
<div class="comp-fw-hdr">
|
|
291
|
+
<span class="comp-fw-name">${esc(fw.framework)}</span>
|
|
292
|
+
<span class="comp-fw-pct" style="background:${pctColor}22;color:${pctColor}">${fw.pct}% (${fw.score}/${fw.max_score})</span>
|
|
293
|
+
<span style="font-size:11px;color:var(--text-muted)">${passed}/${clauses.length} clauses passed</span>
|
|
294
|
+
</div>
|
|
295
|
+
${clauses.map(c=>`<div class="comp-clause-row">
|
|
296
|
+
<span class="comp-clause-id">${esc(c.clause_id)}</span>
|
|
297
|
+
<span class="comp-clause-desc">${esc(c.description)}</span>
|
|
298
|
+
<span class="comp-clause-badge ${c.passed?'comp-pass':'comp-fail'}">${c.passed?'PASS':'FAIL'}</span>
|
|
299
|
+
</div>`).join('')}
|
|
300
|
+
</div>`;
|
|
301
|
+
}
|
|
302
|
+
}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>';}
|
|
303
|
+
|
|
304
|
+
// Model registry card — extract unique models from events
|
|
305
|
+
let modelHtml='';
|
|
306
|
+
const models=new Map();
|
|
307
|
+
for(const ev of S.events){
|
|
308
|
+
const p=ev.payload||{};
|
|
309
|
+
const model=p.model||p.model_id||p.model_name||(p.model_info&&p.model_info.model_id);
|
|
310
|
+
if(model&&typeof model==='string'){
|
|
311
|
+
if(!models.has(model)){
|
|
312
|
+
models.set(model,{count:0,source:new Set(),lastSeen:null});
|
|
313
|
+
}
|
|
314
|
+
const m=models.get(model);
|
|
315
|
+
m.count++;
|
|
316
|
+
if(ev.source)m.source.add(ev.source);
|
|
317
|
+
if(ev.timestamp)m.lastSeen=ev.timestamp;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if(models.size>0){
|
|
321
|
+
const rows=[...models.entries()].sort((a,b)=>b[1].count-a[1].count);
|
|
322
|
+
modelHtml=`<div class="comp-card"><div class="comp-card-title">Model Registry</div>
|
|
323
|
+
${rows.map(([name,info])=>`<div class="comp-model-row">
|
|
324
|
+
<span class="comp-model-name">${esc(name)}</span>
|
|
325
|
+
<span class="comp-model-meta">${info.count} event${info.count!==1?'s':''}</span>
|
|
326
|
+
<span class="comp-model-meta">${[...info.source].join(', ')}</span>
|
|
327
|
+
<span class="comp-model-meta" style="margin-left:auto">${fmtTime(info.lastSeen)}</span>
|
|
328
|
+
</div>`).join('')}
|
|
329
|
+
</div>`;
|
|
330
|
+
}else{
|
|
331
|
+
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>';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
el.innerHTML=`<div class="comp-dash">
|
|
335
|
+
<button class="comp-back" onclick="hideComplianceDashboard()">← Back to Traces</button>
|
|
336
|
+
<div class="comp-card"><div class="comp-card-title">Overview</div>
|
|
337
|
+
${chainHtml}
|
|
338
|
+
<div class="comp-stat-grid" style="margin-top:12px">
|
|
339
|
+
<div class="comp-stat"><div class="comp-stat-val">${d.event_count||0}</div><div class="comp-stat-lbl">Total Events</div></div>
|
|
340
|
+
<div class="comp-stat"><div class="comp-stat-val">${d.chain_event_count||0}</div><div class="comp-stat-lbl">Signed Events</div></div>
|
|
341
|
+
<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>
|
|
342
|
+
<div class="comp-stat"><div class="comp-stat-val">${piiEvts}</div><div class="comp-stat-lbl">Events with PII</div></div>
|
|
343
|
+
<div class="comp-stat"><div class="comp-stat-val">${explPct}</div><div class="comp-stat-lbl">Explanation Coverage</div></div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
${fwHtml}
|
|
347
|
+
${modelHtml}
|
|
348
|
+
</div>`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function hideComplianceDashboard(){S.compView=false;renderCenter();}
|
|
352
|
+
|
|
353
|
+
// ─── Header ─────────────────────────────────────────────────────────────────
|
|
354
|
+
function renderHeader({traces,events,cost,signed}){
|
|
355
|
+
document.getElementById('s-traces').textContent=`${traces} trace${traces!==1?'s':''}`;
|
|
356
|
+
document.getElementById('s-events').textContent=`${events} event${events!==1?'s':''}`;
|
|
357
|
+
document.getElementById('s-cost').textContent=`$${cost.toFixed(4)}`;
|
|
358
|
+
const el=document.getElementById('s-chain');const pct=events>0?Math.round(signed/events*100):0;
|
|
359
|
+
if(!signed)el.innerHTML='<span class="chain-none">chain: unsigned</span>';
|
|
360
|
+
else if(pct===100)el.innerHTML='<span class="chain-ok">✓ chain: 100% signed</span>';
|
|
361
|
+
else el.innerHTML=`<span class="chain-warn">⚠ chain: ${pct}% signed</span>`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── Trace list ──────────────────────────────────────────────────────────────
|
|
365
|
+
function renderTraceList(){
|
|
366
|
+
const el=document.getElementById('traces-list');
|
|
367
|
+
const f=S.filter.toLowerCase();const ts=S.sortedTraces;
|
|
368
|
+
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;}
|
|
369
|
+
el.innerHTML=ts.filter(tid=>!f||(tm=>tm.some(e=>matches(e,f)))(S.traceMap[tid]||[])||(tid!=='__none__'&&tid.includes(f)))
|
|
370
|
+
.map(tid=>{const evts=S.traceMap[tid]||[];const cost=evts.reduce((s,e)=>s+costOf(e),0);
|
|
371
|
+
const hasErr=evts.some(e=>(e.payload||{}).status==='error');const ts2=evts[evts.length-1]?.timestamp;
|
|
372
|
+
const disp=tid==='__none__'?'(ungrouped)':tid.substring(0,14)+'\\u2026';const active=S.selTrace===tid;
|
|
373
|
+
return`<div class="trace-item${active?' active':''}" data-tid="${esc(tid)}" onclick="selTrace(this.dataset.tid)">
|
|
374
|
+
<div class="trace-id">${esc(disp)}</div>
|
|
375
|
+
<div class="trace-meta">
|
|
376
|
+
<span class="badge ${hasErr?'badge-err':'badge-ok'}">${hasErr?'ERR':'OK'}</span>
|
|
377
|
+
<span class="badge badge-n">${evts.length}</span>
|
|
378
|
+
${cost>0?`<span style="color:var(--success)">$${cost.toFixed(4)}</span>`:''}
|
|
379
|
+
<span style="margin-left:auto">${fmtTime(ts2)}</span>
|
|
380
|
+
</div></div>`;}).join('');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Center panel ────────────────────────────────────────────────────────────
|
|
384
|
+
function renderCenter(){const el=document.getElementById('center-content');if(S.compView)showComplianceDashboard();else if(S.selTrace)renderWaterfall(el);else renderEventList(el);}
|
|
385
|
+
|
|
386
|
+
function renderEventList(el){
|
|
387
|
+
const f=S.filter.toLowerCase();const evts=S.events.filter(e=>matches(e,f));
|
|
388
|
+
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;}
|
|
389
|
+
el.innerHTML=`<div class="panel-title" style="padding:9px 16px 6px;position:sticky;top:0;background:var(--bg-panel)">All Events — newest first</div>`
|
|
390
|
+
+evts.slice(0,500).map(ev=>{const c=tc(ev.event_type);const act=S.selEvent?.event_id===ev.event_id;
|
|
391
|
+
return`<div class="event-row${act?' active':''}" data-eid="${esc(ev.event_id)}" onclick="selEvt(this.dataset.eid)">
|
|
392
|
+
<span class="evt-type" style="background:${c}22;color:${c}">${esc(shortType(ev.event_type))}</span>
|
|
393
|
+
<span class="evt-id">${esc((ev.event_id||'').substring(0,14))}</span>
|
|
394
|
+
<span class="evt-source">${esc(ev.source||'')}</span>
|
|
395
|
+
<span class="evt-ts">${fmtTime(ev.timestamp)}</span></div>`;}).join('');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderWaterfall(el){
|
|
399
|
+
const tid=S.selTrace;const evts=S.traceMap[tid]||[];if(!evts.length)return;
|
|
400
|
+
const f=S.filter.toLowerCase();const filtered=evts.filter(e=>matches(e,f));
|
|
401
|
+
const minNs=Math.min(...evts.map(startNs));const maxNs=Math.max(...evts.map(endNs));
|
|
402
|
+
const totalMs=(maxNs-minNs)/1e6||1;const cost=evts.reduce((s,e)=>s+costOf(e),0);
|
|
403
|
+
const dispTid=tid==='__none__'?'(ungrouped)':tid;
|
|
404
|
+
const marks=[0,.25,.5,.75,1].map(f=>`<span class="wf-ruler-mark">${(totalMs*f).toFixed(1)}ms</span>`).join('');
|
|
405
|
+
el.innerHTML=`<div id="wf-header">
|
|
406
|
+
<span class="wf-back" onclick="selTrace(null)">← All Traces</span>
|
|
407
|
+
<span class="wf-tid" title="${esc(dispTid)}">${esc(dispTid.substring(0,36))}${dispTid.length>36?'\\u2026':''}</span>
|
|
408
|
+
${cost>0?`<span class="wf-cost">$${cost.toFixed(4)}</span>`:''}
|
|
409
|
+
<span class="wf-dur">${totalMs.toFixed(2)}ms total</span>
|
|
410
|
+
</div>
|
|
411
|
+
<div class="wf-body">
|
|
412
|
+
<div class="wf-ruler" style="padding-left:196px">${marks}</div>
|
|
413
|
+
${filtered.map(ev=>{const c=tc(ev.event_type);const sMs=(startNs(ev)-minNs)/1e6;const dMs=durMs(ev);
|
|
414
|
+
const left=(sMs/totalMs*100).toFixed(2);const width=Math.max(.3,(dMs/totalMs*100)).toFixed(2);
|
|
415
|
+
const name=(ev.payload||{}).span_name||shortType(ev.event_type);const act=S.selEvent?.event_id===ev.event_id;
|
|
416
|
+
return`<div class="wf-row${act?' active':''}" data-eid="${esc(ev.event_id)}" onclick="selEvt(this.dataset.eid)">
|
|
417
|
+
<div class="wf-label">
|
|
418
|
+
<span class="evt-type" style="background:${c}22;color:${c};font-size:9px;padding:1px 4px">${esc(shortType(ev.event_type))}</span>
|
|
419
|
+
<span style="overflow:hidden;text-overflow:ellipsis" title="${esc(name)}">${esc(name)}</span>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="wf-timeline">
|
|
422
|
+
<div class="wf-bar" style="left:${left}%;width:${width}%;background:${c}cc" title="${esc(ev.event_type)} ${dMs.toFixed(3)}ms"></div>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="wf-dl">${fmtDur(dMs)}</div></div>`;}).join('')}
|
|
425
|
+
</div>`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Detail panel ────────────────────────────────────────────────────────────
|
|
429
|
+
function renderDetail(ev){
|
|
430
|
+
const el=document.getElementById('detail-content');
|
|
431
|
+
if(!ev){el.innerHTML='<div class="empty"><div class="empty-icon">🔍</div><div>Select an event to inspect</div></div>';return;}
|
|
432
|
+
const c=tc(ev.event_type);const isSig=!!ev.signature;
|
|
433
|
+
const kv=(k,v)=>`<div class="kv-row"><span class="kv-k">${k}</span><span class="kv-v">${esc(v||'—')}</span></div>`;
|
|
434
|
+
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>`:'';
|
|
435
|
+
el.innerHTML=`<div class="det-hdr">
|
|
436
|
+
<div class="det-type">
|
|
437
|
+
<span class="evt-type" style="background:${c}22;color:${c};font-size:12px">${esc(ev.event_type||'unknown')}</span>
|
|
438
|
+
<span class="sig-badge ${isSig?'sig-yes':'sig-no'}">${isSig?'✓ Signed':'Unsigned'}</span>
|
|
439
|
+
</div>
|
|
440
|
+
<div class="det-kv" style="margin-top:8px">
|
|
441
|
+
${kv('event_id',(ev.event_id||'').substring(0,20)+'\\u2026')}
|
|
442
|
+
${kv('source',ev.source)}${kv('timestamp',ev.timestamp)}
|
|
443
|
+
${kv('trace_id',(ev.trace_id||'').substring(0,16)+(ev.trace_id?'\\u2026':''))}
|
|
444
|
+
${kv('span_id',ev.span_id)}${kv('schema',ev.schema_version)}
|
|
445
|
+
</div></div>
|
|
446
|
+
${tags}
|
|
447
|
+
<div class="det-sec"><div class="sec-title">Payload</div><div class="json-view">${synHi(ev.payload||{})}</div></div>
|
|
448
|
+
${isSig?`<div class="det-sec"><div class="sec-title">Chain</div><div class="det-kv">
|
|
449
|
+
${kv('checksum',(ev.checksum||'').substring(0,30)+'\\u2026')}
|
|
450
|
+
${kv('signature',(ev.signature||'').substring(0,30)+'\\u2026')}
|
|
451
|
+
${kv('prev_id',ev.prev_id)}</div></div>`:''}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ─── Actions ─────────────────────────────────────────────────────────────────
|
|
455
|
+
function selTrace(tid){S.selTrace=tid;S.selEvent=null;renderTraceList();renderCenter();renderDetail(null);}
|
|
456
|
+
function selEvt(id){const ev=S.events.find(e=>e.event_id===id);S.selEvent=ev||null;renderDetail(ev);renderCenter();}
|
|
457
|
+
function applyFilter(){S.filter=document.getElementById('filter-input').value;renderTraceList();renderCenter();}
|
|
458
|
+
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';}
|
|
459
|
+
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);}
|
|
460
|
+
|
|
461
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
|
462
|
+
loadData();setInterval(loadData,30000);
|
|
463
|
+
</script>
|
|
464
|
+
</body></html>"""
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class _TraceAPIHandler(http.server.BaseHTTPRequestHandler):
|
|
468
|
+
# Injected by TraceViewerServer before binding.
|
|
469
|
+
_get_store: Any # callable: () -> TraceStore
|
|
470
|
+
_cors_origins: str = "" # configurable CORS origin; empty = no CORS header
|
|
471
|
+
|
|
472
|
+
def do_GET(self) -> None:
|
|
473
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
474
|
+
path = parsed.path.rstrip("/") or "/"
|
|
475
|
+
|
|
476
|
+
if path == "/":
|
|
477
|
+
self._html_response(_VIEWER_HTML)
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
if path == "/health":
|
|
481
|
+
self._json_response({"status": "ok"})
|
|
482
|
+
|
|
483
|
+
elif path == "/api/stats":
|
|
484
|
+
self._handle_api_stats()
|
|
485
|
+
|
|
486
|
+
elif path == "/ready":
|
|
487
|
+
self._handle_ready()
|
|
488
|
+
|
|
489
|
+
elif path == "/traces":
|
|
490
|
+
self._handle_list_traces()
|
|
491
|
+
|
|
492
|
+
elif _TRACE_ID_PATH_RE.match(path):
|
|
493
|
+
m = _TRACE_ID_PATH_RE.match(path)
|
|
494
|
+
self._handle_get_trace(m.group(1)) # type: ignore[union-attr]
|
|
495
|
+
|
|
496
|
+
elif path == "/events":
|
|
497
|
+
self._handle_list_events()
|
|
498
|
+
|
|
499
|
+
elif path == "/metrics":
|
|
500
|
+
self._handle_metrics()
|
|
501
|
+
|
|
502
|
+
elif path == "/compliance/summary":
|
|
503
|
+
self._handle_compliance_summary()
|
|
504
|
+
|
|
505
|
+
elif path == "/compliance/events":
|
|
506
|
+
self._handle_compliance_events()
|
|
507
|
+
|
|
508
|
+
elif path in ("/v1/spanforge/status", "/spanforge/status"):
|
|
509
|
+
self._handle_spanforge_status()
|
|
510
|
+
|
|
511
|
+
elif path == "/v1/trust/scorecard":
|
|
512
|
+
self._handle_trust_scorecard()
|
|
513
|
+
|
|
514
|
+
elif path.startswith("/v1/trust/badge/") and path.endswith(".svg"):
|
|
515
|
+
pid = path[len("/v1/trust/badge/") : -len(".svg")]
|
|
516
|
+
self._handle_trust_badge(pid)
|
|
517
|
+
|
|
518
|
+
elif path.startswith("/v1/audit/"):
|
|
519
|
+
record_type = path[len("/v1/audit/") :]
|
|
520
|
+
self._handle_audit_query(record_type)
|
|
521
|
+
|
|
522
|
+
elif path.startswith("/v1/risk/cec/"):
|
|
523
|
+
bundle_id = path[len("/v1/risk/cec/") :]
|
|
524
|
+
self._handle_get_cec_bundle(bundle_id)
|
|
525
|
+
|
|
526
|
+
elif path.startswith("/v1/privacy/dsar/"):
|
|
527
|
+
subject_id = path[len("/v1/privacy/dsar/") :]
|
|
528
|
+
self._handle_dsar_export(subject_id)
|
|
529
|
+
|
|
530
|
+
# Phase 11 — Enterprise Hardening endpoints
|
|
531
|
+
elif path == "/healthz":
|
|
532
|
+
self._handle_healthz()
|
|
533
|
+
elif path == "/readyz":
|
|
534
|
+
self._handle_readyz()
|
|
535
|
+
elif path == "/v1/enterprise/status":
|
|
536
|
+
self._handle_enterprise_status()
|
|
537
|
+
elif path == "/v1/enterprise/health":
|
|
538
|
+
self._handle_enterprise_health()
|
|
539
|
+
elif path == "/v1/security/owasp":
|
|
540
|
+
self._handle_security_owasp()
|
|
541
|
+
elif path == "/v1/security/threat-model":
|
|
542
|
+
self._handle_security_threat_model()
|
|
543
|
+
elif path == "/v1/security/scan":
|
|
544
|
+
self._handle_security_scan()
|
|
545
|
+
|
|
546
|
+
else:
|
|
547
|
+
self._error(404, "Not Found")
|
|
548
|
+
|
|
549
|
+
def do_POST(self) -> None:
|
|
550
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
551
|
+
path = parsed.path.rstrip("/") or "/"
|
|
552
|
+
|
|
553
|
+
if path == "/v1/scan/pii":
|
|
554
|
+
self._handle_post_scan_pii()
|
|
555
|
+
elif path == "/v1/scan/secrets":
|
|
556
|
+
self._handle_post_scan_secrets()
|
|
557
|
+
elif path == "/v1/trust-gate":
|
|
558
|
+
self._handle_post_trust_gate()
|
|
559
|
+
elif path == "/v1/risk/cec":
|
|
560
|
+
self._handle_post_risk_cec()
|
|
561
|
+
elif path == "/v1/feedback":
|
|
562
|
+
self._handle_post_feedback()
|
|
563
|
+
else:
|
|
564
|
+
self._error(404, "Not Found")
|
|
565
|
+
|
|
566
|
+
# ------------------------------------------------------------------
|
|
567
|
+
# Handlers
|
|
568
|
+
# ------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def _handle_api_stats(self) -> None:
|
|
571
|
+
"""Return aggregated stats in JSON (for the SPA header chips)."""
|
|
572
|
+
try:
|
|
573
|
+
store = self._get_store()
|
|
574
|
+
with store._lock:
|
|
575
|
+
events = [e for evts in store._traces.values() for e in evts]
|
|
576
|
+
trace_count = len(store._traces)
|
|
577
|
+
cost = sum(
|
|
578
|
+
float(
|
|
579
|
+
e.payload.get("cost_usd")
|
|
580
|
+
or e.payload.get("total_cost", {}).get("total_cost_usd")
|
|
581
|
+
or 0
|
|
582
|
+
)
|
|
583
|
+
for e in events
|
|
584
|
+
)
|
|
585
|
+
signed = sum(1 for e in events if getattr(e, "signature", None))
|
|
586
|
+
self._json_response(
|
|
587
|
+
{
|
|
588
|
+
"traces": trace_count,
|
|
589
|
+
"events": len(events),
|
|
590
|
+
"total_cost_usd": cost,
|
|
591
|
+
"signed_count": signed,
|
|
592
|
+
"unsigned_count": len(events) - signed,
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
except Exception: # NOSONAR
|
|
596
|
+
_log.exception("api_stats error")
|
|
597
|
+
self._error(500, "Internal Server Error")
|
|
598
|
+
|
|
599
|
+
def _handle_list_traces(self) -> None:
|
|
600
|
+
try:
|
|
601
|
+
store = self._get_store()
|
|
602
|
+
with store._lock:
|
|
603
|
+
trace_ids = list(store._traces.keys())
|
|
604
|
+
self._json_response({"trace_ids": trace_ids, "count": len(trace_ids)})
|
|
605
|
+
except Exception: # NOSONAR
|
|
606
|
+
_log.exception("list_traces error")
|
|
607
|
+
self._error(500, "Internal Server Error")
|
|
608
|
+
|
|
609
|
+
def _handle_ready(self) -> None:
|
|
610
|
+
"""Return readiness status — 200 if the store is accessible, 503 otherwise."""
|
|
611
|
+
import time as _time
|
|
612
|
+
|
|
613
|
+
checks: dict[str, str] = {}
|
|
614
|
+
ready = True
|
|
615
|
+
try:
|
|
616
|
+
store = self._get_store()
|
|
617
|
+
with store._lock:
|
|
618
|
+
_ = len(store._traces)
|
|
619
|
+
checks["store"] = "ok"
|
|
620
|
+
except Exception as exc: # NOSONAR
|
|
621
|
+
checks["store"] = f"error: {exc}"
|
|
622
|
+
ready = False
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
from spanforge.config import get_config
|
|
626
|
+
|
|
627
|
+
cfg = get_config()
|
|
628
|
+
checks["exporter"] = cfg.exporter
|
|
629
|
+
except Exception as exc: # NOSONAR
|
|
630
|
+
checks["exporter"] = f"error: {exc}"
|
|
631
|
+
ready = False
|
|
632
|
+
|
|
633
|
+
payload = {
|
|
634
|
+
"ready": ready,
|
|
635
|
+
"checks": checks,
|
|
636
|
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
|
637
|
+
}
|
|
638
|
+
self._json_response(payload, status=200 if ready else 503)
|
|
639
|
+
|
|
640
|
+
def _handle_get_trace(self, trace_id: str) -> None:
|
|
641
|
+
try:
|
|
642
|
+
store = self._get_store()
|
|
643
|
+
events = store.get_trace(trace_id)
|
|
644
|
+
if events is None:
|
|
645
|
+
self._error(404, f"Trace {trace_id!r} not found")
|
|
646
|
+
return
|
|
647
|
+
self._json_response(
|
|
648
|
+
{
|
|
649
|
+
"trace_id": trace_id,
|
|
650
|
+
"event_count": len(events),
|
|
651
|
+
"events": [_serialise_event(e) for e in events],
|
|
652
|
+
}
|
|
653
|
+
)
|
|
654
|
+
except Exception: # NOSONAR
|
|
655
|
+
_log.exception("get_trace error")
|
|
656
|
+
self._error(500, "Internal Server Error")
|
|
657
|
+
|
|
658
|
+
def _handle_list_events(self) -> None:
|
|
659
|
+
try:
|
|
660
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
661
|
+
params = urllib.parse.parse_qs(parsed_url.query)
|
|
662
|
+
try:
|
|
663
|
+
offset = max(0, int(params.get("offset", ["0"])[0]))
|
|
664
|
+
except (ValueError, TypeError):
|
|
665
|
+
offset = 0
|
|
666
|
+
try:
|
|
667
|
+
limit = min(
|
|
668
|
+
max(1, int(params.get("limit", [str(_MAX_EVENTS_PER_LIST)])[0])),
|
|
669
|
+
_MAX_EVENTS_PER_LIST,
|
|
670
|
+
)
|
|
671
|
+
except (ValueError, TypeError):
|
|
672
|
+
limit = _MAX_EVENTS_PER_LIST
|
|
673
|
+
|
|
674
|
+
store = self._get_store()
|
|
675
|
+
all_events: list[Any] = []
|
|
676
|
+
with store._lock:
|
|
677
|
+
for evts in store._traces.values():
|
|
678
|
+
all_events.extend(evts)
|
|
679
|
+
# Newest first.
|
|
680
|
+
all_events.sort(
|
|
681
|
+
key=lambda e: getattr(e, "timestamp", 0),
|
|
682
|
+
reverse=True,
|
|
683
|
+
)
|
|
684
|
+
subset = all_events[offset : offset + limit]
|
|
685
|
+
self._json_response(
|
|
686
|
+
{
|
|
687
|
+
"event_count": len(subset),
|
|
688
|
+
"total": len(all_events),
|
|
689
|
+
"offset": offset,
|
|
690
|
+
"limit": limit,
|
|
691
|
+
"events": [_serialise_event(e) for e in subset],
|
|
692
|
+
}
|
|
693
|
+
)
|
|
694
|
+
except Exception: # NOSONAR
|
|
695
|
+
_log.exception("list_events error")
|
|
696
|
+
self._error(500, "Internal Server Error")
|
|
697
|
+
|
|
698
|
+
def _handle_metrics(self) -> None:
|
|
699
|
+
try:
|
|
700
|
+
from spanforge._stream import _export_error_count
|
|
701
|
+
|
|
702
|
+
store = self._get_store()
|
|
703
|
+
with store._lock:
|
|
704
|
+
trace_count = len(store._traces)
|
|
705
|
+
event_count = sum(len(v) for v in store._traces.values())
|
|
706
|
+
body = (
|
|
707
|
+
f"spanforge_traces_in_store {trace_count}\n"
|
|
708
|
+
f"spanforge_events_in_store {event_count}\n"
|
|
709
|
+
f"spanforge_export_errors_total {_export_error_count}\n"
|
|
710
|
+
)
|
|
711
|
+
self._text_response(body)
|
|
712
|
+
except Exception: # NOSONAR
|
|
713
|
+
_log.exception("metrics error")
|
|
714
|
+
self._error(500, "Internal Server Error")
|
|
715
|
+
|
|
716
|
+
def _handle_compliance_summary(self) -> None:
|
|
717
|
+
"""``GET /compliance/summary`` — aggregate compliance posture.
|
|
718
|
+
|
|
719
|
+
GA-02-A: Returns chain integrity status, PII scan summary, and
|
|
720
|
+
framework compliance scores.
|
|
721
|
+
"""
|
|
722
|
+
try:
|
|
723
|
+
from spanforge.core.compliance_mapping import ComplianceMappingEngine
|
|
724
|
+
from spanforge.redact import scan_payload
|
|
725
|
+
|
|
726
|
+
store = self._get_store()
|
|
727
|
+
all_events: list[Any] = []
|
|
728
|
+
with store._lock:
|
|
729
|
+
for evts in store._traces.values():
|
|
730
|
+
all_events.extend(evts)
|
|
731
|
+
|
|
732
|
+
# Chain integrity check
|
|
733
|
+
chain_valid = False
|
|
734
|
+
chain_tampered = 0
|
|
735
|
+
chain_gaps = 0
|
|
736
|
+
signed_count = sum(1 for e in all_events if getattr(e, "signature", None))
|
|
737
|
+
try:
|
|
738
|
+
import os as _os
|
|
739
|
+
|
|
740
|
+
org_secret = _os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
741
|
+
if org_secret and all_events:
|
|
742
|
+
from spanforge.signing import verify_chain as _vc
|
|
743
|
+
|
|
744
|
+
chain_result = _vc(all_events, org_secret)
|
|
745
|
+
chain_valid = chain_result.valid
|
|
746
|
+
chain_tampered = chain_result.tampered_count
|
|
747
|
+
chain_gaps = len(chain_result.gaps)
|
|
748
|
+
except Exception as _err:
|
|
749
|
+
_log.debug("chain verification failed: %s", _err)
|
|
750
|
+
pii_hits_total = 0
|
|
751
|
+
pii_events_with_hits = 0
|
|
752
|
+
for e in all_events:
|
|
753
|
+
payload = getattr(e, "payload", None)
|
|
754
|
+
if isinstance(payload, dict):
|
|
755
|
+
result = scan_payload(payload)
|
|
756
|
+
if not result.clean:
|
|
757
|
+
pii_hits_total += len(result.hits)
|
|
758
|
+
pii_events_with_hits += 1
|
|
759
|
+
|
|
760
|
+
# Framework compliance
|
|
761
|
+
mapper = ComplianceMappingEngine()
|
|
762
|
+
frameworks: list[dict[str, Any]] = []
|
|
763
|
+
serialised_events = [dict(_serialise_event(event)) for event in all_events]
|
|
764
|
+
for framework_name in (
|
|
765
|
+
"soc2",
|
|
766
|
+
"hipaa",
|
|
767
|
+
"gdpr",
|
|
768
|
+
"nist_ai_rmf",
|
|
769
|
+
"eu_ai_act",
|
|
770
|
+
"iso_42001",
|
|
771
|
+
):
|
|
772
|
+
pkg = mapper.generate_evidence_package(
|
|
773
|
+
model_id="",
|
|
774
|
+
framework=framework_name,
|
|
775
|
+
from_date="1970-01-01",
|
|
776
|
+
to_date="9999-12-31",
|
|
777
|
+
audit_events=serialised_events,
|
|
778
|
+
)
|
|
779
|
+
clauses = [
|
|
780
|
+
{
|
|
781
|
+
"clause_id": c.clause_id,
|
|
782
|
+
"description": c.summary,
|
|
783
|
+
"passed": c.status.value == "pass",
|
|
784
|
+
"evidence_count": c.evidence_count,
|
|
785
|
+
"required_event_types": [],
|
|
786
|
+
}
|
|
787
|
+
for c in pkg.attestation.clauses
|
|
788
|
+
]
|
|
789
|
+
passed = sum(1 for clause in clauses if clause["passed"])
|
|
790
|
+
frameworks.append(
|
|
791
|
+
{
|
|
792
|
+
"framework": pkg.attestation.framework,
|
|
793
|
+
"score": passed,
|
|
794
|
+
"max_score": len(clauses),
|
|
795
|
+
"pct": round(passed / len(clauses) * 100, 1) if clauses else 0,
|
|
796
|
+
"clauses": clauses,
|
|
797
|
+
}
|
|
798
|
+
)
|
|
799
|
+
self._json_response(
|
|
800
|
+
{
|
|
801
|
+
"event_count": len(all_events),
|
|
802
|
+
"chain_valid": chain_valid,
|
|
803
|
+
"chain_event_count": signed_count,
|
|
804
|
+
"chain_tampered": chain_tampered,
|
|
805
|
+
"chain_gaps": chain_gaps,
|
|
806
|
+
"pii_hits": pii_hits_total,
|
|
807
|
+
"pii_events_with_hits": pii_events_with_hits,
|
|
808
|
+
"frameworks": frameworks,
|
|
809
|
+
"explanation_coverage_pct": self._compute_explanation_coverage_pct(all_events),
|
|
810
|
+
}
|
|
811
|
+
)
|
|
812
|
+
except Exception: # NOSONAR
|
|
813
|
+
_log.exception("compliance_summary error")
|
|
814
|
+
self._error(500, "Internal Server Error")
|
|
815
|
+
|
|
816
|
+
@staticmethod
|
|
817
|
+
def _compute_explanation_coverage_pct(all_events: list[Any]) -> float | None:
|
|
818
|
+
"""Return the % of trace/HITL decisions that have a matching explanation event."""
|
|
819
|
+
decision_count = sum(
|
|
820
|
+
1
|
|
821
|
+
for e in all_events
|
|
822
|
+
if str(getattr(e, "event_type", "")).startswith(("llm.trace.", "hitl."))
|
|
823
|
+
)
|
|
824
|
+
explanation_count = sum(
|
|
825
|
+
1 for e in all_events if str(getattr(e, "event_type", "")).startswith("explanation.")
|
|
826
|
+
)
|
|
827
|
+
if decision_count > 0:
|
|
828
|
+
return round(min(explanation_count / decision_count * 100, 100.0), 1)
|
|
829
|
+
return None
|
|
830
|
+
|
|
831
|
+
def _handle_compliance_events(self) -> None:
|
|
832
|
+
"""``GET /compliance/events?type=<prefix>&offset=N&limit=N`` — filter events by namespace prefix.
|
|
833
|
+
|
|
834
|
+
GA-02-B: Uses namespace prefix matching (e.g. ``llm.audit`` matches
|
|
835
|
+
``llm.audit.chain.verified``), adds ``hmac_valid`` per event, and
|
|
836
|
+
supports standard pagination via ``offset`` and ``limit``.
|
|
837
|
+
Omitting ``type`` returns all compliance-relevant events (``llm.audit``,
|
|
838
|
+
``llm.guard``, ``llm.redact``, ``llm.compliance``).
|
|
839
|
+
"""
|
|
840
|
+
try:
|
|
841
|
+
import os as _os
|
|
842
|
+
|
|
843
|
+
from spanforge.signing import verify as _verify
|
|
844
|
+
|
|
845
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
846
|
+
params = urllib.parse.parse_qs(parsed_url.query)
|
|
847
|
+
type_filter = params.get("type", [None])[0]
|
|
848
|
+
try:
|
|
849
|
+
offset = max(0, int(params.get("offset", ["0"])[0]))
|
|
850
|
+
except (ValueError, TypeError):
|
|
851
|
+
offset = 0
|
|
852
|
+
try:
|
|
853
|
+
limit = min(
|
|
854
|
+
max(1, int(params.get("limit", [str(_MAX_EVENTS_PER_LIST)])[0])),
|
|
855
|
+
_MAX_EVENTS_PER_LIST,
|
|
856
|
+
)
|
|
857
|
+
except (ValueError, TypeError):
|
|
858
|
+
limit = _MAX_EVENTS_PER_LIST
|
|
859
|
+
|
|
860
|
+
compliance_prefixes = ("llm.audit", "llm.guard", "llm.redact", "llm.compliance")
|
|
861
|
+
|
|
862
|
+
store = self._get_store()
|
|
863
|
+
all_events: list[Any] = []
|
|
864
|
+
with store._lock:
|
|
865
|
+
for evts in store._traces.values():
|
|
866
|
+
all_events.extend(evts)
|
|
867
|
+
|
|
868
|
+
# Namespace prefix matching
|
|
869
|
+
if type_filter:
|
|
870
|
+
prefix = type_filter.lower()
|
|
871
|
+
all_events = [
|
|
872
|
+
e
|
|
873
|
+
for e in all_events
|
|
874
|
+
if str(getattr(e, "event_type", "")).lower().startswith(prefix)
|
|
875
|
+
]
|
|
876
|
+
else:
|
|
877
|
+
# Return all compliance-relevant events
|
|
878
|
+
all_events = [
|
|
879
|
+
e
|
|
880
|
+
for e in all_events
|
|
881
|
+
if any(
|
|
882
|
+
str(getattr(e, "event_type", "")).lower().startswith(p)
|
|
883
|
+
for p in compliance_prefixes
|
|
884
|
+
)
|
|
885
|
+
]
|
|
886
|
+
|
|
887
|
+
all_events.sort(
|
|
888
|
+
key=lambda e: getattr(e, "timestamp", 0),
|
|
889
|
+
reverse=True,
|
|
890
|
+
)
|
|
891
|
+
total = len(all_events)
|
|
892
|
+
subset = all_events[offset : offset + limit]
|
|
893
|
+
|
|
894
|
+
org_secret = _os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
895
|
+
|
|
896
|
+
serialised: list[dict[str, Any]] = []
|
|
897
|
+
for ev in subset:
|
|
898
|
+
d = _serialise_event(ev)
|
|
899
|
+
# GA-02-B: Add hmac_valid per event
|
|
900
|
+
if org_secret and getattr(ev, "signature", None):
|
|
901
|
+
try:
|
|
902
|
+
d["hmac_valid"] = _verify(ev, org_secret)
|
|
903
|
+
except Exception:
|
|
904
|
+
d["hmac_valid"] = False
|
|
905
|
+
else:
|
|
906
|
+
d["hmac_valid"] = None # unsigned or no key
|
|
907
|
+
serialised.append(d)
|
|
908
|
+
|
|
909
|
+
self._json_response(
|
|
910
|
+
{
|
|
911
|
+
"type_filter": type_filter,
|
|
912
|
+
"event_count": len(serialised),
|
|
913
|
+
"total": total,
|
|
914
|
+
"offset": offset,
|
|
915
|
+
"limit": limit,
|
|
916
|
+
"events": serialised,
|
|
917
|
+
}
|
|
918
|
+
)
|
|
919
|
+
except Exception: # NOSONAR
|
|
920
|
+
_log.exception("compliance_events error")
|
|
921
|
+
self._error(500, "Internal Server Error")
|
|
922
|
+
|
|
923
|
+
# ------------------------------------------------------------------
|
|
924
|
+
# Wire helpers
|
|
925
|
+
# ------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
def _handle_post_scan_pii(self) -> None:
|
|
928
|
+
"""``POST /v1/scan/pii`` — scan plain text for PII (PII-004).
|
|
929
|
+
|
|
930
|
+
Request body (JSON)::
|
|
931
|
+
|
|
932
|
+
{"text": "<string>", "language": "en"}
|
|
933
|
+
|
|
934
|
+
Response (200)::
|
|
935
|
+
|
|
936
|
+
{
|
|
937
|
+
"entities": [{"type": ..., "start": ..., "end": ..., "score": ...}],
|
|
938
|
+
"redacted_text": "...",
|
|
939
|
+
"detected": true|false
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
Returns 400 if the body is not valid JSON or ``text`` is missing.
|
|
943
|
+
Returns 422 if content-length exceeds 1 MB.
|
|
944
|
+
"""
|
|
945
|
+
try:
|
|
946
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
947
|
+
_max_body = 1_048_576 # 1 MiB
|
|
948
|
+
if content_length > _max_body:
|
|
949
|
+
self._error(422, "Request body too large (max 1 MiB)")
|
|
950
|
+
return
|
|
951
|
+
raw = self.rfile.read(content_length) if content_length > 0 else b""
|
|
952
|
+
try:
|
|
953
|
+
body: Any = json.loads(raw.decode("utf-8"))
|
|
954
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
955
|
+
self._error(400, f"Invalid JSON: {exc}")
|
|
956
|
+
return
|
|
957
|
+
if not isinstance(body, dict) or "text" not in body:
|
|
958
|
+
self._error(400, "Request body must be a JSON object with a 'text' field")
|
|
959
|
+
return
|
|
960
|
+
text = body["text"]
|
|
961
|
+
if not isinstance(text, str):
|
|
962
|
+
self._error(400, "'text' must be a string")
|
|
963
|
+
return
|
|
964
|
+
language = body.get("language", "en")
|
|
965
|
+
if not isinstance(language, str):
|
|
966
|
+
language = "en"
|
|
967
|
+
|
|
968
|
+
from spanforge.sdk import sf_pii
|
|
969
|
+
|
|
970
|
+
result = sf_pii.scan_text(text, language=language)
|
|
971
|
+
self._json_response(
|
|
972
|
+
{
|
|
973
|
+
"entities": [
|
|
974
|
+
{
|
|
975
|
+
"type": e.type,
|
|
976
|
+
"start": e.start,
|
|
977
|
+
"end": e.end,
|
|
978
|
+
"score": e.score,
|
|
979
|
+
}
|
|
980
|
+
for e in result.entities
|
|
981
|
+
],
|
|
982
|
+
"redacted_text": result.redacted_text,
|
|
983
|
+
"detected": result.detected,
|
|
984
|
+
}
|
|
985
|
+
)
|
|
986
|
+
except Exception: # NOSONAR
|
|
987
|
+
_log.exception("POST /v1/scan/pii error")
|
|
988
|
+
self._error(500, "Internal Server Error")
|
|
989
|
+
|
|
990
|
+
def _handle_spanforge_status(self) -> None:
|
|
991
|
+
"""``GET /v1/spanforge/status`` — aggregate service status.
|
|
992
|
+
|
|
993
|
+
Returns ``{"sf_pii": {...}}`` contributed by
|
|
994
|
+
:meth:`~spanforge.sdk.pii.SFPIIClient.get_status`.
|
|
995
|
+
"""
|
|
996
|
+
try:
|
|
997
|
+
from spanforge.sdk import sf_pii, sf_trust
|
|
998
|
+
|
|
999
|
+
pii_status = sf_pii.get_status()
|
|
1000
|
+
trust_status = sf_trust.get_status()
|
|
1001
|
+
self._json_response(
|
|
1002
|
+
{
|
|
1003
|
+
"sf_pii": {
|
|
1004
|
+
"status": pii_status.status,
|
|
1005
|
+
"presidio_available": pii_status.presidio_available,
|
|
1006
|
+
"entity_types_loaded": pii_status.entity_types_loaded,
|
|
1007
|
+
"last_scan_at": pii_status.last_scan_at,
|
|
1008
|
+
},
|
|
1009
|
+
"sf_trust": {
|
|
1010
|
+
"status": trust_status.status,
|
|
1011
|
+
"dimension_count": trust_status.dimension_count,
|
|
1012
|
+
"total_trust_records": trust_status.total_trust_records,
|
|
1013
|
+
"pipelines_registered": trust_status.pipelines_registered,
|
|
1014
|
+
"last_scorecard_computed": trust_status.last_scorecard_computed,
|
|
1015
|
+
},
|
|
1016
|
+
}
|
|
1017
|
+
)
|
|
1018
|
+
except Exception: # NOSONAR
|
|
1019
|
+
_log.exception("GET /v1/spanforge/status error")
|
|
1020
|
+
self._error(500, "Internal Server Error")
|
|
1021
|
+
|
|
1022
|
+
# ------------------------------------------------------------------
|
|
1023
|
+
# Phase 10 — T.R.U.S.T. Scorecard & HallucCheck Contract endpoints
|
|
1024
|
+
# ------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
def _handle_trust_scorecard(self) -> None:
|
|
1027
|
+
"""``GET /v1/trust/scorecard`` — T.R.U.S.T. scorecard (TRS-026)."""
|
|
1028
|
+
try:
|
|
1029
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
1030
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
1031
|
+
project_id = qs.get("project_id", [""])[0]
|
|
1032
|
+
from_dt = qs.get("from", [None])[0]
|
|
1033
|
+
to_dt = qs.get("to", [None])[0]
|
|
1034
|
+
|
|
1035
|
+
from spanforge.sdk import sf_trust
|
|
1036
|
+
|
|
1037
|
+
scorecard = sf_trust.get_scorecard(
|
|
1038
|
+
project_id=project_id or None,
|
|
1039
|
+
from_dt=from_dt,
|
|
1040
|
+
to_dt=to_dt,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
def _dim_dict(dim: Any) -> dict[str, Any]:
|
|
1044
|
+
return {
|
|
1045
|
+
"score": dim.score,
|
|
1046
|
+
"trend": dim.trend,
|
|
1047
|
+
"last_updated": dim.last_updated,
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
self._json_response(
|
|
1051
|
+
{
|
|
1052
|
+
"project_id": scorecard.project_id,
|
|
1053
|
+
"overall_score": scorecard.overall_score,
|
|
1054
|
+
"colour_band": scorecard.colour_band,
|
|
1055
|
+
"transparency": _dim_dict(scorecard.transparency),
|
|
1056
|
+
"reliability": _dim_dict(scorecard.reliability),
|
|
1057
|
+
"user_trust": _dim_dict(scorecard.user_trust),
|
|
1058
|
+
"security": _dim_dict(scorecard.security),
|
|
1059
|
+
"traceability": _dim_dict(scorecard.traceability),
|
|
1060
|
+
"from_dt": scorecard.from_dt,
|
|
1061
|
+
"to_dt": scorecard.to_dt,
|
|
1062
|
+
"record_count": scorecard.record_count,
|
|
1063
|
+
}
|
|
1064
|
+
)
|
|
1065
|
+
except Exception: # NOSONAR
|
|
1066
|
+
_log.exception("GET /v1/trust/scorecard error")
|
|
1067
|
+
self._error(500, "Internal Server Error")
|
|
1068
|
+
|
|
1069
|
+
def _handle_trust_badge(self, project_id: str) -> None:
|
|
1070
|
+
"""``GET /v1/trust/badge/{project_id}.svg`` — T.R.U.S.T. badge (TRS-027)."""
|
|
1071
|
+
try:
|
|
1072
|
+
from spanforge.sdk import sf_trust
|
|
1073
|
+
|
|
1074
|
+
badge = sf_trust.get_badge(project_id=project_id or None)
|
|
1075
|
+
body = badge.svg.encode("utf-8")
|
|
1076
|
+
self.send_response(200)
|
|
1077
|
+
self.send_header("Content-Type", "image/svg+xml")
|
|
1078
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1079
|
+
self.send_header("ETag", f'"{badge.etag}"')
|
|
1080
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1081
|
+
self.end_headers()
|
|
1082
|
+
self.wfile.write(body)
|
|
1083
|
+
except Exception: # NOSONAR
|
|
1084
|
+
_log.exception("GET /v1/trust/badge error")
|
|
1085
|
+
self._error(500, "Internal Server Error")
|
|
1086
|
+
|
|
1087
|
+
def _handle_audit_query(self, record_type: str) -> None:
|
|
1088
|
+
"""``GET /v1/audit/{record_type}`` — audit query (TRS-023)."""
|
|
1089
|
+
try:
|
|
1090
|
+
from spanforge.sdk import sf_audit
|
|
1091
|
+
|
|
1092
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
1093
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
1094
|
+
project_id = qs.get("project_id", [None])[0]
|
|
1095
|
+
from_dt = qs.get("from", [None])[0]
|
|
1096
|
+
to_dt = qs.get("to", [None])[0]
|
|
1097
|
+
|
|
1098
|
+
schema_key = f"halluccheck.{record_type}.v1"
|
|
1099
|
+
date_range = (from_dt, to_dt) if from_dt or to_dt else None
|
|
1100
|
+
|
|
1101
|
+
records = sf_audit.export(
|
|
1102
|
+
schema_key=schema_key,
|
|
1103
|
+
date_range=date_range,
|
|
1104
|
+
project_id=project_id,
|
|
1105
|
+
)
|
|
1106
|
+
self._json_response({"records": records, "count": len(records)})
|
|
1107
|
+
except Exception: # NOSONAR
|
|
1108
|
+
_log.exception("GET /v1/audit/%s error", record_type)
|
|
1109
|
+
self._error(500, "Internal Server Error")
|
|
1110
|
+
|
|
1111
|
+
def _handle_dsar_export(self, subject_id: str) -> None:
|
|
1112
|
+
"""``GET /v1/privacy/dsar/{subject_id}`` — DSAR export (TRS-025)."""
|
|
1113
|
+
try:
|
|
1114
|
+
if not subject_id:
|
|
1115
|
+
self._error(400, "subject_id is required")
|
|
1116
|
+
return
|
|
1117
|
+
|
|
1118
|
+
from spanforge.sdk import sf_audit
|
|
1119
|
+
|
|
1120
|
+
records = sf_audit.export(schema_key=None)
|
|
1121
|
+
# Filter records mentioning the subject
|
|
1122
|
+
matching = [
|
|
1123
|
+
r
|
|
1124
|
+
for r in records
|
|
1125
|
+
if subject_id in str(r.get("project_id", ""))
|
|
1126
|
+
or subject_id in str(r.get("payload", {}))
|
|
1127
|
+
or subject_id in json.dumps(r, default=str)
|
|
1128
|
+
]
|
|
1129
|
+
|
|
1130
|
+
from datetime import datetime, timezone
|
|
1131
|
+
|
|
1132
|
+
now_iso = (
|
|
1133
|
+
datetime.now(tz=timezone.utc)
|
|
1134
|
+
.isoformat(timespec="microseconds")
|
|
1135
|
+
.replace("+00:00", "Z")
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
self._json_response(
|
|
1139
|
+
{
|
|
1140
|
+
"subject_id": subject_id,
|
|
1141
|
+
"records": matching,
|
|
1142
|
+
"record_count": len(matching),
|
|
1143
|
+
"exported_at": now_iso,
|
|
1144
|
+
}
|
|
1145
|
+
)
|
|
1146
|
+
except Exception: # NOSONAR
|
|
1147
|
+
_log.exception("GET /v1/privacy/dsar/%s error", subject_id)
|
|
1148
|
+
self._error(500, "Internal Server Error")
|
|
1149
|
+
|
|
1150
|
+
def _handle_post_scan_secrets(self) -> None:
|
|
1151
|
+
"""``POST /v1/scan/secrets`` — standalone secrets scan (TRS-022)."""
|
|
1152
|
+
try:
|
|
1153
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1154
|
+
_max_body = 1_048_576 # 1 MiB
|
|
1155
|
+
if content_length > _max_body:
|
|
1156
|
+
self._error(422, "Request body too large (max 1 MiB)")
|
|
1157
|
+
return
|
|
1158
|
+
raw = self.rfile.read(content_length) if content_length > 0 else b""
|
|
1159
|
+
try:
|
|
1160
|
+
body: Any = json.loads(raw.decode("utf-8"))
|
|
1161
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
1162
|
+
self._error(400, f"Invalid JSON: {exc}")
|
|
1163
|
+
return
|
|
1164
|
+
if not isinstance(body, dict) or "text" not in body:
|
|
1165
|
+
self._error(400, "Request body must be a JSON object with a 'text' field")
|
|
1166
|
+
return
|
|
1167
|
+
text = body["text"]
|
|
1168
|
+
if not isinstance(text, str):
|
|
1169
|
+
self._error(400, "'text' must be a string")
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
from spanforge.sdk import sf_secrets
|
|
1173
|
+
|
|
1174
|
+
result = sf_secrets.scan(text)
|
|
1175
|
+
high_confidence = 0.9
|
|
1176
|
+
medium_confidence = 0.75
|
|
1177
|
+
self._json_response(
|
|
1178
|
+
{
|
|
1179
|
+
"clean": not result.detected,
|
|
1180
|
+
"hits": [
|
|
1181
|
+
{
|
|
1182
|
+
"secret_type": h.secret_type,
|
|
1183
|
+
"line": None,
|
|
1184
|
+
"severity": (
|
|
1185
|
+
"high"
|
|
1186
|
+
if h.confidence >= high_confidence
|
|
1187
|
+
else "medium"
|
|
1188
|
+
if h.confidence >= medium_confidence
|
|
1189
|
+
else "low"
|
|
1190
|
+
),
|
|
1191
|
+
}
|
|
1192
|
+
for h in result.hits
|
|
1193
|
+
],
|
|
1194
|
+
"detected": result.detected,
|
|
1195
|
+
}
|
|
1196
|
+
)
|
|
1197
|
+
except Exception: # NOSONAR
|
|
1198
|
+
_log.exception("POST /v1/scan/secrets error")
|
|
1199
|
+
self._error(500, "Internal Server Error")
|
|
1200
|
+
|
|
1201
|
+
def _handle_post_trust_gate(self) -> None:
|
|
1202
|
+
"""``POST /v1/trust-gate`` — composite trust gate (TRS-020)."""
|
|
1203
|
+
try:
|
|
1204
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1205
|
+
_max_body = 1_048_576
|
|
1206
|
+
if content_length > _max_body:
|
|
1207
|
+
self._error(422, "Request body too large (max 1 MiB)")
|
|
1208
|
+
return
|
|
1209
|
+
raw = self.rfile.read(content_length) if content_length > 0 else b""
|
|
1210
|
+
try:
|
|
1211
|
+
body: Any = json.loads(raw.decode("utf-8"))
|
|
1212
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
1213
|
+
self._error(400, f"Invalid JSON: {exc}")
|
|
1214
|
+
return
|
|
1215
|
+
if not isinstance(body, dict):
|
|
1216
|
+
self._error(400, "Request body must be a JSON object")
|
|
1217
|
+
return
|
|
1218
|
+
|
|
1219
|
+
project_id = body.get("project_id", "")
|
|
1220
|
+
min_score = float(body.get("min_score", 60.0))
|
|
1221
|
+
|
|
1222
|
+
from spanforge.sdk import sf_gate, sf_trust
|
|
1223
|
+
|
|
1224
|
+
# Get current T.R.U.S.T. scorecard
|
|
1225
|
+
scorecard = sf_trust.get_scorecard(project_id=project_id or None)
|
|
1226
|
+
|
|
1227
|
+
failures: list[str] = []
|
|
1228
|
+
|
|
1229
|
+
# Check minimum score
|
|
1230
|
+
if scorecard.overall_score < min_score:
|
|
1231
|
+
failures.append(f"T.R.U.S.T. score {scorecard.overall_score} < minimum {min_score}")
|
|
1232
|
+
|
|
1233
|
+
# Run underlying trust gate if requested
|
|
1234
|
+
trust_gate_result = None
|
|
1235
|
+
if body.get("run_hri_check", True):
|
|
1236
|
+
try:
|
|
1237
|
+
tg = sf_gate.run_trust_gate(
|
|
1238
|
+
project_id=project_id,
|
|
1239
|
+
pipeline_id=body.get("pipeline_id", ""),
|
|
1240
|
+
)
|
|
1241
|
+
trust_gate_result = {
|
|
1242
|
+
"gate_id": tg.gate_id,
|
|
1243
|
+
"verdict": tg.verdict,
|
|
1244
|
+
"pass": tg.pass_,
|
|
1245
|
+
"failures": tg.failures,
|
|
1246
|
+
}
|
|
1247
|
+
if not tg.pass_:
|
|
1248
|
+
failures.extend(tg.failures)
|
|
1249
|
+
except Exception as exc:
|
|
1250
|
+
_log.warning("trust-gate: underlying gate failed: %s", exc)
|
|
1251
|
+
|
|
1252
|
+
from datetime import datetime, timezone
|
|
1253
|
+
|
|
1254
|
+
now_iso = (
|
|
1255
|
+
datetime.now(tz=timezone.utc)
|
|
1256
|
+
.isoformat(timespec="microseconds")
|
|
1257
|
+
.replace("+00:00", "Z")
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
verdict = "PASS" if not failures else "FAIL"
|
|
1261
|
+
self._json_response(
|
|
1262
|
+
{
|
|
1263
|
+
"pass": not failures,
|
|
1264
|
+
"verdict": verdict,
|
|
1265
|
+
"overall_score": scorecard.overall_score,
|
|
1266
|
+
"colour_band": scorecard.colour_band,
|
|
1267
|
+
"trust_gate": trust_gate_result,
|
|
1268
|
+
"failures": failures,
|
|
1269
|
+
"timestamp": now_iso,
|
|
1270
|
+
}
|
|
1271
|
+
)
|
|
1272
|
+
except Exception: # NOSONAR
|
|
1273
|
+
_log.exception("POST /v1/trust-gate error")
|
|
1274
|
+
self._error(500, "Internal Server Error")
|
|
1275
|
+
|
|
1276
|
+
def _handle_post_risk_cec(self) -> None:
|
|
1277
|
+
"""``POST /v1/risk/cec`` — build a CEC bundle (CEC-003).
|
|
1278
|
+
|
|
1279
|
+
Request body (JSON, all fields optional)::
|
|
1280
|
+
|
|
1281
|
+
{
|
|
1282
|
+
"project_id": "my-project",
|
|
1283
|
+
"date_range": ["2024-01-01", "2024-12-31"],
|
|
1284
|
+
"frameworks": ["eu_ai_act", "iso_42001"]
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
Response::
|
|
1288
|
+
|
|
1289
|
+
{
|
|
1290
|
+
"bundle_id": "...",
|
|
1291
|
+
"download_url": "file:///tmp/...",
|
|
1292
|
+
"expires_at": "2024-...",
|
|
1293
|
+
"hmac_manifest":"...",
|
|
1294
|
+
"record_counts":{},
|
|
1295
|
+
"frameworks": [],
|
|
1296
|
+
"generated_at": "2024-..."
|
|
1297
|
+
}
|
|
1298
|
+
"""
|
|
1299
|
+
try:
|
|
1300
|
+
import datetime as _dt
|
|
1301
|
+
|
|
1302
|
+
from spanforge.sdk import sf_cec
|
|
1303
|
+
|
|
1304
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1305
|
+
_max_body = 1_048_576 # 1 MiB
|
|
1306
|
+
if content_length > _max_body:
|
|
1307
|
+
self._error(422, "Request body too large (max 1 MiB)")
|
|
1308
|
+
return
|
|
1309
|
+
raw = self.rfile.read(content_length) if content_length > 0 else b""
|
|
1310
|
+
try:
|
|
1311
|
+
body: Any = json.loads(raw.decode("utf-8")) if raw else {}
|
|
1312
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
1313
|
+
self._error(400, f"Invalid JSON: {exc}")
|
|
1314
|
+
return
|
|
1315
|
+
if not isinstance(body, dict):
|
|
1316
|
+
body = {}
|
|
1317
|
+
|
|
1318
|
+
project_id: str = body.get("project_id", "default")
|
|
1319
|
+
raw_dr = body.get("date_range")
|
|
1320
|
+
if raw_dr and isinstance(raw_dr, list) and len(raw_dr) == 2:
|
|
1321
|
+
date_range: tuple[str, str] = (str(raw_dr[0]), str(raw_dr[1]))
|
|
1322
|
+
else:
|
|
1323
|
+
today = _dt.date.today().isoformat()
|
|
1324
|
+
year_ago = (_dt.date.today().replace(year=_dt.date.today().year - 1)).isoformat()
|
|
1325
|
+
date_range = (year_ago, today)
|
|
1326
|
+
|
|
1327
|
+
frameworks: list[str] = body.get("frameworks", [])
|
|
1328
|
+
|
|
1329
|
+
result = sf_cec.build_bundle(
|
|
1330
|
+
project_id=project_id,
|
|
1331
|
+
date_range=date_range,
|
|
1332
|
+
frameworks=frameworks,
|
|
1333
|
+
)
|
|
1334
|
+
self._json_response(
|
|
1335
|
+
{
|
|
1336
|
+
"bundle_id": result.bundle_id,
|
|
1337
|
+
"download_url": result.download_url,
|
|
1338
|
+
"expires_at": result.expires_at,
|
|
1339
|
+
"hmac_manifest": result.hmac_manifest,
|
|
1340
|
+
"record_counts": result.record_counts,
|
|
1341
|
+
"zip_path": result.zip_path,
|
|
1342
|
+
"frameworks": result.frameworks,
|
|
1343
|
+
"project_id": result.project_id,
|
|
1344
|
+
"generated_at": result.generated_at,
|
|
1345
|
+
},
|
|
1346
|
+
status=201,
|
|
1347
|
+
)
|
|
1348
|
+
except Exception: # NOSONAR
|
|
1349
|
+
_log.exception("POST /v1/risk/cec error")
|
|
1350
|
+
self._error(500, "Internal Server Error")
|
|
1351
|
+
|
|
1352
|
+
def _handle_post_feedback(self) -> None:
|
|
1353
|
+
"""``POST /v1/feedback`` — submit user feedback for an LLM response (F-21).
|
|
1354
|
+
|
|
1355
|
+
Request body (JSON)::
|
|
1356
|
+
|
|
1357
|
+
{
|
|
1358
|
+
"session_id": "<session-id>",
|
|
1359
|
+
"trace_id": "<trace-id>",
|
|
1360
|
+
"rating": "thumbs_up" | "thumbs_down" | ...,
|
|
1361
|
+
"comment": "optional free-text",
|
|
1362
|
+
"user_id": "optional-user-id",
|
|
1363
|
+
"source": "api"
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
Response (201)::
|
|
1367
|
+
|
|
1368
|
+
{
|
|
1369
|
+
"feedback_id": "<ulid>",
|
|
1370
|
+
"accepted": true
|
|
1371
|
+
}
|
|
1372
|
+
"""
|
|
1373
|
+
try:
|
|
1374
|
+
from spanforge.sdk import sf_feedback
|
|
1375
|
+
|
|
1376
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1377
|
+
_max_body = 1_048_576 # 1 MiB
|
|
1378
|
+
if content_length > _max_body:
|
|
1379
|
+
self._error(422, "Request body too large (max 1 MiB)")
|
|
1380
|
+
return
|
|
1381
|
+
raw = self.rfile.read(content_length) if content_length > 0 else b""
|
|
1382
|
+
try:
|
|
1383
|
+
body: Any = json.loads(raw.decode("utf-8")) if raw else {}
|
|
1384
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
1385
|
+
self._error(400, f"Invalid JSON: {exc}")
|
|
1386
|
+
return
|
|
1387
|
+
if not isinstance(body, dict):
|
|
1388
|
+
self._error(400, "Request body must be a JSON object")
|
|
1389
|
+
return
|
|
1390
|
+
|
|
1391
|
+
session_id: str = str(body.get("session_id", ""))
|
|
1392
|
+
trace_id: str = str(body.get("trace_id", ""))
|
|
1393
|
+
rating_raw = body.get("rating", "thumbs_up")
|
|
1394
|
+
|
|
1395
|
+
if not session_id:
|
|
1396
|
+
self._error(422, "'session_id' is required")
|
|
1397
|
+
return
|
|
1398
|
+
if not trace_id:
|
|
1399
|
+
self._error(422, "'trace_id' is required")
|
|
1400
|
+
return
|
|
1401
|
+
|
|
1402
|
+
feedback_id = sf_feedback.submit(
|
|
1403
|
+
session_id=session_id,
|
|
1404
|
+
trace_id=trace_id,
|
|
1405
|
+
rating=rating_raw,
|
|
1406
|
+
comment=body.get("comment"),
|
|
1407
|
+
user_id=body.get("user_id"),
|
|
1408
|
+
source=str(body.get("source", "api")),
|
|
1409
|
+
metadata=body.get("metadata"),
|
|
1410
|
+
linked_trust_dimension=body.get("linked_trust_dimension"),
|
|
1411
|
+
)
|
|
1412
|
+
self._json_response(
|
|
1413
|
+
{"feedback_id": feedback_id, "accepted": True},
|
|
1414
|
+
status=201,
|
|
1415
|
+
)
|
|
1416
|
+
except Exception: # NOSONAR
|
|
1417
|
+
_log.exception("POST /v1/feedback error")
|
|
1418
|
+
self._error(500, "Internal Server Error")
|
|
1419
|
+
|
|
1420
|
+
def _handle_get_cec_bundle(self, bundle_id: str) -> None:
|
|
1421
|
+
"""``GET /v1/risk/cec/{bundle_id}`` — re-issue download URL (CEC-004).
|
|
1422
|
+
|
|
1423
|
+
Re-issues a fresh download URL for an existing bundle without
|
|
1424
|
+
rebuilding it. Returns 404 if *bundle_id* is unknown in this
|
|
1425
|
+
session.
|
|
1426
|
+
|
|
1427
|
+
Response::
|
|
1428
|
+
|
|
1429
|
+
{
|
|
1430
|
+
"bundle_id": "...",
|
|
1431
|
+
"download_url": "file:///tmp/...",
|
|
1432
|
+
"expires_at": "2024-...",
|
|
1433
|
+
"hmac_manifest":"...",
|
|
1434
|
+
"generated_at": "2024-..."
|
|
1435
|
+
}
|
|
1436
|
+
"""
|
|
1437
|
+
if not bundle_id:
|
|
1438
|
+
self._error(400, "bundle_id is required")
|
|
1439
|
+
return
|
|
1440
|
+
|
|
1441
|
+
try:
|
|
1442
|
+
from spanforge.sdk import sf_cec
|
|
1443
|
+
from spanforge.sdk._exceptions import SFCECBuildError
|
|
1444
|
+
|
|
1445
|
+
try:
|
|
1446
|
+
result = sf_cec.reissue_download_url(bundle_id) # type: ignore[attr-defined]
|
|
1447
|
+
except SFCECBuildError as exc:
|
|
1448
|
+
self._error(404, str(exc))
|
|
1449
|
+
return
|
|
1450
|
+
|
|
1451
|
+
self._json_response(
|
|
1452
|
+
{
|
|
1453
|
+
"bundle_id": result.bundle_id,
|
|
1454
|
+
"download_url": result.download_url,
|
|
1455
|
+
"expires_at": result.expires_at,
|
|
1456
|
+
"hmac_manifest": result.hmac_manifest,
|
|
1457
|
+
"record_counts": result.record_counts,
|
|
1458
|
+
"zip_path": result.zip_path,
|
|
1459
|
+
"frameworks": result.frameworks,
|
|
1460
|
+
"project_id": result.project_id,
|
|
1461
|
+
"generated_at": result.generated_at,
|
|
1462
|
+
}
|
|
1463
|
+
)
|
|
1464
|
+
except Exception: # NOSONAR
|
|
1465
|
+
_log.exception("GET /v1/risk/cec/%s error", bundle_id)
|
|
1466
|
+
self._error(500, "Internal Server Error")
|
|
1467
|
+
|
|
1468
|
+
# ------------------------------------------------------------------
|
|
1469
|
+
# Phase 11 — Enterprise & Security handlers
|
|
1470
|
+
# ------------------------------------------------------------------
|
|
1471
|
+
|
|
1472
|
+
def _handle_healthz(self) -> None:
|
|
1473
|
+
"""``GET /healthz`` — Kubernetes liveness probe (ENT-023)."""
|
|
1474
|
+
try:
|
|
1475
|
+
from spanforge._batch_exporter import get_aggregate_health
|
|
1476
|
+
|
|
1477
|
+
batch_health = get_aggregate_health()
|
|
1478
|
+
payload: dict[str, object] = {
|
|
1479
|
+
"status": "ok",
|
|
1480
|
+
"exporters": {
|
|
1481
|
+
"count": batch_health["exporter_count"],
|
|
1482
|
+
"dropped": batch_health["total_dropped"],
|
|
1483
|
+
"exported": batch_health["total_exported"],
|
|
1484
|
+
"errors": batch_health["total_errors"],
|
|
1485
|
+
"circuit_open": batch_health["any_circuit_open"],
|
|
1486
|
+
},
|
|
1487
|
+
}
|
|
1488
|
+
except Exception: # NOSONAR
|
|
1489
|
+
payload = {"status": "ok"}
|
|
1490
|
+
self._json_response(payload)
|
|
1491
|
+
|
|
1492
|
+
def _handle_readyz(self) -> None:
|
|
1493
|
+
"""``GET /readyz`` — Kubernetes readiness probe (ENT-023)."""
|
|
1494
|
+
try:
|
|
1495
|
+
from spanforge.sdk import sf_enterprise
|
|
1496
|
+
|
|
1497
|
+
results = sf_enterprise.check_all_services_health()
|
|
1498
|
+
all_ok = all(r.ok for r in results)
|
|
1499
|
+
status_code = 200 if all_ok else 503
|
|
1500
|
+
self._json_response(
|
|
1501
|
+
{"ready": all_ok, "services": len(results)},
|
|
1502
|
+
status=status_code,
|
|
1503
|
+
)
|
|
1504
|
+
except Exception: # NOSONAR
|
|
1505
|
+
self._json_response({"ready": False, "error": "health check failed"}, status=503)
|
|
1506
|
+
|
|
1507
|
+
def _handle_enterprise_status(self) -> None:
|
|
1508
|
+
"""``GET /v1/enterprise/status`` — enterprise hardening summary."""
|
|
1509
|
+
try:
|
|
1510
|
+
import dataclasses
|
|
1511
|
+
|
|
1512
|
+
from spanforge.sdk import sf_enterprise
|
|
1513
|
+
|
|
1514
|
+
status = sf_enterprise.get_status()
|
|
1515
|
+
self._json_response(dataclasses.asdict(status))
|
|
1516
|
+
except Exception: # NOSONAR
|
|
1517
|
+
_log.exception("GET /v1/enterprise/status error")
|
|
1518
|
+
self._error(500, "Internal Server Error")
|
|
1519
|
+
|
|
1520
|
+
def _handle_enterprise_health(self) -> None:
|
|
1521
|
+
"""``GET /v1/enterprise/health`` — all-services health probe."""
|
|
1522
|
+
try:
|
|
1523
|
+
import dataclasses
|
|
1524
|
+
|
|
1525
|
+
from spanforge.sdk import sf_enterprise
|
|
1526
|
+
|
|
1527
|
+
results = sf_enterprise.check_all_services_health()
|
|
1528
|
+
all_ok = all(r.ok for r in results)
|
|
1529
|
+
self._json_response(
|
|
1530
|
+
{
|
|
1531
|
+
"healthy": all_ok,
|
|
1532
|
+
"results": [dataclasses.asdict(r) for r in results],
|
|
1533
|
+
}
|
|
1534
|
+
)
|
|
1535
|
+
except Exception: # NOSONAR
|
|
1536
|
+
_log.exception("GET /v1/enterprise/health error")
|
|
1537
|
+
self._error(500, "Internal Server Error")
|
|
1538
|
+
|
|
1539
|
+
def _handle_security_owasp(self) -> None:
|
|
1540
|
+
"""``GET /v1/security/owasp`` — OWASP API Security Top 10 audit."""
|
|
1541
|
+
try:
|
|
1542
|
+
import dataclasses
|
|
1543
|
+
|
|
1544
|
+
from spanforge.sdk import sf_security
|
|
1545
|
+
|
|
1546
|
+
result = sf_security.run_owasp_audit()
|
|
1547
|
+
self._json_response(dataclasses.asdict(result))
|
|
1548
|
+
except Exception: # NOSONAR
|
|
1549
|
+
_log.exception("GET /v1/security/owasp error")
|
|
1550
|
+
self._error(500, "Internal Server Error")
|
|
1551
|
+
|
|
1552
|
+
def _handle_security_threat_model(self) -> None:
|
|
1553
|
+
"""``GET /v1/security/threat-model`` — generate STRIDE threat model."""
|
|
1554
|
+
try:
|
|
1555
|
+
import dataclasses
|
|
1556
|
+
|
|
1557
|
+
from spanforge.sdk import sf_security
|
|
1558
|
+
|
|
1559
|
+
entries = sf_security.generate_default_threat_model()
|
|
1560
|
+
self._json_response([dataclasses.asdict(e) for e in entries])
|
|
1561
|
+
except Exception: # NOSONAR
|
|
1562
|
+
_log.exception("GET /v1/security/threat-model error")
|
|
1563
|
+
self._error(500, "Internal Server Error")
|
|
1564
|
+
|
|
1565
|
+
def _handle_security_scan(self) -> None:
|
|
1566
|
+
"""``GET /v1/security/scan`` — run full security scan."""
|
|
1567
|
+
try:
|
|
1568
|
+
import dataclasses
|
|
1569
|
+
|
|
1570
|
+
from spanforge.sdk import sf_security
|
|
1571
|
+
|
|
1572
|
+
result = sf_security.run_full_scan()
|
|
1573
|
+
self._json_response(dataclasses.asdict(result))
|
|
1574
|
+
except Exception: # NOSONAR
|
|
1575
|
+
_log.exception("GET /v1/security/scan error")
|
|
1576
|
+
self._error(500, "Internal Server Error")
|
|
1577
|
+
|
|
1578
|
+
def _html_response(self, html: str, status: int = 200) -> None:
|
|
1579
|
+
body = html.encode("utf-8")
|
|
1580
|
+
self.send_response(status)
|
|
1581
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
1582
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1583
|
+
self.end_headers()
|
|
1584
|
+
self.wfile.write(body)
|
|
1585
|
+
|
|
1586
|
+
def _json_response(self, data: Any, status: int = 200) -> None:
|
|
1587
|
+
body = json.dumps(data, default=str).encode("utf-8")
|
|
1588
|
+
cors = getattr(self, "_cors_origins", "")
|
|
1589
|
+
self.send_response(status)
|
|
1590
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1591
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1592
|
+
if cors:
|
|
1593
|
+
self.send_header("Access-Control-Allow-Origin", cors)
|
|
1594
|
+
self.end_headers()
|
|
1595
|
+
self.wfile.write(body)
|
|
1596
|
+
|
|
1597
|
+
def _text_response(self, text: str, status: int = 200) -> None:
|
|
1598
|
+
body = text.encode("utf-8")
|
|
1599
|
+
self.send_response(status)
|
|
1600
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
1601
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1602
|
+
self.end_headers()
|
|
1603
|
+
self.wfile.write(body)
|
|
1604
|
+
|
|
1605
|
+
def _error(self, status: int, message: str) -> None:
|
|
1606
|
+
self._json_response({"error": message}, status=status)
|
|
1607
|
+
|
|
1608
|
+
def log_message(self, fmt: str, *args: Any) -> None: # pragma: no cover
|
|
1609
|
+
pass # suppress default access log
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _serialise_event(event: Any) -> dict[str, Any]:
|
|
1613
|
+
"""Convert an Event to a plain dict (best-effort)."""
|
|
1614
|
+
if hasattr(event, "to_dict"):
|
|
1615
|
+
try:
|
|
1616
|
+
return dict(event.to_dict())
|
|
1617
|
+
except Exception: # to_dict fallback # nosec B110
|
|
1618
|
+
pass
|
|
1619
|
+
return {
|
|
1620
|
+
"event_type": str(getattr(event, "event_type", "unknown")),
|
|
1621
|
+
"payload": getattr(event, "payload", {}),
|
|
1622
|
+
"timestamp": getattr(event, "timestamp", None),
|
|
1623
|
+
"span_id": getattr(event, "span_id", None),
|
|
1624
|
+
"trace_id": getattr(event, "trace_id", None),
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
# ---------------------------------------------------------------------------
|
|
1629
|
+
# TraceViewerServer
|
|
1630
|
+
# ---------------------------------------------------------------------------
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
class TraceViewerServer:
|
|
1634
|
+
"""Lightweight HTTP server exposing the in-process trace store as JSON.
|
|
1635
|
+
|
|
1636
|
+
Args:
|
|
1637
|
+
port: TCP port to bind (default ``8888``).
|
|
1638
|
+
host: Interface to bind (default ``"127.0.0.1"``).
|
|
1639
|
+
store: Optional explicit :class:`~spanforge._store.TraceStore`
|
|
1640
|
+
instance. If ``None``, uses the global singleton.
|
|
1641
|
+
|
|
1642
|
+
Example::
|
|
1643
|
+
|
|
1644
|
+
server = TraceViewerServer(port=8888)
|
|
1645
|
+
server.start()
|
|
1646
|
+
print("Browse traces at http://localhost:8888/traces")
|
|
1647
|
+
# ...
|
|
1648
|
+
server.stop()
|
|
1649
|
+
"""
|
|
1650
|
+
|
|
1651
|
+
def __init__(
|
|
1652
|
+
self,
|
|
1653
|
+
*,
|
|
1654
|
+
port: int = 8888,
|
|
1655
|
+
host: str = "127.0.0.1",
|
|
1656
|
+
store: Any = None,
|
|
1657
|
+
cors_origins: str = "",
|
|
1658
|
+
) -> None:
|
|
1659
|
+
self._port = port
|
|
1660
|
+
self._host = host
|
|
1661
|
+
self._store = store
|
|
1662
|
+
self._cors_origins = cors_origins
|
|
1663
|
+
self._server: http.server.HTTPServer | None = None
|
|
1664
|
+
self._thread: threading.Thread | None = None
|
|
1665
|
+
|
|
1666
|
+
def _get_store(self) -> Any:
|
|
1667
|
+
if self._store is not None:
|
|
1668
|
+
return self._store
|
|
1669
|
+
from spanforge._store import get_store
|
|
1670
|
+
|
|
1671
|
+
return get_store()
|
|
1672
|
+
|
|
1673
|
+
def start(self) -> None:
|
|
1674
|
+
"""Start the viewer server in a background daemon thread."""
|
|
1675
|
+
if self._thread is not None and self._thread.is_alive():
|
|
1676
|
+
return # already running
|
|
1677
|
+
|
|
1678
|
+
get_store_fn = self._get_store
|
|
1679
|
+
cors = self._cors_origins
|
|
1680
|
+
|
|
1681
|
+
class _Handler(_TraceAPIHandler):
|
|
1682
|
+
pass
|
|
1683
|
+
|
|
1684
|
+
_Handler._get_store = staticmethod(get_store_fn)
|
|
1685
|
+
_Handler._cors_origins = cors
|
|
1686
|
+
|
|
1687
|
+
self._server = http.server.HTTPServer((self._host, self._port), _Handler)
|
|
1688
|
+
self._thread = threading.Thread(
|
|
1689
|
+
target=self._server.serve_forever,
|
|
1690
|
+
name=f"spanforge-viewer-{self._port}",
|
|
1691
|
+
daemon=True,
|
|
1692
|
+
)
|
|
1693
|
+
self._thread.start()
|
|
1694
|
+
_log.info(
|
|
1695
|
+
"spanforge trace viewer running at http://%s:%d",
|
|
1696
|
+
self._host,
|
|
1697
|
+
self._port,
|
|
1698
|
+
)
|
|
1699
|
+
print(f"[spanforge] Trace viewer: http://{self._host}:{self._port}/traces")
|
|
1700
|
+
|
|
1701
|
+
def stop(self) -> None:
|
|
1702
|
+
"""Shut down the viewer server."""
|
|
1703
|
+
if self._server is not None:
|
|
1704
|
+
self._server.shutdown()
|
|
1705
|
+
self._server = None
|
|
1706
|
+
if self._thread is not None:
|
|
1707
|
+
self._thread.join(timeout=3.0)
|
|
1708
|
+
self._thread = None
|