Skip to content

Commit ebe0082

Browse files
joaomdmouraclaude
andauthored
feat(tracing): collect skill usage events (#6727)
* feat(tracing): collect skill usage events PR #6652 added SkillUsedEvent but deliberately shipped no listener wiring, so the event reached no collector. The trace listener subscribed to the five setup events -- discovery, load, activation, failure -- and none of them can answer the question skills observability is for: activation is idempotent and fires once at setup, so an agent using a skill across twenty turns produces exactly one event. SkillUsedEvent is the only runtime signal and the only one that re-fires per execution. Subscribing to it lets a trace attribute skill usage to an agent and a task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): scope the trace-listener handlers, assert the forwarded event CrewAIEventsBus is a singleton, so constructing one in the fixture still registered against the process-wide bus. _register_action_event_handlers attached every action handler with no cleanup, leaving them live after the patch ended -- firing against a listener built with __new__, which has no batch_manager, in whatever test ran next. scoped_handlers clears them. Also assert the event object itself is forwarded, not just its type: the collector serializes the event, so dropping or replacing it would lose every attribution field while still passing a type-only check. Both raised in review on #6727. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: assert the forwarded skill event by identity Comparing field values would still pass if a handler forwarded a reconstructed copy rather than the event itself. Bind the event and assert `forwarded is event`. Raised in review on #6727. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3266932 commit ebe0082

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
SkillDiscoveryStartedEvent,
120120
SkillLoadFailedEvent,
121121
SkillLoadedEvent,
122+
SkillUsedEvent,
122123
)
123124
from crewai.events.types.system_events import SignalEvent, on_signal
124125
from crewai.events.types.task_events import (
@@ -609,6 +610,13 @@ def on_skill_activated(source: Any, event: SkillActivatedEvent) -> None:
609610
def on_skill_load_failed(source: Any, event: SkillLoadFailedEvent) -> None:
610611
self._handle_action_event("skill_load_failed", source, event)
611612

613+
@event_bus.on(SkillUsedEvent)
614+
def on_skill_used(source: Any, event: SkillUsedEvent) -> None:
615+
# The other five describe setup; this is the only one that says a
616+
# skill was actually used, and the only one that re-fires per
617+
# execution. Without it a trace cannot attribute usage to a task.
618+
self._handle_action_event("skill_used", source, event)
619+
612620
def _register_a2a_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
613621
"""Register handlers for A2A (Agent-to-Agent) events."""
614622

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Skill usage must reach the trace collector.
2+
3+
The five setup events (discovery, load, activation, failure) describe how an
4+
agent was configured and fire once. ``SkillUsedEvent`` is the only runtime
5+
signal -- it re-fires on every execution -- so without it a trace cannot say
6+
which skills an agent actually used, on which task, or how often.
7+
"""
8+
9+
from pathlib import Path
10+
from unittest.mock import patch
11+
12+
from crewai.events.event_bus import crewai_event_bus
13+
from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener
14+
from crewai.events.types.skill_events import (
15+
SkillActivatedEvent,
16+
SkillUsedEvent,
17+
)
18+
import pytest
19+
20+
21+
@pytest.fixture
22+
def registered_listener():
23+
"""A listener wired to the bus, with event handling captured.
24+
25+
``scoped_handlers`` is required, not tidiness: ``CrewAIEventsBus`` is a
26+
singleton, so registering on a locally constructed one still mutates the
27+
process-wide bus. Without the scope these handlers outlive the test and
28+
fire against a listener built with ``__new__`` -- no ``batch_manager`` --
29+
in whatever runs next.
30+
"""
31+
listener = TraceCollectionListener.__new__(TraceCollectionListener)
32+
33+
with (
34+
crewai_event_bus.scoped_handlers(),
35+
patch.object(TraceCollectionListener, "_handle_action_event") as handled,
36+
):
37+
listener._register_action_event_handlers(crewai_event_bus)
38+
yield crewai_event_bus, handled
39+
40+
41+
def _event_types(handled) -> list[str]:
42+
return [call.args[0] for call in handled.call_args_list]
43+
44+
45+
def _events_of_type(handled, event_type: str) -> list:
46+
"""The event objects forwarded for one collected type."""
47+
return [call.args[2] for call in handled.call_args_list if call.args[0] == event_type]
48+
49+
50+
class TestSkillUsedIsCollected:
51+
def test_skill_used_reaches_the_collector(self, registered_listener):
52+
bus, handled = registered_listener
53+
54+
bus.emit(
55+
None,
56+
SkillUsedEvent(
57+
skill_name="pdf-processing",
58+
skill_path=Path("/skills/pdf-processing"),
59+
),
60+
)
61+
bus.flush()
62+
63+
assert "skill_used" in _event_types(handled), (
64+
"SkillUsedEvent was emitted but the trace listener ignored it"
65+
)
66+
67+
def test_the_event_itself_is_forwarded_intact(self, registered_listener):
68+
"""The type alone is not enough -- the collector serializes the event,
69+
so dropping or replacing it would lose every attribution field.
70+
71+
Asserted by identity: comparing field values would still pass if a
72+
handler forwarded a reconstructed copy.
73+
"""
74+
bus, handled = registered_listener
75+
event = SkillUsedEvent(
76+
skill_name="pdf-processing",
77+
skill_path=Path("/skills/pdf-processing"),
78+
)
79+
80+
bus.emit(None, event)
81+
bus.flush()
82+
83+
[forwarded] = _events_of_type(handled, "skill_used")
84+
assert forwarded is event
85+
86+
def test_every_use_is_collected(self, registered_listener):
87+
"""Activation is idempotent; usage is not. One event per use."""
88+
bus, handled = registered_listener
89+
90+
for _ in range(3):
91+
bus.emit(None, SkillUsedEvent(skill_name="pdf-processing"))
92+
bus.flush()
93+
94+
assert _event_types(handled).count("skill_used") == 3
95+
96+
def test_setup_events_are_still_collected(self, registered_listener):
97+
bus, handled = registered_listener
98+
99+
bus.emit(None, SkillActivatedEvent(skill_name="pdf-processing"))
100+
bus.flush()
101+
102+
assert "skill_activated" in _event_types(handled)

0 commit comments

Comments
 (0)