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
@@ -0,0 +1,349 @@
1
+ """spanforge.integrations.langchain — LangChain callback handler.
2
+
3
+ Provides :class:`LLMSchemaCallbackHandler`, a LangChain-compatible
4
+ callback handler that records LLM and tool-call activity as SpanForge events.
5
+
6
+ Usage::
7
+
8
+ from spanforge.integrations.langchain import LLMSchemaCallbackHandler
9
+
10
+ handler = LLMSchemaCallbackHandler(source="my-app", org_id="org-1")
11
+ chain = SomeChain(callbacks=[handler])
12
+ chain.run("What is 2+2?")
13
+
14
+ for event in handler.events:
15
+ print(event.to_json())
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from spanforge.event import Event
24
+ from spanforge.ulid import generate as gen_ulid
25
+
26
+ if TYPE_CHECKING:
27
+ from uuid import UUID
28
+
29
+ __all__ = [
30
+ "LLMSchemaCallbackHandler",
31
+ "is_patched",
32
+ "unpatch",
33
+ ]
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Module resolver
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def _require_langchain() -> Any: # noqa: ANN401
41
+ """Return the LangChain callbacks module from whichever package is installed.
42
+
43
+ Tries ``langchain_core.callbacks`` first (preferred, modern API), then
44
+ falls back to the legacy ``langchain.callbacks`` module.
45
+
46
+ Raises:
47
+ ImportError: If neither ``langchain_core`` nor ``langchain`` is installed.
48
+ """
49
+ # Try modern langchain_core first. Import the parent module first so that
50
+ # a None sentinel in sys.modules propagates as ImportError correctly.
51
+ try:
52
+ import sys # noqa: PLC0415
53
+
54
+ import langchain_core # noqa: PLC0415
55
+ import langchain_core.callbacks # noqa: PLC0415, F401
56
+ return sys.modules["langchain_core.callbacks"]
57
+ except ImportError:
58
+ pass
59
+ # Fall back to legacy langchain package.
60
+ try:
61
+ import sys # noqa: PLC0415
62
+
63
+ import langchain # noqa: PLC0415
64
+ import langchain.callbacks # noqa: PLC0415, F401
65
+ return sys.modules["langchain.callbacks"]
66
+ except ImportError:
67
+ pass
68
+ raise ImportError(
69
+ "LangChain package is required for the spanforge LangChain integration.\n"
70
+ "Install it with: pip install 'spanforge[langchain]'"
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Callback handler
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ class LLMSchemaCallbackHandler:
80
+ """LangChain callback handler that emits SpanForge events.
81
+
82
+ Compatible with both ``langchain_core`` and legacy ``langchain`` SDK
83
+ versions. Events are accumulated in :attr:`events` and optionally
84
+ forwarded to an async exporter.
85
+
86
+ Args:
87
+ source: Value for ``Event.source`` (e.g. ``"my-llm-app@1.0.0"``).
88
+ org_id: Optional organisation identifier.
89
+ exporter: Optional async exporter; must have an ``export(event)``
90
+ coroutine method. When the event loop is running, export is
91
+ scheduled as a task; otherwise the call is silently skipped.
92
+
93
+ Attributes:
94
+ events: List of all :class:`~spanforge.event.Event` objects emitted by
95
+ this handler in chronological order.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ source: str,
101
+ *,
102
+ org_id: str | None = None,
103
+ exporter: Any | None = None, # noqa: ANN401
104
+ ) -> None:
105
+ self._source = source
106
+ self._org_id = org_id
107
+ self._exporter = exporter
108
+ self.events: list[Event] = []
109
+
110
+ # ------------------------------------------------------------------
111
+ # Internal helpers
112
+ # ------------------------------------------------------------------
113
+
114
+ def _make_event(self, event_type: str, payload: dict[str, Any]) -> Event:
115
+ """Create a SpanForge event and optionally schedule async export.
116
+
117
+ Args:
118
+ event_type: Dotted event type string.
119
+ payload: Event payload dict.
120
+
121
+ Returns:
122
+ The newly created :class:`~spanforge.event.Event`.
123
+ """
124
+ event = Event(
125
+ event_type=event_type,
126
+ source=self._source,
127
+ org_id=self._org_id,
128
+ payload=payload,
129
+ event_id=gen_ulid(),
130
+ )
131
+ self.events.append(event)
132
+
133
+ if self._exporter is not None:
134
+ try:
135
+ loop = asyncio.get_running_loop()
136
+ loop.create_task(self._exporter.export(event)) # noqa: RUF006
137
+ except RuntimeError:
138
+ pass # no running event loop
139
+
140
+ return event
141
+
142
+ # ------------------------------------------------------------------
143
+ # LangChain callback interface
144
+ # ------------------------------------------------------------------
145
+
146
+ def on_llm_start(
147
+ self,
148
+ serialized: dict[str, Any],
149
+ prompts: list[str],
150
+ *,
151
+ run_id: UUID | None = None,
152
+ **kwargs: Any, # noqa: ANN401
153
+ ) -> None:
154
+ """Called when an LLM invocation begins.
155
+
156
+ Args:
157
+ serialized: Serialised LLM config dict (contains ``id`` list).
158
+ prompts: List of prompt strings being sent to the LLM.
159
+ run_id: LangChain run identifier.
160
+ **kwargs: Additional keyword arguments (ignored).
161
+ """
162
+ llm_name = ""
163
+ if serialized and "id" in serialized and serialized["id"]:
164
+ llm_name = str(serialized["id"][-1])
165
+ self._make_event(
166
+ "llm.trace.span.started",
167
+ {
168
+ "llm_name": llm_name,
169
+ "prompt_count": len(prompts),
170
+ "run_id": str(run_id) if run_id is not None else None,
171
+ },
172
+ )
173
+
174
+ def on_llm_end(
175
+ self,
176
+ response: Any, # noqa: ANN401
177
+ *,
178
+ run_id: UUID | None = None,
179
+ **kwargs: Any, # noqa: ANN401
180
+ ) -> None:
181
+ """Called when an LLM invocation completes.
182
+
183
+ Args:
184
+ response: LangChain ``LLMResult`` object with ``llm_output`` attribute.
185
+ run_id: LangChain run identifier.
186
+ **kwargs: Additional keyword arguments (ignored).
187
+ """
188
+ llm_output = getattr(response, "llm_output", None)
189
+ prompt_tokens: int | None = None
190
+ completion_tokens: int | None = None
191
+ total_tokens: int | None = None
192
+
193
+ if isinstance(llm_output, dict):
194
+ token_usage = llm_output.get("token_usage") or {}
195
+ if isinstance(token_usage, dict):
196
+ prompt_tokens = token_usage.get("prompt_tokens")
197
+ completion_tokens = token_usage.get("completion_tokens")
198
+ total_tokens = token_usage.get("total_tokens")
199
+
200
+ self._make_event(
201
+ "llm.trace.span.completed",
202
+ {
203
+ "run_id": str(run_id) if run_id is not None else None,
204
+ "prompt_tokens": prompt_tokens,
205
+ "completion_tokens": completion_tokens,
206
+ "total_tokens": total_tokens,
207
+ },
208
+ )
209
+
210
+ def on_llm_error(
211
+ self,
212
+ error: BaseException,
213
+ *,
214
+ run_id: UUID | None = None,
215
+ **kwargs: Any, # noqa: ANN401
216
+ ) -> None:
217
+ """Called when an LLM invocation raises an error.
218
+
219
+ Args:
220
+ error: The exception that was raised.
221
+ run_id: LangChain run identifier.
222
+ **kwargs: Additional keyword arguments (ignored).
223
+ """
224
+ self._make_event(
225
+ "llm.trace.span.error",
226
+ {
227
+ "run_id": str(run_id) if run_id is not None else None,
228
+ "error": str(error),
229
+ "error_type": type(error).__name__,
230
+ },
231
+ )
232
+
233
+ def on_tool_start(
234
+ self,
235
+ serialized: dict[str, Any],
236
+ input_str: str,
237
+ *,
238
+ run_id: UUID | None = None,
239
+ **kwargs: Any, # noqa: ANN401
240
+ ) -> None:
241
+ """Called when a tool invocation begins.
242
+
243
+ Args:
244
+ serialized: Serialised tool config dict (usually has ``"name"`` key).
245
+ input_str: String input passed to the tool.
246
+ run_id: LangChain run identifier.
247
+ **kwargs: Additional keyword arguments (ignored).
248
+ """
249
+ tool_name = serialized.get("name", "") if serialized else ""
250
+ self._make_event(
251
+ "llm.trace.tool_call.started",
252
+ {
253
+ "tool_name": tool_name,
254
+ "input": input_str,
255
+ "run_id": str(run_id) if run_id is not None else None,
256
+ },
257
+ )
258
+
259
+ def on_tool_end(
260
+ self,
261
+ output: str,
262
+ *,
263
+ run_id: UUID | None = None,
264
+ **kwargs: Any, # noqa: ANN401
265
+ ) -> None:
266
+ """Called when a tool invocation completes.
267
+
268
+ Args:
269
+ output: String output returned by the tool.
270
+ run_id: LangChain run identifier.
271
+ **kwargs: Additional keyword arguments (ignored).
272
+ """
273
+ self._make_event(
274
+ "llm.trace.tool_call.completed",
275
+ {
276
+ "run_id": str(run_id) if run_id is not None else None,
277
+ "output": str(output)[:1024] if output else None,
278
+ },
279
+ )
280
+
281
+ def on_tool_error(
282
+ self,
283
+ error: BaseException,
284
+ *,
285
+ run_id: UUID | None = None,
286
+ **kwargs: Any, # noqa: ANN401
287
+ ) -> None:
288
+ """Called when a tool invocation raises an error.
289
+
290
+ Args:
291
+ error: The exception that was raised.
292
+ run_id: LangChain run identifier.
293
+ **kwargs: Additional keyword arguments (ignored).
294
+ """
295
+ self._make_event(
296
+ "llm.trace.tool_call.error",
297
+ {
298
+ "run_id": str(run_id) if run_id is not None else None,
299
+ "error": str(error),
300
+ "error_type": type(error).__name__,
301
+ },
302
+ )
303
+
304
+ def clear_events(self) -> None:
305
+ """Remove all accumulated events from :attr:`events`."""
306
+ self.events.clear()
307
+
308
+ # ------------------------------------------------------------------
309
+ # dunder
310
+ # ------------------------------------------------------------------
311
+
312
+ def __repr__(self) -> str:
313
+ return (
314
+ f"LLMSchemaCallbackHandler("
315
+ f"source={self._source!r}, "
316
+ f"events={len(self.events)})"
317
+ )
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Module-level patch / unpatch API (for API consistency with other integrations)
322
+ # ---------------------------------------------------------------------------
323
+
324
+ def is_patched() -> bool:
325
+ """Return ``True`` if the LangChain integration module is importable.
326
+
327
+ LangChain uses a callback-handler pattern rather than monkey-patching, so
328
+ there is no global state to check. This function simply verifies that the
329
+ LangChain package is available and the handler class is ready to use.
330
+
331
+ Returns:
332
+ ``True`` when LangChain is installed and the handler can be created.
333
+ """
334
+ try:
335
+ _require_langchain()
336
+ return True
337
+ except ImportError:
338
+ return False
339
+
340
+
341
+ def unpatch() -> None:
342
+ """No-op for the LangChain integration.
343
+
344
+ LangChain uses an explicit callback handler (not monkey-patching), so
345
+ there is nothing to undo globally. Remove the handler from any
346
+ ``CallbackManager`` or chain you attached it to::
347
+
348
+ chain = SomeChain(callbacks=[]) # re-create without the handler
349
+ """