feat(tracing): port trace events sessions to OSS (#7464)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled

* feat(tracing): port enterprise event sessions to OSS

* fix(tracing): address review findings and verify concurrent exports

* fix(tracing): keep redactor ownership in enterprise integrations

* test(tracing): isolate intentional failures from cleanup assertions
This commit is contained in:
Lorenze Jay
2026-09-15 13:09:40 -07:00
committed by GitHub
parent 756d8d33c8
commit 993a96c4e0
36 changed files with 9411 additions and 1046 deletions

View File

@@ -3,8 +3,6 @@ import time
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import CrewKickoffStartedEvent
from crewai.events.types.flow_events import (
@@ -34,16 +32,17 @@ from crewai.events.types.tool_usage_events import (
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai_cli.command import AuthenticationRequiredError
from crewai_cli import run_crew
from crewai_cli.command import AuthenticationRequiredError
from crewai_cli.crew_run_tui import (
CrewRunApp,
_LOG_ARGS_TEXT_LIMIT,
_LOG_RESULT_TEXT_LIMIT,
_LOG_TRUNCATION_SUFFIX,
CrewRunApp,
_format_json_in_text,
_try_parse_structured,
)
import pytest
def _app_with_plan() -> CrewRunApp:
@@ -141,16 +140,18 @@ def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> Non
def test_view_traces_button_click_records_telemetry(monkeypatch) -> None:
app = CrewRunApp()
app._status = "completed"
app._trace_url = "https://app.crewai.com/traces/test"
app._telemetry = Mock()
opened_urls: list[str] = []
monkeypatch.setattr("webbrowser.open", lambda url: opened_urls.append(url))
notice = Mock()
monkeypatch.setattr(app, "notify", notice)
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces")))
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
assert opened_urls == ["https://app.crewai.com/traces/test"]
notice.assert_called_once_with(
"Trace sharing is requested when the execution finishes. "
"A trace link is not available for this run.",
title="Execution traces",
)
def test_deploy_button_click_records_telemetry() -> None:
@@ -1710,7 +1711,9 @@ async def test_declarative_flow_runs_on_tui() -> None:
app._flow = FakeFlow()
app._flow_inputs = {"topic": "AI"}
# A step left active (no Finished event) must be swept to done by _on_crew_done.
app._flow_steps = [{"name": "compute", "call_type": "expression", "status": "active"}]
app._flow_steps = [
{"name": "compute", "call_type": "expression", "status": "active"}
]
async with app.run_test() as pilot:
for _ in range(100):
@@ -1729,16 +1732,14 @@ def test_view_traces_keybinding_records_telemetry(monkeypatch) -> None:
"""The `t` binding reaches the action directly, never on_button_pressed."""
app = CrewRunApp()
app._status = "completed"
app._trace_url = "https://app.crewai.com/traces/test"
app._telemetry = Mock()
opened_urls: list[str] = []
monkeypatch.setattr("webbrowser.open", lambda url: opened_urls.append(url))
notice = Mock()
monkeypatch.setattr(app, "notify", notice)
app.action_view_traces()
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
assert opened_urls == ["https://app.crewai.com/traces/test"]
notice.assert_called_once()
def test_deploy_keybinding_records_telemetry() -> None:
@@ -1784,30 +1785,14 @@ def test_button_press_records_exactly_once(monkeypatch) -> None:
"""Recording moved into the action; the button must not double-count."""
app = CrewRunApp()
app._status = "completed"
app._trace_url = "https://app.crewai.com/traces/test"
app._telemetry = Mock()
monkeypatch.setattr("webbrowser.open", lambda url: None)
monkeypatch.setattr(app, "notify", Mock())
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces")))
assert app._telemetry.feature_usage_span.call_count == 1
def test_finished_traces_button_still_records(monkeypatch) -> None:
"""The button's id is swapped to btn-traces-done once a trace URL exists."""
app = CrewRunApp()
app._status = "completed"
app._trace_url = "https://app.crewai.com/traces/test"
app._telemetry = Mock()
monkeypatch.setattr("webbrowser.open", lambda url: None)
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces-done")))
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
def test_try_parse_structured_rejects_non_serializable_literals() -> None:
"""ast.literal_eval("[...]") is a valid [Ellipsis] list but cannot be JSON-encoded."""
assert _try_parse_structured("[...]") is None
@@ -1840,5 +1825,5 @@ def test_format_json_in_text_survives_deep_nesting() -> None:
def test_format_json_in_text_still_pretty_prints_valid_json() -> None:
assert _format_json_in_text('data: {"a": 1} and [...]') == (
'data: ' + '{\n "a": 1\n}' + ' and [...]'
"data: " + '{\n "a": 1\n}' + " and [...]"
)

View File

@@ -0,0 +1,234 @@
"""The terminal UI resolves local-session consent without the legacy uploader."""
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import Mock
from crewai_cli.crew_run_tui import CrewRunApp, TraceConsentScreen
import pytest
from textual.events import Mount
from textual.widgets import Button
class ConsentApp(CrewRunApp):
def on_mount(self, event: Mount | None = None) -> None:
"""Show the real UI without starting a crew or a refresh worker."""
if event is not None:
event.prevent_default()
async def wait_for_consent(app, pilot):
for _ in range(50):
await pilot.pause(0.01)
if isinstance(app.screen, TraceConsentScreen):
return app.screen
raise AssertionError("The execution worker did not open the consent prompt")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("action", "approved"),
[
("y", True),
("n", False),
("escape", False),
("click-yes", True),
("click-no", False),
],
)
async def test_session_consent_buttons_and_keys_resolve_execution_worker(
monkeypatch, action, approved
):
app = ConsentApp()
legacy = Mock(side_effect=AssertionError("Session consent invoked legacy upload"))
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
async with app.run_test(size=(100, 40)) as pilot:
result = asyncio.create_task(asyncio.to_thread(app._request_trace_consent))
screen = await wait_for_consent(app, pilot)
assert str(screen.query_one("#btn-consent-yes", Button).label) == "Share Trace"
content = screen._build_content().plain
assert "stored locally" in content and "Sharing uploads it" in content
assert "prompts, inputs, outputs, and tool calls" in content
assert "20 seconds" in content
assert not result.done()
if action.startswith("click-"):
assert await pilot.click(f"#btn-consent-{action.removeprefix('click-')}")
else:
await pilot.press(action)
assert await asyncio.wait_for(result, timeout=5) is approved
await pilot.pause()
assert not isinstance(app.screen, TraceConsentScreen)
legacy.assert_not_called()
assert app._trace_consent_pending is None
@pytest.mark.asyncio
async def test_consent_timeout_returns_false_and_dismisses_modal(monkeypatch):
app = ConsentApp()
waits = []
event = threading.Event()
class ExpiredEvent:
def set(self):
event.set()
def wait(self, *, timeout):
waits.append(timeout)
return False
# Replace only this module's event factory, leaving Textual's threads alone.
monkeypatch.setattr(
"crewai_cli.crew_run_tui.threading", SimpleNamespace(Event=ExpiredEvent)
)
legacy = Mock(side_effect=AssertionError("Timeout invoked legacy upload"))
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
async with app.run_test(size=(100, 40)) as pilot:
result = await asyncio.wait_for(
asyncio.to_thread(app._request_trace_consent), timeout=5
)
assert result is False
await pilot.pause()
assert not isinstance(app.screen, TraceConsentScreen)
assert waits == [20]
assert app._trace_consent_pending is None
legacy.assert_not_called()
@pytest.mark.asyncio
async def test_quit_rejects_pending_consent_and_releases_worker(monkeypatch):
app = ConsentApp()
legacy = Mock(side_effect=AssertionError("Quit invoked legacy upload"))
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
async with app.run_test(size=(100, 40)) as pilot:
result = asyncio.create_task(asyncio.to_thread(app._request_trace_consent))
await wait_for_consent(app, pilot)
await app.action_quit()
assert await asyncio.wait_for(result, timeout=5) is False
assert app._trace_consent_pending is None
legacy.assert_not_called()
def test_consent_callback_refuses_nonboolean_screen_results(monkeypatch):
app = CrewRunApp()
monkeypatch.setattr(app, "call_from_thread", lambda callback: callback())
monkeypatch.setattr(app, "push_screen", lambda screen, callback: callback("yes"))
monkeypatch.setattr(app, "_dismiss_consent_modal", lambda: None)
assert app._request_trace_consent() is False
@pytest.mark.asyncio
async def test_conversation_quit_can_ask_for_deferred_session_consent(monkeypatch):
app = ConsentApp(conversational=True)
decisions = []
class DeferredFlow:
defer_trace_finalization = True
def finalize_session_traces(self):
decisions.append(app._request_trace_consent())
app._flow = DeferredFlow()
app._conversation_previous_defer_trace_finalization = False
legacy = Mock(side_effect=AssertionError("Conversation quit invoked legacy upload"))
monkeypatch.setattr(app, "_send_traces_worker", legacy, raising=False)
async with app.run_test(size=(100, 40)) as pilot:
quit_task = asyncio.create_task(app.action_quit())
await wait_for_consent(app, pilot)
await pilot.press("n")
await asyncio.wait_for(quit_task, timeout=5)
assert decisions == [False]
assert app._flow.defer_trace_finalization is False
assert app._trace_consent_pending is None
legacy.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("prior_turn", [False, True])
async def test_quit_during_turn_discards_trace_after_worker_finishes(
monkeypatch, prior_turn
):
entered, release, finalized = (
threading.Event(),
threading.Event(),
threading.Event(),
)
app = ConsentApp(conversational=True)
completions = []
class RunningFlow:
defer_trace_finalization = True
trace = "prior turn" if prior_turn else None
def handle_turn(self, message):
entered.set()
release.wait(timeout=5)
# A first kickoff stores its deferred lifetime just before returning.
self.trace = "completed turn"
return "done"
def finalize_session_traces(self, *, discard=False):
completions.append(discard)
self.trace = None
finalized.set()
app._flow = RunningFlow()
app._conversation_previous_defer_trace_finalization = False
app._conversation_turn_in_progress = True
app._status = "working"
prompt = Mock(side_effect=AssertionError("Cancelled execution asked for consent"))
monkeypatch.setattr(app, "push_screen", prompt)
async with app.run_test(size=(100, 40)):
app._run_conversation_turn_worker("hello")
assert await asyncio.to_thread(entered.wait, 5)
try:
await app.action_quit()
assert completions == []
assert app._request_trace_consent() is False
finally:
release.set()
assert await asyncio.to_thread(finalized.wait, 5)
assert completions == [True]
assert app._flow.trace is None
assert app._flow.defer_trace_finalization is False
prompt.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["crew", "flow"])
async def test_quit_during_execution_rejects_late_consent(monkeypatch, kind):
entered, release = threading.Event(), threading.Event()
app = ConsentApp()
decisions = []
class RunningExecution:
def kickoff(self, inputs=None):
entered.set()
release.wait(timeout=5)
decisions.append(app._request_trace_consent())
return "done"
setattr(app, f"_{kind}", RunningExecution())
app._status = "working"
prompt = Mock(side_effect=AssertionError("Cancelled execution asked for consent"))
completed = Mock()
failed = Mock()
monkeypatch.setattr(app, "push_screen", prompt)
monkeypatch.setattr(app, "_on_crew_done", completed)
monkeypatch.setattr(app, "_on_crew_failed", failed)
async with app.run_test(size=(100, 40)):
# Own the worker thread so the test awaits its body even after Textual
# cancels its worker wrappers on exit (running threads are not stopped).
run_worker = getattr(app, f"_run_{kind}_worker").__wrapped__
worker = asyncio.create_task(asyncio.to_thread(run_worker, app))
assert await asyncio.to_thread(entered.wait, 5)
try:
await app.action_quit()
finally:
release.set()
await asyncio.wait_for(worker, timeout=5)
assert decisions == [False]
assert app._trace_consent_pending is None
prompt.assert_not_called()
completed.assert_not_called()
failed.assert_not_called()