agent-observability-trace-cli 0.1.2__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 (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. agent_trace/py.typed +0 -0
@@ -0,0 +1,486 @@
1
+ """
2
+ crewAI integration — event-bus listener and span enrichment.
3
+
4
+ Subscribes to crewAI's process-wide event bus (``crewai.events.crewai_event_bus``)
5
+ and emits agent-trace spans for each crew kickoff, agent execution, task, LLM
6
+ call, and tool invocation — the crewAI-native equivalent of what
7
+ ``LangGraphTracer`` does for LangGraph's callback-handler list and
8
+ ``AgentTraceHook`` does for the OpenAI Agents SDK's hooks interface.
9
+
10
+ Unlike those two frameworks, crewAI has no per-invocation "pass a callback
11
+ object to this call" extension point. Instead it exposes a global event bus
12
+ (confirmed via a live install of ``crewai==1.15.2``:
13
+ ``crewai/events/event_bus.py``) that every ``Crew``/``Agent``/``Task``/``LLM``
14
+ emits onto via ``crewai_event_bus.emit(source, event)``. Handlers register with
15
+ ``crewai_event_bus.register_handler(event_type, handler)`` and receive
16
+ ``(source, event)`` — ``source`` is the emitting object itself (the ``Crew``,
17
+ ``Agent``, or ``LLM`` instance), and ``event`` is a typed Pydantic event model
18
+ (``crewai.events.types.*``).
19
+
20
+ Span pairing uses crewAI's own event-scope bookkeeping rather than an ad hoc
21
+ run-id: every "started" event type in this integration is one of crewAI's
22
+ ``SCOPE_STARTING_EVENTS`` (``crewai/events/event_context.py``), and its
23
+ matching "completed"/"failed"/"error" event is a ``SCOPE_ENDING_EVENT`` whose
24
+ ``started_event_id`` field is set (by ``CrewAIEventsBus._prepare_event``) to
25
+ the ``event_id`` of the started event it closes. Spans are therefore keyed by
26
+ ``event.event_id`` on open and looked up by ``event.started_event_id`` on
27
+ close — the same mechanism crewAI itself uses internally to validate
28
+ start/end pairing. Parent/child nesting likewise reuses crewAI's own
29
+ ``event.parent_event_id`` (the enclosing open scope's ``event_id`` at
30
+ emission time), so a task span opened while a crew-kickoff span is open is
31
+ automatically parented under it with no bookkeeping of our own.
32
+
33
+ Usage:
34
+ from agent_trace import Tracer
35
+ from agent_trace.integrations.crewai import CrewAITracer
36
+
37
+ t = Tracer()
38
+ with t.start_trace("my_crew", record=True) as trace:
39
+ with CrewAITracer(tracer=t, trace=trace):
40
+ result = crew.kickoff()
41
+
42
+ Known limitation — process-wide event bus:
43
+ Because ``crewai_event_bus`` is a process-wide singleton, a
44
+ ``CrewAITracer`` instance receives events from *every* Crew/Agent/Task run
45
+ in the process while its handlers are registered — not just the run
46
+ wrapped by the enclosing ``start_trace()`` block. If your process runs
47
+ multiple concurrent ``crew.kickoff()`` calls, a single ``CrewAITracer``
48
+ will see spans from all of them interleaved into one trace. This mirrors
49
+ the concurrent-recording isolation gap already tracked in this repo for
50
+ ``Tracer._install_recording_transport`` — treat both as "one in-flight
51
+ run per process" until addressed. Always use ``CrewAITracer`` as a context
52
+ manager (or call ``.close()`` explicitly) so handlers are unregistered
53
+ when the trace ends; leaving it open leaks handlers onto the shared bus
54
+ for the remaining lifetime of the process.
55
+
56
+ Known limitation — parent linkage can be dropped under concurrent handler
57
+ dispatch:
58
+ Confirmed via a live end-to-end run (``examples/04-crewai-research-crew``)
59
+ against a real (deliberately invalid) OpenAI key: ``CrewAIEventsBus.emit``
60
+ computes ``event.parent_event_id`` synchronously on the emitting thread
61
+ (correct and stable by the time any handler sees it), but *dispatches*
62
+ each registered sync handler via a 10-worker ``ThreadPoolExecutor``
63
+ (``crewai/events/event_bus.py``) with no ordering guarantee between
64
+ handlers for different events. A child event's handler (e.g.
65
+ ``llm_call_started``) can therefore run — and look up its parent span in
66
+ ``self._spans`` — *before* the parent event's handler (e.g.
67
+ ``agent_execution_started``) has finished registering that span,
68
+ especially under an agent's internal retry loop, which fires several
69
+ started/error pairs in quick succession. When this race is lost the
70
+ child span still opens, closes, and records errors correctly — only its
71
+ ``parent_id`` falls back to ``None`` (a flat span instead of a nested
72
+ one) rather than being silently dropped or mis-attached to the wrong
73
+ parent.
74
+
75
+ Known race (fixed) — a "close" event's handler can run before its own
76
+ "open" event's handler:
77
+ The same unordered ``ThreadPoolExecutor`` dispatch above has a second,
78
+ more severe consequence, reproduced live (a scripted, fully-offline
79
+ ``BaseLLM`` making two sequential calls within one ReAct loop):
80
+ ``llm_call_completed``'s handler was observed running — and printing its
81
+ debug line — *before* the matching ``llm_call_started``'s handler for
82
+ the very same call had run at all, on a different pool worker thread.
83
+ Naively, this would mean the "started" handler eventually creates the
84
+ span (via ``_open_span``) but no "completed" handler is left to ever
85
+ close it — a span silently, permanently stuck at ``SpanStatus.UNSET``
86
+ with ``end_time=None``, worse than the parent-linkage degradation above
87
+ since a genuinely open span (not just a flat one) sits in the trace
88
+ forever. ``_open_span``/``_close_span``/``_close_span_with_error`` below
89
+ handle this: a close that finds no span yet is stashed in
90
+ ``self._pending_closes`` instead of being dropped, and ``_open_span``
91
+ checks (and immediately applies) any pending close for its own key the
92
+ moment it does run — so the span still always ends up correctly closed,
93
+ regardless of which of the pair's two handlers happens to run first.
94
+
95
+ Known limitation (fixed by ``close()``) — ``kickoff()`` returns before its
96
+ own last events are processed:
97
+ Confirmed via a live, fully-offline ``crew.kickoff()`` reproduction (a
98
+ custom ``BaseLLM`` subclass returning canned responses, no network
99
+ calls): because ``crewai_event_bus.emit()`` dispatches sync handlers onto
100
+ a background ``ThreadPoolExecutor`` and nothing in crewAI's own call
101
+ chain awaits the returned ``Future``, ``Crew.kickoff()`` routinely
102
+ returns to its caller *before* the handlers for its own final events
103
+ (typically the last ``llm_call_completed``/``agent_execution_completed``/
104
+ ``crew_kickoff_completed`` trio) have finished running — reproduced with
105
+ spans still ``SpanStatus.UNSET`` (open) immediately after ``kickoff()``
106
+ returned, which then closed correctly a moment later on a background
107
+ thread. ``CrewAITracer.close()`` calls ``crewai_event_bus.flush()``
108
+ (crewAI's own public API for exactly this "wait for pending handlers at
109
+ the end of an operation like kickoff" case) before unregistering, so a
110
+ caller using the documented ``with CrewAITracer(...): crew.kickoff()``
111
+ pattern is guaranteed a fully-closed span tree by the time that ``with``
112
+ block exits — do not skip calling ``close()`` (e.g. by holding a
113
+ ``CrewAITracer`` open across multiple ``kickoff()`` calls without ever
114
+ exiting it) if you need the trace to be complete before you read or
115
+ export it.
116
+ """
117
+
118
+ from __future__ import annotations
119
+
120
+ import logging
121
+ import threading
122
+ from collections.abc import Callable
123
+ from typing import TYPE_CHECKING, Any
124
+
125
+ from agent_trace.core.span import Span, SpanStatus
126
+
127
+ if TYPE_CHECKING:
128
+ from agent_trace import Trace, Tracer
129
+
130
+ __all__ = ["CrewAITracer"]
131
+
132
+ logger = logging.getLogger(__name__)
133
+
134
+ _INSTALL_HINT = (
135
+ "CrewAITracer requires crewai.\n"
136
+ "Install it with:\n\n"
137
+ " pip install crewai\n"
138
+ )
139
+
140
+
141
+ def _require_crewai_events() -> Any:
142
+ """Lazy import guard — raises a clear error if crewai is absent."""
143
+ try:
144
+ import crewai.events as crewai_events
145
+
146
+ return crewai_events
147
+ except ImportError as exc:
148
+ raise ImportError(_INSTALL_HINT) from exc
149
+
150
+
151
+ class CrewAITracer:
152
+ """Registers agent-trace span handlers on crewAI's global event bus.
153
+
154
+ Parameters
155
+ ----------
156
+ tracer:
157
+ The active :class:`~agent_trace.Tracer` instance.
158
+ trace:
159
+ The :class:`~agent_trace.Trace` that spans will be registered on.
160
+
161
+ ``crewai`` is imported lazily — importing this module succeeds even when
162
+ ``crewai`` is not installed; only constructing ``CrewAITracer`` requires
163
+ it.
164
+
165
+ Use as a context manager (or call :meth:`close`) so handlers are removed
166
+ from the shared event bus once the traced run finishes::
167
+
168
+ with CrewAITracer(tracer=t, trace=trace):
169
+ crew.kickoff()
170
+ """
171
+
172
+ def __init__(self, tracer: Tracer, trace: Trace) -> None:
173
+ events = _require_crewai_events()
174
+
175
+ self._tracer: Tracer = tracer
176
+ self._trace: Trace = trace
177
+ # Span registry keyed by the crewAI event_id of the *_started event
178
+ # that opened the span (see module docstring — this mirrors crewAI's
179
+ # own started_event_id pairing rather than inventing a new run-id).
180
+ self._spans: dict[str, Span] = {}
181
+ # Keyed by the *_started event's event_id, same as self._spans.
182
+ # Holds a (status, finisher) pair for a "close" event that arrived
183
+ # before its matching "open" event's handler had a chance to run —
184
+ # see the module docstring's "close arrives before its own open"
185
+ # known-and-fixed race for why this exists. Consumed (and popped)
186
+ # the moment the matching _open_span() call for that key runs.
187
+ self._pending_closes: dict[str, tuple[SpanStatus, Callable[[Span], None]]] = {}
188
+ self._lock: threading.Lock = threading.Lock()
189
+ self._bus = events.crewai_event_bus
190
+ self._registered: list[tuple[type, Any]] = []
191
+ self._closed = False
192
+
193
+ self._register(events.CrewKickoffStartedEvent, self._on_crew_started)
194
+ self._register(events.CrewKickoffCompletedEvent, self._on_crew_completed)
195
+ self._register(events.CrewKickoffFailedEvent, self._on_crew_failed)
196
+
197
+ self._register(events.AgentExecutionStartedEvent, self._on_agent_started)
198
+ self._register(events.AgentExecutionCompletedEvent, self._on_agent_completed)
199
+ self._register(events.AgentExecutionErrorEvent, self._on_agent_error)
200
+
201
+ self._register(events.TaskStartedEvent, self._on_task_started)
202
+ self._register(events.TaskCompletedEvent, self._on_task_completed)
203
+ self._register(events.TaskFailedEvent, self._on_task_failed)
204
+
205
+ self._register(events.LLMCallStartedEvent, self._on_llm_started)
206
+ self._register(events.LLMCallCompletedEvent, self._on_llm_completed)
207
+ self._register(events.LLMCallFailedEvent, self._on_llm_failed)
208
+
209
+ self._register(events.ToolUsageStartedEvent, self._on_tool_started)
210
+ self._register(events.ToolUsageFinishedEvent, self._on_tool_finished)
211
+ self._register(events.ToolUsageErrorEvent, self._on_tool_error)
212
+
213
+ # ------------------------------------------------------------------
214
+ # Registration / lifecycle
215
+ # ------------------------------------------------------------------
216
+
217
+ def _register(self, event_type: type, handler: Any) -> None:
218
+ self._bus.register_handler(event_type, handler)
219
+ self._registered.append((event_type, handler))
220
+
221
+ def close(self) -> None:
222
+ """Flush pending event-bus work, then unregister all handlers from
223
+ the shared crewAI event bus.
224
+
225
+ Confirmed via a live, fully-offline ``crew.kickoff()`` reproduction
226
+ (a custom ``BaseLLM`` subclass, no network): ``crewai_event_bus.emit()``
227
+ dispatches sync handlers onto a background ``ThreadPoolExecutor`` and
228
+ returns a ``Future`` that nothing in crewAI's own call chain awaits —
229
+ ``Crew.kickoff()`` itself returns to its caller *before* the handlers
230
+ for its own last few events (typically the final ``llm_call_completed``/
231
+ ``agent_execution_completed``/``crew_kickoff_completed`` trio) have
232
+ necessarily finished running. Without draining that queue here, a
233
+ caller doing::
234
+
235
+ with CrewAITracer(tracer=t, trace=trace):
236
+ result = crew.kickoff()
237
+ # trace.spans / trace.json written right here could still show
238
+ # dangling open llm:/tool: spans that close moments later,
239
+ # invisibly, on a background thread.
240
+
241
+ would non-deterministically get an incomplete trace — worse, a
242
+ *silently* incomplete one, since every span that does eventually
243
+ close still ends up correct; only the *timing* relative to
244
+ ``kickoff()`` returning is wrong. ``crewai_event_bus.flush()`` (a
245
+ public method documented for exactly this "at the end of operations
246
+ like kickoff" use case) blocks until every pending handler future
247
+ bus-wide has completed, so calling it here — before unregistering —
248
+ guarantees every event this instance's handlers were going to see for
249
+ the just-completed run has, in fact, been seen by the time ``close()``
250
+ returns.
251
+
252
+ Safe to call more than once. After ``close()``, this instance no
253
+ longer receives crewAI events; any span that is still open at that
254
+ point (e.g. because the wrapped run itself was cut off, such as by
255
+ an unhandled exception outside crewAI's own try/except) is simply
256
+ left open, matching what LangGraph's ``on_chain_end`` not firing
257
+ would look like.
258
+ """
259
+ if self._closed:
260
+ return
261
+ self._closed = True
262
+ self._bus.flush()
263
+ for event_type, handler in self._registered:
264
+ self._bus.off(event_type, handler)
265
+ self._registered.clear()
266
+
267
+ def __enter__(self) -> CrewAITracer:
268
+ return self
269
+
270
+ def __exit__(self, *exc_info: object) -> None:
271
+ self.close()
272
+
273
+ # ------------------------------------------------------------------
274
+ # Internal span helpers (same pattern as LangGraphTracer / AgentTraceHook)
275
+ # ------------------------------------------------------------------
276
+
277
+ def _open_span(
278
+ self,
279
+ key: str,
280
+ name: str,
281
+ parent_key: str | None = None,
282
+ ) -> Span:
283
+ """Open a span for *key* (a ``*_started`` event's ``event_id``).
284
+
285
+ If a ``_close_span``/``_close_span_with_error`` call for this exact
286
+ *key* already ran and found nothing to close (see
287
+ ``self._pending_closes`` and the module docstring's "close arrives
288
+ before its own open" race), that pending close is applied
289
+ immediately here — the span still goes through a real open-then-close
290
+ transition, just compressed into this one call, instead of being
291
+ left open forever because the handler that would have closed it
292
+ already ran and gave up.
293
+ """
294
+ parent_span_id: str | None = None
295
+ if parent_key is not None:
296
+ with self._lock:
297
+ parent_span = self._spans.get(parent_key)
298
+ if parent_span is not None:
299
+ parent_span_id = parent_span.span_id
300
+
301
+ span = self._tracer.start_span(name, parent_id=parent_span_id)
302
+ with self._lock:
303
+ pending = self._pending_closes.pop(key, None)
304
+ if pending is None:
305
+ self._spans[key] = span
306
+
307
+ if pending is not None:
308
+ status, finisher = pending
309
+ finisher(span)
310
+ if span.end_time is None:
311
+ span.end(status)
312
+ return span
313
+
314
+ def _close_span(
315
+ self,
316
+ key: str,
317
+ status: SpanStatus = SpanStatus.OK,
318
+ finisher: Callable[[Span], None] | None = None,
319
+ ) -> Span | None:
320
+ """Close the span opened under *key*, applying *finisher* (if given)
321
+ before ending it — attributes are always set on a still-open span,
322
+ never after ``end()`` (``Span`` documents post-``end()`` mutation as
323
+ undefined for exporters).
324
+
325
+ If no span is registered under *key* yet — the ``_open_span`` call
326
+ that would have created it hasn't run yet, confirmed via a live,
327
+ fully-offline reproduction to be a real, reproducible race in
328
+ crewAI's own thread-pool handler dispatch, not a hypothetical one —
329
+ the close is stashed in ``self._pending_closes`` instead of being
330
+ silently dropped, so ``_open_span`` can apply it retroactively the
331
+ moment it does run.
332
+ """
333
+ with self._lock:
334
+ span = self._spans.pop(key, None)
335
+ if span is None:
336
+ self._pending_closes[key] = (status, finisher or (lambda _span: None))
337
+ if span is not None:
338
+ if finisher is not None:
339
+ finisher(span)
340
+ if span.end_time is None:
341
+ span.end(status)
342
+ return span
343
+
344
+ def _close_span_with_error(self, key: str | None, error_message: str) -> None:
345
+ """Close the span opened under *key* (if the started_event_id key is
346
+ known) as ERROR, recording *error_message* as an exception event —
347
+ same open-before-close-arrives handling as ``_close_span``.
348
+
349
+ crewAI's failure events carry ``error: str`` rather than a raised
350
+ exception object, so a synthetic ``RuntimeError`` is used to satisfy
351
+ ``Span.record_exception``'s ``BaseException`` signature.
352
+ """
353
+ if key is None:
354
+ return
355
+
356
+ def finisher(span: Span) -> None:
357
+ span.record_exception(RuntimeError(error_message))
358
+
359
+ self._close_span(key, SpanStatus.ERROR, finisher=finisher)
360
+
361
+ # ------------------------------------------------------------------
362
+ # Crew kickoff
363
+ # ------------------------------------------------------------------
364
+
365
+ def _on_crew_started(self, source: Any, event: Any) -> None:
366
+ crew_name = event.crew_name or "crew"
367
+ span = self._open_span(
368
+ event.event_id, f"crew:{crew_name}", event.parent_event_id
369
+ )
370
+ span.set_attribute("crew.name", crew_name)
371
+
372
+ def _on_crew_completed(self, source: Any, event: Any) -> None:
373
+ def finisher(span: Span) -> None:
374
+ try:
375
+ span.set_attribute("crew.total_tokens", int(event.total_tokens or 0))
376
+ except Exception:
377
+ logger.debug(
378
+ "agent-trace: failed to record crew total_tokens", exc_info=True
379
+ )
380
+
381
+ self._close_span(event.started_event_id, SpanStatus.OK, finisher=finisher)
382
+
383
+ def _on_crew_failed(self, source: Any, event: Any) -> None:
384
+ self._close_span_with_error(event.started_event_id, event.error)
385
+
386
+ # ------------------------------------------------------------------
387
+ # Agent execution
388
+ # ------------------------------------------------------------------
389
+
390
+ def _on_agent_started(self, source: Any, event: Any) -> None:
391
+ agent_role = getattr(event.agent, "role", None) or "agent"
392
+ span = self._open_span(
393
+ event.event_id, f"agent:{agent_role}", event.parent_event_id
394
+ )
395
+ span.set_attribute("agent.role", agent_role)
396
+
397
+ def _on_agent_completed(self, source: Any, event: Any) -> None:
398
+ self._close_span(event.started_event_id, SpanStatus.OK)
399
+
400
+ def _on_agent_error(self, source: Any, event: Any) -> None:
401
+ self._close_span_with_error(event.started_event_id, event.error)
402
+
403
+ # ------------------------------------------------------------------
404
+ # Task
405
+ # ------------------------------------------------------------------
406
+
407
+ def _on_task_started(self, source: Any, event: Any) -> None:
408
+ task_name = event.task_name or "task"
409
+ span = self._open_span(
410
+ event.event_id, f"task:{task_name}", event.parent_event_id
411
+ )
412
+ span.set_attribute("task.name", task_name)
413
+ if event.task_id:
414
+ span.set_attribute("task.id", event.task_id)
415
+
416
+ def _on_task_completed(self, source: Any, event: Any) -> None:
417
+ self._close_span(event.started_event_id, SpanStatus.OK)
418
+
419
+ def _on_task_failed(self, source: Any, event: Any) -> None:
420
+ self._close_span_with_error(event.started_event_id, event.error)
421
+
422
+ # ------------------------------------------------------------------
423
+ # LLM calls
424
+ # ------------------------------------------------------------------
425
+
426
+ def _on_llm_started(self, source: Any, event: Any) -> None:
427
+ model = event.model or "unknown"
428
+ span = self._open_span(event.event_id, f"llm:{model}", event.parent_event_id)
429
+ span.set_attribute("llm.model", model)
430
+
431
+ def _on_llm_completed(self, source: Any, event: Any) -> None:
432
+ def finisher(span: Span) -> None:
433
+ try:
434
+ usage = event.usage or {}
435
+ if usage.get("prompt_tokens") is not None:
436
+ span.set_attribute(
437
+ "llm.usage.prompt_tokens", int(usage["prompt_tokens"])
438
+ )
439
+ if usage.get("completion_tokens") is not None:
440
+ span.set_attribute(
441
+ "llm.usage.completion_tokens", int(usage["completion_tokens"])
442
+ )
443
+ if usage.get("total_tokens") is not None:
444
+ span.set_attribute(
445
+ "llm.usage.total_tokens", int(usage["total_tokens"])
446
+ )
447
+ if event.finish_reason:
448
+ span.set_attribute("llm.finish_reason", event.finish_reason)
449
+ except Exception:
450
+ logger.debug(
451
+ "agent-trace: failed to record token usage for crewAI LLM call",
452
+ exc_info=True,
453
+ )
454
+
455
+ self._close_span(event.started_event_id, SpanStatus.OK, finisher=finisher)
456
+
457
+ def _on_llm_failed(self, source: Any, event: Any) -> None:
458
+ self._close_span_with_error(event.started_event_id, event.error)
459
+
460
+ # ------------------------------------------------------------------
461
+ # Tool usage
462
+ # ------------------------------------------------------------------
463
+
464
+ def _on_tool_started(self, source: Any, event: Any) -> None:
465
+ tool_name = event.tool_name or "tool"
466
+ span = self._open_span(
467
+ event.event_id, f"tool:{tool_name}", event.parent_event_id
468
+ )
469
+ span.set_attribute("tool.name", tool_name)
470
+
471
+ def _on_tool_finished(self, source: Any, event: Any) -> None:
472
+ def finisher(span: Span) -> None:
473
+ try:
474
+ span.set_attribute(
475
+ "tool.output_length",
476
+ len(str(event.output)) if event.output is not None else 0,
477
+ )
478
+ except Exception:
479
+ logger.debug(
480
+ "agent-trace: failed to record tool output length", exc_info=True
481
+ )
482
+
483
+ self._close_span(event.started_event_id, SpanStatus.OK, finisher=finisher)
484
+
485
+ def _on_tool_error(self, source: Any, event: Any) -> None:
486
+ self._close_span_with_error(event.started_event_id, str(event.error))