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