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
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
|