spanforge 2.0.0__py3-none-any.whl

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