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/_stream.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""spanforge._stream — Internal synchronous event emitter.
|
|
2
|
+
|
|
3
|
+
This module is the bridge between the tracer's context managers and the
|
|
4
|
+
configured export backend. It is intentionally private — user code should
|
|
5
|
+
interact with the tracer, not this module directly.
|
|
6
|
+
|
|
7
|
+
Flow
|
|
8
|
+
----
|
|
9
|
+
::
|
|
10
|
+
|
|
11
|
+
Span.__exit__
|
|
12
|
+
→ _stream.emit_span(span)
|
|
13
|
+
→ build SpanPayload
|
|
14
|
+
→ build Event(event_type=TRACE_SPAN_COMPLETED, payload=span_payload.to_dict())
|
|
15
|
+
→ _active_exporter().export(event) ← sync
|
|
16
|
+
|
|
17
|
+
The active exporter is resolved lazily on first use and cached until the
|
|
18
|
+
config changes (call :func:`_reset_exporter` after ``configure()``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import atexit
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
import secrets
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import warnings
|
|
30
|
+
|
|
31
|
+
from spanforge.config import SpanForgeConfig, get_config
|
|
32
|
+
from spanforge.event import Event, Tags
|
|
33
|
+
from spanforge.exceptions import ExportError
|
|
34
|
+
from spanforge.types import RFC_SPANFORGE_NAMESPACES, EventType
|
|
35
|
+
|
|
36
|
+
__all__: list[str] = ["flush", "shutdown"] # public helpers re-exported from spanforge root
|
|
37
|
+
|
|
38
|
+
_export_logger = logging.getLogger("spanforge.export")
|
|
39
|
+
|
|
40
|
+
# Thread-safe export error counter (useful for metrics / health checks).
|
|
41
|
+
_export_error_count: int = 0
|
|
42
|
+
_export_error_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
# Ephemeral per-process signing key used to auto-sign RFC-0001 SPANFORGE
|
|
45
|
+
# namespace events when no org-level signing_key is configured.
|
|
46
|
+
# Generated once at import time with 256 bits of entropy.
|
|
47
|
+
_EPHEMERAL_SIGNING_KEY: str = secrets.token_hex(32)
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Source field sanitisation
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_SOURCE_START_RE = re.compile(r"^[a-zA-Z]")
|
|
54
|
+
_SOURCE_BODY_RE = re.compile(r"[^a-zA-Z0-9._\-]")
|
|
55
|
+
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_source(service_name: str, service_version: str) -> str:
|
|
59
|
+
"""Return a valid ``name@version`` source string.
|
|
60
|
+
|
|
61
|
+
Sanitise ``service_name`` so it always starts with a letter and contains
|
|
62
|
+
only ``[a-zA-Z0-9._-]``. Ensures ``service_version`` looks like a semver.
|
|
63
|
+
"""
|
|
64
|
+
name = _SOURCE_BODY_RE.sub("-", service_name)
|
|
65
|
+
if not _SOURCE_START_RE.match(name):
|
|
66
|
+
name = "s" + name # prepend 's' if name starts with a digit/special char
|
|
67
|
+
if not _VERSION_RE.match(service_version):
|
|
68
|
+
service_version = "0.0.0"
|
|
69
|
+
return f"{name}@{service_version}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Exporter resolution
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
_exporter_lock = threading.Lock()
|
|
77
|
+
_cached_exporter: object | None = None # SyncExporter protocol instance
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Signing chain state
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_sign_lock = threading.Lock()
|
|
84
|
+
_prev_signed_event: Event | None = None # last event in the HMAC chain
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _handle_export_error(exc: Exception) -> None:
|
|
88
|
+
"""Apply the configured ``on_export_error`` policy for *exc*.
|
|
89
|
+
|
|
90
|
+
Policies:
|
|
91
|
+
|
|
92
|
+
- ``"drop"`` — silently discard the error (opt-in to original behaviour).
|
|
93
|
+
- ``"warn"`` — emit a :mod:`warnings` ``UserWarning`` (default).
|
|
94
|
+
- ``"raise"`` — re-raise the exception into caller code.
|
|
95
|
+
|
|
96
|
+
Regardless of policy, the optional ``export_error_callback`` is always
|
|
97
|
+
invoked first so callers can implement custom alerting or metrics.
|
|
98
|
+
"""
|
|
99
|
+
global _export_error_count # noqa: PLW0603
|
|
100
|
+
with _export_error_lock:
|
|
101
|
+
_export_error_count += 1
|
|
102
|
+
|
|
103
|
+
_export_logger.warning(
|
|
104
|
+
"spanforge export error (%s): %s",
|
|
105
|
+
type(exc).__name__,
|
|
106
|
+
exc,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
cfg = get_config()
|
|
111
|
+
except Exception: # NOSONAR
|
|
112
|
+
cfg = None # type: ignore[assignment]
|
|
113
|
+
|
|
114
|
+
# Invoke the optional error callback (never raises).
|
|
115
|
+
if cfg is not None and cfg.export_error_callback is not None:
|
|
116
|
+
try:
|
|
117
|
+
cfg.export_error_callback(exc)
|
|
118
|
+
except Exception as cb_exc: # NOSONAR
|
|
119
|
+
_export_logger.debug("export_error_callback raised: %s", cb_exc)
|
|
120
|
+
|
|
121
|
+
policy = cfg.on_export_error if cfg is not None else "warn"
|
|
122
|
+
|
|
123
|
+
if policy == "raise":
|
|
124
|
+
raise exc
|
|
125
|
+
if policy == "warn":
|
|
126
|
+
warnings.warn(
|
|
127
|
+
f"spanforge export error ({type(exc).__name__}): {exc}",
|
|
128
|
+
stacklevel=3,
|
|
129
|
+
)
|
|
130
|
+
# "drop": discard silently
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _reset_exporter() -> None:
|
|
134
|
+
"""Invalidate the cached exporter and reset the HMAC signing chain."""
|
|
135
|
+
global _cached_exporter, _prev_signed_event # noqa: PLW0603
|
|
136
|
+
with _exporter_lock:
|
|
137
|
+
if _cached_exporter is not None:
|
|
138
|
+
# Flush + close any open file handles before discarding the exporter.
|
|
139
|
+
try:
|
|
140
|
+
if hasattr(_cached_exporter, "close"):
|
|
141
|
+
_cached_exporter.close() # type: ignore[union-attr]
|
|
142
|
+
except Exception as exc: # NOSONAR
|
|
143
|
+
_handle_export_error(exc)
|
|
144
|
+
_cached_exporter = None
|
|
145
|
+
with _sign_lock:
|
|
146
|
+
_prev_signed_event = None
|
|
147
|
+
# Recreate the trace store with the (possibly updated) size from config.
|
|
148
|
+
try:
|
|
149
|
+
from spanforge._store import _reset_store # noqa: PLC0415
|
|
150
|
+
from spanforge.config import get_config as _gc # noqa: PLC0415
|
|
151
|
+
_reset_store(_gc().trace_store_size)
|
|
152
|
+
except Exception: # NOSONAR
|
|
153
|
+
pass # never let store reset failures affect the exporter reset
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _active_exporter() -> object:
|
|
157
|
+
"""Return the cached exporter, instantiating it from config if necessary."""
|
|
158
|
+
global _cached_exporter # noqa: PLW0603
|
|
159
|
+
if _cached_exporter is not None:
|
|
160
|
+
return _cached_exporter
|
|
161
|
+
with _exporter_lock:
|
|
162
|
+
if _cached_exporter is not None:
|
|
163
|
+
return _cached_exporter
|
|
164
|
+
_cached_exporter = _build_exporter()
|
|
165
|
+
return _cached_exporter
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_exporter() -> object:
|
|
169
|
+
"""Instantiate the correct exporter based on the current config."""
|
|
170
|
+
cfg = get_config()
|
|
171
|
+
|
|
172
|
+
# SF-11-C: Dual-stream export support — if cfg.exporters has multiple
|
|
173
|
+
# entries, build a _FanOutExporter that dispatches to all of them.
|
|
174
|
+
if cfg.exporters and len(cfg.exporters) > 1:
|
|
175
|
+
children = []
|
|
176
|
+
for name in cfg.exporters:
|
|
177
|
+
child = _build_single_exporter(name.lower(), cfg)
|
|
178
|
+
if child is not None:
|
|
179
|
+
children.append((name, child))
|
|
180
|
+
if children:
|
|
181
|
+
return _FanOutExporter(children)
|
|
182
|
+
|
|
183
|
+
name = (cfg.exporter or "console").lower()
|
|
184
|
+
return _build_single_exporter(name, cfg) or _build_single_exporter("console", cfg)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _build_single_exporter(name: str, cfg: "SpanForgeConfig") -> object | None:
|
|
188
|
+
"""Instantiate a single exporter by *name*."""
|
|
189
|
+
if name in ("otel_passthrough", "otel_bridge"):
|
|
190
|
+
try:
|
|
191
|
+
from spanforge.export.otel_bridge import OTelBridgeExporter # noqa: PLC0415
|
|
192
|
+
return OTelBridgeExporter()
|
|
193
|
+
except ImportError:
|
|
194
|
+
raise ImportError(
|
|
195
|
+
"opentelemetry-sdk is required for otel_passthrough mode. "
|
|
196
|
+
"Install it with: pip install 'spanforge[otel]'"
|
|
197
|
+
) from None
|
|
198
|
+
|
|
199
|
+
if name == "jsonl":
|
|
200
|
+
from spanforge.exporters.jsonl import SyncJSONLExporter # noqa: PLC0415
|
|
201
|
+
path = cfg.endpoint or "spanforge_events.jsonl"
|
|
202
|
+
return SyncJSONLExporter(path)
|
|
203
|
+
|
|
204
|
+
if name == "console":
|
|
205
|
+
from spanforge.exporters.console import SyncConsoleExporter # noqa: PLC0415
|
|
206
|
+
return SyncConsoleExporter()
|
|
207
|
+
|
|
208
|
+
# Named exporters that are only supported via EventStream (async path).
|
|
209
|
+
_supported_via_eventstream = frozenset({"otlp", "webhook", "datadog", "grafana_loki"})
|
|
210
|
+
if name in _supported_via_eventstream:
|
|
211
|
+
warnings.warn(
|
|
212
|
+
f"spanforge: exporter={name!r} is not supported by the synchronous tracer "
|
|
213
|
+
f"(configure / start_trace). Use spanforge.stream.EventStream with the "
|
|
214
|
+
f"spanforge.export.{name} module instead. Falling back to console output.",
|
|
215
|
+
UserWarning,
|
|
216
|
+
stacklevel=5,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Default fallback: use the console exporter.
|
|
220
|
+
from spanforge.exporters.console import SyncConsoleExporter # noqa: PLC0415
|
|
221
|
+
return SyncConsoleExporter()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class _FanOutExporter:
|
|
225
|
+
"""Dispatches events to multiple exporters independently (SF-11-C).
|
|
226
|
+
|
|
227
|
+
Each child exporter receives the same event. Failures in one exporter
|
|
228
|
+
do not affect the others (circuit-breaker per exporter).
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
__slots__ = ("_children", "_failed")
|
|
232
|
+
|
|
233
|
+
def __init__(self, children: list[tuple[str, object]]) -> None:
|
|
234
|
+
self._children = children
|
|
235
|
+
self._failed: set[str] = set()
|
|
236
|
+
|
|
237
|
+
def export(self, event: "Event") -> None:
|
|
238
|
+
for name, child in self._children:
|
|
239
|
+
if name in self._failed:
|
|
240
|
+
continue
|
|
241
|
+
try:
|
|
242
|
+
child.export(event) # type: ignore[attr-defined]
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
_export_logger.warning(
|
|
245
|
+
"spanforge fan-out exporter %r failed: %s — disabling",
|
|
246
|
+
name, exc,
|
|
247
|
+
)
|
|
248
|
+
self._failed.add(name)
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
for _name, child in self._children:
|
|
252
|
+
if hasattr(child, "close"):
|
|
253
|
+
try:
|
|
254
|
+
child.close() # type: ignore[attr-defined]
|
|
255
|
+
except Exception: # NOSONAR
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Event construction helpers
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _build_event(
|
|
265
|
+
event_type: EventType,
|
|
266
|
+
payload_dict: dict,
|
|
267
|
+
span_id: str | None = None,
|
|
268
|
+
trace_id: str | None = None,
|
|
269
|
+
parent_span_id: str | None = None,
|
|
270
|
+
) -> Event:
|
|
271
|
+
"""Construct a fully-populated :class:`~spanforge.event.Event` envelope."""
|
|
272
|
+
cfg = get_config()
|
|
273
|
+
source = _build_source(cfg.service_name, cfg.service_version)
|
|
274
|
+
|
|
275
|
+
kwargs: dict = {
|
|
276
|
+
"event_type": event_type,
|
|
277
|
+
"source": source,
|
|
278
|
+
"payload": payload_dict,
|
|
279
|
+
}
|
|
280
|
+
if cfg.org_id:
|
|
281
|
+
kwargs["org_id"] = cfg.org_id
|
|
282
|
+
if span_id:
|
|
283
|
+
kwargs["span_id"] = span_id
|
|
284
|
+
if trace_id:
|
|
285
|
+
kwargs["trace_id"] = trace_id
|
|
286
|
+
if parent_span_id:
|
|
287
|
+
kwargs["parent_span_id"] = parent_span_id
|
|
288
|
+
|
|
289
|
+
tags_kwargs: dict = {"env": cfg.env}
|
|
290
|
+
kwargs["tags"] = Tags(**tags_kwargs)
|
|
291
|
+
|
|
292
|
+
return Event(**kwargs)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Public emit functions (called by _span.py context managers)
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def emit_span(span: object) -> None:
|
|
301
|
+
"""Build a ``SpanPayload`` event from *span* and export it.
|
|
302
|
+
|
|
303
|
+
Also notifies the active :class:`~spanforge._trace.Trace` collector (if any)
|
|
304
|
+
so it can accumulate spans for :meth:`~spanforge._trace.Trace.to_json`.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
span: A :class:`~spanforge._span.Span` instance.
|
|
308
|
+
"""
|
|
309
|
+
# Import here to avoid circular import at module load time.
|
|
310
|
+
from spanforge._span import Span, _run_stack_var # noqa: PLC0415
|
|
311
|
+
|
|
312
|
+
assert isinstance(span, Span)
|
|
313
|
+
payload = span.to_span_payload()
|
|
314
|
+
event_type = (
|
|
315
|
+
EventType.TRACE_SPAN_FAILED if span.status == "error"
|
|
316
|
+
else EventType.TRACE_SPAN_COMPLETED
|
|
317
|
+
)
|
|
318
|
+
event = _build_event(
|
|
319
|
+
event_type=event_type,
|
|
320
|
+
payload_dict=payload.to_dict(),
|
|
321
|
+
span_id=span.span_id,
|
|
322
|
+
trace_id=span.trace_id,
|
|
323
|
+
parent_span_id=span.parent_span_id,
|
|
324
|
+
)
|
|
325
|
+
_dispatch(event)
|
|
326
|
+
|
|
327
|
+
# Notify the Trace collector (set by start_trace()) so it can accumulate spans.
|
|
328
|
+
run_tuple = _run_stack_var.get()
|
|
329
|
+
if run_tuple:
|
|
330
|
+
collector = getattr(run_tuple[-1], "_trace_collector", None)
|
|
331
|
+
if collector is not None:
|
|
332
|
+
try:
|
|
333
|
+
collector._record_span(span)
|
|
334
|
+
except Exception: # NOSONAR
|
|
335
|
+
pass # never let collection errors affect the main emit path
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def emit_agent_step(step: object) -> None:
|
|
339
|
+
"""Build an ``AgentStepPayload`` event from *step* and export it."""
|
|
340
|
+
from spanforge._span import AgentStepContext # noqa: PLC0415
|
|
341
|
+
|
|
342
|
+
assert isinstance(step, AgentStepContext)
|
|
343
|
+
payload = step.to_agent_step_payload()
|
|
344
|
+
event = _build_event(
|
|
345
|
+
event_type=EventType.TRACE_AGENT_STEP,
|
|
346
|
+
payload_dict=payload.to_dict(),
|
|
347
|
+
span_id=step.span_id,
|
|
348
|
+
trace_id=step.trace_id,
|
|
349
|
+
parent_span_id=step.parent_span_id,
|
|
350
|
+
)
|
|
351
|
+
_dispatch(event)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def emit_agent_run(run: object) -> None:
|
|
355
|
+
"""Build an ``AgentRunPayload`` event from *run* and export it."""
|
|
356
|
+
from spanforge._span import AgentRunContext # noqa: PLC0415
|
|
357
|
+
|
|
358
|
+
assert isinstance(run, AgentRunContext)
|
|
359
|
+
payload = run.to_agent_run_payload()
|
|
360
|
+
event = _build_event(
|
|
361
|
+
event_type=EventType.TRACE_AGENT_COMPLETED,
|
|
362
|
+
payload_dict=payload.to_dict(),
|
|
363
|
+
trace_id=run.trace_id,
|
|
364
|
+
span_id=run.root_span_id,
|
|
365
|
+
)
|
|
366
|
+
_dispatch(event)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Dispatch
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _is_error_or_timeout(event: "Event") -> bool:
|
|
375
|
+
"""Return True if the event payload status is 'error' or 'timeout'."""
|
|
376
|
+
return event.payload.get("status", "") in ("error", "timeout")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _passes_sample_rate(event: "Event", sample_rate: float) -> bool:
|
|
380
|
+
"""Deterministic per-trace sampling; returns True if the event should be kept."""
|
|
381
|
+
trace_id: str = event.payload.get("trace_id", "")
|
|
382
|
+
if trace_id:
|
|
383
|
+
token = trace_id[:8]
|
|
384
|
+
try:
|
|
385
|
+
bucket = int(token, 16)
|
|
386
|
+
except ValueError:
|
|
387
|
+
bucket = 0
|
|
388
|
+
return bucket / 0xFFFF_FFFF <= sample_rate
|
|
389
|
+
# No trace_id — use cryptographically secure random fallback
|
|
390
|
+
rand_token = secrets.token_hex(4) # 8 hex chars = 32-bit random value
|
|
391
|
+
bucket = int(rand_token, 16)
|
|
392
|
+
return bucket / 0xFFFF_FFFF <= sample_rate
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _should_emit(event: "Event", cfg: "SpanForgeConfig") -> bool:
|
|
396
|
+
"""Return ``True`` if *event* should be exported under the current config.
|
|
397
|
+
|
|
398
|
+
The sampling decision is made in this order:
|
|
399
|
+
|
|
400
|
+
1. **Error pass-through** — when ``always_sample_errors=True`` (the
|
|
401
|
+
default), spans with ``status="error"`` or ``status="timeout"`` are
|
|
402
|
+
always emitted regardless of *sample_rate*.
|
|
403
|
+
2. **Probabilistic sampling** — the decision is deterministic per
|
|
404
|
+
``trace_id``: all spans of a given trace are sampled or dropped
|
|
405
|
+
together. Uses the first 8 hex digits of the trace_id as a
|
|
406
|
+
32-bit hash so the decision is reproducible.
|
|
407
|
+
3. **Custom filters** — all ``trace_filters`` callables must return
|
|
408
|
+
``True`` for the event to be emitted.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
event: The candidate event.
|
|
412
|
+
cfg: Live :class:`~spanforge.config.SpanForgeConfig` snapshot.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
``True`` to emit, ``False`` to drop.
|
|
416
|
+
"""
|
|
417
|
+
# Fast path: no sampling configured, no filters — always emit.
|
|
418
|
+
if cfg.sample_rate >= 1.0 and not cfg.trace_filters:
|
|
419
|
+
return True
|
|
420
|
+
|
|
421
|
+
# Step 1: always emit errors when configured.
|
|
422
|
+
if cfg.always_sample_errors and _is_error_or_timeout(event):
|
|
423
|
+
return True
|
|
424
|
+
|
|
425
|
+
# Step 2: probabilistic sampling keyed on trace_id.
|
|
426
|
+
if cfg.sample_rate < 1.0 and not _passes_sample_rate(event, cfg.sample_rate):
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
# Step 3: custom filters (all must pass).
|
|
430
|
+
for f in cfg.trace_filters:
|
|
431
|
+
try:
|
|
432
|
+
if not f(event):
|
|
433
|
+
return False
|
|
434
|
+
except Exception: # NOSONAR
|
|
435
|
+
pass # a failing filter never silently drops the event
|
|
436
|
+
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _dispatch(event: Event) -> None:
|
|
441
|
+
"""Export *event* through the active exporter, handling errors per policy.
|
|
442
|
+
|
|
443
|
+
Pipeline (in order):
|
|
444
|
+
0. **Redaction** — apply :class:`~spanforge.redact.RedactionPolicy` when
|
|
445
|
+
``config.redaction_policy`` is set. PII is masked first so that
|
|
446
|
+
sampling decisions are never made on un-redacted data.
|
|
447
|
+
1. **Sampling** — apply probabilistic sampling and custom filters; drop
|
|
448
|
+
the event immediately if it should not be emitted.
|
|
449
|
+
2. **Signing** — sign with HMAC-SHA256 and chain to the previous event
|
|
450
|
+
when ``config.signing_key`` is set.
|
|
451
|
+
3. **Export** — hand the event to the active exporter.
|
|
452
|
+
|
|
453
|
+
On failure the error is routed through :func:`_handle_export_error` which
|
|
454
|
+
applies the ``on_export_error`` policy (``"warn"`` | ``"raise"`` | ``"drop"``).
|
|
455
|
+
"""
|
|
456
|
+
global _prev_signed_event # noqa: PLW0603
|
|
457
|
+
try:
|
|
458
|
+
cfg = get_config()
|
|
459
|
+
|
|
460
|
+
# 0. Redaction FIRST — sampling must see the redacted payload so that
|
|
461
|
+
# raw PII never influences sampling decisions or propagates further.
|
|
462
|
+
if cfg.redaction_policy is not None:
|
|
463
|
+
event = cfg.redaction_policy.apply(event).event
|
|
464
|
+
|
|
465
|
+
# 1. Sampling — drop early to avoid unnecessary work.
|
|
466
|
+
if not _should_emit(event, cfg):
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
# 2. Signing — maintain the audit chain.
|
|
470
|
+
# RFC-0001 SPANFORGE namespaces are ALWAYS signed (auto-signed with
|
|
471
|
+
# the configured key if available, or the ephemeral per-process key).
|
|
472
|
+
# Legacy llm.* namespaces are signed only when signing_key is set.
|
|
473
|
+
event_ns = event.event_type.split(".")[0]
|
|
474
|
+
is_rfc_ns = event_ns in RFC_SPANFORGE_NAMESPACES
|
|
475
|
+
signing_key = cfg.signing_key or (
|
|
476
|
+
_EPHEMERAL_SIGNING_KEY if is_rfc_ns else None
|
|
477
|
+
)
|
|
478
|
+
if signing_key:
|
|
479
|
+
from spanforge.signing import sign # noqa: PLC0415
|
|
480
|
+
with _sign_lock:
|
|
481
|
+
event = sign(
|
|
482
|
+
event,
|
|
483
|
+
org_secret=signing_key,
|
|
484
|
+
prev_event=_prev_signed_event,
|
|
485
|
+
)
|
|
486
|
+
_prev_signed_event = event
|
|
487
|
+
|
|
488
|
+
# 3. Export (with retry + exponential backoff on transient ExportError only).
|
|
489
|
+
exporter = _active_exporter()
|
|
490
|
+
max_retries: int = cfg.export_max_retries
|
|
491
|
+
for attempt in range(max_retries + 1):
|
|
492
|
+
try:
|
|
493
|
+
exporter.export(event) # type: ignore[attr-defined]
|
|
494
|
+
break
|
|
495
|
+
except ExportError as exc:
|
|
496
|
+
if attempt < max_retries:
|
|
497
|
+
_export_logger.debug(
|
|
498
|
+
"spanforge export attempt %d/%d failed (%s): %s — retrying",
|
|
499
|
+
attempt + 1,
|
|
500
|
+
max_retries + 1,
|
|
501
|
+
type(exc).__name__,
|
|
502
|
+
exc,
|
|
503
|
+
)
|
|
504
|
+
time.sleep(0.5 * (2 ** attempt)) # 0.5 s, 1 s, 2 s …
|
|
505
|
+
else:
|
|
506
|
+
raise # exhausted — let outer except call _handle_export_error once
|
|
507
|
+
|
|
508
|
+
# 4. Trace store (opt-in ring buffer for programmatic querying).
|
|
509
|
+
if cfg.enable_trace_store:
|
|
510
|
+
try:
|
|
511
|
+
from spanforge._store import get_store # noqa: PLC0415
|
|
512
|
+
get_store().record(event)
|
|
513
|
+
except Exception as exc:
|
|
514
|
+
_handle_export_error(exc)
|
|
515
|
+
except Exception as exc:
|
|
516
|
+
_handle_export_error(exc)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def get_export_error_count() -> int:
|
|
520
|
+
"""Return the total number of export errors recorded since process start.
|
|
521
|
+
|
|
522
|
+
Useful for health checks and instrumentation::
|
|
523
|
+
|
|
524
|
+
from spanforge._stream import get_export_error_count
|
|
525
|
+
assert get_export_error_count() == 0, "export errors detected"
|
|
526
|
+
"""
|
|
527
|
+
with _export_error_lock:
|
|
528
|
+
return _export_error_count
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def emit_rfc_event(
|
|
532
|
+
event_type: EventType,
|
|
533
|
+
payload: dict,
|
|
534
|
+
span_id: str | None = None,
|
|
535
|
+
trace_id: str | None = None,
|
|
536
|
+
parent_span_id: str | None = None,
|
|
537
|
+
) -> None:
|
|
538
|
+
"""Emit an RFC-0001 SPANFORGE namespace event (decision, tool_call, chain, etc.).
|
|
539
|
+
|
|
540
|
+
Events emitted through this function are guaranteed to be HMAC-signed
|
|
541
|
+
regardless of whether ``config.signing_key`` is set. When no org-level
|
|
542
|
+
key is configured an ephemeral per-process key is used so the audit chain
|
|
543
|
+
remains intact within the current process lifetime.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
event_type: An :class:`~spanforge.types.EventType` from one of the
|
|
547
|
+
10 RFC-0001 SPANFORGE namespaces.
|
|
548
|
+
payload: Plain-dict representation of the namespace payload
|
|
549
|
+
(e.g. the output of ``DecisionPayload.to_dict()``).
|
|
550
|
+
span_id: Optional W3C-format span ID (16 hex chars).
|
|
551
|
+
trace_id: Optional W3C-format trace ID (32 hex chars).
|
|
552
|
+
parent_span_id: Optional parent span ID.
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
ValueError: If *event_type* is not in an RFC-0001 SPANFORGE namespace.
|
|
556
|
+
"""
|
|
557
|
+
event_ns = str(event_type).split(".")[0]
|
|
558
|
+
if event_ns not in RFC_SPANFORGE_NAMESPACES:
|
|
559
|
+
raise ValueError(
|
|
560
|
+
f"emit_rfc_event requires an RFC-0001 SPANFORGE namespace event type "
|
|
561
|
+
f"(got {event_type!r}; namespace {event_ns!r} is not in "
|
|
562
|
+
f"RFC_SPANFORGE_NAMESPACES)."
|
|
563
|
+
)
|
|
564
|
+
event = _build_event(
|
|
565
|
+
event_type=event_type,
|
|
566
|
+
payload_dict=payload,
|
|
567
|
+
span_id=span_id,
|
|
568
|
+
trace_id=trace_id,
|
|
569
|
+
parent_span_id=parent_span_id,
|
|
570
|
+
)
|
|
571
|
+
_dispatch(event)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------------------------------------------------------------------------
|
|
575
|
+
# Graceful shutdown and flush
|
|
576
|
+
# ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
_shutdown_called = False
|
|
579
|
+
_shutdown_lock = threading.Lock()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def flush(timeout_seconds: float = 5.0) -> bool:
|
|
583
|
+
"""Flush any buffered events to the configured exporter.
|
|
584
|
+
|
|
585
|
+
For synchronous exporters (console, JSONL) this is a no-op since every
|
|
586
|
+
event is dispatched immediately. For the asynchronous batch exporter it
|
|
587
|
+
drains the in-memory queue and waits up to *timeout_seconds*.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
timeout_seconds: Maximum time to wait for the flush to complete.
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
``True`` if all events were flushed within the timeout, ``False``
|
|
594
|
+
if the queue still had items after *timeout_seconds*.
|
|
595
|
+
"""
|
|
596
|
+
exporter = _cached_exporter
|
|
597
|
+
if exporter is None:
|
|
598
|
+
return True
|
|
599
|
+
|
|
600
|
+
# Async batch exporter exposes flush().
|
|
601
|
+
flush_fn = getattr(exporter, "flush", None)
|
|
602
|
+
if callable(flush_fn):
|
|
603
|
+
try:
|
|
604
|
+
import inspect as _inspect
|
|
605
|
+
sig = _inspect.signature(flush_fn)
|
|
606
|
+
if "timeout_seconds" in sig.parameters:
|
|
607
|
+
return flush_fn(timeout_seconds=timeout_seconds)
|
|
608
|
+
else:
|
|
609
|
+
flush_fn()
|
|
610
|
+
return True
|
|
611
|
+
except Exception as exc: # NOSONAR
|
|
612
|
+
_export_logger.warning("spanforge flush error: %s", exc)
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
# For file-backed exporters, ensure data is flushed to disk.
|
|
616
|
+
ffs = getattr(exporter, "_fh", None)
|
|
617
|
+
if ffs is not None:
|
|
618
|
+
try:
|
|
619
|
+
ffs.flush()
|
|
620
|
+
except Exception: # NOSONAR
|
|
621
|
+
pass
|
|
622
|
+
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def shutdown(timeout_seconds: float = 5.0) -> None:
|
|
627
|
+
"""Flush and release resources for the active exporter.
|
|
628
|
+
|
|
629
|
+
Safe to call multiple times — subsequent calls after the first are
|
|
630
|
+
no-ops. Registered with :mod:`atexit` at module import time so it is
|
|
631
|
+
always invoked on clean process exit.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
timeout_seconds: Maximum time to wait for in-flight events to drain.
|
|
635
|
+
"""
|
|
636
|
+
global _shutdown_called # noqa: PLW0603
|
|
637
|
+
with _shutdown_lock:
|
|
638
|
+
if _shutdown_called:
|
|
639
|
+
return
|
|
640
|
+
_shutdown_called = True
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
flush(timeout_seconds=timeout_seconds)
|
|
644
|
+
except Exception: # NOSONAR
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
_reset_exporter()
|
|
649
|
+
except Exception: # NOSONAR
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# Register the shutdown hook so in-flight events are always flushed on exit.
|
|
654
|
+
atexit.register(shutdown)
|