spanforge 1.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 +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -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:
|
|
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
|
|
53
|
+
|
|
54
|
+
import langchain_core
|
|
55
|
+
import langchain_core.callbacks
|
|
56
|
+
|
|
57
|
+
return sys.modules["langchain_core.callbacks"]
|
|
58
|
+
except ImportError:
|
|
59
|
+
pass
|
|
60
|
+
# Fall back to legacy langchain package.
|
|
61
|
+
try:
|
|
62
|
+
import sys
|
|
63
|
+
|
|
64
|
+
import langchain
|
|
65
|
+
import langchain.callbacks
|
|
66
|
+
|
|
67
|
+
return sys.modules["langchain.callbacks"]
|
|
68
|
+
except ImportError:
|
|
69
|
+
pass
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"LangChain package is required for the spanforge LangChain integration.\n"
|
|
72
|
+
"Install it with: pip install 'spanforge[langchain]'"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Callback handler
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LLMSchemaCallbackHandler:
|
|
82
|
+
"""LangChain callback handler that emits SpanForge events.
|
|
83
|
+
|
|
84
|
+
Compatible with both ``langchain_core`` and legacy ``langchain`` SDK
|
|
85
|
+
versions. Events are accumulated in :attr:`events` and optionally
|
|
86
|
+
forwarded to an async exporter.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
source: Value for ``Event.source`` (e.g. ``"my-llm-app@1.0.0"``).
|
|
90
|
+
org_id: Optional organisation identifier.
|
|
91
|
+
exporter: Optional async exporter; must have an ``export(event)``
|
|
92
|
+
coroutine method. When the event loop is running, export is
|
|
93
|
+
scheduled as a task; otherwise the call is silently skipped.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
events: List of all :class:`~spanforge.event.Event` objects emitted by
|
|
97
|
+
this handler in chronological order.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
source: str,
|
|
103
|
+
*,
|
|
104
|
+
org_id: str | None = None,
|
|
105
|
+
exporter: Any | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._source = source
|
|
108
|
+
self._org_id = org_id
|
|
109
|
+
self._exporter = exporter
|
|
110
|
+
self.events: list[Event] = []
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Internal helpers
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _make_event(self, event_type: str, payload: dict[str, Any]) -> Event:
|
|
117
|
+
"""Create a SpanForge event and optionally schedule async export.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
event_type: Dotted event type string.
|
|
121
|
+
payload: Event payload dict.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The newly created :class:`~spanforge.event.Event`.
|
|
125
|
+
"""
|
|
126
|
+
event = Event(
|
|
127
|
+
event_type=event_type,
|
|
128
|
+
source=self._source,
|
|
129
|
+
org_id=self._org_id,
|
|
130
|
+
payload=payload,
|
|
131
|
+
event_id=gen_ulid(),
|
|
132
|
+
)
|
|
133
|
+
self.events.append(event)
|
|
134
|
+
|
|
135
|
+
if self._exporter is not None:
|
|
136
|
+
try:
|
|
137
|
+
loop = asyncio.get_running_loop()
|
|
138
|
+
loop.create_task(self._exporter.export(event))
|
|
139
|
+
except RuntimeError:
|
|
140
|
+
pass # no running event loop
|
|
141
|
+
|
|
142
|
+
return event
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# LangChain callback interface
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def on_llm_start(
|
|
149
|
+
self,
|
|
150
|
+
serialized: dict[str, Any],
|
|
151
|
+
prompts: list[str],
|
|
152
|
+
*,
|
|
153
|
+
run_id: UUID | None = None,
|
|
154
|
+
**kwargs: Any,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Called when an LLM invocation begins.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
serialized: Serialised LLM config dict (contains ``id`` list).
|
|
160
|
+
prompts: List of prompt strings being sent to the LLM.
|
|
161
|
+
run_id: LangChain run identifier.
|
|
162
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
163
|
+
"""
|
|
164
|
+
llm_name = ""
|
|
165
|
+
if serialized and "id" in serialized and serialized["id"]:
|
|
166
|
+
llm_name = str(serialized["id"][-1])
|
|
167
|
+
self._make_event(
|
|
168
|
+
"llm.trace.span.started",
|
|
169
|
+
{
|
|
170
|
+
"llm_name": llm_name,
|
|
171
|
+
"prompt_count": len(prompts),
|
|
172
|
+
"run_id": str(run_id) if run_id is not None else None,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def on_llm_end(
|
|
177
|
+
self,
|
|
178
|
+
response: Any,
|
|
179
|
+
*,
|
|
180
|
+
run_id: UUID | None = None,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Called when an LLM invocation completes.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
response: LangChain ``LLMResult`` object with ``llm_output`` attribute.
|
|
187
|
+
run_id: LangChain run identifier.
|
|
188
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
189
|
+
"""
|
|
190
|
+
llm_output = getattr(response, "llm_output", None)
|
|
191
|
+
prompt_tokens: int | None = None
|
|
192
|
+
completion_tokens: int | None = None
|
|
193
|
+
total_tokens: int | None = None
|
|
194
|
+
|
|
195
|
+
if isinstance(llm_output, dict):
|
|
196
|
+
token_usage = llm_output.get("token_usage") or {}
|
|
197
|
+
if isinstance(token_usage, dict):
|
|
198
|
+
prompt_tokens = token_usage.get("prompt_tokens")
|
|
199
|
+
completion_tokens = token_usage.get("completion_tokens")
|
|
200
|
+
total_tokens = token_usage.get("total_tokens")
|
|
201
|
+
|
|
202
|
+
self._make_event(
|
|
203
|
+
"llm.trace.span.completed",
|
|
204
|
+
{
|
|
205
|
+
"run_id": str(run_id) if run_id is not None else None,
|
|
206
|
+
"prompt_tokens": prompt_tokens,
|
|
207
|
+
"completion_tokens": completion_tokens,
|
|
208
|
+
"total_tokens": total_tokens,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def on_llm_error(
|
|
213
|
+
self,
|
|
214
|
+
error: BaseException,
|
|
215
|
+
*,
|
|
216
|
+
run_id: UUID | None = None,
|
|
217
|
+
**kwargs: Any,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Called when an LLM invocation raises an error.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
error: The exception that was raised.
|
|
223
|
+
run_id: LangChain run identifier.
|
|
224
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
225
|
+
"""
|
|
226
|
+
self._make_event(
|
|
227
|
+
"llm.trace.span.error",
|
|
228
|
+
{
|
|
229
|
+
"run_id": str(run_id) if run_id is not None else None,
|
|
230
|
+
"error": str(error),
|
|
231
|
+
"error_type": type(error).__name__,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def on_tool_start(
|
|
236
|
+
self,
|
|
237
|
+
serialized: dict[str, Any],
|
|
238
|
+
input_str: str,
|
|
239
|
+
*,
|
|
240
|
+
run_id: UUID | None = None,
|
|
241
|
+
**kwargs: Any,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Called when a tool invocation begins.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
serialized: Serialised tool config dict (usually has ``"name"`` key).
|
|
247
|
+
input_str: String input passed to the tool.
|
|
248
|
+
run_id: LangChain run identifier.
|
|
249
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
250
|
+
"""
|
|
251
|
+
tool_name = serialized.get("name", "") if serialized else ""
|
|
252
|
+
self._make_event(
|
|
253
|
+
"llm.trace.tool_call.started",
|
|
254
|
+
{
|
|
255
|
+
"tool_name": tool_name,
|
|
256
|
+
"input": input_str,
|
|
257
|
+
"run_id": str(run_id) if run_id is not None else None,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def on_tool_end(
|
|
262
|
+
self,
|
|
263
|
+
output: str,
|
|
264
|
+
*,
|
|
265
|
+
run_id: UUID | None = None,
|
|
266
|
+
**kwargs: Any,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Called when a tool invocation completes.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
output: String output returned by the tool.
|
|
272
|
+
run_id: LangChain run identifier.
|
|
273
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
274
|
+
"""
|
|
275
|
+
self._make_event(
|
|
276
|
+
"llm.trace.tool_call.completed",
|
|
277
|
+
{
|
|
278
|
+
"run_id": str(run_id) if run_id is not None else None,
|
|
279
|
+
"output": str(output)[:1024] if output else None,
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def on_tool_error(
|
|
284
|
+
self,
|
|
285
|
+
error: BaseException,
|
|
286
|
+
*,
|
|
287
|
+
run_id: UUID | None = None,
|
|
288
|
+
**kwargs: Any,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Called when a tool invocation raises an error.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
error: The exception that was raised.
|
|
294
|
+
run_id: LangChain run identifier.
|
|
295
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
296
|
+
"""
|
|
297
|
+
self._make_event(
|
|
298
|
+
"llm.trace.tool_call.error",
|
|
299
|
+
{
|
|
300
|
+
"run_id": str(run_id) if run_id is not None else None,
|
|
301
|
+
"error": str(error),
|
|
302
|
+
"error_type": type(error).__name__,
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def clear_events(self) -> None:
|
|
307
|
+
"""Remove all accumulated events from :attr:`events`."""
|
|
308
|
+
self.events.clear()
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# dunder
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def __repr__(self) -> str:
|
|
315
|
+
return f"LLMSchemaCallbackHandler(source={self._source!r}, events={len(self.events)})"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Module-level patch / unpatch API (for API consistency with other integrations)
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def is_patched() -> bool:
|
|
324
|
+
"""Return ``True`` if the LangChain integration module is importable.
|
|
325
|
+
|
|
326
|
+
LangChain uses a callback-handler pattern rather than monkey-patching, so
|
|
327
|
+
there is no global state to check. This function simply verifies that the
|
|
328
|
+
LangChain package is available and the handler class is ready to use.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
``True`` when LangChain is installed and the handler can be created.
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
_require_langchain()
|
|
335
|
+
except ImportError:
|
|
336
|
+
return False
|
|
337
|
+
else:
|
|
338
|
+
return True
|
|
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
|
+
"""
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""spanforge.integrations.langgraph - LangGraph governance demo integration.
|
|
2
|
+
|
|
3
|
+
Implements a lightweight handler that records graph and node execution while
|
|
4
|
+
optionally invoking SpanForge runtime-governance services. This is intended to
|
|
5
|
+
cover the GA demo path rather than deep framework auto-patching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from spanforge.event import Event
|
|
15
|
+
from spanforge.ulid import generate as gen_ulid
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"LangGraphGovernanceHandler",
|
|
19
|
+
"LangGraphNodeResult",
|
|
20
|
+
"LangGraphRunRecord",
|
|
21
|
+
"is_available",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _utc_now() -> str:
|
|
26
|
+
return datetime.now(tz=timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _require_langgraph() -> Any:
|
|
30
|
+
try:
|
|
31
|
+
import langgraph
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"LangGraph is required for this integration.\n"
|
|
35
|
+
"Install it with: pip install 'spanforge[langgraph]'"
|
|
36
|
+
) from exc
|
|
37
|
+
return langgraph
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_available() -> bool:
|
|
41
|
+
"""Return ``True`` when LangGraph is importable."""
|
|
42
|
+
try:
|
|
43
|
+
_require_langgraph()
|
|
44
|
+
except ImportError:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class LangGraphNodeResult:
|
|
51
|
+
"""Recorded result for one node in a LangGraph run."""
|
|
52
|
+
|
|
53
|
+
node_id: str
|
|
54
|
+
trace_id: str
|
|
55
|
+
node_name: str
|
|
56
|
+
node_type: str
|
|
57
|
+
started_at: str
|
|
58
|
+
completed_at: str | None = None
|
|
59
|
+
status: str = "started"
|
|
60
|
+
scope_result: Any | None = None
|
|
61
|
+
rbac_result: Any | None = None
|
|
62
|
+
lineage_result: Any | None = None
|
|
63
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
data = {
|
|
67
|
+
"node_id": self.node_id,
|
|
68
|
+
"trace_id": self.trace_id,
|
|
69
|
+
"node_name": self.node_name,
|
|
70
|
+
"node_type": self.node_type,
|
|
71
|
+
"started_at": self.started_at,
|
|
72
|
+
"status": self.status,
|
|
73
|
+
"metadata": dict(self.metadata),
|
|
74
|
+
}
|
|
75
|
+
if self.completed_at is not None:
|
|
76
|
+
data["completed_at"] = self.completed_at
|
|
77
|
+
if self.scope_result is not None:
|
|
78
|
+
data["scope_result"] = _to_dict(self.scope_result)
|
|
79
|
+
if self.rbac_result is not None:
|
|
80
|
+
data["rbac_result"] = _to_dict(self.rbac_result)
|
|
81
|
+
if self.lineage_result is not None:
|
|
82
|
+
data["lineage_result"] = _to_dict(self.lineage_result)
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class LangGraphRunRecord:
|
|
88
|
+
"""High-level trace record for one governed LangGraph run."""
|
|
89
|
+
|
|
90
|
+
run_id: str
|
|
91
|
+
trace_id: str
|
|
92
|
+
graph_name: str
|
|
93
|
+
environment: str
|
|
94
|
+
started_at: str
|
|
95
|
+
completed_at: str | None = None
|
|
96
|
+
status: str = "started"
|
|
97
|
+
agent_id: str | None = None
|
|
98
|
+
actor_id: str | None = None
|
|
99
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
100
|
+
nodes: list[LangGraphNodeResult] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
data = {
|
|
104
|
+
"run_id": self.run_id,
|
|
105
|
+
"trace_id": self.trace_id,
|
|
106
|
+
"graph_name": self.graph_name,
|
|
107
|
+
"environment": self.environment,
|
|
108
|
+
"started_at": self.started_at,
|
|
109
|
+
"status": self.status,
|
|
110
|
+
"metadata": dict(self.metadata),
|
|
111
|
+
"nodes": [node.to_dict() for node in self.nodes],
|
|
112
|
+
}
|
|
113
|
+
if self.completed_at is not None:
|
|
114
|
+
data["completed_at"] = self.completed_at
|
|
115
|
+
if self.agent_id is not None:
|
|
116
|
+
data["agent_id"] = self.agent_id
|
|
117
|
+
if self.actor_id is not None:
|
|
118
|
+
data["actor_id"] = self.actor_id
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _to_dict(value: Any) -> Any:
|
|
123
|
+
if hasattr(value, "to_dict"):
|
|
124
|
+
return value.to_dict()
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class LangGraphGovernanceHandler:
|
|
129
|
+
"""Record LangGraph execution and invoke runtime-governance controls."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
source: str = "spanforge.langgraph@1.0.0",
|
|
135
|
+
environment: str = "prod",
|
|
136
|
+
policy_client: Any | None = None,
|
|
137
|
+
scope_client: Any | None = None,
|
|
138
|
+
rbac_client: Any | None = None,
|
|
139
|
+
lineage_client: Any | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
self._source = source
|
|
142
|
+
self._environment = environment
|
|
143
|
+
self._policy_client = policy_client
|
|
144
|
+
self._scope_client = scope_client
|
|
145
|
+
self._rbac_client = rbac_client
|
|
146
|
+
self._lineage_client = lineage_client
|
|
147
|
+
self.events: list[Event] = []
|
|
148
|
+
self.runs: dict[str, LangGraphRunRecord] = {}
|
|
149
|
+
self.nodes: dict[str, LangGraphNodeResult] = {}
|
|
150
|
+
|
|
151
|
+
def on_graph_start(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
trace_id: str,
|
|
155
|
+
graph_name: str,
|
|
156
|
+
agent_id: str | None = None,
|
|
157
|
+
actor_id: str | None = None,
|
|
158
|
+
metadata: dict[str, Any] | None = None,
|
|
159
|
+
) -> LangGraphRunRecord:
|
|
160
|
+
"""Start recording one LangGraph run."""
|
|
161
|
+
run = LangGraphRunRecord(
|
|
162
|
+
run_id=gen_ulid(),
|
|
163
|
+
trace_id=trace_id,
|
|
164
|
+
graph_name=graph_name,
|
|
165
|
+
environment=self._environment,
|
|
166
|
+
started_at=_utc_now(),
|
|
167
|
+
agent_id=agent_id,
|
|
168
|
+
actor_id=actor_id,
|
|
169
|
+
metadata=metadata or {},
|
|
170
|
+
)
|
|
171
|
+
self.runs[trace_id] = run
|
|
172
|
+
self._emit_event(
|
|
173
|
+
"llm.langgraph.run.started",
|
|
174
|
+
trace_id=trace_id,
|
|
175
|
+
payload=run.to_dict(),
|
|
176
|
+
)
|
|
177
|
+
return run
|
|
178
|
+
|
|
179
|
+
def on_node_start(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
trace_id: str,
|
|
183
|
+
node_name: str,
|
|
184
|
+
node_type: str = "chain",
|
|
185
|
+
agent_id: str | None = None,
|
|
186
|
+
actor_id: str | None = None,
|
|
187
|
+
resource: str | None = None,
|
|
188
|
+
action_name: str | None = None,
|
|
189
|
+
capability: str | None = None,
|
|
190
|
+
required_roles: list[str] | None = None,
|
|
191
|
+
metadata: dict[str, Any] | None = None,
|
|
192
|
+
) -> LangGraphNodeResult:
|
|
193
|
+
"""Record node start and optionally run scope/RBAC checks."""
|
|
194
|
+
started_at = _utc_now()
|
|
195
|
+
result = LangGraphNodeResult(
|
|
196
|
+
node_id=gen_ulid(),
|
|
197
|
+
trace_id=trace_id,
|
|
198
|
+
node_name=node_name,
|
|
199
|
+
node_type=node_type,
|
|
200
|
+
started_at=started_at,
|
|
201
|
+
metadata=metadata or {},
|
|
202
|
+
)
|
|
203
|
+
if self._scope_client is not None and agent_id and resource and action_name:
|
|
204
|
+
result.scope_result = self._scope_client.evaluate_with_policy(
|
|
205
|
+
environment=self._environment,
|
|
206
|
+
trace_id=trace_id,
|
|
207
|
+
agent_id=agent_id,
|
|
208
|
+
resource=resource,
|
|
209
|
+
action_name=action_name,
|
|
210
|
+
checked_at=started_at,
|
|
211
|
+
capability=capability,
|
|
212
|
+
policy_client=self._policy_client,
|
|
213
|
+
)
|
|
214
|
+
if self._rbac_client is not None and actor_id and resource and action_name:
|
|
215
|
+
result.rbac_result = self._rbac_client.authorize_with_policy(
|
|
216
|
+
environment=self._environment,
|
|
217
|
+
trace_id=trace_id,
|
|
218
|
+
actor_id=actor_id,
|
|
219
|
+
resource=resource,
|
|
220
|
+
action_name=action_name,
|
|
221
|
+
checked_at=started_at,
|
|
222
|
+
required_roles=required_roles,
|
|
223
|
+
policy_client=self._policy_client,
|
|
224
|
+
)
|
|
225
|
+
self.nodes[result.node_id] = result
|
|
226
|
+
run = self.runs.get(trace_id)
|
|
227
|
+
if run is not None:
|
|
228
|
+
run.nodes.append(result)
|
|
229
|
+
self._emit_event(
|
|
230
|
+
"llm.langgraph.node.started",
|
|
231
|
+
trace_id=trace_id,
|
|
232
|
+
payload=result.to_dict(),
|
|
233
|
+
)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
def on_node_end(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
trace_id: str,
|
|
240
|
+
node_id: str,
|
|
241
|
+
decision_id: str,
|
|
242
|
+
subject_type: str = "langgraph_node",
|
|
243
|
+
output_refs: list[str] | None = None,
|
|
244
|
+
metadata: dict[str, Any] | None = None,
|
|
245
|
+
) -> LangGraphNodeResult:
|
|
246
|
+
"""Complete one node and optionally capture lineage."""
|
|
247
|
+
node = self.nodes[node_id]
|
|
248
|
+
completed_at = _utc_now()
|
|
249
|
+
node.completed_at = completed_at
|
|
250
|
+
node.status = "completed"
|
|
251
|
+
if self._lineage_client is not None:
|
|
252
|
+
node.lineage_result = self._lineage_client.record_with_policy(
|
|
253
|
+
environment=self._environment,
|
|
254
|
+
trace_id=trace_id,
|
|
255
|
+
decision_id=decision_id,
|
|
256
|
+
subject_type=subject_type,
|
|
257
|
+
subject_id=node.node_id,
|
|
258
|
+
operation=node.node_name,
|
|
259
|
+
recorded_at=completed_at,
|
|
260
|
+
policy_client=self._policy_client,
|
|
261
|
+
output_refs=output_refs,
|
|
262
|
+
metadata=metadata,
|
|
263
|
+
)
|
|
264
|
+
self._emit_event(
|
|
265
|
+
"llm.langgraph.node.completed",
|
|
266
|
+
trace_id=trace_id,
|
|
267
|
+
payload=node.to_dict(),
|
|
268
|
+
)
|
|
269
|
+
return node
|
|
270
|
+
|
|
271
|
+
def on_node_error(self, *, trace_id: str, node_id: str, error: BaseException) -> LangGraphNodeResult:
|
|
272
|
+
"""Record node failure."""
|
|
273
|
+
node = self.nodes[node_id]
|
|
274
|
+
node.completed_at = _utc_now()
|
|
275
|
+
node.status = "error"
|
|
276
|
+
node.metadata["error"] = str(error)
|
|
277
|
+
node.metadata["error_type"] = type(error).__name__
|
|
278
|
+
self._emit_event(
|
|
279
|
+
"llm.langgraph.node.error",
|
|
280
|
+
trace_id=trace_id,
|
|
281
|
+
payload=node.to_dict(),
|
|
282
|
+
)
|
|
283
|
+
return node
|
|
284
|
+
|
|
285
|
+
def on_graph_end(self, *, trace_id: str, status: str = "completed") -> LangGraphRunRecord:
|
|
286
|
+
"""Complete one LangGraph run."""
|
|
287
|
+
run = self.runs[trace_id]
|
|
288
|
+
run.completed_at = _utc_now()
|
|
289
|
+
run.status = status
|
|
290
|
+
self._emit_event(
|
|
291
|
+
"llm.langgraph.run.completed",
|
|
292
|
+
trace_id=trace_id,
|
|
293
|
+
payload=run.to_dict(),
|
|
294
|
+
)
|
|
295
|
+
return run
|
|
296
|
+
|
|
297
|
+
def _emit_event(self, event_type: str, *, trace_id: str, payload: dict[str, Any]) -> Event:
|
|
298
|
+
event = Event(
|
|
299
|
+
event_type=event_type,
|
|
300
|
+
source=self._source,
|
|
301
|
+
payload=payload,
|
|
302
|
+
trace_id=trace_id,
|
|
303
|
+
event_id=gen_ulid(),
|
|
304
|
+
)
|
|
305
|
+
self.events.append(event)
|
|
306
|
+
return event
|