fix(tracing): a process with no terminal answers with the tracing switch

`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) <[email protected]>
This commit is contained in:
Joao Moura
2026-09-27 23:48:39 -07:00
co-authored by Claude Opus 5.5
parent 58bc1de74a
commit 87b1dc929c
5 changed files with 98 additions and 50 deletions
+7 -11
View File
@@ -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"):
+7 -6
View File
@@ -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
@@ -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
@@ -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 = (
@@ -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 = []