Files
crewAI/lib/crewai/tests/telemetry/test_flow_telemetry.py
Joao Moura fbe1debd96 feat(flow): record how long a flow ran
Adds a Flow Completed span carrying flow_name, duration_ms and outcome,
emitted when a flow finishes or fails. Elapsed time comes from a monotonic
stamp taken at flow start and cleared on use.

Kept separate from the Flow Execution span rather than holding that one open:
it is emitted and closed at start and the daily aggregate counts it, so
holding it would drop every run that is killed or crashes from the execution
count. A killed run now simply has no Flow Completed row, and the count is
unaffected.

Elapsed time is an explicit duration_ms attribute rather than the span's own
duration, which the ingestion pipeline stores as a suffixed string
("0.0000184s") that downstream aggregation parses to zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASfWmW3RGy4qAQm6s8U9jH
2026-08-11 15:51:18 -07:00

306 lines
9.4 KiB
Python

"""Flow outcome and human-in-the-loop signals must reach telemetry.
Driven through real ``Flow`` executions rather than by emitting events directly,
so these fail if the event bus, the listener wiring, or the emitting call site
changes - not just if the listener body does.
Before this, a flow reported only that it *started*: ``FlowFinishedEvent``,
``FlowFailedEvent``, ``MethodExecutionFailedEvent``, ``MethodExecutionPausedEvent``
and ``FlowPausedEvent`` all reached the console formatter and stopped there, and
the input and conversation-failure events had no listener at all.
"""
from __future__ import annotations
import contextlib
import time
import pytest
from crewai.flow.async_feedback import HumanFeedbackPending, PendingFeedbackContext
from crewai.flow.flow import Flow, listen, start
from crewai.flow.human_feedback import human_feedback
from crewai.flow.input_provider import InputResponse
def _reregister_listener() -> None:
"""Re-subscribe the global listener to the event bus.
The repo-wide ``cleanup_event_handlers`` fixture clears every handler after
each test, so anything relying on the shared listener sees an empty bus
unless it happens to run first.
"""
from crewai.events import event_listener as listener_module
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import FlowStartedEvent
# Only when the bus is empty: subscribing a second time registers a fresh
# set of closures, and every handler then fires twice.
if crewai_event_bus._sync_handlers.get(FlowStartedEvent):
return
listener_module.event_listener.setup_listeners(crewai_event_bus)
@pytest.fixture
def durations(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, float, str]]:
"""Record every (flow_name, duration_ms, outcome) the listener reports."""
from crewai.events import event_listener as listener_module
_reregister_listener()
recorded: list[tuple[str, float, str]] = []
monkeypatch.setattr(
listener_module.event_listener._telemetry,
"flow_completed_span",
lambda flow_name, duration_ms, outcome: recorded.append(
(flow_name, duration_ms, outcome)
),
)
return recorded
@pytest.fixture
def features(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Record every feature the listener reports for a real flow run.
Observes the telemetry boundary rather than exported spans: the suite builds
the Telemetry singleton with collection disabled, so it has no provider to
export through, and replacing that singleton mid-session leaves the event
bus without its handlers. That the recorded features become spans is covered
by ``test_tracer_isolation``.
"""
from crewai.events import event_listener as listener_module
_reregister_listener()
recorded: list[str] = []
monkeypatch.setattr(
listener_module.event_listener._telemetry,
"feature_usage_span",
recorded.append,
)
return recorded
def test_completed_flow_reports_its_outcome(features: list[str]) -> None:
class OkFlow(Flow):
@start()
def go(self) -> str:
return "ok"
OkFlow().kickoff()
assert "flow:completed" in features
def test_failed_flow_reports_the_failure_and_the_method(features: list[str]) -> None:
class BoomFlow(Flow):
@start()
def go(self) -> str:
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
BoomFlow().kickoff()
emitted = features
assert "flow:failed" in emitted
assert "flow:method_failed" in emitted
assert "flow:completed" not in emitted
def test_a_failed_flow_is_still_counted_as_an_execution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The start-time span must survive, or aborted runs vanish from counts.
``flow_executions_daily_target`` counts ``Flow Execution`` spans, emitted
when the flow starts. Holding that span open until completion to measure
duration - the obvious way to add duration - would drop every run that never
finishes, so the outcome signals are reported separately instead.
"""
from crewai.events import event_listener as listener_module
_reregister_listener()
started: list[str] = []
monkeypatch.setattr(
listener_module.event_listener._telemetry,
"flow_execution_span",
lambda flow_name, node_names: started.append(flow_name),
)
class BoomFlow(Flow):
@start()
def go(self) -> str:
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
BoomFlow().kickoff()
assert "BoomFlow" in started
def test_requesting_input_reports_both_sides(features: list[str]) -> None:
class StubProvider:
def request_input(self, message: str, flow: Flow, metadata=None):
return InputResponse(value="typed answer")
class AskFlow(Flow):
@start()
def go(self) -> str:
return self.ask("What topic?")
AskFlow(input_provider=StubProvider()).kickoff()
emitted = features
assert "flow:input_requested" in emitted
assert "flow:input_received" in emitted
def test_paused_flow_reports_the_pause(features: list[str]) -> None:
"""An async feedback provider pauses the flow; both signals must land."""
class AsyncProvider:
def request_feedback(self, context: PendingFeedbackContext, flow: Flow) -> str:
raise HumanFeedbackPending(context=context)
class PausingFlow(Flow):
@start()
@human_feedback(message="Review:", provider=AsyncProvider())
def generate(self) -> str:
return "content"
@listen(generate)
def process(self, result) -> str:
return f"processed: {result.feedback}"
# Whether the pause surfaces as an exception depends on the persistence
# backend in use; the signals must land either way.
with contextlib.suppress(BaseException):
PausingFlow().kickoff()
emitted = features
assert "flow:hitl_paused" in emitted
assert "flow:paused" in emitted
def test_failed_conversation_turn_is_reported(features: list[str]) -> None:
"""Only completed turns were tracked, so failure rate was unknowable."""
class FailingChat(Flow):
conversational = True
@start()
def begin(self) -> str:
raise RuntimeError("turn exploded")
with pytest.raises(RuntimeError, match="turn exploded"):
FailingChat().handle_turn("hello")
assert "flow:conversation_turn_failed" in features
def test_no_user_authored_strings_are_recorded(features: list[str]) -> None:
"""Method names, flow names and error text must not reach telemetry."""
class SecretNamedFlow(Flow):
@start()
def my_secret_method_name(self) -> str:
raise RuntimeError("secret error detail")
with pytest.raises(RuntimeError, match="secret error detail"):
SecretNamedFlow().kickoff()
emitted = features
assert emitted
for feature in emitted:
assert "my_secret_method_name" not in feature
assert "secret error detail" not in feature
assert "SecretNamedFlow" not in feature
def test_completed_flow_reports_a_real_duration(
durations: list[tuple[str, float, str]],
) -> None:
"""Elapsed time must be measured, not merely present."""
class SlowFlow(Flow):
@start()
def go(self) -> str:
time.sleep(0.05)
return "ok"
SlowFlow().kickoff()
assert len(durations) == 1
flow_name, duration_ms, outcome = durations[0]
assert flow_name == "SlowFlow"
assert outcome == "completed"
assert duration_ms >= 50
def test_failed_flow_reports_its_duration_and_outcome(
durations: list[tuple[str, float, str]],
) -> None:
class SlowBoomFlow(Flow):
@start()
def go(self) -> str:
time.sleep(0.05)
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
SlowBoomFlow().kickoff()
assert len(durations) == 1
flow_name, duration_ms, outcome = durations[0]
assert flow_name == "SlowBoomFlow"
assert outcome == "failed"
assert duration_ms >= 50
def test_no_duration_is_reported_without_a_recorded_start(
durations: list[tuple[str, float, str]],
) -> None:
"""A completion with no observed start reports nothing, and does not raise.
A conversational turn can re-emit completion for a restored run, so the
stamp is genuinely absent rather than impossible.
"""
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import FlowFinishedEvent
class NeverStartedFlow(Flow):
@start()
def go(self) -> str:
return "ok"
flow = NeverStartedFlow()
crewai_event_bus.emit(
flow,
FlowFinishedEvent(flow_name="NeverStartedFlow", result="ok", state={}),
)
assert durations == []
def test_duration_is_reported_once_per_run(
durations: list[tuple[str, float, str]],
) -> None:
"""The stamp is cleared on use, so a repeated completion cannot double-count."""
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import FlowFinishedEvent
class OkFlow(Flow):
@start()
def go(self) -> str:
return "ok"
flow = OkFlow()
flow.kickoff()
crewai_event_bus.emit(
flow, FlowFinishedEvent(flow_name="OkFlow", result="ok", state={})
)
assert len(durations) == 1