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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. 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