diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 1ee18bc4f..d4b6a2d3f 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -54,6 +54,7 @@ from crewai.events.types.flow_events import ( MethodExecutionPausedEvent, MethodExecutionStartedEvent, ) +from crewai.events.types.hook_events import HookDispatchedEvent from crewai.events.types.knowledge_events import ( KnowledgeQueryCompletedEvent, KnowledgeQueryFailedEvent, @@ -875,5 +876,12 @@ class EventListener(BaseEventListener): if has_hooks: self._telemetry.feature_usage_span("hooks:registered") + @crewai_event_bus.on(HookDispatchedEvent) + def on_hook_dispatched(_: Any, event: HookDispatchedEvent) -> None: + self._telemetry.hook_dispatched_span( + interception_point=event.interception_point, + outcome=event.outcome, + ) + event_listener = EventListener() diff --git a/lib/crewai/src/crewai/telemetry/telemetry.py b/lib/crewai/src/crewai/telemetry/telemetry.py index b952f3756..ddc5bcfd3 100644 --- a/lib/crewai/src/crewai/telemetry/telemetry.py +++ b/lib/crewai/src/crewai/telemetry/telemetry.py @@ -1148,7 +1148,8 @@ class Telemetry: Args: feature: Feature identifier, e.g. "planning:creation", - "mcp:connection", "a2a:delegation". + "mcp:connection", "a2a:delegation", + "hooks:pre_tool_call", "hooks:aborted". """ def _operation() -> None: @@ -1160,6 +1161,21 @@ class Telemetry: self._safe_telemetry_operation(_operation) + def hook_dispatched_span( + self, + interception_point: str, + outcome: str, + ) -> None: + """Records an interception-hook dispatch via Feature Usage. + + Emits ``hooks:`` on every dispatch, plus ``hooks:aborted`` when + a hook aborted the operation (e.g. a policy check). No reasons, + payloads, or other user content are recorded. + """ + self.feature_usage_span(f"hooks:{interception_point}") + if outcome == "aborted": + self.feature_usage_span("hooks:aborted") + def coding_agent_span(self) -> None: """Records which AI coding assistant (if any) is running this process. diff --git a/lib/crewai/tests/telemetry/test_telemetry.py b/lib/crewai/tests/telemetry/test_telemetry.py index eed582421..5b565371f 100644 --- a/lib/crewai/tests/telemetry/test_telemetry.py +++ b/lib/crewai/tests/telemetry/test_telemetry.py @@ -230,3 +230,73 @@ def test_no_signal_handler_traceback_in_non_main_thread(): mock_holder["logger"].debug.assert_any_call( "Skipping signal handler registration: not running in main thread" ) + + +def test_hook_dispatched_span_counts_point_usage(): + with ( + patch.dict( + os.environ, + { + "CREWAI_DISABLE_TELEMETRY": "false", + "CREWAI_DISABLE_TRACKING": "false", + "OTEL_SDK_DISABLED": "false", + }, + ), + patch("crewai.telemetry.telemetry.TracerProvider"), + ): + telemetry = Telemetry() + with patch.object(telemetry, "feature_usage_span") as feature_usage_span: + telemetry.hook_dispatched_span("pre_tool_call", "proceeded") + + feature_usage_span.assert_called_once_with("hooks:pre_tool_call") + + +def test_hook_dispatched_span_counts_aborts(): + with ( + patch.dict( + os.environ, + { + "CREWAI_DISABLE_TELEMETRY": "false", + "CREWAI_DISABLE_TRACKING": "false", + "OTEL_SDK_DISABLED": "false", + }, + ), + patch("crewai.telemetry.telemetry.TracerProvider"), + ): + telemetry = Telemetry() + with patch.object(telemetry, "feature_usage_span") as feature_usage_span: + telemetry.hook_dispatched_span("pre_tool_call", "aborted") + + feature_usage_span.assert_any_call("hooks:pre_tool_call") + feature_usage_span.assert_any_call("hooks:aborted") + assert feature_usage_span.call_count == 2 + + +def test_event_listener_tracks_hook_dispatched_events(): + from crewai.events.event_bus import crewai_event_bus + from crewai.events.event_listener import event_listener + from crewai.events.types.hook_events import HookDispatchedEvent + + with ( + crewai_event_bus.scoped_handlers(), + patch.object( + event_listener._telemetry, + "hook_dispatched_span", + ) as hook_dispatched_span, + ): + event_listener.setup_listeners(crewai_event_bus) + crewai_event_bus.emit( + "test", + HookDispatchedEvent( + interception_point="pre_tool_call", + outcome="aborted", + hook_count=1, + duration_ms=1.5, + ), + ) + crewai_event_bus.flush() + + hook_dispatched_span.assert_called_once_with( + interception_point="pre_tool_call", + outcome="aborted", + )