feat: track interception-hook dispatches in telemetry (#6805)

`HookDispatchedEvent` was already emitted from the dispatcher but never
landed in Feature Usage. Wire it through `hook_dispatched_span` so hook
adoption and abort outcomes (e.g. policy checks) show up in the same
ClickHouse aggregation as other features.
This commit is contained in:
Lucas Gomide
2026-08-04 14:13:24 -03:00
committed by GitHub
parent c5b9d9a4c9
commit 9d659a644b
3 changed files with 95 additions and 1 deletions

View File

@@ -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()

View File

@@ -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:<point>`` 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.

View File

@@ -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",
)