spanforge 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/drift.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""spanforge.drift — Behavioural drift detection engine (Phase 3).
|
|
2
|
+
|
|
3
|
+
:class:`DriftDetector` maintains a sliding window of observed metric values
|
|
4
|
+
and compares them against a :class:`~spanforge.baseline.BehaviouralBaseline`
|
|
5
|
+
using Z-score and KL-divergence statistics. When a threshold is breached it
|
|
6
|
+
returns :class:`~spanforge.namespaces.drift.DriftPayload` objects that can be
|
|
7
|
+
emitted as RFC-0001 SPANFORGE ``drift.*`` events via
|
|
8
|
+
:func:`~spanforge._stream.emit_rfc_event`.
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from spanforge.baseline import BehaviouralBaseline
|
|
13
|
+
from spanforge.drift import DriftDetector
|
|
14
|
+
from spanforge._stream import emit_rfc_event
|
|
15
|
+
from spanforge.types import EventType
|
|
16
|
+
|
|
17
|
+
baseline = BehaviouralBaseline.load("baseline.json")
|
|
18
|
+
detector = DriftDetector(baseline, agent_id="my-agent")
|
|
19
|
+
|
|
20
|
+
for event in live_event_stream():
|
|
21
|
+
results = detector.record(event)
|
|
22
|
+
for payload in results:
|
|
23
|
+
emit_rfc_event(
|
|
24
|
+
EventType("drift." + payload.status.replace("_", "_")),
|
|
25
|
+
payload.to_dict(),
|
|
26
|
+
)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
import statistics
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from collections import deque
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from spanforge.baseline import BehaviouralBaseline
|
|
40
|
+
from spanforge.namespaces.drift import DriftPayload
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from spanforge.event import Event
|
|
44
|
+
|
|
45
|
+
__all__ = ["DriftDetector", "DriftResult"]
|
|
46
|
+
|
|
47
|
+
# Minimum observations required in the window before drift analysis is attempted.
|
|
48
|
+
_MIN_WINDOW_SAMPLES: int = 10
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Value object
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class DriftResult:
|
|
58
|
+
"""Drift assessment for a single metric observation.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
metric_name: Dot-separated metric identifier (e.g. ``"tokens"``,
|
|
62
|
+
``"confidence.classification"``, ``"latency.chat"``).
|
|
63
|
+
current_value: The raw observed value that triggered the assessment.
|
|
64
|
+
window_mean: Current window mean (rolling).
|
|
65
|
+
window_stddev: Current window standard deviation (rolling).
|
|
66
|
+
baseline_mean: Mean from the :class:`~spanforge.baseline.BehaviouralBaseline`.
|
|
67
|
+
baseline_stddev: Std-dev from the baseline.
|
|
68
|
+
z_score: ``(window_mean - baseline_mean) / baseline_stddev``.
|
|
69
|
+
kl_divergence: KL-divergence between window and baseline Gaussian
|
|
70
|
+
(``None`` if baseline stddev is zero or window has
|
|
71
|
+
fewer than 2 samples).
|
|
72
|
+
threshold: The configured Z-score threshold.
|
|
73
|
+
status: ``"ok"`` | ``"detected"`` | ``"threshold_breach"`` |
|
|
74
|
+
``"resolved"``.
|
|
75
|
+
payload: Ready-to-emit :class:`~spanforge.namespaces.drift.DriftPayload`
|
|
76
|
+
(``None`` when status is ``"ok"``).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
metric_name: str
|
|
80
|
+
current_value: float
|
|
81
|
+
window_mean: float
|
|
82
|
+
window_stddev: float
|
|
83
|
+
baseline_mean: float
|
|
84
|
+
baseline_stddev: float
|
|
85
|
+
z_score: float
|
|
86
|
+
kl_divergence: float | None
|
|
87
|
+
threshold: float
|
|
88
|
+
status: str
|
|
89
|
+
payload: DriftPayload | None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# KL-divergence (Gaussian approximation)
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _kl_divergence_gaussian(
|
|
98
|
+
mu_p: float,
|
|
99
|
+
sigma_p: float,
|
|
100
|
+
mu_q: float,
|
|
101
|
+
sigma_q: float,
|
|
102
|
+
) -> float | None:
|
|
103
|
+
"""KL-divergence KL(P || Q) between two univariate Gaussians.
|
|
104
|
+
|
|
105
|
+
KL(N(μ_P, σ_P²) || N(μ_Q, σ_Q²)) =
|
|
106
|
+
log(σ_Q / σ_P) + (σ_P² + (μ_P − μ_Q)²) / (2 σ_Q²) − 1/2
|
|
107
|
+
|
|
108
|
+
Returns ``None`` when σ_P ≤ 0 or σ_Q ≤ 0 (degenerate distribution).
|
|
109
|
+
"""
|
|
110
|
+
if sigma_p <= 0.0 or sigma_q <= 0.0:
|
|
111
|
+
return None
|
|
112
|
+
return (
|
|
113
|
+
math.log(sigma_q / sigma_p)
|
|
114
|
+
+ (sigma_p ** 2 + (mu_p - mu_q) ** 2) / (2.0 * sigma_q ** 2)
|
|
115
|
+
- 0.5
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# DriftDetector
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DriftDetector:
|
|
125
|
+
"""Sliding-window behavioural drift detector.
|
|
126
|
+
|
|
127
|
+
Maintains per-metric rolling windows and reports
|
|
128
|
+
:class:`DriftResult` / :class:`~spanforge.namespaces.drift.DriftPayload`
|
|
129
|
+
objects whenever the current window deviates significantly from the
|
|
130
|
+
recorded :class:`~spanforge.baseline.BehaviouralBaseline`.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
baseline: Deployment-time statistical baseline.
|
|
134
|
+
agent_id: Identifier for the monitored agent (embedded in every
|
|
135
|
+
emitted :class:`~spanforge.namespaces.drift.DriftPayload`).
|
|
136
|
+
window_size: Maximum number of observations per metric in the rolling
|
|
137
|
+
window (default 500).
|
|
138
|
+
z_threshold: Z-score that triggers a ``threshold_breach`` (default 3.0).
|
|
139
|
+
kl_threshold: KL-divergence that triggers a ``threshold_breach``
|
|
140
|
+
(default 0.5).
|
|
141
|
+
window_seconds: Nominal window duration embedded in emitted payloads
|
|
142
|
+
(default 3 600 s = 1 h).
|
|
143
|
+
auto_emit: When ``True`` (default), calls
|
|
144
|
+
:func:`~spanforge._stream.emit_rfc_event` for each
|
|
145
|
+
``detected`` / ``threshold_breach`` / ``resolved`` result.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
baseline: BehaviouralBaseline,
|
|
151
|
+
agent_id: str,
|
|
152
|
+
window_size: int = 500,
|
|
153
|
+
z_threshold: float = 3.0,
|
|
154
|
+
kl_threshold: float = 0.5,
|
|
155
|
+
window_seconds: int = 3600,
|
|
156
|
+
auto_emit: bool = True,
|
|
157
|
+
metric_ttl_seconds: int = 86400,
|
|
158
|
+
) -> None:
|
|
159
|
+
if not agent_id:
|
|
160
|
+
raise ValueError("DriftDetector: agent_id must be non-empty")
|
|
161
|
+
if window_size < 1:
|
|
162
|
+
raise ValueError("DriftDetector: window_size must be >= 1")
|
|
163
|
+
if not math.isfinite(z_threshold) or z_threshold <= 0:
|
|
164
|
+
raise ValueError("DriftDetector: z_threshold must be a finite positive number")
|
|
165
|
+
if window_seconds <= 0:
|
|
166
|
+
raise ValueError("DriftDetector: window_seconds must be > 0")
|
|
167
|
+
if metric_ttl_seconds <= 0:
|
|
168
|
+
raise ValueError("DriftDetector: metric_ttl_seconds must be > 0")
|
|
169
|
+
|
|
170
|
+
self._baseline = baseline
|
|
171
|
+
self._agent_id = agent_id
|
|
172
|
+
self._window_size = window_size
|
|
173
|
+
self._z_threshold = z_threshold
|
|
174
|
+
self._kl_threshold = kl_threshold
|
|
175
|
+
self._window_seconds = window_seconds
|
|
176
|
+
self._auto_emit = auto_emit
|
|
177
|
+
self._metric_ttl_seconds = metric_ttl_seconds
|
|
178
|
+
|
|
179
|
+
self._lock = threading.Lock()
|
|
180
|
+
# metric_name → rolling deque of float observations
|
|
181
|
+
self._windows: dict[str, deque[float]] = {}
|
|
182
|
+
# metric_name → current breach state
|
|
183
|
+
self._in_breach: dict[str, bool] = {}
|
|
184
|
+
# metric_name → last observation time (monotonic clock)
|
|
185
|
+
self._last_seen: dict[str, float] = {}
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Public API
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def baseline(self) -> BehaviouralBaseline:
|
|
193
|
+
"""The baseline this detector is comparing against."""
|
|
194
|
+
return self._baseline
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def agent_id(self) -> str:
|
|
198
|
+
return self._agent_id
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def window_size(self) -> int:
|
|
202
|
+
return self._window_size
|
|
203
|
+
|
|
204
|
+
def record(self, event: "Event") -> list[DriftResult]:
|
|
205
|
+
"""Ingest *event*, update rolling windows, and return drift results.
|
|
206
|
+
|
|
207
|
+
Extracts metric observations from the event payload based on its
|
|
208
|
+
event type and compares the updated window statistics against the
|
|
209
|
+
baseline.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
event: A :class:`~spanforge.event.Event` (any type; non-metric
|
|
213
|
+
events are silently ignored).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
A list of :class:`DriftResult` objects for every metric that had a
|
|
217
|
+
state transition (``ok``, ``detected``, ``threshold_breach``,
|
|
218
|
+
or ``resolved``). Returns an empty list for most events.
|
|
219
|
+
"""
|
|
220
|
+
observations = _extract_metric_observations(event)
|
|
221
|
+
if not observations:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
results: list[DriftResult] = []
|
|
225
|
+
with self._lock:
|
|
226
|
+
for metric_name, value in observations:
|
|
227
|
+
result = self._assess(metric_name, value)
|
|
228
|
+
if result is not None:
|
|
229
|
+
results.append(result)
|
|
230
|
+
|
|
231
|
+
if self._auto_emit:
|
|
232
|
+
self._emit_results(results)
|
|
233
|
+
|
|
234
|
+
return results
|
|
235
|
+
|
|
236
|
+
def window_stats(self, metric_name: str) -> tuple[float, float, int] | None:
|
|
237
|
+
"""Return ``(mean, stddev, count)`` for *metric_name*'s current window.
|
|
238
|
+
|
|
239
|
+
Returns ``None`` if no data has been recorded for the metric yet.
|
|
240
|
+
"""
|
|
241
|
+
with self._lock:
|
|
242
|
+
window = self._windows.get(metric_name)
|
|
243
|
+
if not window:
|
|
244
|
+
return None
|
|
245
|
+
data = list(window)
|
|
246
|
+
mean = statistics.mean(data)
|
|
247
|
+
stddev = statistics.stdev(data) if len(data) >= 2 else 0.0
|
|
248
|
+
return mean, stddev, len(data)
|
|
249
|
+
|
|
250
|
+
def reset_window(self, metric_name: str | None = None) -> None:
|
|
251
|
+
"""Clear the rolling window(s).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
metric_name: If given, clears only that metric's window and breach
|
|
255
|
+
state. If ``None``, clears all metrics.
|
|
256
|
+
"""
|
|
257
|
+
with self._lock:
|
|
258
|
+
if metric_name is None:
|
|
259
|
+
self._windows.clear()
|
|
260
|
+
self._in_breach.clear()
|
|
261
|
+
else:
|
|
262
|
+
self._windows.pop(metric_name, None)
|
|
263
|
+
self._in_breach.pop(metric_name, None)
|
|
264
|
+
|
|
265
|
+
def in_breach(self, metric_name: str) -> bool:
|
|
266
|
+
"""Return ``True`` if *metric_name* is currently in threshold breach."""
|
|
267
|
+
with self._lock:
|
|
268
|
+
return self._in_breach.get(metric_name, False)
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Internal helpers (must be called with self._lock held)
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def _get_baseline_stats(
|
|
275
|
+
self, metric_name: str
|
|
276
|
+
) -> tuple[float, float] | None:
|
|
277
|
+
"""Return (baseline_mean, baseline_stddev) for *metric_name*, or None."""
|
|
278
|
+
if metric_name == "tokens":
|
|
279
|
+
return self._baseline.tokens.mean, self._baseline.tokens.stddev
|
|
280
|
+
|
|
281
|
+
if metric_name.startswith("confidence."):
|
|
282
|
+
dtype = metric_name[len("confidence."):]
|
|
283
|
+
stats = self._baseline.confidence_by_type.get(dtype)
|
|
284
|
+
if stats is not None:
|
|
285
|
+
return stats.mean, stats.stddev
|
|
286
|
+
|
|
287
|
+
if metric_name.startswith("latency."):
|
|
288
|
+
op = metric_name[len("latency."):]
|
|
289
|
+
stats = self._baseline.latency_by_operation.get(op)
|
|
290
|
+
if stats is not None:
|
|
291
|
+
return stats.mean, stats.stddev
|
|
292
|
+
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def _evict_stale(self) -> None:
|
|
296
|
+
"""Evict metrics that have not been observed within ``metric_ttl_seconds``.\n\n Called with ``self._lock`` already held. Prevents unbounded memory\n growth when many short-lived agent instances write unique metric keys.\n """
|
|
297
|
+
now = time.monotonic()
|
|
298
|
+
cutoff = now - self._metric_ttl_seconds
|
|
299
|
+
stale = [k for k, ts in self._last_seen.items() if ts < cutoff]
|
|
300
|
+
for k in stale:
|
|
301
|
+
self._windows.pop(k, None)
|
|
302
|
+
self._in_breach.pop(k, None)
|
|
303
|
+
self._last_seen.pop(k, None)
|
|
304
|
+
|
|
305
|
+
def _assess(self, metric_name: str, value: float) -> DriftResult | None:
|
|
306
|
+
"""Update the window for *metric_name* with *value* and return a result.
|
|
307
|
+
|
|
308
|
+
Returns ``None`` when there is no baseline for the metric or the window
|
|
309
|
+
has fewer than ``_MIN_WINDOW_SAMPLES`` observations.
|
|
310
|
+
"""
|
|
311
|
+
# Update rolling window
|
|
312
|
+
window = self._windows.setdefault(
|
|
313
|
+
metric_name, deque(maxlen=self._window_size)
|
|
314
|
+
)
|
|
315
|
+
window.append(value)
|
|
316
|
+
self._last_seen[metric_name] = time.monotonic()
|
|
317
|
+
|
|
318
|
+
# Evict metrics that haven't been seen within the TTL.
|
|
319
|
+
self._evict_stale()
|
|
320
|
+
|
|
321
|
+
if len(window) < _MIN_WINDOW_SAMPLES:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
baseline_stats = self._get_baseline_stats(metric_name)
|
|
325
|
+
if baseline_stats is None:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
baseline_mean, baseline_stddev = baseline_stats
|
|
329
|
+
|
|
330
|
+
# Avoid division by zero for constant-baseline metrics
|
|
331
|
+
effective_stddev = baseline_stddev if baseline_stddev > 0 else 1e-9
|
|
332
|
+
|
|
333
|
+
data = list(window)
|
|
334
|
+
win_mean = statistics.mean(data)
|
|
335
|
+
win_stddev = statistics.stdev(data) if len(data) >= 2 else 0.0
|
|
336
|
+
|
|
337
|
+
z_score = abs(win_mean - baseline_mean) / effective_stddev
|
|
338
|
+
|
|
339
|
+
kl_div = _kl_divergence_gaussian(
|
|
340
|
+
mu_p=win_mean,
|
|
341
|
+
sigma_p=win_stddev,
|
|
342
|
+
mu_q=baseline_mean,
|
|
343
|
+
sigma_q=baseline_stddev,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Determine status
|
|
347
|
+
was_in_breach = self._in_breach.get(metric_name, False)
|
|
348
|
+
|
|
349
|
+
if z_score >= self._z_threshold or (
|
|
350
|
+
kl_div is not None and kl_div >= self._kl_threshold
|
|
351
|
+
):
|
|
352
|
+
new_status = "threshold_breach"
|
|
353
|
+
self._in_breach[metric_name] = True
|
|
354
|
+
else:
|
|
355
|
+
# No active breach — resolve or downgrade
|
|
356
|
+
if was_in_breach:
|
|
357
|
+
new_status = "resolved"
|
|
358
|
+
self._in_breach[metric_name] = False
|
|
359
|
+
elif z_score >= self._z_threshold * (2.0 / 3.0):
|
|
360
|
+
# "detected" zone: Z is elevated but below the breach threshold
|
|
361
|
+
new_status = "detected"
|
|
362
|
+
else:
|
|
363
|
+
new_status = "ok"
|
|
364
|
+
|
|
365
|
+
if new_status == "ok":
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
# Map to DriftPayload status literals
|
|
369
|
+
payload_status: str
|
|
370
|
+
if new_status == "threshold_breach":
|
|
371
|
+
payload_status = "threshold_breach"
|
|
372
|
+
elif new_status == "detected":
|
|
373
|
+
payload_status = "detected"
|
|
374
|
+
else: # resolved
|
|
375
|
+
payload_status = "resolved"
|
|
376
|
+
|
|
377
|
+
drift_payload = DriftPayload(
|
|
378
|
+
metric_name=metric_name,
|
|
379
|
+
agent_id=self._agent_id,
|
|
380
|
+
current_value=value,
|
|
381
|
+
baseline_mean=baseline_mean,
|
|
382
|
+
baseline_stddev=baseline_stddev,
|
|
383
|
+
z_score=round(z_score, 6),
|
|
384
|
+
kl_divergence=round(kl_div, 6) if kl_div is not None else None,
|
|
385
|
+
threshold=self._z_threshold,
|
|
386
|
+
window_seconds=self._window_seconds,
|
|
387
|
+
status=payload_status, # type: ignore[arg-type]
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return DriftResult(
|
|
391
|
+
metric_name=metric_name,
|
|
392
|
+
current_value=value,
|
|
393
|
+
window_mean=win_mean,
|
|
394
|
+
window_stddev=win_stddev,
|
|
395
|
+
baseline_mean=baseline_mean,
|
|
396
|
+
baseline_stddev=baseline_stddev,
|
|
397
|
+
z_score=z_score,
|
|
398
|
+
kl_divergence=kl_div,
|
|
399
|
+
threshold=self._z_threshold,
|
|
400
|
+
status=new_status,
|
|
401
|
+
payload=drift_payload,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
# Auto-emit
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def _emit_results(self, results: list[DriftResult]) -> None:
|
|
409
|
+
"""Emit drift events for each non-ok result via emit_rfc_event."""
|
|
410
|
+
if not results:
|
|
411
|
+
return
|
|
412
|
+
try:
|
|
413
|
+
from spanforge._stream import emit_rfc_event # noqa: PLC0415
|
|
414
|
+
from spanforge.types import EventType # noqa: PLC0415
|
|
415
|
+
|
|
416
|
+
_status_to_event_type = {
|
|
417
|
+
"detected": EventType.DRIFT_DETECTED,
|
|
418
|
+
"threshold_breach": EventType.DRIFT_THRESHOLD_BREACH,
|
|
419
|
+
"resolved": EventType.DRIFT_RESOLVED,
|
|
420
|
+
}
|
|
421
|
+
for result in results:
|
|
422
|
+
if result.payload is None:
|
|
423
|
+
continue
|
|
424
|
+
et = _status_to_event_type.get(result.status)
|
|
425
|
+
if et is not None:
|
|
426
|
+
try:
|
|
427
|
+
emit_rfc_event(et, result.payload.to_dict())
|
|
428
|
+
except Exception: # noqa: BLE001
|
|
429
|
+
pass # never let auto-emit failures disrupt the caller
|
|
430
|
+
except ImportError:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# Metric extraction helpers
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _event_type_str(event: "Event") -> str:
|
|
440
|
+
et = event.event_type
|
|
441
|
+
return et.value if hasattr(et, "value") else str(et)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _extract_metric_observations(
|
|
445
|
+
event: "Event",
|
|
446
|
+
) -> list[tuple[str, float]]:
|
|
447
|
+
"""Extract (metric_name, value) pairs from *event*.
|
|
448
|
+
|
|
449
|
+
Returns an empty list for event types that carry no drift-relevant metrics.
|
|
450
|
+
"""
|
|
451
|
+
etype = _event_type_str(event)
|
|
452
|
+
payload = event.payload
|
|
453
|
+
observations: list[tuple[str, float]] = []
|
|
454
|
+
|
|
455
|
+
# LLM span events — token count + latency per operation
|
|
456
|
+
if etype in ("llm.trace.span.completed", "llm.trace.span.failed"):
|
|
457
|
+
tu = payload.get("token_usage")
|
|
458
|
+
if tu:
|
|
459
|
+
total = int(tu.get("total_tokens", 0) or 0)
|
|
460
|
+
if total > 0:
|
|
461
|
+
observations.append(("tokens", float(total)))
|
|
462
|
+
dur = payload.get("duration_ms")
|
|
463
|
+
if dur is not None:
|
|
464
|
+
op = str(payload.get("operation", "unknown"))
|
|
465
|
+
observations.append((f"latency.{op}", float(dur)))
|
|
466
|
+
|
|
467
|
+
# Confidence namespace
|
|
468
|
+
elif etype == "confidence.sample":
|
|
469
|
+
dtype = str(payload.get("decision_type", "unknown"))
|
|
470
|
+
score = payload.get("score")
|
|
471
|
+
if score is not None:
|
|
472
|
+
observations.append((f"confidence.{dtype}", float(score)))
|
|
473
|
+
|
|
474
|
+
# Latency namespace
|
|
475
|
+
elif etype == "latency.sample":
|
|
476
|
+
op = str(payload.get("operation", "unknown"))
|
|
477
|
+
lat = payload.get("latency_ms")
|
|
478
|
+
if lat is not None:
|
|
479
|
+
observations.append((f"latency.{op}", float(lat)))
|
|
480
|
+
|
|
481
|
+
# Tool call namespace
|
|
482
|
+
elif etype.startswith("tool_call."):
|
|
483
|
+
lat = payload.get("latency_ms")
|
|
484
|
+
tool_name = str(payload.get("tool_name", "unknown"))
|
|
485
|
+
if lat is not None:
|
|
486
|
+
observations.append((f"latency.{tool_name}", float(lat)))
|
|
487
|
+
|
|
488
|
+
return observations
|
spanforge/egress.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Egress enforcement for SpanForge export pipeline.
|
|
2
|
+
|
|
3
|
+
Provides a centralized guard that blocks network exports when the SDK is
|
|
4
|
+
configured in no-egress (air-gapped) mode. Exporters call
|
|
5
|
+
:func:`check_egress` before making any HTTP request.
|
|
6
|
+
|
|
7
|
+
Configuration
|
|
8
|
+
-------------
|
|
9
|
+
* ``no_egress=True`` on :class:`~spanforge.config.SpanForgeConfig` blocks
|
|
10
|
+
**all** outbound network traffic from SpanForge exporters.
|
|
11
|
+
* ``egress_allowlist`` is a ``frozenset[str]`` of URL **prefixes** that are
|
|
12
|
+
permitted even when ``no_egress`` is ``True``. For example::
|
|
13
|
+
|
|
14
|
+
configure(no_egress=True, egress_allowlist=frozenset(["https://internal-collector.corp.local/"]))
|
|
15
|
+
|
|
16
|
+
Raises :class:`~spanforge.exceptions.EgressViolationError` when a blocked
|
|
17
|
+
export is attempted.
|
|
18
|
+
|
|
19
|
+
Example::
|
|
20
|
+
|
|
21
|
+
from spanforge.egress import check_egress
|
|
22
|
+
check_egress("https://example.com/v1/traces", backend="otlp")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from spanforge.exceptions import EgressViolationError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
__all__ = ["check_egress"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_egress(endpoint: str, backend: str = "unknown") -> None:
|
|
38
|
+
"""Raise :class:`EgressViolationError` if egress to *endpoint* is blocked.
|
|
39
|
+
|
|
40
|
+
This function is a no-op when ``no_egress`` is ``False``.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
endpoint: The URL being accessed.
|
|
44
|
+
backend: Exporter name for the error message (e.g. ``"otlp"``).
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
EgressViolationError: If the endpoint is blocked by the egress policy.
|
|
48
|
+
"""
|
|
49
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
50
|
+
|
|
51
|
+
cfg = get_config()
|
|
52
|
+
|
|
53
|
+
if not cfg.no_egress:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Check allowlist
|
|
57
|
+
allowlist = cfg.egress_allowlist
|
|
58
|
+
if allowlist:
|
|
59
|
+
for prefix in allowlist:
|
|
60
|
+
if endpoint.startswith(prefix):
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
raise EgressViolationError(backend=backend, endpoint=endpoint)
|