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
spanforge/_store.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""spanforge._store — In-process ring-buffer trace store.
|
|
2
|
+
|
|
3
|
+
Retains the last *N* traces in memory for programmatic querying via
|
|
4
|
+
:func:`~spanforge.get_trace`, :func:`~spanforge.get_last_agent_run`, etc.
|
|
5
|
+
|
|
6
|
+
The store is opt-in (disabled by default) to keep memory overhead zero for
|
|
7
|
+
users who do not need it. Enable via::
|
|
8
|
+
|
|
9
|
+
from spanforge import configure
|
|
10
|
+
configure(enable_trace_store=True, trace_store_size=200)
|
|
11
|
+
|
|
12
|
+
or environment variable ``SPANFORGE_ENABLE_TRACE_STORE=1``.
|
|
13
|
+
|
|
14
|
+
Security
|
|
15
|
+
--------
|
|
16
|
+
Events are stored **after** the redaction pass in :func:`~spanforge._stream._dispatch`.
|
|
17
|
+
When a :class:`~spanforge.redact.RedactionPolicy` is configured, all PII has
|
|
18
|
+
been masked before an event reaches :meth:`TraceStore.record`. The store
|
|
19
|
+
never bypasses redaction.
|
|
20
|
+
|
|
21
|
+
The ring buffer is bounded to ``trace_store_size`` *traces* (not individual
|
|
22
|
+
events). Once full, the oldest trace is evicted to make room for the new one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import threading
|
|
28
|
+
from collections import OrderedDict
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from typing import TYPE_CHECKING, Generator
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from spanforge.event import Event
|
|
34
|
+
from spanforge.namespaces.trace import SpanPayload
|
|
35
|
+
|
|
36
|
+
__all__ = ["TraceStore", "get_store", "trace_store"]
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# EventType string constants (avoid circular import)
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
_SPAN_COMPLETED = "llm.trace.span.completed"
|
|
43
|
+
_SPAN_FAILED = "llm.trace.span.failed"
|
|
44
|
+
_AGENT_COMPLETED = "llm.trace.agent.completed"
|
|
45
|
+
_SPAN_EVENT_TYPES = frozenset({_SPAN_COMPLETED, _SPAN_FAILED})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _event_type_str(event: "Event") -> str:
|
|
49
|
+
et = event.event_type
|
|
50
|
+
return et.value if hasattr(et, "value") else str(et)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# TraceStore
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TraceStore:
|
|
59
|
+
"""Thread-safe in-memory ring buffer storing the last *max_traces* traces.
|
|
60
|
+
|
|
61
|
+
Each trace is keyed by its ``trace_id``; events without a ``trace_id`` are
|
|
62
|
+
stored under the sentinel key ``"__no_trace_id__"``.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
max_traces: Maximum number of distinct traces to retain. Oldest trace
|
|
66
|
+
is evicted when the buffer is full. Default: 100.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, max_traces: int = 100) -> None:
|
|
70
|
+
if max_traces < 1:
|
|
71
|
+
raise ValueError("TraceStore.max_traces must be >= 1")
|
|
72
|
+
self._max_traces = max_traces
|
|
73
|
+
# OrderedDict preserves insertion order; oldest = first.
|
|
74
|
+
self._traces: OrderedDict[str, list["Event"]] = OrderedDict()
|
|
75
|
+
self._last_agent_trace_id: str | None = None
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Internal helpers
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _resolve_trace_id(self, event: "Event") -> str:
|
|
83
|
+
"""Extract the trace_id from the event payload or envelope."""
|
|
84
|
+
tid = getattr(event, "trace_id", None) or event.payload.get("trace_id", "")
|
|
85
|
+
return str(tid) if tid else "__no_trace_id__"
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Public interface
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def record(self, event: "Event") -> None:
|
|
92
|
+
"""Append *event* to the store.
|
|
93
|
+
|
|
94
|
+
Evicts the oldest trace when the buffer would exceed ``max_traces``.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
event: A fully-formed (and already-redacted) :class:`~spanforge.event.Event`.
|
|
98
|
+
"""
|
|
99
|
+
trace_id = self._resolve_trace_id(event)
|
|
100
|
+
with self._lock:
|
|
101
|
+
if trace_id not in self._traces:
|
|
102
|
+
# Evict oldest if full.
|
|
103
|
+
if len(self._traces) >= self._max_traces:
|
|
104
|
+
self._traces.popitem(last=False)
|
|
105
|
+
self._traces[trace_id] = []
|
|
106
|
+
else:
|
|
107
|
+
# Move to end (most recently active).
|
|
108
|
+
self._traces.move_to_end(trace_id)
|
|
109
|
+
self._traces[trace_id].append(event)
|
|
110
|
+
|
|
111
|
+
# Track the most recently completed agent run.
|
|
112
|
+
if _event_type_str(event) == _AGENT_COMPLETED:
|
|
113
|
+
self._last_agent_trace_id = trace_id
|
|
114
|
+
|
|
115
|
+
def get_trace(self, trace_id: str) -> list["Event"] | None:
|
|
116
|
+
"""Return all stored events for *trace_id*, or ``None`` if not found.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
trace_id: The 32-character hex trace identifier.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A copy of the event list so callers cannot mutate the store's
|
|
123
|
+
internal state.
|
|
124
|
+
"""
|
|
125
|
+
with self._lock:
|
|
126
|
+
events = self._traces.get(trace_id)
|
|
127
|
+
return list(events) if events is not None else None
|
|
128
|
+
|
|
129
|
+
def get_last_agent_run(self) -> list["Event"] | None:
|
|
130
|
+
"""Return all events for the most recently completed agent-run trace.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A copy of the event list, or ``None`` if no agent run has been
|
|
134
|
+
recorded yet.
|
|
135
|
+
"""
|
|
136
|
+
with self._lock:
|
|
137
|
+
if self._last_agent_trace_id is None:
|
|
138
|
+
return None
|
|
139
|
+
events = self._traces.get(self._last_agent_trace_id)
|
|
140
|
+
return list(events) if events is not None else None
|
|
141
|
+
|
|
142
|
+
def list_tool_calls(self, trace_id: str) -> list["SpanPayload"]:
|
|
143
|
+
"""Return deserialized :class:`~spanforge.namespaces.trace.SpanPayload` objects
|
|
144
|
+
for every tool-call span in *trace_id*.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
trace_id: The 32-character hex trace identifier.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
List of ``SpanPayload`` objects for tool-call spans, sorted by
|
|
151
|
+
``start_time_unix_nano``. Returns an empty list if the trace is
|
|
152
|
+
not found or contains no tool-call spans.
|
|
153
|
+
"""
|
|
154
|
+
return self._list_spans_by_operation(trace_id, "tool_call")
|
|
155
|
+
|
|
156
|
+
def list_llm_calls(self, trace_id: str) -> list["SpanPayload"]:
|
|
157
|
+
"""Return deserialized :class:`~spanforge.namespaces.trace.SpanPayload` objects
|
|
158
|
+
for every LLM-operation span in *trace_id*.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
trace_id: The 32-character hex trace identifier.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of ``SpanPayload`` objects for LLM spans, sorted by
|
|
165
|
+
``start_time_unix_nano``.
|
|
166
|
+
"""
|
|
167
|
+
llm_ops = frozenset({"chat", "completion", "embedding", "chat_completion", "generate"})
|
|
168
|
+
return self._list_spans_by_operation(trace_id, *llm_ops)
|
|
169
|
+
|
|
170
|
+
def _list_spans_by_operation(self, trace_id: str, *operations: str) -> list["SpanPayload"]:
|
|
171
|
+
"""Shared implementation for list_tool_calls / list_llm_calls."""
|
|
172
|
+
from spanforge.namespaces.trace import SpanPayload # noqa: PLC0415
|
|
173
|
+
|
|
174
|
+
with self._lock:
|
|
175
|
+
events = self._traces.get(trace_id)
|
|
176
|
+
if not events:
|
|
177
|
+
return []
|
|
178
|
+
result: list[SpanPayload] = []
|
|
179
|
+
for event in events:
|
|
180
|
+
if _event_type_str(event) not in _SPAN_EVENT_TYPES:
|
|
181
|
+
continue
|
|
182
|
+
payload = event.payload
|
|
183
|
+
op = payload.get("operation", "")
|
|
184
|
+
if op in operations:
|
|
185
|
+
try:
|
|
186
|
+
result.append(SpanPayload.from_dict(payload))
|
|
187
|
+
except Exception: # NOSONAR
|
|
188
|
+
pass # malformed span — skip without raising
|
|
189
|
+
result.sort(key=lambda s: s.start_time_unix_nano)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
def clear(self) -> None:
|
|
193
|
+
"""Remove all stored traces and reset the last-agent-run pointer."""
|
|
194
|
+
with self._lock:
|
|
195
|
+
self._traces.clear()
|
|
196
|
+
self._last_agent_trace_id = None
|
|
197
|
+
|
|
198
|
+
def __len__(self) -> int:
|
|
199
|
+
with self._lock:
|
|
200
|
+
return len(self._traces)
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
with self._lock:
|
|
204
|
+
return f"TraceStore(traces={len(self._traces)}, max={self._max_traces})"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Module-level singleton
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
_store: TraceStore = TraceStore()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_store() -> TraceStore:
|
|
215
|
+
"""Return the module-level :class:`TraceStore` singleton.
|
|
216
|
+
|
|
217
|
+
The singleton is recreated whenever
|
|
218
|
+
:func:`~spanforge._stream._reset_exporter` is called (e.g. after
|
|
219
|
+
``configure(trace_store_size=…)``).
|
|
220
|
+
"""
|
|
221
|
+
return _store
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _reset_store(max_traces: int = 100) -> None:
|
|
225
|
+
"""Recreate the module-level store with a new *max_traces* limit.
|
|
226
|
+
|
|
227
|
+
Called by :func:`~spanforge._stream._reset_exporter` after ``configure()``
|
|
228
|
+
so that a changed ``trace_store_size`` takes effect immediately.
|
|
229
|
+
"""
|
|
230
|
+
global _store # noqa: PLW0603
|
|
231
|
+
_store = TraceStore(max_traces=max_traces)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Convenience module-level access functions (re-exported via __init__.py)
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_trace(trace_id: str) -> list["Event"] | None:
|
|
240
|
+
"""Return all stored events for *trace_id*. See :meth:`TraceStore.get_trace`."""
|
|
241
|
+
return get_store().get_trace(trace_id)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_last_agent_run() -> list["Event"] | None:
|
|
245
|
+
"""Return events for the most recent agent-run trace. See :meth:`TraceStore.get_last_agent_run`."""
|
|
246
|
+
return get_store().get_last_agent_run()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def list_tool_calls(trace_id: str) -> list["SpanPayload"]:
|
|
250
|
+
"""Return tool-call spans for *trace_id*. See :meth:`TraceStore.list_tool_calls`."""
|
|
251
|
+
return get_store().list_tool_calls(trace_id)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def list_llm_calls(trace_id: str) -> list["SpanPayload"]:
|
|
255
|
+
"""Return LLM-call spans for *trace_id*. See :meth:`TraceStore.list_llm_calls`."""
|
|
256
|
+
return get_store().list_llm_calls(trace_id)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@contextmanager
|
|
260
|
+
def trace_store(max_traces: int = 100) -> Generator[TraceStore, None, None]:
|
|
261
|
+
"""Context manager that installs a fresh, isolated :class:`TraceStore` for the duration of the block.
|
|
262
|
+
|
|
263
|
+
Useful in tests and interactive sessions where you want a clean store
|
|
264
|
+
without affecting the global singleton::
|
|
265
|
+
|
|
266
|
+
with spanforge.trace_store() as store:
|
|
267
|
+
# run code that emits events ...
|
|
268
|
+
events = store.get_trace(my_trace_id)
|
|
269
|
+
|
|
270
|
+
The previous global store is restored automatically on exit, even if an
|
|
271
|
+
exception is raised.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
max_traces: Ring-buffer size for the temporary store. Default: 100.
|
|
275
|
+
|
|
276
|
+
Yields:
|
|
277
|
+
A fresh :class:`TraceStore` instance that is installed as the global
|
|
278
|
+
singleton for the duration of the block.
|
|
279
|
+
"""
|
|
280
|
+
global _store # noqa: PLW0603
|
|
281
|
+
previous = _store
|
|
282
|
+
fresh = TraceStore(max_traces=max_traces)
|
|
283
|
+
_store = fresh
|
|
284
|
+
try:
|
|
285
|
+
yield fresh
|
|
286
|
+
finally:
|
|
287
|
+
_store = previous
|