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
@@ -0,0 +1,322 @@
1
+ """spanforge._batch_exporter — Background batched export pipeline (RFC-0001 §19).
2
+
3
+ This module provides a bounded, thread-safe batch exporter that wraps any
4
+ synchronous exporter (a callable taking a single :class:`~spanforge.event.Event`)
5
+ and ships events asynchronously from a background daemon thread.
6
+
7
+ Architecture
8
+ ------------
9
+ ::
10
+
11
+ put(event)
12
+
13
+
14
+ queue.Queue[Event | None] (bounded by config.max_queue_size)
15
+
16
+
17
+ _WorkerThread — background daemon thread
18
+
19
+ ├─ accumulates events until batch_size reached
20
+ ├─ or flush_interval_seconds elapsed (whichever comes first)
21
+ └─ calls exporter.export(event) for each event in the batch
22
+
23
+ Circuit breaker
24
+ ~~~~~~~~~~~~~~~
25
+ After ``_CIRCUIT_BREAKER_THRESHOLD`` consecutive export failures the circuit
26
+ trips **open**: new ``put()`` calls are silently dropped (not queued) and the
27
+ exporter is not called. The circuit resets to **closed** after
28
+ ``circuit_breaker_reset_seconds`` with the next successful export.
29
+
30
+ Signals
31
+ ~~~~~~~
32
+ ``flush(timeout)`` — drain the queue; returns ``True`` on success.
33
+ ``shutdown(timeout)`` — drain + stop the worker thread.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import queue
40
+ import threading
41
+ import time
42
+ from typing import Any, Callable
43
+
44
+ __all__ = [
45
+ "BatchExporter",
46
+ ]
47
+
48
+ _log = logging.getLogger("spanforge.batch_exporter")
49
+
50
+ _CIRCUIT_BREAKER_THRESHOLD = 5 # consecutive failures before tripping open
51
+ _SENTINEL = None # sent down the queue to tell the worker to stop
52
+
53
+ # _DROP_SENTINEL distinguishes a flush-wait sentinel from a stop sentinel.
54
+ _FLUSH_TAG = object()
55
+
56
+
57
+ class BatchExporter:
58
+ """Wraps a synchronous exporter with a background batching pipeline.
59
+
60
+ Args:
61
+ export_fn: Callable that receives a single
62
+ :class:`~spanforge.event.Event` and performs the actual export.
63
+ batch_size: Maximum number of events to accumulate before forcibly
64
+ flushing. Defaults to ``config.batch_size`` (512).
65
+ flush_interval_seconds: Maximum time (seconds) to wait before
66
+ flushing a partial batch. Defaults to
67
+ ``config.flush_interval_seconds`` (5.0).
68
+ max_queue_size: Maximum depth of the internal queue. Events that
69
+ arrive when the queue is full are **dropped** (counted in
70
+ :attr:`dropped_count`).
71
+ circuit_breaker_reset_seconds: How long (seconds) the circuit stays
72
+ open after tripping. Defaults to 30.
73
+
74
+ Example::
75
+
76
+ from spanforge._batch_exporter import BatchExporter
77
+ from spanforge.exporters.jsonl import SyncJSONLExporter
78
+
79
+ inner = SyncJSONLExporter("trace.jsonl")
80
+ bexp = BatchExporter(inner.export, batch_size=64, flush_interval_seconds=2.0)
81
+ bexp.put(event)
82
+ # ... later ...
83
+ bexp.shutdown()
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ export_fn: Callable[[Any], None],
89
+ *,
90
+ batch_size: int = 512,
91
+ flush_interval_seconds: float = 5.0,
92
+ max_queue_size: int = 10_000,
93
+ circuit_breaker_reset_seconds: float = 30.0,
94
+ ) -> None:
95
+ self._export_fn = export_fn
96
+ self._batch_size = max(1, batch_size)
97
+ self._flush_interval = max(0.01, flush_interval_seconds)
98
+ self._cb_reset_seconds = circuit_breaker_reset_seconds
99
+
100
+ # Stats (read outside the lock for approximate values — accuracy is
101
+ # not required; correctness of the exporter is).
102
+ self.dropped_count: int = 0
103
+ self.export_error_count: int = 0
104
+ self.exported_count: int = 0
105
+
106
+ # Circuit breaker state.
107
+ self._cb_lock = threading.Lock()
108
+ self._cb_consecutive_failures: int = 0
109
+ self._cb_open: bool = False
110
+ self._cb_tripped_at: float = 0.0
111
+
112
+ # Queue.
113
+ self._queue: queue.Queue[Any] = queue.Queue(maxsize=max_queue_size)
114
+
115
+ # Worker.
116
+ self._stop_event = threading.Event()
117
+ self._thread = threading.Thread(
118
+ target=self._worker,
119
+ name="spanforge-batch-exporter",
120
+ daemon=True,
121
+ )
122
+ self._thread.start()
123
+
124
+ # ------------------------------------------------------------------
125
+ # Public API
126
+ # ------------------------------------------------------------------
127
+
128
+ def put(self, event: Any) -> bool:
129
+ """Enqueue *event* for export.
130
+
131
+ Returns ``True`` if the event was enqueued, ``False`` if it was
132
+ dropped (queue full, circuit open, or exporter shut down).
133
+ """
134
+ # Check circuit breaker first — cheaper than queue operations and
135
+ # prevents work from piling up behind an open circuit.
136
+ if self._circuit_is_open():
137
+ self.dropped_count += 1
138
+ return False
139
+
140
+ # Refuse new work after shutdown. Checked AFTER circuit so that the
141
+ # circuit-open drop is counted even during shutdown sequencing.
142
+ if self._stop_event.is_set():
143
+ self.dropped_count += 1
144
+ return False
145
+
146
+ try:
147
+ self._queue.put_nowait(event)
148
+ return True
149
+ except queue.Full:
150
+ self.dropped_count += 1
151
+ _log.warning(
152
+ "spanforge batch exporter: queue full (%d items dropped so far)",
153
+ self.dropped_count,
154
+ )
155
+ return False
156
+
157
+ def flush(self, timeout_seconds: float = 5.0) -> bool:
158
+ """Block until all currently queued events have been exported.
159
+
160
+ Returns ``True`` if the flush completed within *timeout_seconds*,
161
+ ``False`` on timeout.
162
+
163
+ Each call uses an independent :class:`threading.Event` so concurrent
164
+ flush() calls do not accidentally release each other's barrier.
165
+ """
166
+ if not self._thread.is_alive():
167
+ return True
168
+
169
+ # Per-call done event avoids the race between _flush_done.clear() and
170
+ # the worker setting _flush_done for a *previous* flush request.
171
+ done_event = threading.Event()
172
+ try:
173
+ self._queue.put_nowait((_FLUSH_TAG, done_event))
174
+ except queue.Full:
175
+ return False
176
+ return done_event.wait(timeout=timeout_seconds)
177
+
178
+ def shutdown(self, timeout_seconds: float = 5.0) -> None:
179
+ """Flush remaining events and stop the background thread.
180
+
181
+ Safe to call multiple times.
182
+ """
183
+ if not self._thread.is_alive():
184
+ return
185
+ self._stop_event.set()
186
+ # Send sentinel to wake the worker.
187
+ try:
188
+ self._queue.put_nowait(_SENTINEL)
189
+ except queue.Full:
190
+ pass
191
+ self._thread.join(timeout=timeout_seconds)
192
+
193
+ # ------------------------------------------------------------------
194
+ # Circuit breaker helpers
195
+ # ------------------------------------------------------------------
196
+
197
+ def _circuit_is_open(self) -> bool:
198
+ with self._cb_lock:
199
+ if not self._cb_open:
200
+ return False
201
+ # Auto-reset after timeout.
202
+ if time.monotonic() - self._cb_tripped_at > self._cb_reset_seconds:
203
+ _log.info("spanforge batch exporter: circuit breaker reset to closed")
204
+ self._cb_open = False
205
+ self._cb_consecutive_failures = 0
206
+ return False
207
+ return True
208
+
209
+ def _record_success(self) -> None:
210
+ with self._cb_lock:
211
+ self._cb_consecutive_failures = 0
212
+ if self._cb_open:
213
+ self._cb_open = False
214
+
215
+ def _record_failure(self) -> None:
216
+ with self._cb_lock:
217
+ self._cb_consecutive_failures += 1
218
+ if (
219
+ not self._cb_open
220
+ and self._cb_consecutive_failures >= _CIRCUIT_BREAKER_THRESHOLD
221
+ ):
222
+ self._cb_open = True
223
+ self._cb_tripped_at = time.monotonic()
224
+ _log.error(
225
+ "spanforge batch exporter: circuit breaker OPEN after %d "
226
+ "consecutive failures; new events will be dropped for %.0fs",
227
+ self._cb_consecutive_failures,
228
+ self._cb_reset_seconds,
229
+ )
230
+
231
+ # ------------------------------------------------------------------
232
+ # Worker thread
233
+ # ------------------------------------------------------------------
234
+
235
+ def _worker(self) -> None:
236
+ """Background thread: accumulate + export batches."""
237
+ batch: list[Any] = []
238
+ deadline = time.monotonic() + self._flush_interval
239
+
240
+ while True:
241
+ now = time.monotonic()
242
+ remaining = max(0.0, deadline - now)
243
+
244
+ # Wait for the next item or timeout.
245
+ try:
246
+ item = self._queue.get(timeout=remaining)
247
+ except queue.Empty:
248
+ item = None # timeout — force a flush of whatever we have
249
+
250
+ if item is _SENTINEL or self._stop_event.is_set():
251
+ # Drain remaining items in the queue before stopping.
252
+ self._drain_queue(batch)
253
+ self._export_batch(batch)
254
+ batch = []
255
+ break
256
+
257
+ if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH_TAG:
258
+ # Flush requested externally — item is (_FLUSH_TAG, done_event).
259
+ _, done_event = item
260
+ self._drain_queue(batch)
261
+ self._export_batch(batch)
262
+ batch = []
263
+ deadline = time.monotonic() + self._flush_interval
264
+ done_event.set() # Signal this specific flush caller.
265
+ continue
266
+
267
+ if item is not None:
268
+ batch.append(item)
269
+
270
+ time_expired = time.monotonic() >= deadline
271
+ batch_full = len(batch) >= self._batch_size
272
+
273
+ if time_expired or batch_full:
274
+ self._export_batch(batch)
275
+ batch = []
276
+ deadline = time.monotonic() + self._flush_interval
277
+
278
+ def _drain_queue(self, batch: list[Any]) -> None:
279
+ """Drain remaining items from the queue into *batch* without blocking."""
280
+ while True:
281
+ try:
282
+ item = self._queue.get_nowait()
283
+ if item is _SENTINEL:
284
+ continue
285
+ # Flush tuples: signal done and skip — already mid-flush.
286
+ if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH_TAG:
287
+ _, done_event = item
288
+ done_event.set()
289
+ continue
290
+ if item is not None:
291
+ batch.append(item)
292
+ except queue.Empty:
293
+ break
294
+
295
+ def _export_batch(self, batch: list[Any]) -> None:
296
+ """Export all events in *batch* via the wrapped exporter."""
297
+ if not batch:
298
+ return
299
+ for event in batch:
300
+ try:
301
+ self._export_fn(event)
302
+ except Exception as exc: # NOSONAR
303
+ # Increment error counter ONLY on failure (C2 fix: counter was
304
+ # incremented inside the same try block as success, causing
305
+ # both counters to be set on partial failures).
306
+ self.export_error_count += 1
307
+ self._record_failure()
308
+ _log.warning(
309
+ "spanforge batch exporter: export error (%s): %s",
310
+ type(exc).__name__,
311
+ exc,
312
+ )
313
+ # Propagate to the configured error handler without blocking.
314
+ try:
315
+ from spanforge._stream import _handle_export_error # noqa: PLC0415
316
+ _handle_export_error(exc)
317
+ except Exception: # NOSONAR
318
+ pass
319
+ else:
320
+ # Success path: increment only on confirmed success (C2 fix).
321
+ self.exported_count += 1
322
+ self._record_success()