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
@@ -0,0 +1,343 @@
1
+ """spanforge.metrics_export — Prometheus-compatible metrics export.
2
+
3
+ This module provides a zero-dependency Prometheus text-format metrics
4
+ endpoint for SpanForge. It exposes key observability indicators as gauges
5
+ and counters compatible with any Prometheus scraper.
6
+
7
+ Exported metrics
8
+ ----------------
9
+
10
+ ========================================= =====================================
11
+ Metric name Description
12
+ ========================================= =====================================
13
+ ``spanforge_spans_total`` Total spans emitted (counter).
14
+ ``spanforge_spans_error_total`` Total error spans (counter).
15
+ ``spanforge_export_errors_total`` Total export backend errors (counter).
16
+ ``spanforge_events_dropped_total`` Total events dropped (counter).
17
+ ``spanforge_token_usage_total`` Total tokens used (counter by type).
18
+ ``spanforge_span_duration_ms`` Span duration histogram buckets (gauge).
19
+ ``spanforge_drift_alerts_total`` Total drift alerts emitted (counter).
20
+ ========================================= =====================================
21
+
22
+ Usage
23
+ -----
24
+ Standalone HTTP server::
25
+
26
+ from spanforge.metrics_export import serve_metrics
27
+ serve_metrics(port=9090) # starts a background thread
28
+
29
+ Single scrape (e.g. push-gateway integration)::
30
+
31
+ from spanforge.metrics_export import PrometheusMetricsExporter, MetricsSummary
32
+
33
+ exporter = PrometheusMetricsExporter()
34
+ text = exporter.export(MetricsSummary(spans_total=1000, error_spans=12, ...))
35
+ print(text)
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import http.server
41
+ import logging
42
+ import re
43
+ import threading
44
+ import time
45
+ from dataclasses import dataclass, field
46
+ from typing import Any
47
+
48
+ __all__ = [
49
+ "MetricsSummary",
50
+ "PrometheusMetricsExporter",
51
+ "serve_metrics",
52
+ ]
53
+
54
+ _log = logging.getLogger("spanforge.metrics_export")
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # MetricsSummary
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ @dataclass
63
+ class MetricsSummary:
64
+ """Snapshot of SpanForge observability counters.
65
+
66
+ Instances of this class are passed to
67
+ :meth:`PrometheusMetricsExporter.export` to generate the Prometheus text
68
+ payload. All fields default to zero so callers can omit unknown values.
69
+
70
+ Args:
71
+ spans_total: Cumulative number of spans started.
72
+ error_spans: Cumulative number of spans with status ``"error"``.
73
+ export_errors: Cumulative export backend errors.
74
+ events_dropped: Events silently dropped (queue full / circuit open).
75
+ prompt_tokens: Cumulative prompt token count.
76
+ completion_tokens: Cumulative completion token count.
77
+ total_tokens: Cumulative total token count.
78
+ total_cost_usd: Cumulative estimated cost in USD.
79
+ drift_alerts: Cumulative drift alert events emitted.
80
+ active_spans: Gauge — currently open spans.
81
+ duration_buckets: Histogram bucket counts ``{le_ms: count}``.
82
+ labels: Optional extra label key/value pairs applied to
83
+ every metric (e.g. ``{"service": "my-service"}``).
84
+ timestamp_ms: Unix timestamp (milliseconds) of the snapshot.
85
+ """
86
+
87
+ spans_total: int = 0
88
+ error_spans: int = 0
89
+ export_errors: int = 0
90
+ events_dropped: int = 0
91
+ prompt_tokens: int = 0
92
+ completion_tokens: int = 0
93
+ total_tokens: int = 0
94
+ total_cost_usd: float = 0.0
95
+ drift_alerts: int = 0
96
+ active_spans: int = 0
97
+ duration_buckets: dict[float, int] = field(default_factory=dict)
98
+ labels: dict[str, str] = field(default_factory=dict)
99
+ timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))
100
+
101
+ # Aliases for backward-compat (F-25)
102
+ @property
103
+ def total_events(self) -> int:
104
+ """Alias for :attr:`spans_total`."""
105
+ return self.spans_total
106
+
107
+ @property
108
+ def error_count(self) -> int:
109
+ """Alias for :attr:`error_spans`."""
110
+ return self.error_spans
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # PrometheusMetricsExporter
115
+ # ---------------------------------------------------------------------------
116
+
117
+ _DEFAULT_DURATION_BUCKETS = (5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 5000.0)
118
+
119
+
120
+ class PrometheusMetricsExporter:
121
+ """Render a :class:`MetricsSummary` as Prometheus text format (0.0.4).
122
+
123
+ The output is compatible with Prometheus scraping and the OpenMetrics
124
+ exposition format.
125
+
126
+ Args:
127
+ namespace: Optional metric name prefix. Defaults to ``"spanforge"``.
128
+
129
+ Example::
130
+
131
+ exporter = PrometheusMetricsExporter()
132
+ summary = MetricsSummary(spans_total=500, error_spans=3)
133
+ print(exporter.export(summary))
134
+ """
135
+
136
+ def __init__(self, namespace: str = "spanforge") -> None:
137
+ self._ns = namespace.rstrip("_")
138
+
139
+ def export(self, summary: MetricsSummary) -> str:
140
+ """Return Prometheus text exposition for *summary*.
141
+
142
+ Args:
143
+ summary: Populated :class:`MetricsSummary` snapshot.
144
+
145
+ Returns:
146
+ Multi-line string in Prometheus text format 0.0.4.
147
+ """
148
+ ns = self._ns
149
+ lines: list[str] = []
150
+ ts = summary.timestamp_ms
151
+ base_labels = self._format_labels(summary.labels)
152
+
153
+ def counter(name: str, help_text: str, value: int | float) -> None:
154
+ full = f"{ns}_{name}"
155
+ lines.append(f"# HELP {full} {help_text}")
156
+ lines.append(f"# TYPE {full} counter")
157
+ lines.append(f"{full}{base_labels} {value} {ts}")
158
+
159
+ def gauge(name: str, help_text: str, value: int | float) -> None:
160
+ full = f"{ns}_{name}"
161
+ lines.append(f"# HELP {full} {help_text}")
162
+ lines.append(f"# TYPE {full} gauge")
163
+ lines.append(f"{full}{base_labels} {value} {ts}")
164
+
165
+ # Span counters
166
+ counter("spans_total", "Total number of spans emitted.", summary.spans_total)
167
+ counter("spans_error_total", "Total number of error spans.", summary.error_spans)
168
+ counter("export_errors_total", "Total export backend errors.", summary.export_errors)
169
+ counter("events_dropped_total", "Total events dropped.", summary.events_dropped)
170
+ counter("drift_alerts_total", "Total drift alerts emitted.", summary.drift_alerts)
171
+
172
+ # Token usage (with token_type label)
173
+ tok_name = f"{ns}_token_usage_total"
174
+ lines.append(f"# HELP {tok_name} Total token usage by token type.")
175
+ lines.append(f"# TYPE {tok_name} counter")
176
+ for ttype, count in [
177
+ ("prompt", summary.prompt_tokens),
178
+ ("completion", summary.completion_tokens),
179
+ ("total", summary.total_tokens),
180
+ ]:
181
+ label_str = self._format_labels({**summary.labels, "token_type": ttype})
182
+ lines.append(f"{tok_name}{label_str} {count} {ts}")
183
+
184
+ # Cost
185
+ counter("cost_usd_total", "Total estimated cost in USD.", summary.total_cost_usd)
186
+
187
+ # Active spans (gauge)
188
+ gauge("active_spans", "Currently open (in-flight) spans.", summary.active_spans)
189
+
190
+ # Duration histogram
191
+ if summary.duration_buckets:
192
+ hist_name = f"{ns}_span_duration_ms"
193
+ lines.append(f"# HELP {hist_name} Span duration distribution in milliseconds.")
194
+ lines.append(f"# TYPE {hist_name} histogram")
195
+ cumulative = 0
196
+ sorted_buckets = sorted(summary.duration_buckets.items())
197
+ for le, count in sorted_buckets:
198
+ cumulative += count
199
+ le_label = self._format_labels({**summary.labels, "le": str(le)})
200
+ lines.append(f"{hist_name}_bucket{le_label} {cumulative} {ts}")
201
+ # +Inf bucket
202
+ inf_label = self._format_labels({**summary.labels, "le": "+Inf"})
203
+ lines.append(f"{hist_name}_bucket{inf_label} {cumulative} {ts}")
204
+ else:
205
+ # Emit default zero buckets so scrapers don't see missing series.
206
+ hist_name = f"{ns}_span_duration_ms"
207
+ lines.append(f"# HELP {hist_name} Span duration distribution in milliseconds.")
208
+ lines.append(f"# TYPE {hist_name} histogram")
209
+ for le in _DEFAULT_DURATION_BUCKETS:
210
+ le_label = self._format_labels({**summary.labels, "le": str(le)})
211
+ lines.append(f"{hist_name}_bucket{le_label} 0 {ts}")
212
+ inf_label = self._format_labels({**summary.labels, "le": "+Inf"})
213
+ lines.append(f"{hist_name}_bucket{inf_label} 0 {ts}")
214
+
215
+ lines.append("") # trailing newline
216
+ return "\n".join(lines)
217
+
218
+ # ------------------------------------------------------------------
219
+
220
+ @staticmethod
221
+ def _format_labels(labels: dict[str, str]) -> str:
222
+ if not labels:
223
+ return ""
224
+ # Drop any label keys that don't conform to the Prometheus data model.
225
+ valid_labels = {k: v for k, v in labels.items() if _PROM_LABEL_NAME_RE.match(k)}
226
+ if not valid_labels:
227
+ return ""
228
+ pairs = ",".join(f'{k}="{_escape_label_value(v)}"' for k, v in sorted(valid_labels.items()))
229
+ return "{" + pairs + "}"
230
+
231
+
232
+ # M6: Prometheus label names must match [a-zA-Z_:][a-zA-Z0-9_:]* (Prometheus data model).
233
+ _PROM_LABEL_NAME_RE: re.Pattern[str] = re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$")
234
+
235
+
236
+ def _escape_label_value(value: str) -> str:
237
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Live metrics collector — reads from _stream internals
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ def _collect_live_summary() -> MetricsSummary:
246
+ """Build a :class:`MetricsSummary` from live SpanForge stream counters."""
247
+ summary = MetricsSummary()
248
+ try:
249
+ from spanforge._stream import _export_error_count
250
+
251
+ summary.export_errors = _export_error_count
252
+ except Exception as exc:
253
+ _log.debug("metrics summary collection failed: %s", exc)
254
+ return summary
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # HTTP handler + server
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ class _MetricsHTTPHandler(http.server.BaseHTTPRequestHandler):
263
+ """Minimal HTTP handler serving /metrics in Prometheus text format."""
264
+
265
+ _exporter: PrometheusMetricsExporter
266
+ _collector: Any # callable: () -> MetricsSummary
267
+
268
+ def do_GET(self) -> None:
269
+ if self.path != "/metrics":
270
+ self.send_response(404)
271
+ self.end_headers()
272
+ self.wfile.write(b"Not Found\n")
273
+ return
274
+
275
+ try:
276
+ summary = self._collector()
277
+ body = self._exporter.export(summary).encode("utf-8")
278
+ except Exception as exc: # NOSONAR
279
+ _log.error("metrics handler error: %s", exc)
280
+ self.send_response(500)
281
+ self.end_headers()
282
+ self.wfile.write(b"Internal Server Error\n")
283
+ return
284
+
285
+ self.send_response(200)
286
+ self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
287
+ self.send_header("Content-Length", str(len(body)))
288
+ self.end_headers()
289
+ self.wfile.write(body)
290
+
291
+ def log_message(self, fmt: str, *args: Any) -> None: # pragma: no cover
292
+ # Suppress default access log to stderr.
293
+ pass
294
+
295
+
296
+ def serve_metrics(
297
+ port: int = 9090,
298
+ *,
299
+ host: str = "127.0.0.1",
300
+ collector: Any | None = None,
301
+ namespace: str = "spanforge",
302
+ ) -> http.server.HTTPServer:
303
+ """Start a background HTTP server exposing ``/metrics`` in Prometheus format.
304
+
305
+ The server runs in a daemon thread so it stops automatically when the main
306
+ process exits.
307
+
308
+ Args:
309
+ port: TCP port to bind. Defaults to ``9090``.
310
+ host: Interface to bind. Defaults to ``"127.0.0.1"`` (localhost
311
+ only). Set to ``"0.0.0.0"`` to expose on all interfaces
312
+ (ensure firewall rules are in place).
313
+ collector: Optional callable ``() -> MetricsSummary``. Defaults to
314
+ :func:`_collect_live_summary` which reads from SpanForge
315
+ internals.
316
+ namespace: Metric name prefix (default ``"spanforge"``).
317
+
318
+ Returns:
319
+ The running :class:`http.server.HTTPServer` instance.
320
+
321
+ Example::
322
+
323
+ serve_metrics(port=9090)
324
+ # Scrape at http://localhost:9090/metrics
325
+ """
326
+ exporter = PrometheusMetricsExporter(namespace=namespace)
327
+ _collector = collector if collector is not None else _collect_live_summary
328
+
329
+ class _Handler(_MetricsHTTPHandler):
330
+ pass
331
+
332
+ _Handler._exporter = exporter
333
+ _Handler._collector = staticmethod(_collector)
334
+
335
+ server = http.server.HTTPServer((host, port), _Handler)
336
+ thread = threading.Thread(
337
+ target=server.serve_forever,
338
+ name=f"spanforge-metrics-{port}",
339
+ daemon=True,
340
+ )
341
+ thread.start()
342
+ _log.info("spanforge metrics server listening on http://%s:%d/metrics", host, port)
343
+ return server