mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-13 01:38:41 +00:00
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
306 lines
9.4 KiB
Python
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
|