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/_ansi.py ADDED
@@ -0,0 +1,93 @@
1
+ """spanforge._ansi — ANSI terminal colour helpers.
2
+
3
+ Provides a single :func:`color` function that wraps text in ANSI escape codes
4
+ while honouring the ``NO_COLOR`` environment variable
5
+ (https://no-color.org/) and falling back to plain text when stdout is not a
6
+ TTY (e.g. in CI pipelines or when output is piped to a file).
7
+
8
+ Pre-defined colour codes are exported for convenience.
9
+
10
+ Usage::
11
+
12
+ from spanforge._ansi import color, GREEN, RED, BOLD
13
+
14
+ print(color("PASS", GREEN))
15
+ print(color("FAIL", RED + BOLD))
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from typing import IO, TextIO
23
+
24
+ __all__ = [
25
+ "BOLD",
26
+ "CYAN",
27
+ "GREEN",
28
+ "RED",
29
+ "RESET",
30
+ "YELLOW",
31
+ "color",
32
+ "strip_ansi",
33
+ ]
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # ANSI escape sequences
37
+ # ---------------------------------------------------------------------------
38
+
39
+ GREEN = "\033[32m"
40
+ RED = "\033[31m"
41
+ YELLOW = "\033[33m"
42
+ CYAN = "\033[36m"
43
+ BOLD = "\033[1m"
44
+ RESET = "\033[0m"
45
+
46
+
47
+ def color(text: str, code: str, *, file: TextIO | None = None) -> str:
48
+ """Return *text* wrapped in ANSI *code*, or plain *text* when colours are disabled.
49
+
50
+ Colours are suppressed when **any** of the following is true:
51
+
52
+ * The ``NO_COLOR`` environment variable is set (any value).
53
+ * *file* (default: ``sys.stdout``) is not a TTY.
54
+
55
+ Args:
56
+ text: The string to colourise.
57
+ code: An ANSI escape sequence (e.g. :data:`GREEN`, ``RED + BOLD``).
58
+ file: The stream to check for TTY status. Defaults to
59
+ ``sys.stdout``.
60
+
61
+ Returns:
62
+ ``f"{code}{text}{RESET}"`` when colours are enabled, otherwise
63
+ plain *text*.
64
+
65
+ Example::
66
+
67
+ print(color("PASS", GREEN))
68
+ print(color("WARN", YELLOW + BOLD))
69
+ """
70
+ stream: IO[str] = file if file is not None else sys.stdout
71
+ if os.environ.get("NO_COLOR") or not getattr(stream, "isatty", lambda: False)():
72
+ return text
73
+ return f"{code}{text}{RESET}"
74
+
75
+
76
+ def strip_ansi(text: str) -> str:
77
+ """Remove all ANSI escape sequences from *text*.
78
+
79
+ Useful for testing output that was produced with :func:`color`.
80
+
81
+ Args:
82
+ text: String potentially containing ANSI codes.
83
+
84
+ Returns:
85
+ The string with all ``ESC[...m`` sequences removed.
86
+
87
+ Example::
88
+
89
+ assert strip_ansi(color("hello", GREEN)) == "hello"
90
+ """
91
+ import re
92
+
93
+ return re.sub(r"\033\[[0-9;]*m", "", text)
@@ -0,0 +1,409 @@
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 contextlib
39
+ import logging
40
+ import queue
41
+ import threading
42
+ import time
43
+ import weakref
44
+ from typing import Any, Callable
45
+
46
+ __all__ = [
47
+ "BatchExporter",
48
+ "get_aggregate_health",
49
+ ]
50
+
51
+ _log = logging.getLogger("spanforge.batch_exporter")
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Global registry of active BatchExporter instances (weak refs — no leaks).
55
+ # ---------------------------------------------------------------------------
56
+
57
+ _registry_lock = threading.Lock()
58
+ _active_exporters: list[weakref.ref[BatchExporter]] = []
59
+
60
+
61
+ def _register(exporter: BatchExporter) -> None:
62
+ """Register *exporter* in the global weak-ref registry."""
63
+ with _registry_lock:
64
+ _active_exporters.append(weakref.ref(exporter))
65
+ # Prune dead refs opportunistically to keep the list small.
66
+ _prune_dead_refs()
67
+
68
+
69
+ def _prune_dead_refs() -> None:
70
+ """Remove dead weak references. MUST be called with ``_registry_lock`` held."""
71
+ _active_exporters[:] = [r for r in _active_exporters if r() is not None]
72
+
73
+
74
+ def get_aggregate_health() -> dict[str, object]:
75
+ """Return aggregate health across **all** active :class:`BatchExporter` instances.
76
+
77
+ Returns a dict with:
78
+
79
+ ``exporter_count``
80
+ Number of currently active exporter instances.
81
+ ``total_dropped``
82
+ Sum of :attr:`~BatchExporter.dropped_count` across all instances.
83
+ ``total_exported``
84
+ Sum of :attr:`~BatchExporter.exported_count` across all instances.
85
+ ``total_errors``
86
+ Sum of :attr:`~BatchExporter.export_error_count` across all instances.
87
+ ``any_circuit_open``
88
+ ``True`` if any exporter's circuit breaker is open.
89
+ ``exporters``
90
+ List of per-instance health dicts (from :meth:`BatchExporter.get_health`).
91
+ """
92
+ with _registry_lock:
93
+ _prune_dead_refs()
94
+ live = [e for r in _active_exporters if (e := r()) is not None]
95
+
96
+ exporters_health = [e.get_health() for e in live]
97
+ return {
98
+ "exporter_count": len(live),
99
+ "total_dropped": sum(int(h["dropped_count"]) for h in exporters_health), # type: ignore[call-overload]
100
+ "total_exported": sum(int(h["exported_count"]) for h in exporters_health), # type: ignore[call-overload]
101
+ "total_errors": sum(int(h["export_error_count"]) for h in exporters_health), # type: ignore[call-overload]
102
+ "any_circuit_open": any(bool(h["circuit_open"]) for h in exporters_health),
103
+ "exporters": exporters_health,
104
+ }
105
+
106
+
107
+ _CIRCUIT_BREAKER_THRESHOLD = 5 # consecutive failures before tripping open
108
+ _SENTINEL = None # sent down the queue to tell the worker to stop
109
+
110
+ # _DROP_SENTINEL distinguishes a flush-wait sentinel from a stop sentinel.
111
+ _FLUSH_TAG = object()
112
+
113
+
114
+ class BatchExporter:
115
+ """Wraps a synchronous exporter with a background batching pipeline.
116
+
117
+ Args:
118
+ export_fn: Callable that receives a single
119
+ :class:`~spanforge.event.Event` and performs the actual export.
120
+ batch_size: Maximum number of events to accumulate before forcibly
121
+ flushing. Defaults to ``config.batch_size`` (512).
122
+ flush_interval_seconds: Maximum time (seconds) to wait before
123
+ flushing a partial batch. Defaults to
124
+ ``config.flush_interval_seconds`` (5.0).
125
+ max_queue_size: Maximum depth of the internal queue. Events that
126
+ arrive when the queue is full are **dropped** (counted in
127
+ :attr:`dropped_count`).
128
+ circuit_breaker_reset_seconds: How long (seconds) the circuit stays
129
+ open after tripping. Defaults to 30.
130
+
131
+ Example::
132
+
133
+ from spanforge._batch_exporter import BatchExporter
134
+ from spanforge.exporters.jsonl import SyncJSONLExporter
135
+
136
+ inner = SyncJSONLExporter("trace.jsonl")
137
+ bexp = BatchExporter(inner.export, batch_size=64, flush_interval_seconds=2.0)
138
+ bexp.put(event)
139
+ # ... later ...
140
+ bexp.shutdown()
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ export_fn: Callable[[Any], None],
146
+ *,
147
+ batch_size: int = 512,
148
+ flush_interval_seconds: float = 5.0,
149
+ max_queue_size: int = 10_000,
150
+ circuit_breaker_reset_seconds: float = 30.0,
151
+ ) -> None:
152
+ self._export_fn = export_fn
153
+ self._batch_size = max(1, batch_size)
154
+ self._flush_interval = max(0.01, flush_interval_seconds)
155
+ self._cb_reset_seconds = circuit_breaker_reset_seconds
156
+
157
+ # Stats (read outside the lock for approximate values — accuracy is
158
+ # not required; correctness of the exporter is).
159
+ self.dropped_count: int = 0
160
+ self.export_error_count: int = 0
161
+ self.exported_count: int = 0
162
+
163
+ # Circuit breaker state.
164
+ self._cb_lock = threading.Lock()
165
+ self._cb_consecutive_failures: int = 0
166
+ self._cb_open: bool = False
167
+ self._cb_tripped_at: float = 0.0
168
+
169
+ # Queue.
170
+ self._queue: queue.Queue[Any] = queue.Queue(maxsize=max_queue_size)
171
+
172
+ # Worker.
173
+ self._stop_event = threading.Event()
174
+ self._thread = threading.Thread(
175
+ target=self._worker,
176
+ name="spanforge-batch-exporter",
177
+ daemon=True,
178
+ )
179
+ self._thread.start()
180
+
181
+ # Register with the global registry so healthz can aggregate stats.
182
+ _register(self)
183
+
184
+ # ------------------------------------------------------------------
185
+ # Public API
186
+ # ------------------------------------------------------------------
187
+
188
+ def put(self, event: Any) -> bool:
189
+ """Enqueue *event* for export.
190
+
191
+ Returns ``True`` if the event was enqueued, ``False`` if it was
192
+ dropped (queue full, circuit open, or exporter shut down).
193
+ """
194
+ # Check circuit breaker first — cheaper than queue operations and
195
+ # prevents work from piling up behind an open circuit.
196
+ if self._circuit_is_open():
197
+ self.dropped_count += 1
198
+ return False
199
+
200
+ # Refuse new work after shutdown. Checked AFTER circuit so that the
201
+ # circuit-open drop is counted even during shutdown sequencing.
202
+ if self._stop_event.is_set():
203
+ self.dropped_count += 1
204
+ return False
205
+
206
+ try:
207
+ self._queue.put_nowait(event)
208
+ except queue.Full:
209
+ self.dropped_count += 1
210
+ _log.warning(
211
+ "spanforge batch exporter: queue full (%d items dropped so far)",
212
+ self.dropped_count,
213
+ )
214
+ return False
215
+ else:
216
+ return True
217
+
218
+ def flush(self, timeout_seconds: float = 5.0) -> bool:
219
+ """Block until all currently queued events have been exported.
220
+
221
+ Returns ``True`` if the flush completed within *timeout_seconds*,
222
+ ``False`` on timeout.
223
+
224
+ Each call uses an independent :class:`threading.Event` so concurrent
225
+ flush() calls do not accidentally release each other's barrier.
226
+ """
227
+ if not self._thread.is_alive():
228
+ return True
229
+
230
+ # Per-call done event avoids the race between _flush_done.clear() and
231
+ # the worker setting _flush_done for a *previous* flush request.
232
+ done_event = threading.Event()
233
+ try:
234
+ self._queue.put_nowait((_FLUSH_TAG, done_event))
235
+ except queue.Full:
236
+ return False
237
+ return done_event.wait(timeout=timeout_seconds)
238
+
239
+ def shutdown(self, timeout_seconds: float = 5.0) -> None:
240
+ """Flush remaining events and stop the background thread.
241
+
242
+ Safe to call multiple times.
243
+ """
244
+ if not self._thread.is_alive():
245
+ return
246
+ self._stop_event.set()
247
+ # Send sentinel to wake the worker.
248
+ with contextlib.suppress(queue.Full):
249
+ self._queue.put_nowait(_SENTINEL)
250
+ self._thread.join(timeout=timeout_seconds)
251
+
252
+ def get_health(self) -> dict[str, object]:
253
+ """Return a snapshot of exporter health suitable for ``/healthz`` endpoints.
254
+
255
+ Returns a dict with the following keys:
256
+
257
+ ``queue_size``
258
+ Approximate number of events waiting to be exported.
259
+ ``dropped_count``
260
+ Total events dropped since this exporter was created.
261
+ ``export_error_count``
262
+ Total export attempts that raised an exception.
263
+ ``exported_count``
264
+ Total events successfully exported.
265
+ ``circuit_open``
266
+ ``True`` when the circuit breaker has tripped; new events are
267
+ being dropped until the reset timeout elapses.
268
+ ``worker_alive``
269
+ ``True`` while the background worker thread is running.
270
+ """
271
+ with self._cb_lock:
272
+ cb_open = self._cb_open
273
+ return {
274
+ "queue_size": self._queue.qsize(),
275
+ "dropped_count": self.dropped_count,
276
+ "export_error_count": self.export_error_count,
277
+ "exported_count": self.exported_count,
278
+ "circuit_open": cb_open,
279
+ "worker_alive": self._thread.is_alive(),
280
+ }
281
+
282
+ # ------------------------------------------------------------------
283
+ # Circuit breaker helpers
284
+ # ------------------------------------------------------------------
285
+
286
+ def _circuit_is_open(self) -> bool:
287
+ with self._cb_lock:
288
+ if not self._cb_open:
289
+ return False
290
+ # Auto-reset after timeout.
291
+ if time.monotonic() - self._cb_tripped_at > self._cb_reset_seconds:
292
+ _log.info("spanforge batch exporter: circuit breaker reset to closed")
293
+ self._cb_open = False
294
+ self._cb_consecutive_failures = 0
295
+ return False
296
+ return True
297
+
298
+ def _record_success(self) -> None:
299
+ with self._cb_lock:
300
+ self._cb_consecutive_failures = 0
301
+ if self._cb_open:
302
+ self._cb_open = False
303
+
304
+ def _record_failure(self) -> None:
305
+ with self._cb_lock:
306
+ self._cb_consecutive_failures += 1
307
+ if not self._cb_open and self._cb_consecutive_failures >= _CIRCUIT_BREAKER_THRESHOLD:
308
+ self._cb_open = True
309
+ self._cb_tripped_at = time.monotonic()
310
+ _log.error(
311
+ "spanforge batch exporter: circuit breaker OPEN after %d "
312
+ "consecutive failures; new events will be dropped for %.0fs",
313
+ self._cb_consecutive_failures,
314
+ self._cb_reset_seconds,
315
+ )
316
+
317
+ # ------------------------------------------------------------------
318
+ # Worker thread
319
+ # ------------------------------------------------------------------
320
+
321
+ def _worker(self) -> None:
322
+ """Background thread: accumulate + export batches."""
323
+ batch: list[Any] = []
324
+ deadline = time.monotonic() + self._flush_interval
325
+
326
+ while True:
327
+ now = time.monotonic()
328
+ remaining = max(0.0, deadline - now)
329
+
330
+ # Wait for the next item or timeout.
331
+ try:
332
+ item = self._queue.get(timeout=remaining)
333
+ except queue.Empty:
334
+ item = None # timeout — force a flush of whatever we have
335
+
336
+ if item is _SENTINEL or self._stop_event.is_set():
337
+ # Drain remaining items in the queue before stopping.
338
+ self._drain_queue(batch)
339
+ self._export_batch(batch)
340
+ batch = []
341
+ break
342
+
343
+ if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH_TAG:
344
+ # Flush requested externally — item is (_FLUSH_TAG, done_event).
345
+ _, done_event = item
346
+ self._drain_queue(batch)
347
+ self._export_batch(batch)
348
+ batch = []
349
+ deadline = time.monotonic() + self._flush_interval
350
+ done_event.set() # Signal this specific flush caller.
351
+ continue
352
+
353
+ if item is not None:
354
+ batch.append(item)
355
+
356
+ time_expired = time.monotonic() >= deadline
357
+ batch_full = len(batch) >= self._batch_size
358
+
359
+ if time_expired or batch_full:
360
+ self._export_batch(batch)
361
+ batch = []
362
+ deadline = time.monotonic() + self._flush_interval
363
+
364
+ def _drain_queue(self, batch: list[Any]) -> None:
365
+ """Drain remaining items from the queue into *batch* without blocking."""
366
+ while True:
367
+ try:
368
+ item = self._queue.get_nowait()
369
+ if item is _SENTINEL:
370
+ continue
371
+ # Flush tuples: signal done and skip — already mid-flush.
372
+ if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH_TAG:
373
+ _, done_event = item
374
+ done_event.set()
375
+ continue
376
+ if item is not None:
377
+ batch.append(item)
378
+ except queue.Empty:
379
+ break
380
+
381
+ def _export_batch(self, batch: list[Any]) -> None:
382
+ """Export all events in *batch* via the wrapped exporter."""
383
+ if not batch:
384
+ return
385
+ for event in batch:
386
+ try:
387
+ self._export_fn(event)
388
+ except Exception as exc: # NOSONAR
389
+ # Increment error counter ONLY on failure (C2 fix: counter was
390
+ # incremented inside the same try block as success, causing
391
+ # both counters to be set on partial failures).
392
+ self.export_error_count += 1
393
+ self._record_failure()
394
+ _log.warning(
395
+ "spanforge batch exporter: export error (%s): %s",
396
+ type(exc).__name__,
397
+ exc,
398
+ )
399
+ # Propagate to the configured error handler without blocking.
400
+ try:
401
+ from spanforge._stream import _handle_export_error
402
+
403
+ _handle_export_error(exc)
404
+ except Exception as _err:
405
+ _log.debug("export error handler raised: %s", _err)
406
+ else:
407
+ # Success path: increment only on confirmed success (C2 fix).
408
+ self.exported_count += 1
409
+ self._record_success()