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/_hooks.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""spanforge._hooks — Global span lifecycle hook registry.
|
|
2
|
+
|
|
3
|
+
Provides a :class:`HookRegistry` for registering callbacks that fire when
|
|
4
|
+
spans of specific operation types start or end. A module-level singleton
|
|
5
|
+
``hooks`` is exported from ``spanforge.__init__`` for convenience.
|
|
6
|
+
|
|
7
|
+
Usage — synchronous hooks::
|
|
8
|
+
|
|
9
|
+
import spanforge
|
|
10
|
+
|
|
11
|
+
@spanforge.hooks.on_llm_call
|
|
12
|
+
def log_llm(span) -> None:
|
|
13
|
+
print(f"LLM call started: {span.name!r} model={span.model!r}")
|
|
14
|
+
|
|
15
|
+
@spanforge.hooks.on_agent_end
|
|
16
|
+
def audit_agent(span) -> None:
|
|
17
|
+
if span.status == "error":
|
|
18
|
+
alert(f"Agent error: {span.error}")
|
|
19
|
+
|
|
20
|
+
Usage — async hooks (for async-first applications)::
|
|
21
|
+
|
|
22
|
+
@spanforge.hooks.on_llm_call_async
|
|
23
|
+
async def async_log_llm(span) -> None:
|
|
24
|
+
await db.log_span(span.span_id, span.model)
|
|
25
|
+
|
|
26
|
+
Hook callbacks receive the :class:`~spanforge._span.Span` object. Start
|
|
27
|
+
hooks fire in ``SpanContextManager.__enter__`` (before the body executes);
|
|
28
|
+
end hooks fire in ``SpanContextManager.__exit__`` (after the body, before
|
|
29
|
+
export).
|
|
30
|
+
|
|
31
|
+
**Thread safety**: ``HookRegistry`` uses a ``threading.RLock`` so hooks can
|
|
32
|
+
be registered from any thread. Synchronous hook *callbacks* are called on
|
|
33
|
+
whatever thread the span context manager runs on. Async hook callbacks are
|
|
34
|
+
scheduled via :func:`asyncio.ensure_future` if a loop is running, otherwise
|
|
35
|
+
they are silently skipped.
|
|
36
|
+
|
|
37
|
+
**Error isolation**: if a hook raises an exception the error is suppressed
|
|
38
|
+
(emitted via ``warnings.warn``) so that hook failures never abort user code.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import inspect
|
|
45
|
+
import threading
|
|
46
|
+
import warnings
|
|
47
|
+
from typing import Callable, Coroutine, TYPE_CHECKING, Any
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from spanforge._span import Span
|
|
51
|
+
|
|
52
|
+
__all__ = ["HookRegistry", "hooks", "HookFn"]
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Type aliases
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
HookFn = Callable[["Span"], None]
|
|
59
|
+
AsyncHookFn = Callable[["Span"], Coroutine[Any, Any, None]]
|
|
60
|
+
|
|
61
|
+
# Hook kind constants — match the operation strings used in SpanPayload.
|
|
62
|
+
_HOOK_AGENT_START = "agent_start"
|
|
63
|
+
_HOOK_AGENT_END = "agent_end"
|
|
64
|
+
_HOOK_LLM_CALL = "llm_call"
|
|
65
|
+
_HOOK_TOOL_CALL = "tool_call"
|
|
66
|
+
|
|
67
|
+
# Map span operation values → hook kind (for "start" hooks the same mapping is
|
|
68
|
+
# used; the distinction between start and end is made by the context manager).
|
|
69
|
+
_LLM_OPERATIONS = frozenset({"chat", "completion", "embedding", "chat_completion", "generate"})
|
|
70
|
+
_TOOL_OPERATIONS = frozenset({"tool_call", "execute_tool"})
|
|
71
|
+
_AGENT_OPERATIONS = frozenset({"invoke_agent", "agent"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _classify_span(span: "Span") -> str | None:
|
|
75
|
+
"""Return the hook kind for *span*, or ``None`` if no hook applies."""
|
|
76
|
+
op = str(getattr(span, "operation", "") or "")
|
|
77
|
+
if op in _LLM_OPERATIONS or op == "chat":
|
|
78
|
+
return _HOOK_LLM_CALL
|
|
79
|
+
if op in _TOOL_OPERATIONS:
|
|
80
|
+
return _HOOK_TOOL_CALL
|
|
81
|
+
if op in _AGENT_OPERATIONS:
|
|
82
|
+
return _HOOK_AGENT_START # caller differentiates start/end
|
|
83
|
+
# Fallback: if the span name contains a hint use that.
|
|
84
|
+
name = str(getattr(span, "name", "") or "")
|
|
85
|
+
if "llm" in name.lower() or "model" in name.lower():
|
|
86
|
+
return _HOOK_LLM_CALL
|
|
87
|
+
if "tool" in name.lower():
|
|
88
|
+
return _HOOK_TOOL_CALL
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# HookRegistry
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HookRegistry:
|
|
98
|
+
"""Registry of span lifecycle hooks.
|
|
99
|
+
|
|
100
|
+
Each ``on_*`` method can be used as a **decorator** or called directly
|
|
101
|
+
to register a callback:
|
|
102
|
+
|
|
103
|
+
.. code-block:: python
|
|
104
|
+
|
|
105
|
+
@hooks.on_llm_call
|
|
106
|
+
def my_hook(span): ...
|
|
107
|
+
|
|
108
|
+
# equivalent:
|
|
109
|
+
hooks.on_llm_call(my_hook)
|
|
110
|
+
|
|
111
|
+
Methods:
|
|
112
|
+
on_agent_start: Register a callback fired when any agent-operation span **starts**.
|
|
113
|
+
on_agent_end: Register a callback fired when any agent-operation span **ends**.
|
|
114
|
+
on_llm_call: Register a callback fired at both start and end of LLM spans.
|
|
115
|
+
on_tool_call: Register a callback fired at both start and end of tool spans.
|
|
116
|
+
clear: Remove all registered hooks.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
self._lock = threading.RLock()
|
|
121
|
+
self._hooks: dict[str, list[HookFn]] = {
|
|
122
|
+
_HOOK_AGENT_START: [],
|
|
123
|
+
_HOOK_AGENT_END: [],
|
|
124
|
+
_HOOK_LLM_CALL: [],
|
|
125
|
+
_HOOK_TOOL_CALL: [],
|
|
126
|
+
}
|
|
127
|
+
self._async_hooks: dict[str, list[AsyncHookFn]] = {
|
|
128
|
+
_HOOK_AGENT_START: [],
|
|
129
|
+
_HOOK_AGENT_END: [],
|
|
130
|
+
_HOOK_LLM_CALL: [],
|
|
131
|
+
_HOOK_TOOL_CALL: [],
|
|
132
|
+
}
|
|
133
|
+
# Universal span-end hooks: fire for EVERY span regardless of operation.
|
|
134
|
+
self._all_end_hooks: list[HookFn] = []
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Registration decorators / methods
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def on_agent_start(self, fn: HookFn) -> HookFn:
|
|
141
|
+
"""Register *fn* to fire when an agent-operation span **starts**.
|
|
142
|
+
|
|
143
|
+
Can be used as a decorator::
|
|
144
|
+
|
|
145
|
+
@hooks.on_agent_start
|
|
146
|
+
def cb(span): ...
|
|
147
|
+
"""
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._hooks[_HOOK_AGENT_START].append(fn)
|
|
150
|
+
return fn
|
|
151
|
+
|
|
152
|
+
def on_agent_end(self, fn: HookFn) -> HookFn:
|
|
153
|
+
"""Register *fn* to fire when an agent-operation span **ends**."""
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._hooks[_HOOK_AGENT_END].append(fn)
|
|
156
|
+
return fn
|
|
157
|
+
|
|
158
|
+
def on_llm_call(self, fn: HookFn) -> HookFn:
|
|
159
|
+
"""Register *fn* to fire on LLM-operation spans (start **and** end)."""
|
|
160
|
+
with self._lock:
|
|
161
|
+
self._hooks[_HOOK_LLM_CALL].append(fn)
|
|
162
|
+
return fn
|
|
163
|
+
|
|
164
|
+
def on_tool_call(self, fn: HookFn) -> HookFn:
|
|
165
|
+
"""Register *fn* to fire on tool-call spans (start **and** end)."""
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._hooks[_HOOK_TOOL_CALL].append(fn)
|
|
168
|
+
return fn
|
|
169
|
+
|
|
170
|
+
def on_span_end(self, fn: HookFn) -> HookFn:
|
|
171
|
+
"""Register *fn* to fire when **any** span ends, regardless of operation type.
|
|
172
|
+
|
|
173
|
+
Unlike :meth:`on_agent_end`, :meth:`on_llm_call`, and :meth:`on_tool_call`
|
|
174
|
+
which only fire for operation-classified spans, this hook fires for every
|
|
175
|
+
:class:`~spanforge._span.Span` that exits via :class:`~spanforge._span.SpanContextManager`.
|
|
176
|
+
|
|
177
|
+
Primary use case: collecting all spans in test code via the
|
|
178
|
+
:func:`~spanforge.testing.captured_spans` pytest fixture.
|
|
179
|
+
|
|
180
|
+
Can be used as a decorator::
|
|
181
|
+
|
|
182
|
+
@hooks.on_span_end
|
|
183
|
+
def cb(span): ...
|
|
184
|
+
"""
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._all_end_hooks.append(fn)
|
|
187
|
+
return fn
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Async registration decorators / methods
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def on_agent_start_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
194
|
+
"""Register an **async** callback to fire when an agent span **starts**.
|
|
195
|
+
|
|
196
|
+
The coroutine is scheduled via :func:`asyncio.ensure_future` when a
|
|
197
|
+
running event loop is detected. If no loop is running the callback is
|
|
198
|
+
silently skipped.
|
|
199
|
+
|
|
200
|
+
Can be used as a decorator::
|
|
201
|
+
|
|
202
|
+
@hooks.on_agent_start_async
|
|
203
|
+
async def cb(span): await db.record_start(span.span_id)
|
|
204
|
+
"""
|
|
205
|
+
with self._lock:
|
|
206
|
+
self._async_hooks[_HOOK_AGENT_START].append(fn)
|
|
207
|
+
return fn
|
|
208
|
+
|
|
209
|
+
def on_agent_end_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
210
|
+
"""Register an **async** callback to fire when an agent span **ends**."""
|
|
211
|
+
with self._lock:
|
|
212
|
+
self._async_hooks[_HOOK_AGENT_END].append(fn)
|
|
213
|
+
return fn
|
|
214
|
+
|
|
215
|
+
def on_llm_call_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
216
|
+
"""Register an **async** callback to fire on LLM spans (start **and** end)."""
|
|
217
|
+
with self._lock:
|
|
218
|
+
self._async_hooks[_HOOK_LLM_CALL].append(fn)
|
|
219
|
+
return fn
|
|
220
|
+
|
|
221
|
+
def on_tool_call_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
222
|
+
"""Register an **async** callback to fire on tool-call spans (start **and** end)."""
|
|
223
|
+
with self._lock:
|
|
224
|
+
self._async_hooks[_HOOK_TOOL_CALL].append(fn)
|
|
225
|
+
return fn
|
|
226
|
+
|
|
227
|
+
def clear(self) -> None:
|
|
228
|
+
"""Unregister all synchronous, async, and universal hooks."""
|
|
229
|
+
with self._lock:
|
|
230
|
+
for key in self._hooks:
|
|
231
|
+
self._hooks[key].clear()
|
|
232
|
+
for key in self._async_hooks:
|
|
233
|
+
self._async_hooks[key].clear()
|
|
234
|
+
self._all_end_hooks.clear()
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
# Internal fire helpers (called by SpanContextManager)
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def _fire_start(self, span: "Span") -> None:
|
|
241
|
+
"""Fire the appropriate start hooks for *span*."""
|
|
242
|
+
kind = _classify_span(span)
|
|
243
|
+
if kind is None:
|
|
244
|
+
return
|
|
245
|
+
if kind in (_HOOK_LLM_CALL, _HOOK_TOOL_CALL):
|
|
246
|
+
self._fire(kind, span)
|
|
247
|
+
elif kind == _HOOK_AGENT_START:
|
|
248
|
+
self._fire(_HOOK_AGENT_START, span)
|
|
249
|
+
|
|
250
|
+
def _fire_end(self, span: "Span") -> None:
|
|
251
|
+
"""Fire the appropriate end hooks for *span*, plus universal span-end hooks."""
|
|
252
|
+
kind = _classify_span(span)
|
|
253
|
+
if kind is not None:
|
|
254
|
+
if kind in (_HOOK_LLM_CALL, _HOOK_TOOL_CALL):
|
|
255
|
+
self._fire(kind, span)
|
|
256
|
+
elif kind == _HOOK_AGENT_START:
|
|
257
|
+
# Re-use agent_end bucket for end hooks.
|
|
258
|
+
self._fire(_HOOK_AGENT_END, span)
|
|
259
|
+
# Always fire universal span-end hooks regardless of classification.
|
|
260
|
+
self._fire_all_end(span)
|
|
261
|
+
|
|
262
|
+
def _fire_all_end(self, span: "Span") -> None:
|
|
263
|
+
"""Fire all universal span-end hooks registered via :meth:`on_span_end`."""
|
|
264
|
+
with self._lock:
|
|
265
|
+
callbacks = list(self._all_end_hooks)
|
|
266
|
+
for cb in callbacks:
|
|
267
|
+
try:
|
|
268
|
+
cb(span)
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
try:
|
|
271
|
+
warnings.warn(
|
|
272
|
+
f"spanforge on_span_end hook error in {cb!r}: {exc}",
|
|
273
|
+
UserWarning,
|
|
274
|
+
stacklevel=2,
|
|
275
|
+
)
|
|
276
|
+
except Exception: # NOSONAR
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
def _fire(self, kind: str, span: "Span") -> None:
|
|
280
|
+
with self._lock:
|
|
281
|
+
callbacks = list(self._hooks.get(kind, []))
|
|
282
|
+
for cb in callbacks:
|
|
283
|
+
try:
|
|
284
|
+
cb(span)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
try:
|
|
287
|
+
warnings.warn(
|
|
288
|
+
f"spanforge hook error in {cb!r}: {exc}",
|
|
289
|
+
UserWarning,
|
|
290
|
+
stacklevel=2,
|
|
291
|
+
)
|
|
292
|
+
except Exception: # NOSONAR
|
|
293
|
+
pass # if warn itself raises (e.g. treated as error), ignore
|
|
294
|
+
# Fire async hooks if a loop is running.
|
|
295
|
+
self._fire_async(kind, span)
|
|
296
|
+
|
|
297
|
+
def _fire_async(self, kind: str, span: "Span") -> None:
|
|
298
|
+
"""Schedule async hook coroutines on the running event loop (if any)."""
|
|
299
|
+
with self._lock:
|
|
300
|
+
async_callbacks = list(self._async_hooks.get(kind, []))
|
|
301
|
+
if not async_callbacks:
|
|
302
|
+
return
|
|
303
|
+
try:
|
|
304
|
+
loop = asyncio.get_running_loop()
|
|
305
|
+
except RuntimeError:
|
|
306
|
+
return # no event loop running — skip async hooks silently
|
|
307
|
+
for cb in async_callbacks:
|
|
308
|
+
try:
|
|
309
|
+
coro = cb(span)
|
|
310
|
+
if inspect.isawaitable(coro):
|
|
311
|
+
_task = asyncio.ensure_future(coro, loop=loop) # noqa: F841
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
try:
|
|
314
|
+
warnings.warn(
|
|
315
|
+
f"spanforge async hook error in {cb!r}: {exc}",
|
|
316
|
+
UserWarning,
|
|
317
|
+
stacklevel=2,
|
|
318
|
+
)
|
|
319
|
+
except Exception: # NOSONAR
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
def __repr__(self) -> str:
|
|
323
|
+
with self._lock:
|
|
324
|
+
counts = {k: len(v) for k, v in self._hooks.items()}
|
|
325
|
+
async_counts = {k: len(v) for k, v in self._async_hooks.items()}
|
|
326
|
+
return f"HookRegistry(sync={counts}, async={async_counts})"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Module-level singleton
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
hooks: HookRegistry = HookRegistry()
|
|
334
|
+
"""Global singleton :class:`HookRegistry` — import and use directly::
|
|
335
|
+
|
|
336
|
+
from spanforge import hooks
|
|
337
|
+
|
|
338
|
+
@hooks.on_llm_call
|
|
339
|
+
def my_callback(span): ...
|
|
340
|
+
"""
|