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
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