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.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. 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)