spanforge 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/config.py ADDED
@@ -0,0 +1,532 @@
1
+ """spanforge.config — Global configuration singleton and ``configure()`` entry point.
2
+
3
+ The configuration layer is intentionally simple: a single mutable dataclass
4
+ backed by a module-level ``threading.Lock`` for safe concurrent mutation.
5
+ Environment variables are read once at import time; subsequent calls to
6
+ :func:`configure` override individual fields.
7
+
8
+ Environment variable mapping
9
+ -----------------------------
10
+ +-----------------------------+-----------------------+
11
+ | Env var | Config field |
12
+ +=============================+=======================+
13
+ | ``SPANFORGE_EXPORTER`` | ``exporter`` |
14
+ | ``SPANFORGE_ENDPOINT`` | ``endpoint`` |
15
+ | ``SPANFORGE_ORG_ID`` | ``org_id`` |
16
+ | ``SPANFORGE_SERVICE_NAME`` | ``service_name`` |
17
+ | ``SPANFORGE_ENV`` | ``env`` |
18
+ | ``SPANFORGE_SERVICE_VERSION``| ``service_version`` |
19
+ | ``SPANFORGE_SIGNING_KEY`` | ``signing_key`` |
20
+ | ``SPANFORGE_SAMPLE_RATE`` | ``sample_rate`` |
21
+ +-----------------------------+-----------------------+
22
+
23
+ Usage::
24
+
25
+ from spanforge import configure
26
+ configure(exporter="jsonl", service_name="my-agent", endpoint="./events.jsonl")
27
+
28
+ from spanforge.config import get_config
29
+ cfg = get_config()
30
+ print(cfg.service_name) # "my-agent"
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import os
37
+ import threading
38
+ from dataclasses import dataclass, field
39
+ from typing import TYPE_CHECKING, Any, Callable
40
+
41
+ if TYPE_CHECKING:
42
+ from spanforge.event import Event
43
+
44
+ __all__ = ["SpanForgeConfig", "configure", "get_config", "interpolate_env"]
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Configuration dataclass
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _VALID_EXPORTERS = frozenset(
51
+ {
52
+ "console",
53
+ "jsonl",
54
+ "sqlite",
55
+ "otlp",
56
+ "webhook",
57
+ "datadog",
58
+ "grafana_loki",
59
+ "otel_bridge",
60
+ "otel_passthrough",
61
+ }
62
+ )
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Config presets
66
+ # ---------------------------------------------------------------------------
67
+
68
+ _PRESETS: dict[str, dict[str, Any]] = {
69
+ "development": {
70
+ "exporter": "console",
71
+ "sample_rate": 1.0,
72
+ "enable_trace_store": True,
73
+ "trace_store_size": 500,
74
+ "on_export_error": "warn",
75
+ "allow_private_endpoints": True,
76
+ "env": "development",
77
+ "flush_interval_seconds": 1.0,
78
+ },
79
+ "testing": {
80
+ "exporter": "console",
81
+ "sample_rate": 1.0,
82
+ "enable_trace_store": True,
83
+ "trace_store_size": 1000,
84
+ "on_export_error": "raise",
85
+ "allow_private_endpoints": True,
86
+ "env": "testing",
87
+ "flush_interval_seconds": 0.1,
88
+ },
89
+ "staging": {
90
+ "exporter": "console",
91
+ "sample_rate": 0.5,
92
+ "enable_trace_store": True,
93
+ "trace_store_size": 200,
94
+ "on_export_error": "warn",
95
+ "always_sample_errors": True,
96
+ "env": "staging",
97
+ },
98
+ "production": {
99
+ "exporter": "otlp",
100
+ "sample_rate": 0.1,
101
+ "enable_trace_store": False,
102
+ "on_export_error": "drop",
103
+ "always_sample_errors": True,
104
+ "batch_size": 512,
105
+ "flush_interval_seconds": 5.0,
106
+ "max_queue_size": 10_000,
107
+ "env": "production",
108
+ },
109
+ "otel_passthrough": {
110
+ "exporter": "otel_bridge",
111
+ "sample_rate": 1.0,
112
+ "enable_trace_store": True,
113
+ "on_export_error": "warn",
114
+ "compliance_sampling": True,
115
+ "env": "production",
116
+ },
117
+ }
118
+
119
+
120
+ @dataclass
121
+ class SpanForgeConfig:
122
+ """Mutable global configuration for the SpanForge SDK.
123
+
124
+ All fields have safe defaults so zero-configuration usage works
125
+ out-of-the-box (``exporter="console"`` prints to stdout).
126
+
127
+ Attributes:
128
+ exporter: Backend to use: ``"console"`` | ``"jsonl"`` | ``"otlp"``
129
+ | ``"webhook"`` | ``"datadog"`` | ``"grafana_loki"``.
130
+ endpoint: Exporter-specific destination
131
+ (file path for JSONL, URL for OTLP/webhook/Datadog/Loki).
132
+ org_id: Organisation identifier; included on all emitted events.
133
+ service_name: Human-readable service name (used in ``source`` field).
134
+ Must start with a letter and contain only
135
+ ``[a-zA-Z0-9._-]``. Defaults to ``"unknown-service"``.
136
+ env: Deployment environment tag (e.g. ``"production"``).
137
+ service_version: SemVer string for the emitting service.
138
+ Defaults to ``"0.0.0"``.
139
+ signing_key: Base64-encoded HMAC-SHA256 key for audit-chain signing.
140
+ ``None`` disables signing.
141
+ redaction_policy: :class:`~spanforge.redact.RedactionPolicy` instance or
142
+ ``None`` to disable PII redaction.
143
+ on_export_error: Policy when an exporter or emission error occurs.
144
+ One of ``"warn"`` (emit to ``stderr``, default),
145
+ ``"raise"`` (re-raise the exception into caller code),
146
+ or ``"drop"`` (silently discard).
147
+ include_raw_tool_io: Opt-in flag to include raw tool arguments
148
+ (``arguments_raw``) and results (``result_raw``)
149
+ in serialised :class:`~spanforge.namespaces.trace.ToolCall`
150
+ payloads. Defaults to ``False`` to prevent
151
+ accidental PII leakage. Set programmatically;
152
+ no corresponding environment variable is provided.
153
+ sample_rate: Fraction of traces to emit (0.0-1.0 inclusive).
154
+ Sampling is deterministic per ``trace_id`` so
155
+ all spans of a trace are sampled together.
156
+ Defaults to ``1.0`` (emit everything). Set via
157
+ ``SPANFORGE_SAMPLE_RATE`` env var.
158
+ always_sample_errors: When ``True`` (the default), spans/traces with
159
+ ``status="error"`` or ``status="timeout"`` are
160
+ always emitted regardless of *sample_rate*.
161
+ trace_filters: List of callables ``(Event) -> bool``. An event
162
+ is emitted only when **all** filters return
163
+ ``True``. Applied after probabilistic sampling.
164
+ Not configurable via environment variable.
165
+ enable_trace_store: When ``True``, every dispatched event is also
166
+ written to the in-process
167
+ :class:`~spanforge._store.TraceStore` ring buffer so
168
+ it can be queried via :func:`~spanforge.get_trace`
169
+ etc. Defaults to ``False``. Set via
170
+ ``SPANFORGE_ENABLE_TRACE_STORE=1``.
171
+ trace_store_size: Maximum number of distinct traces the ring buffer
172
+ retains. Oldest trace is evicted when full.
173
+ Default: 100.
174
+ export_max_retries: Number of retry attempts on transient export failures
175
+ before the ``on_export_error`` policy is applied.
176
+ Retries use exponential back-off (0.5 s, 1 s, 2 s …).
177
+ Default: 3.
178
+ auto_emit_cost: When ``True``, automatically emit a
179
+ ``llm.cost.token.recorded`` event whenever a span
180
+ closes with a non-``None`` ``cost`` attribute.
181
+ Defaults to ``False``.
182
+ budget_usd_per_run: When set, a budget alert is fired on the global
183
+ :class:`~spanforge.cost.CostTracker` when any single
184
+ agent run accumulates costs exceeding this value.
185
+ ``None`` disables per-run budget checks.
186
+ budget_usd_per_day: Rolling 24-hour USD budget cap on the global tracker.
187
+ ``None`` disables the daily budget check.
188
+ """
189
+
190
+ exporter: str = "console"
191
+ endpoint: str | None = None
192
+ org_id: str | None = None
193
+ service_name: str = "unknown-service"
194
+ env: str = "production"
195
+ service_version: str = "0.0.0"
196
+ signing_key: str | None = field(default=None, repr=False)
197
+ redaction_policy: Any = None # RedactionPolicy | None — avoids circular import
198
+ on_export_error: str = "warn" # "warn" | "raise" | "drop"
199
+ include_raw_tool_io: bool = (
200
+ False # opt-in to store raw tool I/O (ToolCall.arguments_raw / result_raw)
201
+ )
202
+ sample_rate: float = 1.0 # 0.0-1.0; fraction of traces to emit
203
+ always_sample_errors: bool = True # emit error/timeout spans regardless of sample_rate
204
+ trace_filters: list[Callable[[Event], bool]] = field(default_factory=list)
205
+ enable_trace_store: bool = False # opt-in in-process trace store
206
+ trace_store_size: int = 100 # ring buffer capacity (number of traces)
207
+ export_max_retries: int = 3 # retry count for transient export failures
208
+ # SSRF protection: set to True to allow private/loopback endpoints (local dev only)
209
+ allow_private_endpoints: bool = False # SPANFORGE_ALLOW_PRIVATE_ENDPOINTS=true
210
+ # Tool 2 — Cost Calculation Engine
211
+ auto_emit_cost: bool = False # auto-emit llm.cost.token.recorded on span close
212
+ budget_usd_per_run: float | None = None # per-run budget cap (USD)
213
+ budget_usd_per_day: float | None = None # rolling 24-hour budget cap (USD)
214
+ # ---------------------------------------------------------------------------
215
+ # New fields (P0 + P1 + P2 additions)
216
+ # ---------------------------------------------------------------------------
217
+ # Async batch export pipeline
218
+ batch_size: int = 512 # max events per batch
219
+ flush_interval_seconds: float = 5.0 # max seconds between flushes
220
+ max_queue_size: int = 10_000 # bounded in-memory queue depth
221
+ # Error callback (invoked on every export error, regardless of on_export_error policy)
222
+ export_error_callback: Callable[[Exception], None] | None = field(default=None, repr=False)
223
+ # Span processor pipeline
224
+ span_processors: list[Any] = field(default_factory=list) # list[SpanProcessor]
225
+ # Custom sampler (overrides sample_rate when set)
226
+ sampler: Any = field(default=None, repr=False) # Sampler | None
227
+ # Session / user tracking defaults
228
+ default_session_id: str | None = None
229
+ default_user_id: str | None = None
230
+ # Maximum span events held per Span (deque maxlen); 0 means unlimited
231
+ max_span_events: int = 1000
232
+ # ---------------------------------------------------------------------------
233
+ # Alerting
234
+ # ---------------------------------------------------------------------------
235
+ # alert_config: AlertConfig data class (loaded from SPANFORGE_ALERT_* env vars).
236
+ # When set, build_manager() is called lazily the first time an
237
+ # alert fires. Ignored when alert_manager is provided directly.
238
+ # alert_manager: Pre-built AlertManager instance. Takes precedence over
239
+ # alert_config. Inject directly for full control.
240
+ alert_config: Any = field(default=None, repr=False) # AlertConfig | None
241
+ alert_manager: Any = field(default=None, repr=False) # AlertManager | None
242
+ # ---------------------------------------------------------------------------
243
+ # v1.0 — Compliance layer additions
244
+ # ---------------------------------------------------------------------------
245
+ # SF-14: Data residency & no-egress controls
246
+ no_egress: bool = False # block all network exporters
247
+ egress_allowlist: frozenset[str] = field(default_factory=frozenset) # URL prefixes
248
+ # SF-16: Compliance-aware sampling
249
+ compliance_sampling: bool = True # always-record compliance events when sample_rate < 1.0
250
+ # GA-01: Signing key security
251
+ signing_key_expires_at: str | None = None # ISO-8601 date
252
+ # GA-01-D: Context-based key derivation for multi-env isolation
253
+ signing_key_context: str | None = None # e.g. "production", "staging"
254
+ # GA-04: Multi-tenant key isolation
255
+ require_org_id: bool = False # raise SigningError if event.org_id is None
256
+ # SF-11-C: Dual-stream export — multiple simultaneous exporters
257
+ exporters: list[str] = field(default_factory=list) # e.g. ['otel_passthrough', 'jsonl']
258
+ # ---------------------------------------------------------------------------
259
+ # v2.0 — T.R.U.S.T. Framework additions
260
+ # ---------------------------------------------------------------------------
261
+ # Consent boundary enforcement
262
+ consent_enforcement: bool = False # enable runtime consent checks
263
+ # Human-in-the-loop (HITL) review queue
264
+ hitl_enabled: bool = False # activate HITL queue
265
+ hitl_confidence_threshold: float = 0.7 # auto-queue below this confidence
266
+ hitl_sla_seconds: int = 3600 # SLA timeout for pending reviews
267
+ # Model registry
268
+ model_registry_path: str | None = None # JSON persistence path (optional)
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Module-level singleton
273
+ # ---------------------------------------------------------------------------
274
+
275
+ _config: SpanForgeConfig = SpanForgeConfig()
276
+ _config_lock: threading.Lock = threading.Lock()
277
+
278
+
279
+ def _load_from_env() -> None:
280
+ """Read environment variables and overlay them onto *_config*."""
281
+ env_map = {
282
+ "SPANFORGE_EXPORTER": "exporter",
283
+ "SPANFORGE_ENDPOINT": "endpoint",
284
+ "SPANFORGE_ORG_ID": "org_id",
285
+ "SPANFORGE_SERVICE_NAME": "service_name",
286
+ "SPANFORGE_ENV": "env",
287
+ "SPANFORGE_SERVICE_VERSION": "service_version",
288
+ "SPANFORGE_SIGNING_KEY": "signing_key",
289
+ "SPANFORGE_ON_EXPORT_ERROR": "on_export_error",
290
+ }
291
+ for env_var, field_name in env_map.items():
292
+ value = os.environ.get(env_var)
293
+ if value is not None:
294
+ setattr(_config, field_name, value)
295
+ # Numeric env vars need explicit conversion.
296
+ raw_rate = os.environ.get("SPANFORGE_SAMPLE_RATE")
297
+ if raw_rate is not None:
298
+ try:
299
+ rate = float(raw_rate)
300
+ except ValueError:
301
+ rate = 1.0
302
+ _config.sample_rate = max(0.0, min(1.0, rate))
303
+ # Boolean env var: SPANFORGE_ENABLE_TRACE_STORE=1 / true / yes enables the store.
304
+ raw_store = os.environ.get("SPANFORGE_ENABLE_TRACE_STORE")
305
+ if raw_store is not None:
306
+ _config.enable_trace_store = raw_store.strip().lower() in ("1", "true", "yes")
307
+ # SSRF override: SPANFORGE_ALLOW_PRIVATE_ENDPOINTS=true allows private IPs (dev only).
308
+ raw_priv = os.environ.get("SPANFORGE_ALLOW_PRIVATE_ENDPOINTS")
309
+ if raw_priv is not None:
310
+ _config.allow_private_endpoints = raw_priv.strip().lower() in ("1", "true", "yes")
311
+ # v1.0 — No-egress mode
312
+ raw_no_egress = os.environ.get("SPANFORGE_NO_EGRESS")
313
+ if raw_no_egress is not None:
314
+ _config.no_egress = raw_no_egress.strip().lower() in ("1", "true", "yes")
315
+ # v1.0 — Egress allowlist (comma-separated URLs)
316
+ raw_allowlist = os.environ.get("SPANFORGE_EGRESS_ALLOWLIST")
317
+ if raw_allowlist is not None:
318
+ _config.egress_allowlist = frozenset(
319
+ u.strip() for u in raw_allowlist.split(",") if u.strip()
320
+ )
321
+ # v1.0 — Compliance sampling
322
+ raw_comp_samp = os.environ.get("SPANFORGE_COMPLIANCE_SAMPLING")
323
+ if raw_comp_samp is not None:
324
+ _config.compliance_sampling = raw_comp_samp.strip().lower() not in ("0", "false", "no")
325
+ # v1.0 — Signing key expiry
326
+ raw_key_expiry = os.environ.get("SPANFORGE_SIGNING_KEY_EXPIRES_AT")
327
+ if raw_key_expiry is not None:
328
+ _config.signing_key_expires_at = raw_key_expiry.strip()
329
+ # v1.0 — Signing key context (GA-01-D)
330
+ raw_key_ctx = os.environ.get("SPANFORGE_SIGNING_KEY_CONTEXT")
331
+ if raw_key_ctx is not None:
332
+ _config.signing_key_context = raw_key_ctx.strip() or None
333
+ # v1.0 — Require org_id
334
+ raw_req_org = os.environ.get("SPANFORGE_REQUIRE_ORG_ID")
335
+ if raw_req_org is not None:
336
+ _config.require_org_id = raw_req_org.strip().lower() in ("1", "true", "yes")
337
+ # v2.0 — T.R.U.S.T. Framework env vars
338
+ raw_consent = os.environ.get("SPANFORGE_CONSENT_ENFORCEMENT")
339
+ if raw_consent is not None:
340
+ _config.consent_enforcement = raw_consent.strip().lower() in ("1", "true", "yes")
341
+ raw_hitl = os.environ.get("SPANFORGE_HITL_ENABLED")
342
+ if raw_hitl is not None:
343
+ _config.hitl_enabled = raw_hitl.strip().lower() in ("1", "true", "yes")
344
+ raw_hitl_thresh = os.environ.get("SPANFORGE_HITL_CONFIDENCE_THRESHOLD")
345
+ if raw_hitl_thresh is not None:
346
+ with contextlib.suppress(ValueError):
347
+ _config.hitl_confidence_threshold = max(0.0, min(1.0, float(raw_hitl_thresh)))
348
+ raw_hitl_sla = os.environ.get("SPANFORGE_HITL_SLA_SECONDS")
349
+ if raw_hitl_sla is not None:
350
+ with contextlib.suppress(ValueError):
351
+ _config.hitl_sla_seconds = max(1, int(raw_hitl_sla))
352
+ raw_registry_path = os.environ.get("SPANFORGE_MODEL_REGISTRY_PATH")
353
+ if raw_registry_path is not None:
354
+ _config.model_registry_path = raw_registry_path.strip() or None
355
+
356
+
357
+ # Apply env vars immediately at import time.
358
+ _load_from_env()
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Public API
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ def get_config() -> SpanForgeConfig:
367
+ """Return the active :class:`SpanForgeConfig` singleton.
368
+
369
+ The returned object is the *live* singleton — modifications to it will
370
+ affect all subsequent tracer operations. Prefer :func:`configure` for
371
+ intentional mutations.
372
+ """
373
+ return _config
374
+
375
+
376
+ def configure(**kwargs: Any) -> None:
377
+ """Mutate the global :class:`SpanForgeConfig` singleton.
378
+
379
+ Accepts the same keyword arguments as :class:`SpanForgeConfig` field names.
380
+ Unknown keys raise :exc:`ValueError` immediately. Calling ``configure()``
381
+ with no arguments is a no-op (safe for idempotent setup scripts).
382
+
383
+ Passing ``preset="<name>"`` applies a set of sensible defaults for the
384
+ environment **before** applying any other kwargs. Available presets:
385
+ ``"development"``, ``"testing"``, ``"staging"``, ``"production"``.
386
+
387
+ Args:
388
+ **kwargs: One or more :class:`SpanForgeConfig` field names and their
389
+ new values. ``preset`` is a special keyword handled here.
390
+
391
+ Raises:
392
+ ValueError: If an unknown configuration key or preset name is passed.
393
+
394
+ Examples::
395
+
396
+ configure(preset="production", exporter="otlp", endpoint="http://collector:4318")
397
+ configure(preset="development")
398
+ configure(exporter="jsonl", endpoint="./events.jsonl")
399
+ """
400
+ if not kwargs:
401
+ return
402
+ with _config_lock:
403
+ # Handle mode shortcut (SF-11-B): configure(mode='otel_passthrough')
404
+ mode = kwargs.pop("mode", None)
405
+ if mode is not None:
406
+ if mode == "otel_passthrough":
407
+ kwargs.setdefault("preset", "otel_passthrough")
408
+ else:
409
+ raise ValueError(
410
+ f"Unknown spanforge mode {mode!r}. Valid modes: 'otel_passthrough'"
411
+ )
412
+
413
+ # Handle preset first so explicit kwargs override preset defaults.
414
+ preset_name = kwargs.pop("preset", None)
415
+ if preset_name is not None:
416
+ if preset_name not in _PRESETS:
417
+ valid_presets = sorted(_PRESETS.keys())
418
+ raise ValueError(
419
+ f"Unknown spanforge preset {preset_name!r}. Valid presets: {valid_presets}"
420
+ )
421
+ for key, value in _PRESETS[preset_name].items():
422
+ setattr(_config, key, value)
423
+
424
+ for key, value in kwargs.items():
425
+ if not hasattr(_config, key):
426
+ valid = sorted(vars(_config).keys())
427
+ raise ValueError(
428
+ f"Unknown spanforge configuration key {key!r}. Valid keys: {valid}"
429
+ )
430
+ # Validate numeric range fields.
431
+ if key == "batch_size":
432
+ if not isinstance(value, int) or value < 1:
433
+ raise ValueError("batch_size must be a positive integer >= 1")
434
+ elif key == "flush_interval_seconds":
435
+ if not isinstance(value, (int, float)) or value <= 0:
436
+ raise ValueError("flush_interval_seconds must be a positive number > 0")
437
+ elif key == "max_queue_size":
438
+ if not isinstance(value, int) or value < 1:
439
+ raise ValueError("max_queue_size must be a positive integer >= 1")
440
+ elif key == "sample_rate":
441
+ if not isinstance(value, (int, float)) or not (0.0 <= value <= 1.0):
442
+ raise ValueError("sample_rate must be a float in [0.0, 1.0]")
443
+ setattr(_config, key, value)
444
+ # Auto-wire ComplianceSampler when compliance_sampling is enabled
445
+ # and a sub-1.0 sample_rate is set but no explicit sampler provided.
446
+ if _config.compliance_sampling and _config.sample_rate < 1.0 and _config.sampler is None:
447
+ from spanforge.sampling import ComplianceSampler
448
+
449
+ _config.sampler = ComplianceSampler(base_rate=_config.sample_rate)
450
+ # GA-01-A: Validate signing key strength when a key is configured.
451
+ if _config.signing_key:
452
+ import logging as _logging
453
+
454
+ from spanforge.signing import validate_key_strength
455
+
456
+ _key_warnings = validate_key_strength(_config.signing_key)
457
+ if _key_warnings:
458
+ _log = _logging.getLogger("spanforge.config")
459
+ for _w in _key_warnings:
460
+ _log.warning("signing key: %s", _w)
461
+ # Invalidate the cached exporter in the stream so the next emit
462
+ # picks up the new configuration. Import here to avoid circular
463
+ # import at module load time.
464
+ try:
465
+ from spanforge import _stream
466
+
467
+ _stream._reset_exporter()
468
+ except (ImportError, AttributeError):
469
+ # _stream not yet loaded (e.g. during package init) — safe to skip.
470
+ pass
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # interpolate_env — recursive ${VAR} / ${VAR:default} substitution
475
+ # ---------------------------------------------------------------------------
476
+
477
+ import re as _re
478
+
479
+ _ENV_VAR_RE = _re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}")
480
+
481
+
482
+ def interpolate_env(data: Any) -> Any:
483
+ """Recursively replace ``${VAR}`` and ``${VAR:default}`` patterns in *data*.
484
+
485
+ Walks *data* depth-first and performs environment-variable interpolation
486
+ on every string value:
487
+
488
+ * ``${FOO}`` — replaced with ``os.environ["FOO"]``; left as-is if the
489
+ variable is not set and no default is provided.
490
+ * ``${FOO:bar}`` — replaced with ``os.environ["FOO"]`` when set, or
491
+ ``"bar"`` when the variable is not set.
492
+
493
+ Non-string leaves (numbers, booleans, ``None``) are returned unchanged.
494
+ Dicts and lists are recursed into.
495
+
496
+ Args:
497
+ data: A Python value of any type. Typically the parsed contents of
498
+ a YAML or JSON configuration file.
499
+
500
+ Returns:
501
+ A deep copy of *data* with all interpolatable strings substituted.
502
+
503
+ Example::
504
+
505
+ import os
506
+ from spanforge.config import interpolate_env
507
+
508
+ os.environ["MODEL"] = "gpt-4o"
509
+ result = interpolate_env({
510
+ "model": "${MODEL}",
511
+ "endpoint": "${ENDPOINT:https://api.openai.com/v1}",
512
+ })
513
+ # {"model": "gpt-4o", "endpoint": "https://api.openai.com/v1"}
514
+ """
515
+ if isinstance(data, str):
516
+ return _ENV_VAR_RE.sub(_replace_env_var, data)
517
+ if isinstance(data, dict):
518
+ return {k: interpolate_env(v) for k, v in data.items()}
519
+ if isinstance(data, list):
520
+ return [interpolate_env(item) for item in data]
521
+ return data
522
+
523
+
524
+ def _replace_env_var(match: _re.Match[str]) -> str:
525
+ """Regex substitution callback for :func:`interpolate_env`."""
526
+ var_name, default = match.group(1), match.group(2)
527
+ env_val = os.environ.get(var_name)
528
+ if env_val is not None:
529
+ return env_val
530
+ if default is not None:
531
+ return default
532
+ return match.group(0) # leave unresolved when no default