spanforge 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
|
@@ -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
|