spanforge 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/_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">&#x2B21; 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()">&#9728;</button>
188
+ <button class="icon-btn" title="Refresh" onclick="loadData()">&#8635;</button>
189
+ <button class="icon-btn" title="Export as JSONL" onclick="exportEvents()">&#8659;</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">&#128269;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
220
+
221
+ // ─── Syntax highlight ───────────────────────────────────────────────────────
222
+ function synHi(obj){
223
+ return JSON.stringify(obj,null,2).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
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)">&#10007; 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">&#10003; compliance: ${Math.round(avg)}%${extra}</span>`;
258
+ else if(avg>=50)el.innerHTML=`<span class="chain-warn">&#9888; compliance: ${Math.round(avg)}%${extra}</span>`;
259
+ else el.innerHTML=`<span style="color:var(--error)">&#10007; 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">&#128203;</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">&#10007; Chain TAMPERED &mdash; ${tampered} tampered event(s), ${gaps} gap(s)</div>`;
275
+ 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>`;
276
+ 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>`;
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()">&#8592; 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">&#10003; chain: 100% signed</span>';
361
+ else el.innerHTML=`<span class="chain-warn">&#9888; 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">&#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;}
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">&#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;}
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)">&#8592; 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">&#128269;</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
+ &nbsp;<span class="sig-badge ${isSig?'sig-yes':'sig-no'}">${isSig?'&#10003; 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