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
|
@@ -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()
|