From 87b1dc929ccdde3aa29d054a49361a8b977e940f Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Tue, 22 Sep 2026 15:15:51 -0700 Subject: [PATCH] fix(tracing): a process with no terminal answers with the tracing switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CREWAI_TRACING_ENABLED=true` — or `tracing=True` — in a script collected an anonymous run and then threw it away. Where nothing can answer the share prompt (CI, a container, an agent), it returns False rather than block for twenty seconds, and for an ephemeral buffer that False was a silent no: the spans were buffered and dropped with an INFO line nobody reads. `tracing_asked_for()` is already the answer the buffer takes before it would prompt. It now holds whether or not a prompt could be shown: the switch is the yes in a terminal and outside one. A suite under test stays a no, a host's consent callback is still consulted first, and the first-time collection nobody asked for still asks — and still declines when nobody answers. The run TUI's own copy of the rule follows. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/cli/src/crewai_cli/crew_run_tui.py | 18 ++--- lib/cli/tests/test_crew_run_tui.py | 13 +-- .../crewai/events/listeners/tracing/utils.py | 35 ++++---- .../src/crewai/telemetry/tracing/ephemeral.py | 2 +- .../test_deferred_trace_transitions.py | 80 +++++++++++++++---- 5 files changed, 98 insertions(+), 50 deletions(-) diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py index dedf506f6..46f56ea83 100644 --- a/lib/cli/src/crewai_cli/crew_run_tui.py +++ b/lib/cli/src/crewai_cli/crew_run_tui.py @@ -1235,21 +1235,17 @@ FooterKey .footer-key--key { this worker thread — and a flow never sets it at all. So the declaration is read off the object itself. - Only ONE of the helper's guards is dropped: it refuses while tracing - messages are suppressed, which this app does deliberately to keep - crewAI's console out of its own layout, and that guard is about a prompt - nobody would see. The others hold. A suite under test, or a TUI with no - interactive user behind it — embedded, redirected — has nobody whose yes - this could be, and gets the modal's own fail-closed answer instead. + The helper's one guard holds: a suite under test is nobody asking to + upload. Neither suppressed tracing messages (which this app sets to keep + crewAI's console out of its layout) nor a TUI with no interactive user + behind it changes the answer — the switch is the yes whether or not a + prompt could have been shown. """ import os - from crewai.events.listeners.tracing.utils import ( - _is_interactive_terminal, - _is_test_environment, - ) + from crewai.events.listeners.tracing.utils import _is_test_environment - if _is_test_environment() or not _is_interactive_terminal(): + if _is_test_environment(): return False if os.getenv("CREWAI_TRACING_ENABLED", "").lower() in ("true", "1"): diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py index a2076ea3d..27faa6a12 100644 --- a/lib/cli/tests/test_crew_run_tui.py +++ b/lib/cli/tests/test_crew_run_tui.py @@ -403,12 +403,13 @@ def test_evaluate_says_so_when_this_run_was_not_traced(monkeypatch, why) -> None assert "not traced" in notices[0] -def test_tracing_asked_for_reads_the_declaration_but_keeps_the_other_guards( +def test_tracing_asked_for_reads_the_declaration_and_keeps_the_test_guard( monkeypatch, ) -> None: """The ContextVar is set where the crew is CONSTRUCTED, not on the worker - thread that later asks — but an app with nobody in front of it is still - nobody's yes.""" + thread that later asks — so the declaration is read off the object. With no + interactive user behind the app the declaration is still the yes; under test + it is nobody's.""" utils = "crewai.events.listeners.tracing.utils" monkeypatch.setattr(f"{utils}._is_test_environment", lambda: False) monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: True) @@ -421,11 +422,11 @@ def test_tracing_asked_for_reads_the_declaration_but_keeps_the_other_guards( app._crew = SimpleNamespace(tracing=None) assert app._tracing_was_asked_for() is False - # embedded, redirected, or under test: the declaration is not a person + # embedded or redirected: the switch still answers app._crew = SimpleNamespace(tracing=True) monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: False) - assert app._tracing_was_asked_for() is False - monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: True) + assert app._tracing_was_asked_for() is True + # under test: it does not monkeypatch.setattr(f"{utils}._is_test_environment", lambda: True) assert app._tracing_was_asked_for() is False diff --git a/lib/crewai/src/crewai/events/listeners/tracing/utils.py b/lib/crewai/src/crewai/events/listeners/tracing/utils.py index c58125441..c2085c5bf 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/utils.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/utils.py @@ -107,29 +107,30 @@ def should_suppress_tracing_messages() -> bool: def tracing_asked_for() -> bool: - """True when a PERSON turned tracing on for this run — `CREWAI_TRACING_ENABLED`, - or `tracing=True` on the crew or flow in this context — and is there to have - meant it. + """True when somebody turned tracing on for this run — `CREWAI_TRACING_ENABLED`, + or `tracing=True` on the crew or flow in this context. Turning tracing on is the answer to "may we collect this?"; asking again when the run ends is asking the same question twice. First-time auto-collection is the case that still has to ask, because nobody asked for it. - Where the prompt could not be shown, there is nothing to answer. A copied - `.env` that reaches CI, a container or a server carries the variable without - carrying the person; a suite under test, and a host that asked for tracing - messages to be suppressed, are the same case. An ephemeral run has always - failed closed in all three rather than upload what nobody approved in front - of a screen, and it still does — this turns a second question into an - answer, and changes nothing about where the first one is asked. + That holds where no prompt could be shown too — CI, a container, an agent, a + host that suppressed tracing messages. There the prompt returns False rather + than block for twenty seconds with nobody to answer it, and for an ephemeral + run that False was a silent no: the spans of a run whose tracing the user had + explicitly turned on were buffered and then dropped without a word. The + switch is the answer, so it is taken as one. A host that must refuse keeps + the say through its own consent callback, which is consulted first. + + A suite under test is the one exception: a test that sets the variable is + not somebody asking to upload. """ - if not ( - is_tracing_enabled_in_context() - or os.getenv("CREWAI_TRACING_ENABLED", "").lower() in ("true", "1") - ): + if _is_test_environment(): return False - return _prompt_can_be_shown() + return is_tracing_enabled_in_context() or os.getenv( + "CREWAI_TRACING_ENABLED", "" + ).lower() in ("true", "1") def should_enable_tracing(*, override: bool | None = None) -> bool: @@ -520,8 +521,8 @@ def _prompt_can_be_shown() -> bool: tracing messages to be suppressed, and a process with no terminal (CI, API servers, Docker) where a prompt would block for twenty seconds with nobody to answer it. `prompt_user_for_trace_viewing` returns False in each, which - is what keeps an unapproved ephemeral trace on the machine — and - `tracing_asked_for` asks the same question, so the two cannot drift apart. + keeps a trace nobody asked for on the machine; a run whose tracing was + turned on never reaches the prompt (`tracing_asked_for`). """ if _is_test_environment(): return False diff --git a/lib/crewai/src/crewai/telemetry/tracing/ephemeral.py b/lib/crewai/src/crewai/telemetry/tracing/ephemeral.py index 4c870a519..0cc2d1ed4 100644 --- a/lib/crewai/src/crewai/telemetry/tracing/ephemeral.py +++ b/lib/crewai/src/crewai/telemetry/tracing/ephemeral.py @@ -129,7 +129,7 @@ class EphemeralSpanBuffer(SpanExporter): # an embedder may answer no for reasons of its own. What it replaces # is the PROMPT, and a prompt is what a user who turned tracing on # has already answered: CREWAI_TRACING_ENABLED, or tracing=True, is - # the yes. (A callback that opens its own prompt — the TUI's modal — + # the yes — with a terminal or without one. (A callback that opens its own prompt — the TUI's modal — # asks `tracing_asked_for()` for itself.) consent = self._consent or _trace_consent.get() approved = ( diff --git a/lib/crewai/tests/telemetry/test_deferred_trace_transitions.py b/lib/crewai/tests/telemetry/test_deferred_trace_transitions.py index f7a8b5cc6..55053c3a3 100644 --- a/lib/crewai/tests/telemetry/test_deferred_trace_transitions.py +++ b/lib/crewai/tests/telemetry/test_deferred_trace_transitions.py @@ -122,8 +122,8 @@ def test_tracing_asked_for_is_the_answer_and_the_run_is_not_asked_again( """Turning tracing on IS consent. The prompt at the end of a run is for the first-time collection nobody asked for — not for a user who said collect it.""" buffers, grants, recorders, prompt = traces - # the rule only holds where a prompt could have been shown, and a suite is - # one of the places it could not — so say a person is here + # a suite under test is the one place the switch is not an answer — so say + # this is not one (and that somebody is at a terminal, the ordinary case) utils = "crewai.events.listeners.tracing.utils" monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: True) monkeypatch.setattr(f"{utils}._is_test_environment", lambda: False) @@ -145,26 +145,28 @@ def test_tracing_asked_for_is_the_answer_and_the_run_is_not_asked_again( assert grants and recorders # and the trace went where it was told to go -@pytest.mark.parametrize( - "closed", ["no terminal", "under test", "messages suppressed"] -) -def test_where_the_prompt_could_not_be_shown_nothing_is_uploaded( - traces, monkeypatch, closed +@pytest.mark.parametrize("how", ["env", "flag"]) +@pytest.mark.parametrize("closed", ["no terminal", "messages suppressed"]) +def test_where_no_prompt_can_be_shown_the_switch_is_the_answer( + traces, monkeypatch, closed, how ): - """The switch answers a question; where the question could not have been - put to anybody, there is nothing to answer. A copied `.env` reaching CI - carries the variable, not the person — and a suite under test, and a host - that suppressed tracing messages, are the same case. All three have always - failed closed, and still do.""" + """CI, a container, an agent: nobody is there to answer the prompt, and it + answers False rather than block. For an ephemeral run that False was a + silent no — the spans of a run whose tracing the user had explicitly turned + on were buffered and then dropped without a word. The switch is the answer + here too, whether it was the variable or `tracing=True` in code.""" buffers, grants, recorders, prompt = traces - monkeypatch.setenv("CREWAI_TRACING_ENABLED", "true") utils = "crewai.events.listeners.tracing.utils" monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: closed != "no terminal") - monkeypatch.setattr(f"{utils}._is_test_environment", lambda: closed == "under test") + monkeypatch.setattr(f"{utils}._is_test_environment", lambda: False) monkeypatch.setattr( f"{utils}.should_suppress_tracing_messages", lambda: closed == "messages suppressed", ) + if how == "env": + monkeypatch.setenv("CREWAI_TRACING_ENABLED", "true") + else: # the flag alone must carry it, or this case proves nothing + monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False) prompt.return_value = False # what the prompt answers where nobody can answer class Conversation(Flow): @@ -172,12 +174,60 @@ def test_where_the_prompt_could_not_be_shown_nothing_is_uploaded( def turn(self): return "said in a container" - assert Conversation(tracing=True).kickoff() == "said in a container" + flow = Conversation(tracing=(how == "flag") or None) + assert flow.kickoff() == "said in a container" + assert crewai_event_bus.flush() + + prompt.assert_not_called() + assert grants and recorders # the trace went where it was told to go + + +def test_under_test_the_switch_uploads_nothing(traces, monkeypatch): + """A suite that sets the variable is not somebody asking to upload.""" + buffers, grants, recorders, prompt = traces + monkeypatch.setenv("CREWAI_TRACING_ENABLED", "true") + utils = "crewai.events.listeners.tracing.utils" + monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: False) + monkeypatch.setattr(f"{utils}._is_test_environment", lambda: True) + prompt.return_value = False + + class Conversation(Flow): + @start() + def turn(self): + return "said under test" + + assert Conversation(tracing=True).kickoff() == "said under test" assert crewai_event_bus.flush() assert not grants and not recorders # nothing left the machine +def test_tracing_nobody_asked_for_is_still_a_no_without_a_terminal( + traces, monkeypatch +): + """The first-time collection turns tracing on by itself; nobody asked, so + with nobody to answer, nothing is uploaded.""" + buffers, grants, recorders, prompt = traces + monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False) + utils = "crewai.events.listeners.tracing.utils" + monkeypatch.setattr(f"{utils}._is_interactive_terminal", lambda: False) + monkeypatch.setattr(f"{utils}._is_test_environment", lambda: False) + monkeypatch.setattr(f"{utils}.is_tracing_enabled_in_context", lambda: False) + written = Mock() + monkeypatch.setattr("crewai.telemetry.tracing.ephemeral.update_user_data", written) + prompt.return_value = False + + buffer = EphemeralSpanBuffer() + buffer._spans.append((object(), 1)) + buffer.share("run-nobody-asked-for", first_time=True) + + prompt.assert_called_once() + written.assert_called_once_with( + {"first_execution_done": True, "trace_consent": False} + ) + assert not grants and not recorders + + def test_enabling_deferred_trace_opens_a_new_flow_root(traces): buffers, grants, recorders, prompt = traces sessions = []