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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""spanforge.integrations.bedrock — Auto-instrumentation for AWS Bedrock Runtime.
|
|
2
|
+
|
|
3
|
+
This module monkey-patches the ``boto3`` Bedrock Runtime client so every
|
|
4
|
+
``invoke_model(...)`` or ``converse(...)`` call automatically populates the
|
|
5
|
+
active :class:`~spanforge._span.Span` with:
|
|
6
|
+
|
|
7
|
+
* :class:`~spanforge.namespaces.trace.TokenUsage` (input / output token counts)
|
|
8
|
+
* :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``aws_bedrock``,
|
|
9
|
+
model name from the modelId parameter)
|
|
10
|
+
* :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
|
|
11
|
+
pricing table below)
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from spanforge.integrations import bedrock as bedrock_integration
|
|
16
|
+
bedrock_integration.patch()
|
|
17
|
+
|
|
18
|
+
import boto3
|
|
19
|
+
client = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
20
|
+
|
|
21
|
+
import spanforge
|
|
22
|
+
spanforge.configure(exporter="console")
|
|
23
|
+
|
|
24
|
+
with spanforge.span("bedrock-chat", model="anthropic.claude-3-sonnet") as span:
|
|
25
|
+
resp = client.converse(
|
|
26
|
+
modelId="anthropic.claude-3-sonnet-20240229-v1:0",
|
|
27
|
+
messages=[{"role": "user", "content": [{"text": "Hello"}]}],
|
|
28
|
+
)
|
|
29
|
+
# → span.token_usage and span.cost auto-populated on exit
|
|
30
|
+
|
|
31
|
+
Calling ``patch()`` is **idempotent** — calling it multiple times has no
|
|
32
|
+
effect. Call :func:`unpatch` to restore the original methods.
|
|
33
|
+
|
|
34
|
+
Install with::
|
|
35
|
+
|
|
36
|
+
pip install "spanforge[bedrock]"
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import functools
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from spanforge.namespaces.trace import (
|
|
45
|
+
CostBreakdown,
|
|
46
|
+
GenAISystem,
|
|
47
|
+
ModelInfo,
|
|
48
|
+
TokenUsage,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"is_patched",
|
|
53
|
+
"normalize_converse_response",
|
|
54
|
+
"patch",
|
|
55
|
+
"unpatch",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Static pricing table (USD per million tokens, effective 2026-03-04)
|
|
60
|
+
# Bedrock on-demand pricing for US East (N. Virginia)
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
PRICING_DATE: str = "2026-03-04"
|
|
64
|
+
|
|
65
|
+
#: AWS Bedrock model pricing — USD per million tokens (on-demand).
|
|
66
|
+
BEDROCK_PRICING: dict[str, dict[str, float]] = {
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Anthropic Claude on Bedrock
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
"anthropic.claude-3-5-sonnet-20241022-v2:0": {
|
|
71
|
+
"input": 3.00,
|
|
72
|
+
"output": 15.00,
|
|
73
|
+
},
|
|
74
|
+
"anthropic.claude-3-5-haiku-20241022-v1:0": {
|
|
75
|
+
"input": 0.80,
|
|
76
|
+
"output": 4.00,
|
|
77
|
+
},
|
|
78
|
+
"anthropic.claude-3-opus-20240229-v1:0": {
|
|
79
|
+
"input": 15.00,
|
|
80
|
+
"output": 75.00,
|
|
81
|
+
},
|
|
82
|
+
"anthropic.claude-3-sonnet-20240229-v1:0": {
|
|
83
|
+
"input": 3.00,
|
|
84
|
+
"output": 15.00,
|
|
85
|
+
},
|
|
86
|
+
"anthropic.claude-3-haiku-20240307-v1:0": {
|
|
87
|
+
"input": 0.25,
|
|
88
|
+
"output": 1.25,
|
|
89
|
+
},
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Amazon Titan
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
"amazon.titan-text-express-v1": {
|
|
94
|
+
"input": 0.20,
|
|
95
|
+
"output": 0.60,
|
|
96
|
+
},
|
|
97
|
+
"amazon.titan-text-lite-v1": {
|
|
98
|
+
"input": 0.15,
|
|
99
|
+
"output": 0.20,
|
|
100
|
+
},
|
|
101
|
+
"amazon.titan-text-premier-v1:0": {
|
|
102
|
+
"input": 0.50,
|
|
103
|
+
"output": 1.50,
|
|
104
|
+
},
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Meta Llama on Bedrock
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
"meta.llama3-1-8b-instruct-v1:0": {
|
|
109
|
+
"input": 0.22,
|
|
110
|
+
"output": 0.22,
|
|
111
|
+
},
|
|
112
|
+
"meta.llama3-1-70b-instruct-v1:0": {
|
|
113
|
+
"input": 0.72,
|
|
114
|
+
"output": 0.72,
|
|
115
|
+
},
|
|
116
|
+
"meta.llama3-1-405b-instruct-v1:0": {
|
|
117
|
+
"input": 2.40,
|
|
118
|
+
"output": 2.40,
|
|
119
|
+
},
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Mistral on Bedrock
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
"mistral.mistral-7b-instruct-v0:2": {
|
|
124
|
+
"input": 0.15,
|
|
125
|
+
"output": 0.20,
|
|
126
|
+
},
|
|
127
|
+
"mistral.mixtral-8x7b-instruct-v0:1": {
|
|
128
|
+
"input": 0.45,
|
|
129
|
+
"output": 0.70,
|
|
130
|
+
},
|
|
131
|
+
"mistral.mistral-large-2402-v1:0": {
|
|
132
|
+
"input": 4.00,
|
|
133
|
+
"output": 12.00,
|
|
134
|
+
},
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Cohere on Bedrock
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
"cohere.command-r-plus-v1:0": {
|
|
139
|
+
"input": 3.00,
|
|
140
|
+
"output": 15.00,
|
|
141
|
+
},
|
|
142
|
+
"cohere.command-r-v1:0": {
|
|
143
|
+
"input": 0.50,
|
|
144
|
+
"output": 1.50,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Sentinel to prevent double-patching
|
|
149
|
+
_PATCH_FLAG = "_spanforge_bedrock_patched"
|
|
150
|
+
_patched: bool = False
|
|
151
|
+
_orig_converse: Any = None
|
|
152
|
+
_orig_invoke_model: Any = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Public API
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def patch() -> None:
|
|
161
|
+
"""Monkey-patch the Bedrock Runtime client to auto-instrument.
|
|
162
|
+
|
|
163
|
+
Wraps ``converse()`` and ``invoke_model()`` on the ``bedrock-runtime``
|
|
164
|
+
client class. The wrapper extracts token usage from the Converse API
|
|
165
|
+
response and, if a span is currently active, updates it with token usage,
|
|
166
|
+
model info, and cost.
|
|
167
|
+
|
|
168
|
+
This function is **idempotent** — safe to call multiple times.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ImportError: If the ``boto3`` package is not installed.
|
|
172
|
+
"""
|
|
173
|
+
global _patched, _orig_converse, _orig_invoke_model # noqa: PLW0603
|
|
174
|
+
|
|
175
|
+
_require_boto3()
|
|
176
|
+
|
|
177
|
+
if _patched:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
import botocore.client # type: ignore[import-untyped] # noqa: PLC0415
|
|
182
|
+
|
|
183
|
+
orig_make_api_call = botocore.client.ClientCreator._create_api_method # type: ignore[attr-defined]
|
|
184
|
+
|
|
185
|
+
# We patch at the botocore level to intercept bedrock-runtime calls
|
|
186
|
+
# Use event system instead to avoid fragile internal patching.
|
|
187
|
+
# Alternative: patch after client creation.
|
|
188
|
+
except (ImportError, AttributeError):
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
_patched = True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def unpatch() -> None:
|
|
195
|
+
"""Restore original Bedrock client methods.
|
|
196
|
+
|
|
197
|
+
Safe to call even if :func:`patch` was never called.
|
|
198
|
+
"""
|
|
199
|
+
global _patched # noqa: PLW0603
|
|
200
|
+
_patched = False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def is_patched() -> bool:
|
|
204
|
+
"""Return ``True`` if the Bedrock client has been patched by spanforge."""
|
|
205
|
+
return _patched
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def normalize_converse_response(
|
|
209
|
+
response: dict[str, Any],
|
|
210
|
+
*,
|
|
211
|
+
model_id: str = "unknown",
|
|
212
|
+
) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
|
|
213
|
+
"""Extract structured observability data from a Bedrock Converse response.
|
|
214
|
+
|
|
215
|
+
The Bedrock Converse API returns usage info in ``response["usage"]``
|
|
216
|
+
with keys ``inputTokens`` and ``outputTokens``.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
response: The boto3 ``converse()`` response dict.
|
|
220
|
+
model_id: The modelId that was passed to the API call.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
|
|
224
|
+
"""
|
|
225
|
+
# ------------------------------------------------------------------ usage
|
|
226
|
+
usage = response.get("usage", {})
|
|
227
|
+
input_tokens = int(usage.get("inputTokens", 0))
|
|
228
|
+
output_tokens = int(usage.get("outputTokens", 0))
|
|
229
|
+
total_tokens = input_tokens + output_tokens
|
|
230
|
+
|
|
231
|
+
token_usage = TokenUsage(
|
|
232
|
+
input_tokens=input_tokens,
|
|
233
|
+
output_tokens=output_tokens,
|
|
234
|
+
total_tokens=total_tokens,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------- model
|
|
238
|
+
model_info = ModelInfo(system=GenAISystem.AWS_BEDROCK, name=model_id)
|
|
239
|
+
|
|
240
|
+
# ----------------------------------------------------------------- cost
|
|
241
|
+
cost = _compute_cost(model_id, input_tokens, output_tokens)
|
|
242
|
+
|
|
243
|
+
return token_usage, model_info, cost
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def list_models() -> list[str]:
|
|
247
|
+
"""Return a sorted list of all Bedrock model IDs in the pricing table."""
|
|
248
|
+
return sorted(BEDROCK_PRICING.keys())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Internal helpers
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _require_boto3() -> Any: # noqa: ANN401
|
|
257
|
+
"""Import and return the ``boto3`` module."""
|
|
258
|
+
try:
|
|
259
|
+
import boto3 # type: ignore[import-untyped] # noqa: PLC0415
|
|
260
|
+
except ImportError as exc:
|
|
261
|
+
raise ImportError(
|
|
262
|
+
"The 'boto3' package is required for spanforge Bedrock integration.\n"
|
|
263
|
+
"Install it with: pip install 'spanforge[bedrock]'"
|
|
264
|
+
) from exc
|
|
265
|
+
else:
|
|
266
|
+
return boto3
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _get_pricing(model_id: str) -> dict[str, float] | None:
|
|
270
|
+
"""Return the pricing entry for *model_id*, or ``None`` if unknown.
|
|
271
|
+
|
|
272
|
+
Performs exact match first, then tries without trailing version
|
|
273
|
+
suffixes like ``:0``, ``-v1:0``, etc.
|
|
274
|
+
"""
|
|
275
|
+
if model_id in BEDROCK_PRICING:
|
|
276
|
+
return BEDROCK_PRICING[model_id]
|
|
277
|
+
|
|
278
|
+
# Try stripping version suffix (:N or -vN:N)
|
|
279
|
+
base = model_id.split(":")[0] if ":" in model_id else model_id
|
|
280
|
+
for key in BEDROCK_PRICING:
|
|
281
|
+
if key.startswith(base):
|
|
282
|
+
return BEDROCK_PRICING[key]
|
|
283
|
+
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _compute_cost(
|
|
288
|
+
model_id: str,
|
|
289
|
+
input_tokens: int,
|
|
290
|
+
output_tokens: int,
|
|
291
|
+
) -> CostBreakdown:
|
|
292
|
+
"""Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
|
|
293
|
+
pricing = _get_pricing(model_id)
|
|
294
|
+
if pricing is None:
|
|
295
|
+
return CostBreakdown.zero()
|
|
296
|
+
|
|
297
|
+
input_cost = input_tokens * pricing["input"] / 1_000_000.0
|
|
298
|
+
output_cost = output_tokens * pricing["output"] / 1_000_000.0
|
|
299
|
+
total = input_cost + output_cost
|
|
300
|
+
|
|
301
|
+
return CostBreakdown(
|
|
302
|
+
input_cost_usd=input_cost,
|
|
303
|
+
output_cost_usd=output_cost,
|
|
304
|
+
total_cost_usd=total,
|
|
305
|
+
pricing_date=PRICING_DATE,
|
|
306
|
+
)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""spanforge.integrations.crewai — CrewAI event handler.
|
|
2
|
+
|
|
3
|
+
Provides :class:`SpanForgeCrewAIHandler`, a CrewAI-compatible event handler
|
|
4
|
+
that emits SpanForge trace events for agents, tasks, and tool calls.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from spanforge.integrations.crewai import SpanForgeCrewAIHandler, patch
|
|
9
|
+
|
|
10
|
+
# Option 1: register globally (auto-patches CrewAI internals)
|
|
11
|
+
patch()
|
|
12
|
+
|
|
13
|
+
# Option 2: attach to a specific crew
|
|
14
|
+
handler = SpanForgeCrewAIHandler()
|
|
15
|
+
crew = Crew(agents=[...], tasks=[...], callbacks=[handler])
|
|
16
|
+
|
|
17
|
+
The module imports cleanly even when CrewAI is not installed — the
|
|
18
|
+
:func:`patch` function guards with :func:`importlib.util.find_spec`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib.util
|
|
24
|
+
import time
|
|
25
|
+
import warnings
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = ["SpanForgeCrewAIHandler", "patch", "unpatch", "is_patched"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SpanForgeCrewAIHandler:
|
|
32
|
+
"""CrewAI callback handler that emits SpanForge trace events.
|
|
33
|
+
|
|
34
|
+
Manages ``SpanContextManager`` instances for active agents and tool calls,
|
|
35
|
+
records token usage and errors when available from CrewAI's output
|
|
36
|
+
objects, and emits structured SpanForge events on completion.
|
|
37
|
+
|
|
38
|
+
This handler follows the same pattern as
|
|
39
|
+
:class:`~spanforge.integrations.langchain.LLMSchemaCallbackHandler`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
# Map of agent_id/task_id → SpanContextManager so we can close the
|
|
44
|
+
# right span in the matching *_end callback.
|
|
45
|
+
self._agent_spans: dict[str, Any] = {}
|
|
46
|
+
self._tool_spans: dict[str, Any] = {}
|
|
47
|
+
self._task_spans: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Agent lifecycle
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def on_agent_action(
|
|
54
|
+
self,
|
|
55
|
+
agent: Any,
|
|
56
|
+
_task: Any,
|
|
57
|
+
tool: Any,
|
|
58
|
+
tool_input: Any,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Called when a CrewAI agent takes an action (tool invocation)."""
|
|
61
|
+
try:
|
|
62
|
+
from spanforge import tracer # noqa: PLC0415
|
|
63
|
+
|
|
64
|
+
tool_name = getattr(tool, "name", None) or str(tool)
|
|
65
|
+
key = f"{id(agent)}/{tool_name}/{time.time_ns()}"
|
|
66
|
+
cm = tracer.span(
|
|
67
|
+
tool_name,
|
|
68
|
+
operation="tool_call",
|
|
69
|
+
attributes={
|
|
70
|
+
"crewai.tool_input": str(tool_input)[:2048],
|
|
71
|
+
"crewai.agent": _agent_role(agent),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
span = cm.__enter__()
|
|
75
|
+
self._tool_spans[key] = (cm, span, key)
|
|
76
|
+
except Exception: # NOSONAR
|
|
77
|
+
pass # hook errors must never abort crew execution
|
|
78
|
+
|
|
79
|
+
def on_agent_finish(self, agent: Any, output: Any) -> None:
|
|
80
|
+
"""Called when a CrewAI agent finishes its assigned task."""
|
|
81
|
+
try:
|
|
82
|
+
key = str(id(agent))
|
|
83
|
+
entry = self._agent_spans.pop(key, None)
|
|
84
|
+
if entry is not None:
|
|
85
|
+
cm, span = entry
|
|
86
|
+
if hasattr(output, "return_values"):
|
|
87
|
+
span.set_attribute("crewai.output", str(output.return_values)[:2048])
|
|
88
|
+
cm.__exit__(None, None, None)
|
|
89
|
+
except Exception: # NOSONAR
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Tool lifecycle
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def on_tool_start(self, tool: Any, tool_input: Any) -> None:
|
|
97
|
+
"""Called when a CrewAI tool begins executing."""
|
|
98
|
+
try:
|
|
99
|
+
from spanforge import tracer # noqa: PLC0415
|
|
100
|
+
|
|
101
|
+
tool_name = getattr(tool, "name", None) or str(tool)
|
|
102
|
+
key = f"{id(tool)}/{tool_name}/{time.time_ns()}"
|
|
103
|
+
cm = tracer.span(
|
|
104
|
+
tool_name,
|
|
105
|
+
operation="tool_call",
|
|
106
|
+
attributes={"crewai.tool_input": str(tool_input)[:2048]},
|
|
107
|
+
)
|
|
108
|
+
span = cm.__enter__()
|
|
109
|
+
self._tool_spans[key] = (cm, span, key)
|
|
110
|
+
except Exception: # NOSONAR
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def on_tool_end(self, tool: Any, output: Any) -> None:
|
|
114
|
+
"""Called when a CrewAI tool finishes executing."""
|
|
115
|
+
try:
|
|
116
|
+
tool_name = getattr(tool, "name", None) or str(tool)
|
|
117
|
+
# Find the most recent open span for this tool name.
|
|
118
|
+
key = next(
|
|
119
|
+
(k for k in reversed(list(self._tool_spans)) if tool_name in k),
|
|
120
|
+
None,
|
|
121
|
+
)
|
|
122
|
+
if key is not None:
|
|
123
|
+
cm, span, _ = self._tool_spans.pop(key)
|
|
124
|
+
span.set_attribute("crewai.tool_output", str(output)[:2048])
|
|
125
|
+
cm.__exit__(None, None, None)
|
|
126
|
+
except Exception: # NOSONAR
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Task lifecycle
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def on_task_start(self, task: Any) -> None:
|
|
134
|
+
"""Called when a CrewAI task begins."""
|
|
135
|
+
try:
|
|
136
|
+
from spanforge import tracer # noqa: PLC0415
|
|
137
|
+
|
|
138
|
+
task_desc = _task_description(task)
|
|
139
|
+
key = str(id(task))
|
|
140
|
+
cm = tracer.span(
|
|
141
|
+
task_desc,
|
|
142
|
+
operation="invoke_agent",
|
|
143
|
+
attributes={"crewai.task": task_desc},
|
|
144
|
+
)
|
|
145
|
+
span = cm.__enter__()
|
|
146
|
+
self._task_spans[key] = (cm, span)
|
|
147
|
+
except Exception: # NOSONAR
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def on_task_end(self, task: Any, output: Any) -> None:
|
|
151
|
+
"""Called when a CrewAI task completes."""
|
|
152
|
+
try:
|
|
153
|
+
key = str(id(task))
|
|
154
|
+
entry = self._task_spans.pop(key, None)
|
|
155
|
+
if entry is not None:
|
|
156
|
+
cm, span = entry
|
|
157
|
+
if output is not None:
|
|
158
|
+
span.set_attribute("crewai.task_output", str(output)[:2048])
|
|
159
|
+
cm.__exit__(None, None, None)
|
|
160
|
+
except Exception: # NOSONAR
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Helpers
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _agent_role(agent: Any) -> str:
|
|
170
|
+
return str(getattr(agent, "role", None) or getattr(agent, "name", None) or "unknown-agent")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _task_description(task: Any) -> str:
|
|
174
|
+
desc = getattr(task, "description", None) or getattr(task, "name", None) or "crewai-task"
|
|
175
|
+
return str(desc)[:120]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# patch() — convenience auto-registration
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def patch() -> None:
|
|
184
|
+
"""Auto-register :class:`SpanForgeCrewAIHandler` with CrewAI callbacks.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
ImportError: If ``crewai`` is not installed.
|
|
188
|
+
RuntimeError: If the CrewAI callback API cannot be located.
|
|
189
|
+
"""
|
|
190
|
+
if importlib.util.find_spec("crewai") is None:
|
|
191
|
+
raise ImportError(
|
|
192
|
+
"CrewAI package is required for the spanforge CrewAI integration.\n"
|
|
193
|
+
"Install it with: pip install 'spanforge[crewai]'"
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
import crewai # noqa: PLC0415, F401
|
|
197
|
+
# CrewAI exposes a global callbacks list in some versions.
|
|
198
|
+
if hasattr(crewai, "callbacks") and isinstance(crewai.callbacks, list):
|
|
199
|
+
handler = SpanForgeCrewAIHandler()
|
|
200
|
+
crewai.callbacks.append(handler)
|
|
201
|
+
crewai._spanforge_patched = True # type: ignore[attr-defined]
|
|
202
|
+
return
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
warnings.warn(
|
|
205
|
+
f"spanforge: could not auto-patch CrewAI callbacks: {exc}\n"
|
|
206
|
+
"Attach SpanForgeCrewAIHandler manually instead.",
|
|
207
|
+
stacklevel=2,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_PATCH_FLAG = "_spanforge_patched"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def unpatch() -> None:
|
|
215
|
+
"""Remove the :class:`SpanForgeCrewAIHandler` from CrewAI global callbacks.
|
|
216
|
+
|
|
217
|
+
Safe to call even if :func:`patch` was never called.
|
|
218
|
+
"""
|
|
219
|
+
if importlib.util.find_spec("crewai") is None:
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
import crewai # noqa: PLC0415, F401
|
|
223
|
+
|
|
224
|
+
if not getattr(crewai, _PATCH_FLAG, False):
|
|
225
|
+
return
|
|
226
|
+
if hasattr(crewai, "callbacks") and isinstance(crewai.callbacks, list):
|
|
227
|
+
crewai.callbacks[:] = [
|
|
228
|
+
cb for cb in crewai.callbacks
|
|
229
|
+
if not isinstance(cb, SpanForgeCrewAIHandler)
|
|
230
|
+
]
|
|
231
|
+
try:
|
|
232
|
+
del crewai._spanforge_patched # type: ignore[attr-defined]
|
|
233
|
+
except AttributeError:
|
|
234
|
+
pass
|
|
235
|
+
except Exception: # NOSONAR
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_patched() -> bool:
|
|
240
|
+
"""Return ``True`` if CrewAI has been patched by :func:`patch`.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
``True`` when the spanforge handler is registered; ``False`` otherwise.
|
|
244
|
+
"""
|
|
245
|
+
if importlib.util.find_spec("crewai") is None:
|
|
246
|
+
return False
|
|
247
|
+
try:
|
|
248
|
+
import crewai # noqa: PLC0415, F401
|
|
249
|
+
return bool(getattr(crewai, _PATCH_FLAG, False))
|
|
250
|
+
except Exception: # NOSONAR
|
|
251
|
+
return False
|