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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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()
|