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,356 @@
1
+ """Phase 11 enterprise and security command groups for the SpanForge CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+
10
+ def add_phase11_subcommands(
11
+ sub: argparse._SubParsersAction[argparse.ArgumentParser],
12
+ ) -> tuple[argparse.ArgumentParser, argparse.ArgumentParser]:
13
+ """Register enterprise and security CLI subcommands."""
14
+ enterprise_parser = sub.add_parser(
15
+ "enterprise",
16
+ help="Enterprise hardening & multi-tenancy operations",
17
+ )
18
+ enterprise_sub = enterprise_parser.add_subparsers(
19
+ dest="enterprise_command",
20
+ metavar="<action>",
21
+ )
22
+
23
+ enterprise_sub.add_parser(
24
+ "status",
25
+ help="Show enterprise hardening status",
26
+ ).add_argument(
27
+ "--format",
28
+ choices=["text", "json"],
29
+ default="text",
30
+ help="Output format (default: text)",
31
+ )
32
+
33
+ ent_reg = enterprise_sub.add_parser(
34
+ "register-tenant",
35
+ help="Register a project tenant with isolation config",
36
+ )
37
+ ent_reg.add_argument("--project-id", required=True, metavar="ID", help="Project identifier")
38
+ ent_reg.add_argument("--org-id", required=True, metavar="ID", help="Organisation identifier")
39
+ ent_reg.add_argument(
40
+ "--residency",
41
+ default="global",
42
+ choices=["eu", "us", "ap", "in", "global"],
43
+ help="Data residency region (default: global)",
44
+ )
45
+
46
+ enterprise_sub.add_parser(
47
+ "list-tenants",
48
+ help="List all registered tenants",
49
+ ).add_argument(
50
+ "--format",
51
+ choices=["text", "json"],
52
+ default="text",
53
+ help="Output format (default: text)",
54
+ )
55
+
56
+ enterprise_sub.add_parser(
57
+ "encrypt-config",
58
+ help="Show current encryption configuration",
59
+ )
60
+
61
+ enterprise_sub.add_parser(
62
+ "health",
63
+ help="Run health checks on all SpanForge services",
64
+ ).add_argument(
65
+ "--format",
66
+ choices=["text", "json"],
67
+ default="text",
68
+ help="Output format (default: text)",
69
+ )
70
+
71
+ security_parser = sub.add_parser(
72
+ "security",
73
+ help="Security review & supply-chain scanning",
74
+ )
75
+ security_sub = security_parser.add_subparsers(
76
+ dest="security_command",
77
+ metavar="<action>",
78
+ )
79
+
80
+ security_sub.add_parser(
81
+ "owasp",
82
+ help="Run OWASP API Security Top 10 audit",
83
+ ).add_argument(
84
+ "--format",
85
+ choices=["text", "json"],
86
+ default="text",
87
+ help="Output format (default: text)",
88
+ )
89
+
90
+ security_sub.add_parser(
91
+ "threat-model",
92
+ help="Generate default STRIDE threat model",
93
+ ).add_argument(
94
+ "--format",
95
+ choices=["text", "json"],
96
+ default="text",
97
+ help="Output format (default: text)",
98
+ )
99
+
100
+ security_sub.add_parser(
101
+ "scan",
102
+ help="Run full security scan (deps + static + secrets)",
103
+ ).add_argument(
104
+ "--format",
105
+ choices=["text", "json"],
106
+ default="text",
107
+ help="Output format (default: text)",
108
+ )
109
+
110
+ sec_audit = security_sub.add_parser(
111
+ "audit-logs",
112
+ help="Check log file for leaked secrets",
113
+ )
114
+ sec_audit.add_argument(
115
+ "--file",
116
+ default=None,
117
+ metavar="PATH",
118
+ help="Path to log file to audit",
119
+ )
120
+
121
+ return enterprise_parser, security_parser
122
+
123
+
124
+ def dispatch_phase11_command(
125
+ args: argparse.Namespace,
126
+ enterprise_parser: argparse.ArgumentParser,
127
+ security_parser: argparse.ArgumentParser,
128
+ ) -> int | None:
129
+ """Dispatch enterprise or security commands when selected."""
130
+ command = getattr(args, "command", None)
131
+ if command == "enterprise":
132
+ return _cmd_enterprise(args, enterprise_parser)
133
+ if command == "security":
134
+ return _cmd_security(args, security_parser)
135
+ return None
136
+
137
+
138
+ def _cmd_enterprise(
139
+ args: argparse.Namespace,
140
+ enterprise_parser: argparse.ArgumentParser,
141
+ ) -> int:
142
+ """Handle ``spanforge enterprise`` subcommands."""
143
+ action = getattr(args, "enterprise_command", None)
144
+
145
+ if action == "status":
146
+ return _cmd_enterprise_status(args)
147
+ if action == "register-tenant":
148
+ return _cmd_enterprise_register_tenant(args)
149
+ if action == "list-tenants":
150
+ return _cmd_enterprise_list_tenants(args)
151
+ if action == "encrypt-config":
152
+ return _cmd_enterprise_encrypt_config(args)
153
+ if action == "health":
154
+ return _cmd_enterprise_health(args)
155
+
156
+ enterprise_parser.print_help()
157
+ return 2
158
+
159
+
160
+ def _cmd_enterprise_status(args: argparse.Namespace) -> int:
161
+ """``spanforge enterprise status`` - show enterprise hardening status."""
162
+ from spanforge.sdk import sf_enterprise
163
+
164
+ status = sf_enterprise.get_status()
165
+ fmt = getattr(args, "format", "text")
166
+
167
+ if fmt == "json":
168
+ import dataclasses
169
+
170
+ print(json.dumps(dataclasses.asdict(status), indent=2, default=str))
171
+ else:
172
+ print("Enterprise Hardening Status")
173
+ print(f" Multi-Tenancy: {'enabled' if status.multi_tenancy_enabled else 'disabled'}")
174
+ print(f" Tenants: {status.tenant_count}")
175
+ print(f" Encryption: {'at-rest' if status.encryption_at_rest else 'off'}")
176
+ print(f" FIPS Mode: {'on' if status.fips_mode else 'off'}")
177
+ print(f" Offline Mode: {'on' if status.offline_mode else 'off'}")
178
+ print(f" Residency: {status.data_residency}")
179
+ return 0
180
+
181
+
182
+ def _cmd_enterprise_register_tenant(args: argparse.Namespace) -> int:
183
+ """``spanforge enterprise register-tenant`` - register a new tenant."""
184
+ from spanforge.sdk import sf_enterprise
185
+
186
+ tenant = sf_enterprise.register_tenant(
187
+ project_id=args.project_id,
188
+ org_id=args.org_id,
189
+ data_residency=getattr(args, "residency", "global"),
190
+ )
191
+ print(f"[✓] Tenant registered: project={tenant.project_id} org={tenant.org_id}")
192
+ print(f" Residency: {tenant.data_residency}")
193
+ return 0
194
+
195
+
196
+ def _cmd_enterprise_list_tenants(args: argparse.Namespace) -> int:
197
+ """``spanforge enterprise list-tenants`` - list registered tenants."""
198
+ from spanforge.sdk import sf_enterprise
199
+
200
+ tenants = sf_enterprise.list_tenants()
201
+ fmt = getattr(args, "format", "text")
202
+
203
+ if fmt == "json":
204
+ import dataclasses
205
+
206
+ print(json.dumps([dataclasses.asdict(t) for t in tenants], indent=2, default=str))
207
+ elif not tenants:
208
+ print("No tenants registered.")
209
+ else:
210
+ for tenant in tenants:
211
+ print(
212
+ f" {tenant.project_id} (org={tenant.org_id}, residency={tenant.data_residency})"
213
+ )
214
+ return 0
215
+
216
+
217
+ def _cmd_enterprise_encrypt_config(_args: argparse.Namespace) -> int:
218
+ """``spanforge enterprise encrypt-config`` - show encryption settings."""
219
+ from spanforge.sdk import sf_enterprise
220
+
221
+ enc = sf_enterprise.get_encryption_config()
222
+ import dataclasses
223
+
224
+ print(json.dumps(dataclasses.asdict(enc), indent=2, default=str))
225
+ return 0
226
+
227
+
228
+ def _cmd_enterprise_health(args: argparse.Namespace) -> int:
229
+ """``spanforge enterprise health`` - run health checks on all services."""
230
+ from spanforge.sdk import sf_enterprise
231
+
232
+ results = sf_enterprise.check_all_services_health()
233
+ fmt = getattr(args, "format", "text")
234
+
235
+ if fmt == "json":
236
+ import dataclasses
237
+
238
+ print(json.dumps([dataclasses.asdict(result) for result in results], indent=2, default=str))
239
+ else:
240
+ all_ok = all(result.ok for result in results)
241
+ for result in results:
242
+ marker = "✓" if result.ok else "✗"
243
+ print(
244
+ f" [{marker}] {result.service}{result.endpoint} - "
245
+ f"{result.status} ({result.latency_ms:.1f}ms)"
246
+ )
247
+ print()
248
+ print(f"Overall: {'HEALTHY' if all_ok else 'DEGRADED'}")
249
+ return 0
250
+
251
+
252
+ def _cmd_security(
253
+ args: argparse.Namespace,
254
+ security_parser: argparse.ArgumentParser,
255
+ ) -> int:
256
+ """Handle ``spanforge security`` subcommands."""
257
+ action = getattr(args, "security_command", None)
258
+
259
+ if action == "owasp":
260
+ return _cmd_security_owasp(args)
261
+ if action == "threat-model":
262
+ return _cmd_security_threat_model(args)
263
+ if action == "scan":
264
+ return _cmd_security_scan(args)
265
+ if action == "audit-logs":
266
+ return _cmd_security_audit_logs(args)
267
+
268
+ security_parser.print_help()
269
+ return 2
270
+
271
+
272
+ def _cmd_security_owasp(args: argparse.Namespace) -> int:
273
+ """``spanforge security owasp`` - run OWASP API Security audit."""
274
+ from spanforge.sdk import sf_security
275
+
276
+ result = sf_security.run_owasp_audit()
277
+ fmt = getattr(args, "format", "text")
278
+
279
+ if fmt == "json":
280
+ import dataclasses
281
+
282
+ print(json.dumps(dataclasses.asdict(result), indent=2, default=str))
283
+ else:
284
+ verdict = "PASS" if result.pass_ else "FAIL"
285
+ print(f"OWASP API Security Top 10 Audit: {verdict}")
286
+ for cat_id, info in result.categories.items():
287
+ marker = "✓" if info["status"] == "pass" else "✗"
288
+ print(f" [{marker}] {cat_id}: {info['name']}")
289
+ if info.get("detail"):
290
+ print(f" {info['detail']}")
291
+ return 0 if result.pass_ else 1
292
+
293
+
294
+ def _cmd_security_threat_model(args: argparse.Namespace) -> int:
295
+ """``spanforge security threat-model`` - generate default STRIDE threat model."""
296
+ from spanforge.sdk import sf_security
297
+
298
+ entries = sf_security.generate_default_threat_model()
299
+ fmt = getattr(args, "format", "text")
300
+
301
+ if fmt == "json":
302
+ import dataclasses
303
+
304
+ print(json.dumps([dataclasses.asdict(entry) for entry in entries], indent=2, default=str))
305
+ else:
306
+ print(f"STRIDE Threat Model ({len(entries)} entries):")
307
+ for entry in entries:
308
+ print(f" [{entry.risk_level.upper()}] {entry.service} / {entry.category}")
309
+ print(f" Threat: {entry.threat}")
310
+ print(f" Mitigation: {entry.mitigation}")
311
+ return 0
312
+
313
+
314
+ def _cmd_security_scan(args: argparse.Namespace) -> int:
315
+ """``spanforge security scan`` - run full security scan."""
316
+ from spanforge.sdk import sf_security
317
+
318
+ result = sf_security.run_full_scan()
319
+ fmt = getattr(args, "format", "text")
320
+
321
+ if fmt == "json":
322
+ import dataclasses
323
+
324
+ print(json.dumps(dataclasses.asdict(result), indent=2, default=str))
325
+ else:
326
+ verdict = "PASS" if result.pass_ else "FAIL"
327
+ print(f"Security Scan: {verdict}")
328
+ print(f" Vulnerabilities: {len(result.vulnerabilities)}")
329
+ print(f" Static findings: {len(result.static_findings)}")
330
+ print(f" Secrets in logs: {result.secrets_in_logs}")
331
+ return 0 if result.pass_ else 1
332
+
333
+
334
+ def _cmd_security_audit_logs(args: argparse.Namespace) -> int:
335
+ """``spanforge security audit-logs`` - check logs for leaked secrets."""
336
+ from spanforge.sdk import sf_security
337
+
338
+ log_file = getattr(args, "file", None)
339
+ if not log_file:
340
+ print("[✓] No log file specified - clean.")
341
+ return 0
342
+
343
+ try:
344
+ with open(log_file, encoding="utf-8") as fh:
345
+ lines = fh.readlines()
346
+ except OSError as exc:
347
+ print(f"error: {exc}", file=sys.stderr)
348
+ return 2
349
+
350
+ count = sf_security.audit_logs_for_secrets_safe(lines)
351
+ if count:
352
+ print(f"[✗] Found {count} secret(s) in log output!")
353
+ return 1
354
+
355
+ print("[✓] No secrets detected in logs.")
356
+ return 0
spanforge/_hooks.py ADDED
@@ -0,0 +1,337 @@
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 contextlib
45
+ import inspect
46
+ import threading
47
+ import warnings
48
+ from collections.abc import Coroutine
49
+ from typing import TYPE_CHECKING, Any, Callable
50
+
51
+ if TYPE_CHECKING:
52
+ from spanforge._span import Span
53
+
54
+ __all__ = ["HookFn", "HookRegistry", "hooks"]
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Type aliases
58
+ # ---------------------------------------------------------------------------
59
+
60
+ HookFn = Callable[["Span"], None]
61
+ AsyncHookFn = Callable[["Span"], Coroutine[Any, Any, None]]
62
+
63
+ # Hook kind constants — match the operation strings used in SpanPayload.
64
+ _HOOK_AGENT_START = "agent_start"
65
+ _HOOK_AGENT_END = "agent_end"
66
+ _HOOK_LLM_CALL = "llm_call"
67
+ _HOOK_TOOL_CALL = "tool_call"
68
+
69
+ # Map span operation values → hook kind (for "start" hooks the same mapping is
70
+ # used; the distinction between start and end is made by the context manager).
71
+ _LLM_OPERATIONS = frozenset({"chat", "completion", "embedding", "chat_completion", "generate"})
72
+ _TOOL_OPERATIONS = frozenset({"tool_call", "execute_tool"})
73
+ _AGENT_OPERATIONS = frozenset({"invoke_agent", "agent"})
74
+
75
+
76
+ def _classify_span(span: Span) -> str | None:
77
+ """Return the hook kind for *span*, or ``None`` if no hook applies."""
78
+ op = str(getattr(span, "operation", "") or "")
79
+ if op in _LLM_OPERATIONS or op == "chat":
80
+ return _HOOK_LLM_CALL
81
+ if op in _TOOL_OPERATIONS:
82
+ return _HOOK_TOOL_CALL
83
+ if op in _AGENT_OPERATIONS:
84
+ return _HOOK_AGENT_START # caller differentiates start/end
85
+ # Fallback: if the span name contains a hint use that.
86
+ name = str(getattr(span, "name", "") or "")
87
+ if "llm" in name.lower() or "model" in name.lower():
88
+ return _HOOK_LLM_CALL
89
+ if "tool" in name.lower():
90
+ return _HOOK_TOOL_CALL
91
+ return None
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # HookRegistry
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ class HookRegistry:
100
+ """Registry of span lifecycle hooks.
101
+
102
+ Each ``on_*`` method can be used as a **decorator** or called directly
103
+ to register a callback:
104
+
105
+ .. code-block:: python
106
+
107
+ @hooks.on_llm_call
108
+ def my_hook(span): ...
109
+
110
+ # equivalent:
111
+ hooks.on_llm_call(my_hook)
112
+
113
+ Methods:
114
+ on_agent_start: Register a callback fired when any agent-operation span **starts**.
115
+ on_agent_end: Register a callback fired when any agent-operation span **ends**.
116
+ on_llm_call: Register a callback fired at both start and end of LLM spans.
117
+ on_tool_call: Register a callback fired at both start and end of tool spans.
118
+ clear: Remove all registered hooks.
119
+ """
120
+
121
+ def __init__(self) -> None:
122
+ self._lock = threading.RLock()
123
+ self._hooks: dict[str, list[HookFn]] = {
124
+ _HOOK_AGENT_START: [],
125
+ _HOOK_AGENT_END: [],
126
+ _HOOK_LLM_CALL: [],
127
+ _HOOK_TOOL_CALL: [],
128
+ }
129
+ self._async_hooks: dict[str, list[AsyncHookFn]] = {
130
+ _HOOK_AGENT_START: [],
131
+ _HOOK_AGENT_END: [],
132
+ _HOOK_LLM_CALL: [],
133
+ _HOOK_TOOL_CALL: [],
134
+ }
135
+ # Universal span-end hooks: fire for EVERY span regardless of operation.
136
+ self._all_end_hooks: list[HookFn] = []
137
+
138
+ # ------------------------------------------------------------------
139
+ # Registration decorators / methods
140
+ # ------------------------------------------------------------------
141
+
142
+ def on_agent_start(self, fn: HookFn) -> HookFn:
143
+ """Register *fn* to fire when an agent-operation span **starts**.
144
+
145
+ Can be used as a decorator::
146
+
147
+ @hooks.on_agent_start
148
+ def cb(span): ...
149
+ """
150
+ with self._lock:
151
+ self._hooks[_HOOK_AGENT_START].append(fn)
152
+ return fn
153
+
154
+ def on_agent_end(self, fn: HookFn) -> HookFn:
155
+ """Register *fn* to fire when an agent-operation span **ends**."""
156
+ with self._lock:
157
+ self._hooks[_HOOK_AGENT_END].append(fn)
158
+ return fn
159
+
160
+ def on_llm_call(self, fn: HookFn) -> HookFn:
161
+ """Register *fn* to fire on LLM-operation spans (start **and** end)."""
162
+ with self._lock:
163
+ self._hooks[_HOOK_LLM_CALL].append(fn)
164
+ return fn
165
+
166
+ def on_tool_call(self, fn: HookFn) -> HookFn:
167
+ """Register *fn* to fire on tool-call spans (start **and** end)."""
168
+ with self._lock:
169
+ self._hooks[_HOOK_TOOL_CALL].append(fn)
170
+ return fn
171
+
172
+ def on_span_end(self, fn: HookFn) -> HookFn:
173
+ """Register *fn* to fire when **any** span ends, regardless of operation type.
174
+
175
+ Unlike :meth:`on_agent_end`, :meth:`on_llm_call`, and :meth:`on_tool_call`
176
+ which only fire for operation-classified spans, this hook fires for every
177
+ :class:`~spanforge._span.Span` that exits via :class:`~spanforge._span.SpanContextManager`.
178
+
179
+ Primary use case: collecting all spans in test code via the
180
+ :func:`~spanforge.testing.captured_spans` pytest fixture.
181
+
182
+ Can be used as a decorator::
183
+
184
+ @hooks.on_span_end
185
+ def cb(span): ...
186
+ """
187
+ with self._lock:
188
+ self._all_end_hooks.append(fn)
189
+ return fn
190
+
191
+ # ------------------------------------------------------------------
192
+ # Async registration decorators / methods
193
+ # ------------------------------------------------------------------
194
+
195
+ def on_agent_start_async(self, fn: AsyncHookFn) -> AsyncHookFn:
196
+ """Register an **async** callback to fire when an agent span **starts**.
197
+
198
+ The coroutine is scheduled via :func:`asyncio.ensure_future` when a
199
+ running event loop is detected. If no loop is running the callback is
200
+ silently skipped.
201
+
202
+ Can be used as a decorator::
203
+
204
+ @hooks.on_agent_start_async
205
+ async def cb(span): await db.record_start(span.span_id)
206
+ """
207
+ with self._lock:
208
+ self._async_hooks[_HOOK_AGENT_START].append(fn)
209
+ return fn
210
+
211
+ def on_agent_end_async(self, fn: AsyncHookFn) -> AsyncHookFn:
212
+ """Register an **async** callback to fire when an agent span **ends**."""
213
+ with self._lock:
214
+ self._async_hooks[_HOOK_AGENT_END].append(fn)
215
+ return fn
216
+
217
+ def on_llm_call_async(self, fn: AsyncHookFn) -> AsyncHookFn:
218
+ """Register an **async** callback to fire on LLM spans (start **and** end)."""
219
+ with self._lock:
220
+ self._async_hooks[_HOOK_LLM_CALL].append(fn)
221
+ return fn
222
+
223
+ def on_tool_call_async(self, fn: AsyncHookFn) -> AsyncHookFn:
224
+ """Register an **async** callback to fire on tool-call spans (start **and** end)."""
225
+ with self._lock:
226
+ self._async_hooks[_HOOK_TOOL_CALL].append(fn)
227
+ return fn
228
+
229
+ def clear(self) -> None:
230
+ """Unregister all synchronous, async, and universal hooks."""
231
+ with self._lock:
232
+ for key in self._hooks:
233
+ self._hooks[key].clear()
234
+ for key in self._async_hooks:
235
+ self._async_hooks[key].clear()
236
+ self._all_end_hooks.clear()
237
+
238
+ # ------------------------------------------------------------------
239
+ # Internal fire helpers (called by SpanContextManager)
240
+ # ------------------------------------------------------------------
241
+
242
+ def _fire_start(self, span: Span) -> None:
243
+ """Fire the appropriate start hooks for *span*."""
244
+ kind = _classify_span(span)
245
+ if kind is None:
246
+ return
247
+ if kind in (_HOOK_LLM_CALL, _HOOK_TOOL_CALL):
248
+ self._fire(kind, span)
249
+ elif kind == _HOOK_AGENT_START:
250
+ self._fire(_HOOK_AGENT_START, span)
251
+
252
+ def _fire_end(self, span: Span) -> None:
253
+ """Fire the appropriate end hooks for *span*, plus universal span-end hooks."""
254
+ kind = _classify_span(span)
255
+ if kind is not None:
256
+ if kind in (_HOOK_LLM_CALL, _HOOK_TOOL_CALL):
257
+ self._fire(kind, span)
258
+ elif kind == _HOOK_AGENT_START:
259
+ # Re-use agent_end bucket for end hooks.
260
+ self._fire(_HOOK_AGENT_END, span)
261
+ # Always fire universal span-end hooks regardless of classification.
262
+ self._fire_all_end(span)
263
+
264
+ def _fire_all_end(self, span: Span) -> None:
265
+ """Fire all universal span-end hooks registered via :meth:`on_span_end`."""
266
+ with self._lock:
267
+ callbacks = list(self._all_end_hooks)
268
+ for cb in callbacks:
269
+ try:
270
+ cb(span)
271
+ except Exception as exc:
272
+ with contextlib.suppress(Exception):
273
+ warnings.warn(
274
+ f"spanforge on_span_end hook error in {cb!r}: {exc}",
275
+ UserWarning,
276
+ stacklevel=2,
277
+ )
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
+ with contextlib.suppress(Exception):
287
+ warnings.warn(
288
+ f"spanforge hook error in {cb!r}: {exc}",
289
+ UserWarning,
290
+ stacklevel=2,
291
+ )
292
+ # Fire async hooks if a loop is running.
293
+ self._fire_async(kind, span)
294
+
295
+ def _fire_async(self, kind: str, span: Span) -> None:
296
+ """Schedule async hook coroutines on the running event loop (if any)."""
297
+ with self._lock:
298
+ async_callbacks = list(self._async_hooks.get(kind, []))
299
+ if not async_callbacks:
300
+ return
301
+ try:
302
+ loop = asyncio.get_running_loop()
303
+ except RuntimeError:
304
+ return # no event loop running — skip async hooks silently
305
+ for cb in async_callbacks:
306
+ try:
307
+ coro = cb(span)
308
+ if inspect.isawaitable(coro):
309
+ _task = asyncio.ensure_future(coro, loop=loop)
310
+ _task.add_done_callback(lambda t: None)
311
+ except Exception as exc:
312
+ with contextlib.suppress(Exception):
313
+ warnings.warn(
314
+ f"spanforge async hook error in {cb!r}: {exc}",
315
+ UserWarning,
316
+ stacklevel=2,
317
+ )
318
+
319
+ def __repr__(self) -> str:
320
+ with self._lock:
321
+ counts = {k: len(v) for k, v in self._hooks.items()}
322
+ async_counts = {k: len(v) for k, v in self._async_hooks.items()}
323
+ return f"HookRegistry(sync={counts}, async={async_counts})"
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Module-level singleton
328
+ # ---------------------------------------------------------------------------
329
+
330
+ hooks: HookRegistry = HookRegistry()
331
+ """Global singleton :class:`HookRegistry` — import and use directly::
332
+
333
+ from spanforge import hooks
334
+
335
+ @hooks.on_llm_call
336
+ def my_callback(span): ...
337
+ """