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/_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
+ """