mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-10-12 02:12:31 +00:00
The review's asks: anonymous eval stats, a handshake a project cannot forge
Usage stats are anonymous, so the `crewai eval` count says whether the caller was logged in and nothing else — no execution id, no organization (the product decision on this PR). Which runs were evaluated, and by whom, is AMP's record. A feature span now keeps only the keys its own feature lists, in both telemetry classes; everything else — the dimensions every span carries included — is dropped. The run app's "an evaluation is waiting" handshake was an environment variable any project's `.env` could set, which would start an evaluation with the machine's login on a plain `crewai run`. Its value is now a random token that counts only while the command's file of that name exists in the user's crewAI data directory; a `.env` cannot create a file. And three smaller ones: when no app evaluated the run, the command grades the project's record only if it was written after this run began; the "Wrote eval.jsonc" line after the app closes now appears (the key it read was never set); an oversized eval.jsonc is said once, through the caller's note, rather than printed under the TUI on every retry. uv.lock is back to main's: the change was re-resolution drift, nothing this PR needs. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
58bc1de74a
commit
52fa5f3ae0
@@ -11,7 +11,9 @@ from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
import json as _json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, ClassVar
|
||||
@@ -304,7 +306,21 @@ _AUTO_EVAL: ContextVar[dict[str, str | None] | None] = ContextVar(
|
||||
# the only thing that does. Set and cleared by `evaluating_after_run()` alone:
|
||||
# an internal handshake between two parts of one command, never a setting for
|
||||
# anyone to turn on.
|
||||
#
|
||||
# The child loads the project's `.env` over its environment, so the variable
|
||||
# alone would be a switch any project could flip — an evaluation started, with
|
||||
# the machine's `crewai login`, on a plain `crewai run`. Its value is therefore a
|
||||
# random token, and it counts only while the file of that name exists in this
|
||||
# user's crewAI data directory: the command writes it before the run and removes
|
||||
# it after, and a `.env` cannot create a file.
|
||||
_AWAITING_EVAL_ENV = "CREWAI_EVAL_AWAITING_RUN"
|
||||
_AWAITING_TOKEN = re.compile(r"[0-9a-f]{32}")
|
||||
|
||||
|
||||
def _awaiting_dir() -> Path:
|
||||
import appdirs
|
||||
|
||||
return Path(appdirs.user_data_dir("crewai", "CrewAI")) / "eval-awaiting"
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -324,11 +340,22 @@ def evaluating_after_run() -> Iterator[dict[str, str | None]]:
|
||||
holder: dict[str, str | None] = {"execution_id": None}
|
||||
token = _AUTO_EVAL.set(holder)
|
||||
before = os.environ.get(_AWAITING_EVAL_ENV)
|
||||
os.environ[_AWAITING_EVAL_ENV] = "1"
|
||||
# Without the file a child app cannot be told, and the command grades the
|
||||
# run itself once the app closes — slower to the verdict, never wrong.
|
||||
marker: Path | None = None
|
||||
with contextlib.suppress(OSError):
|
||||
nonce = secrets.token_hex(16)
|
||||
_awaiting_dir().mkdir(parents=True, exist_ok=True)
|
||||
(_awaiting_dir() / nonce).touch(exist_ok=False)
|
||||
marker = _awaiting_dir() / nonce
|
||||
os.environ[_AWAITING_EVAL_ENV] = nonce
|
||||
try:
|
||||
yield holder
|
||||
finally:
|
||||
_AUTO_EVAL.reset(token)
|
||||
if marker is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
marker.unlink()
|
||||
if before is None:
|
||||
os.environ.pop(_AWAITING_EVAL_ENV, None)
|
||||
else:
|
||||
@@ -347,7 +374,14 @@ def _an_evaluation_is_waiting() -> dict[str, str | None] | None:
|
||||
if holder is not None:
|
||||
return holder
|
||||
|
||||
return {"execution_id": None} if os.environ.get(_AWAITING_EVAL_ENV) else None
|
||||
value = os.environ.get(_AWAITING_EVAL_ENV) or ""
|
||||
if not _AWAITING_TOKEN.fullmatch(value):
|
||||
return None
|
||||
try:
|
||||
waiting = (_awaiting_dir() / value).is_file()
|
||||
except OSError:
|
||||
return None
|
||||
return {"execution_id": None} if waiting else None
|
||||
|
||||
|
||||
def _recorded_run(execution_uuid: str) -> dict[str, Any] | None:
|
||||
@@ -1435,6 +1469,9 @@ FooterKey .footer-key--key {
|
||||
"state": "done",
|
||||
"verdict": finished.get("verdict") or {},
|
||||
"note": None,
|
||||
# the file the evaluation wrote, for the line printed once
|
||||
# the app has gone (`_print_evaluation_line`)
|
||||
"wrote_config": finished.get("wrote_eval_config"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -60,35 +60,26 @@ FINISHED = {"done", "failed"}
|
||||
STATUSES = {"queued", "running"} | FINISHED
|
||||
|
||||
|
||||
def _record_usage(execution_id: str, *, logged_in: bool) -> None:
|
||||
"""Count an evaluation that is actually starting, and what can be joined on.
|
||||
def _record_usage(*, logged_in: bool) -> None:
|
||||
"""Count an evaluation that is actually starting, and whether the caller was
|
||||
logged in.
|
||||
|
||||
The project id, the runtime and the version ride every span already. What
|
||||
only this command knows is WHICH run is being graded, whether the caller was
|
||||
logged in, and — when they are — which organization they are logged in to.
|
||||
Nothing about the run's content is recorded here: no inputs, no output, no
|
||||
verdict. Those live in AMP, which the execution id joins to.
|
||||
Usage stats are anonymous, so nothing that names the run or the account is
|
||||
sent — no execution id, no organization, and nothing about the run's content.
|
||||
Which runs were evaluated, and by whom, is AMP's record, not these stats'.
|
||||
|
||||
The TUI's button counts `cli_usage:evaluate` when it is pressed, so the
|
||||
difference between that and `cli_usage:eval` is intent that never became an
|
||||
evaluation.
|
||||
"""
|
||||
try:
|
||||
from crewai_core.settings import Settings
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
organization = (
|
||||
str(getattr(Settings(), "org_uuid", "") or "") if logged_in else ""
|
||||
)
|
||||
telemetry = Telemetry()
|
||||
telemetry.set_tracer()
|
||||
telemetry.feature_usage_span(
|
||||
"cli_usage:eval",
|
||||
{
|
||||
"execution_id": execution_id,
|
||||
"authenticated": "true" if logged_in else "false",
|
||||
"organization_id": organization,
|
||||
},
|
||||
{"authenticated": "true" if logged_in else "false"},
|
||||
)
|
||||
except Exception: # noqa: S110 - telemetry must never break a command
|
||||
pass
|
||||
@@ -155,7 +146,7 @@ def eval_crew(run_id: str | None = None) -> None:
|
||||
# After, not before: `cli_usage:eval` counts an evaluation, and a refused
|
||||
# request — a run AMP does not hold, a credential it will not take — is not
|
||||
# one. `_start_evaluation` raises rather than returning on those.
|
||||
_record_usage(execution_id, logged_in=client.api_key is not None)
|
||||
_record_usage(logged_in=client.api_key is not None)
|
||||
url = started.get("url")
|
||||
console.print(Text("Evaluating run ").append(execution_id, style="bold"))
|
||||
if url:
|
||||
@@ -207,7 +198,7 @@ EVAL_CONFIG_FILE = "eval.jsonc"
|
||||
MAX_EVAL_CONFIG_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def project_eval_config() -> str | None:
|
||||
def project_eval_config(*, note: Callable[[str], None] = _note) -> str | None:
|
||||
"""What this project says good means, if it has said.
|
||||
|
||||
Read from the project's own directory, beside `pyproject.toml`, because
|
||||
@@ -225,13 +216,12 @@ def project_eval_config() -> str | None:
|
||||
return None
|
||||
|
||||
if len(text.encode("utf-8")) > MAX_EVAL_CONFIG_BYTES:
|
||||
console.print(
|
||||
Text(
|
||||
f"{EVAL_CONFIG_FILE} is larger than "
|
||||
f"{MAX_EVAL_CONFIG_BYTES // 1024}KB and was not sent; this run is graded on "
|
||||
"the crew's own expectations."
|
||||
),
|
||||
style="yellow",
|
||||
# Through NOTE, which the run app routes onto its own screen: a print
|
||||
# would land under its layout.
|
||||
note(
|
||||
f"{EVAL_CONFIG_FILE} is larger than "
|
||||
f"{MAX_EVAL_CONFIG_BYTES // 1024}KB and was not sent; this run is graded on "
|
||||
"the crew's own expectations."
|
||||
)
|
||||
return None
|
||||
return text or None
|
||||
@@ -338,7 +328,7 @@ def evaluate_run(
|
||||
started = _start_evaluation(client, execution_id, wait_for_spans=True, note=note)
|
||||
# After the start, exactly as the command counts it: `cli_usage:eval` is the
|
||||
# count of evaluations that began, and an evaluation the app runs is one.
|
||||
_record_usage(execution_id, logged_in=client.api_key is not None)
|
||||
_record_usage(logged_in=client.api_key is not None)
|
||||
record_evaluation_outcome(execution_id)
|
||||
on_started(started)
|
||||
finished = _wait(client, started["id"], started.get("url"), on_status=on_status)
|
||||
@@ -538,7 +528,13 @@ def _run_and_let_the_app_evaluate() -> str | None:
|
||||
console.print(NOT_TRACED, style="bold red")
|
||||
raise SystemExit(1)
|
||||
|
||||
traced = watched["execution_id"] or (read_last_run() or {}).get("execution_id")
|
||||
# The project's record, only when it was written after this run began: a
|
||||
# record is the project's LAST run, and another run finishing in the same
|
||||
# project would otherwise be graded in this one's place.
|
||||
record = read_last_run() or {}
|
||||
traced = watched["execution_id"] or (
|
||||
record.get("execution_id") if _recorded_since(record, began) else None
|
||||
)
|
||||
if traced:
|
||||
return str(traced)
|
||||
|
||||
@@ -546,6 +542,19 @@ def _run_and_let_the_app_evaluate() -> str | None:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _recorded_since(record: dict[str, Any], began: datetime) -> bool:
|
||||
"""Was RECORD written after BEGAN? A record without a readable, zoned stamp
|
||||
is not assumed to be this run's."""
|
||||
stamp = str(record.get("recorded_at") or record.get("finished_at") or "")
|
||||
try:
|
||||
when = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
return False
|
||||
if when.tzinfo is None:
|
||||
return False
|
||||
return when >= began - timedelta(seconds=1)
|
||||
|
||||
|
||||
def _enable_tracing() -> None:
|
||||
"""`CREWAI_TRACING_ENABLED=true` in the project's .env, and in this process for the run about to start."""
|
||||
env_file = Path.cwd() / ".env"
|
||||
@@ -567,11 +576,12 @@ def _start_evaluation(
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + SPANS_WAIT_SECONDS if wait_for_spans else 0.0
|
||||
said = False
|
||||
# Read once: the file does not change while the spans are in flight, and a
|
||||
# warning about it belongs on the screen once, not on every retry.
|
||||
eval_config = project_eval_config(note=note)
|
||||
while True:
|
||||
try:
|
||||
response = client.create_evaluation(
|
||||
execution_id, eval_config=project_eval_config()
|
||||
)
|
||||
response = client.create_evaluation(execution_id, eval_config=eval_config)
|
||||
except httpx.HTTPError as error:
|
||||
raise EvaluationStoppedError(
|
||||
f"Could not reach AMP to start the evaluation: {error}"
|
||||
|
||||
@@ -17,6 +17,16 @@ from crewai_cli.experimental import eval_crew as eval_module
|
||||
from crewai_cli.cli import eval_command
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _awaiting_files_stay_in_tmp(tmp_path, monkeypatch):
|
||||
"""The waiting-evaluation token file belongs to the user's crewAI data
|
||||
directory; a test keeps it in its own."""
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.crew_run_tui._awaiting_dir", lambda: tmp_path / "eval-awaiting"
|
||||
)
|
||||
|
||||
|
||||
|
||||
EXECUTION_ID = "6f31fe1a-20bd-4bfe-a011-25d6b9341f62"
|
||||
URL = "https://evolve.crewai.test/e/ev-1"
|
||||
|
||||
@@ -77,6 +87,12 @@ def record_last_run(directory: Path, execution_id: str = EXECUTION_ID, **fields)
|
||||
(directory / ".crewai" / "last_run.json").write_text(json.dumps(record))
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def install(monkeypatch, amp: FakeAMP, configured_amp: str = "https://amp.test") -> FakeAMP:
|
||||
def build(api_key=None, base_url=None):
|
||||
# PlusAPI's own resolution: explicit, then CREWAI_PLUS_URL, then the saved settings.
|
||||
@@ -600,13 +616,13 @@ def test_without_a_traced_run_it_offers_to_turn_tracing_on_and_run_the_crew(proj
|
||||
|
||||
|
||||
@pytest.mark.parametrize("logged_in", [True, False])
|
||||
def test_the_command_counts_the_evaluation_and_what_it_can_be_joined_on(
|
||||
def test_the_command_counts_the_evaluation_and_nothing_that_names_it(
|
||||
project, monkeypatch, logged_in
|
||||
):
|
||||
"""`cli_usage:eval` is every evaluation; the TUI's own `cli_usage:evaluate`
|
||||
is intent, and the difference is intent that never became one. The span
|
||||
carries which run, whether the caller was logged in, and to which
|
||||
organization — never anything about the run's content."""
|
||||
is intent, and the difference is intent that never became one. Usage stats
|
||||
are anonymous, so the span says whether the caller was logged in — never
|
||||
which run, which organization, or anything about the run's content."""
|
||||
directory, _ = project
|
||||
record_last_run(directory, "counted-run")
|
||||
spans: list[tuple[str, dict[str, str]]] = []
|
||||
@@ -619,20 +635,68 @@ def test_the_command_counts_the_evaluation_and_what_it_can_be_joined_on(
|
||||
spans.append((feature, attributes or {}))
|
||||
|
||||
monkeypatch.setattr("crewai_core.telemetry.Telemetry", FakeTelemetry)
|
||||
monkeypatch.setattr(
|
||||
"crewai_core.settings.Settings", lambda: SimpleNamespace(org_uuid="org-42")
|
||||
)
|
||||
monkeypatch.setattr(eval_module, "saved_login", lambda: "tok" if logged_in else None)
|
||||
install(monkeypatch, FakeAMP(statuses=[done()]))
|
||||
|
||||
eval_module.eval_crew()
|
||||
|
||||
feature, attributes = spans[0]
|
||||
assert feature == "cli_usage:eval"
|
||||
assert attributes["execution_id"] == "counted-run"
|
||||
assert attributes["authenticated"] == ("true" if logged_in else "false")
|
||||
# an anonymous caller has no organization to report, and is not asked for one
|
||||
assert attributes["organization_id"] == ("org-42" if logged_in else "")
|
||||
assert spans == [
|
||||
("cli_usage:eval", {"authenticated": "true" if logged_in else "false"})
|
||||
]
|
||||
|
||||
|
||||
def test_an_oversized_config_is_said_once_and_through_the_callers_note(
|
||||
project, monkeypatch
|
||||
):
|
||||
"""The run app routes notes onto its own screen, and the spans of a fresh
|
||||
run can take several 404s to land — the warning belongs there, once."""
|
||||
directory, _ = project
|
||||
(directory / "eval.jsonc").write_text("x" * (eval_module.MAX_EVAL_CONFIG_BYTES + 1))
|
||||
sent: list[str | None] = []
|
||||
answers = iter([httpx.Response(404, json={}), httpx.Response(404, json={}), QUEUED])
|
||||
|
||||
class Client:
|
||||
def create_evaluation(self, execution_id, *, eval_config=None):
|
||||
sent.append(eval_config)
|
||||
return next(answers)
|
||||
|
||||
monkeypatch.setattr(eval_module.time, "sleep", lambda _s: None)
|
||||
notes: list[str] = []
|
||||
|
||||
eval_module._start_evaluation(
|
||||
Client(), "run-1", wait_for_spans=True, note=notes.append
|
||||
)
|
||||
|
||||
assert sent == [None, None, None]
|
||||
assert [n for n in notes if "larger than" in n] == [notes[0]]
|
||||
|
||||
|
||||
def test_the_fallback_grades_only_a_run_recorded_after_it_began(
|
||||
project, monkeypatch, capsys
|
||||
):
|
||||
"""No app evaluated the run, so the command reads the project's record — but
|
||||
that record is the project's LAST run, and one written before this run
|
||||
began belongs to some other run."""
|
||||
directory, _ = project
|
||||
(directory / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
monkeypatch.setattr(eval_module.click, "confirm", lambda *a, **k: True)
|
||||
monkeypatch.setattr(eval_module.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"run_crew",
|
||||
lambda: record_last_run(
|
||||
directory, "somebody-elses-run", recorded_at="2020-01-01T00:00:00+00:00"
|
||||
),
|
||||
)
|
||||
monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False)
|
||||
amp = install(monkeypatch, FakeAMP(statuses=[done()]))
|
||||
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
eval_module.eval_crew()
|
||||
|
||||
assert exited.value.code == 1
|
||||
assert amp.calls == []
|
||||
assert "no trace was recorded" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_telemetry_never_breaks_the_command(project, monkeypatch):
|
||||
@@ -751,8 +815,11 @@ def test_the_app_it_opens_is_told_an_evaluation_is_waiting(project, monkeypatch)
|
||||
from crewai_cli.crew_run_tui import _AUTO_EVAL
|
||||
|
||||
assert _AUTO_EVAL.get() is not None, "the app must be told in this process"
|
||||
# and in a child process, where a ContextVar cannot reach
|
||||
assert os.environ["CREWAI_EVAL_AWAITING_RUN"] == "1"
|
||||
# and in a child process, where a ContextVar cannot reach: a token, and
|
||||
# the file of that name that makes it count
|
||||
from crewai_cli.crew_run_tui import _awaiting_dir
|
||||
|
||||
assert (_awaiting_dir() / os.environ["CREWAI_EVAL_AWAITING_RUN"]).is_file()
|
||||
record_last_run(directory, "run-it-just-did")
|
||||
eval_module.record_evaluation_outcome("run-it-just-did")
|
||||
|
||||
@@ -764,6 +831,9 @@ def test_the_app_it_opens_is_told_an_evaluation_is_waiting(project, monkeypatch)
|
||||
|
||||
assert amp.calls == [] # the app did it, on its own screen
|
||||
assert "CREWAI_EVAL_AWAITING_RUN" not in os.environ
|
||||
from crewai_cli.crew_run_tui import _awaiting_dir
|
||||
|
||||
assert not any(_awaiting_dir().iterdir()) # and the token went with it
|
||||
|
||||
|
||||
def test_a_run_no_app_evaluated_is_graded_by_the_command(project, monkeypatch):
|
||||
@@ -778,7 +848,7 @@ def test_a_run_no_app_evaluated_is_graded_by_the_command(project, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"run_crew",
|
||||
lambda: record_last_run(directory, "run-nobody-graded"),
|
||||
lambda: record_last_run(directory, "run-nobody-graded", recorded_at=_now()),
|
||||
)
|
||||
monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False)
|
||||
amp = install(monkeypatch, FakeAMP(statuses=[done()]))
|
||||
@@ -798,7 +868,7 @@ def test_a_marker_from_an_earlier_run_never_counts_for_this_one(project, monkeyp
|
||||
_write_marker(directory, "some-older-run", _seconds_ago(3600))
|
||||
|
||||
def fake_run_crew() -> None:
|
||||
record_last_run(directory, "the-new-run")
|
||||
record_last_run(directory, "the-new-run", recorded_at=_now())
|
||||
|
||||
monkeypatch.setattr(run_crew_module, "run_crew", fake_run_crew)
|
||||
monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextvars
|
||||
from datetime import datetime
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
@@ -46,6 +47,16 @@ import pytest
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _awaiting_files_stay_in_tmp(tmp_path, monkeypatch):
|
||||
"""The waiting-evaluation token file belongs to the user's crewAI data
|
||||
directory; a test keeps it in its own."""
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.crew_run_tui._awaiting_dir", lambda: tmp_path / "eval-awaiting"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _app_with_plan() -> CrewRunApp:
|
||||
app = CrewRunApp()
|
||||
app._plan = {
|
||||
@@ -465,19 +476,37 @@ def test_a_child_process_run_reads_the_waiting_evaluation_off_its_environment(
|
||||
"_evaluate_worker",
|
||||
lambda self, execution_id: started.append(execution_id),
|
||||
)
|
||||
|
||||
def the_child() -> None:
|
||||
# a fresh context: that variable, and nothing this process holds in memory
|
||||
assert crew_run_tui._AUTO_EVAL.get() is None
|
||||
app = CrewRunApp()
|
||||
app._execution_uuid = "run-in-the-child"
|
||||
app.exit = lambda result=None: pytest.fail("the app must stay open") # type: ignore[method-assign]
|
||||
app._evaluate_if_one_is_waiting()
|
||||
|
||||
with crew_run_tui.evaluating_after_run():
|
||||
contextvars.Context().run(the_child)
|
||||
|
||||
assert started == ["run-in-the-child"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "0" * 32])
|
||||
def test_a_project_env_cannot_say_an_evaluation_is_waiting(monkeypatch, value) -> None:
|
||||
"""The child loads the project's `.env` over its environment. A value the
|
||||
command did not mint — with no token file behind it — is no handshake, so
|
||||
a plain `crewai run` never starts an evaluation on its own."""
|
||||
monkeypatch.setenv("CREWAI_EVAL_AWAITING_RUN", value)
|
||||
|
||||
assert crew_run_tui._an_evaluation_is_waiting() is None
|
||||
|
||||
|
||||
def test_the_token_stops_counting_when_the_command_is_done(monkeypatch) -> None:
|
||||
with crew_run_tui.evaluating_after_run():
|
||||
passed = crew_run_tui.os.environ["CREWAI_EVAL_AWAITING_RUN"]
|
||||
|
||||
# the child's world: that variable, and nothing this process set
|
||||
assert "CREWAI_EVAL_AWAITING_RUN" not in crew_run_tui.os.environ
|
||||
monkeypatch.setenv("CREWAI_EVAL_AWAITING_RUN", passed)
|
||||
app = CrewRunApp()
|
||||
app._execution_uuid = "run-in-the-child"
|
||||
app.exit = lambda result=None: pytest.fail("the app must stay open") # type: ignore[method-assign]
|
||||
|
||||
app._evaluate_if_one_is_waiting()
|
||||
|
||||
assert started == ["run-in-the-child"]
|
||||
assert crew_run_tui._an_evaluation_is_waiting() is None
|
||||
|
||||
|
||||
def test_a_run_with_no_trace_says_so_instead_of_evaluating(monkeypatch) -> None:
|
||||
|
||||
@@ -572,12 +572,15 @@ class Telemetry:
|
||||
|
||||
self._safe_telemetry_procedure(_operation)
|
||||
|
||||
# What a caller may add to a feature span. Anything else is dropped: the
|
||||
# span counts a use, and a key nobody agreed on is how run content — a
|
||||
# prompt, an output, a path — reaches a place that promises not to hold it.
|
||||
# The dimensions every span already carries are reserved: a caller that
|
||||
# could overwrite `feature` could file its use under somebody else's name.
|
||||
FEATURE_ATTRIBUTES = frozenset({"execution_id", "authenticated", "organization_id"})
|
||||
# What a caller may add to a feature span, per feature. Anything else is
|
||||
# dropped: the span counts a use, and a key nobody agreed on is how run
|
||||
# content — a prompt, an output, a path — or an identifier reaches stats
|
||||
# that promise to hold neither. The dimensions every span already carries
|
||||
# are never on a list: a caller that could overwrite `feature` could file
|
||||
# its use under somebody else's name.
|
||||
FEATURE_ATTRIBUTES: ClassVar[dict[str, frozenset[str]]] = {
|
||||
"cli_usage:eval": frozenset({"authenticated"}),
|
||||
}
|
||||
|
||||
def feature_usage_span(
|
||||
self, feature: str, attributes: dict[str, str] | None = None
|
||||
@@ -587,10 +590,10 @@ class Telemetry:
|
||||
Args:
|
||||
feature: Feature identifier, e.g. ``"cli_usage:eval"``.
|
||||
attributes: What a caller knows about THIS use that the common
|
||||
attributes cannot know. Only the keys in ``FEATURE_ATTRIBUTES``
|
||||
are kept; anything else is dropped, including the dimensions
|
||||
every span already carries. Every span already carries the project
|
||||
id, the runtime and the version.
|
||||
attributes cannot know. Only the keys ``FEATURE_ATTRIBUTES`` lists
|
||||
for this feature are kept; anything else is dropped, including
|
||||
the dimensions every span already carries (the project id, the
|
||||
runtime and the version).
|
||||
"""
|
||||
from crewai_core.version import get_crewai_version
|
||||
|
||||
@@ -599,8 +602,9 @@ class Telemetry:
|
||||
span = tracer.start_span("Feature Usage")
|
||||
self._add_attribute(span, "crewai_version", get_crewai_version())
|
||||
self._add_attribute(span, "feature", feature)
|
||||
allowed = self.FEATURE_ATTRIBUTES.get(feature, frozenset())
|
||||
for key, value in (attributes or {}).items():
|
||||
if key in self.FEATURE_ATTRIBUTES:
|
||||
if key in allowed:
|
||||
self._add_attribute(span, key, str(value))
|
||||
close_span(span)
|
||||
|
||||
|
||||
@@ -269,6 +269,48 @@ def test_core_telemetry_records_feature_usage(
|
||||
span.end.assert_called_once()
|
||||
|
||||
|
||||
def test_core_feature_span_keeps_only_what_the_feature_may_send(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Each feature names what it may add. A key off its list — an id, run
|
||||
content, or a dimension every span already carries — is dropped, and a
|
||||
feature with no list sends nothing extra."""
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
Telemetry._instance = None
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
|
||||
|
||||
tracer = Mock()
|
||||
span = Mock()
|
||||
tracer.start_span.return_value = span
|
||||
monkeypatch.setattr(
|
||||
"crewai_core.telemetry.TracerProvider",
|
||||
lambda **_kwargs: Mock(get_tracer=Mock(return_value=tracer)),
|
||||
)
|
||||
offered = {
|
||||
"authenticated": "true",
|
||||
"execution_id": "run-1",
|
||||
"organization_id": "org-1",
|
||||
"feature": "someone_else",
|
||||
"crewai_version": "0.0.0",
|
||||
}
|
||||
|
||||
telemetry = Telemetry()
|
||||
telemetry.feature_usage_span("cli_usage:eval", offered)
|
||||
sent = {call.args[0]: call.args[1] for call in span.set_attribute.call_args_list}
|
||||
assert sent["authenticated"] == "true"
|
||||
assert sent["feature"] == "cli_usage:eval"
|
||||
assert sent["crewai_version"] != "0.0.0"
|
||||
assert "execution_id" not in sent and "organization_id" not in sent
|
||||
|
||||
span.set_attribute.reset_mock()
|
||||
telemetry.feature_usage_span("cli_usage:deploy", offered)
|
||||
sent = {call.args[0]: call.args[1] for call in span.set_attribute.call_args_list}
|
||||
assert "authenticated" not in sent and sent["feature"] == "cli_usage:deploy"
|
||||
|
||||
|
||||
def test_core_telemetry_records_flow_creation_version(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
import platform
|
||||
import signal
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from crewai_core.telemetry import (
|
||||
CommonAttributesSpanProcessor,
|
||||
@@ -1285,12 +1285,15 @@ class Telemetry:
|
||||
|
||||
self._safe_telemetry_operation(_operation)
|
||||
|
||||
# What a caller may add to a feature span. Anything else is dropped: the
|
||||
# span counts a use, and a key nobody agreed on is how run content — a
|
||||
# prompt, an output, a path — reaches a place that promises not to hold it.
|
||||
# The dimensions every span already carries are reserved: a caller that
|
||||
# could overwrite `feature` could file its use under somebody else's name.
|
||||
FEATURE_ATTRIBUTES = frozenset({"execution_id", "authenticated", "organization_id"})
|
||||
# What a caller may add to a feature span, per feature. Anything else is
|
||||
# dropped: the span counts a use, and a key nobody agreed on is how run
|
||||
# content — a prompt, an output, a path — or an identifier reaches stats
|
||||
# that promise to hold neither. The dimensions every span already carries
|
||||
# are never on a list: a caller that could overwrite `feature` could file
|
||||
# its use under somebody else's name.
|
||||
FEATURE_ATTRIBUTES: ClassVar[dict[str, frozenset[str]]] = {
|
||||
"cli_usage:eval": frozenset({"authenticated"}),
|
||||
}
|
||||
|
||||
def feature_usage_span(
|
||||
self, feature: str, attributes: dict[str, str] | None = None
|
||||
@@ -1302,9 +1305,9 @@ class Telemetry:
|
||||
"mcp:connection", "a2a:delegation",
|
||||
"hooks:pre_tool_call", "hooks:aborted".
|
||||
attributes: What a caller knows about THIS use that the common
|
||||
attributes cannot know. Only the keys in ``FEATURE_ATTRIBUTES``
|
||||
are kept; anything else is dropped, including the dimensions
|
||||
every span already carries.
|
||||
attributes cannot know. Only the keys ``FEATURE_ATTRIBUTES`` lists
|
||||
for this feature are kept; anything else is dropped, including
|
||||
the dimensions every span already carries.
|
||||
"""
|
||||
|
||||
def _operation() -> None:
|
||||
@@ -1312,8 +1315,9 @@ class Telemetry:
|
||||
span = tracer.start_span("Feature Usage")
|
||||
self._add_attribute(span, "crewai_version", version("crewai"))
|
||||
self._add_attribute(span, "feature", feature)
|
||||
allowed = self.FEATURE_ATTRIBUTES.get(feature, frozenset())
|
||||
for key, value in (attributes or {}).items():
|
||||
if key in self.FEATURE_ATTRIBUTES:
|
||||
if key in allowed:
|
||||
self._add_attribute(span, key, str(value))
|
||||
close_span(span)
|
||||
|
||||
|
||||
@@ -94,6 +94,30 @@ def test_our_own_spans_still_reach_our_exporter(telemetry_with_exporter):
|
||||
assert [span.name for span in exporter.get_finished_spans()] == ["Feature Usage"]
|
||||
|
||||
|
||||
def test_a_feature_span_keeps_only_what_the_feature_may_send(telemetry_with_exporter):
|
||||
"""The same policy as crewai-core's: each feature names what it may add, and
|
||||
the dimensions every span already carries cannot be overwritten."""
|
||||
telemetry, exporter = telemetry_with_exporter
|
||||
telemetry.set_tracer()
|
||||
offered = {
|
||||
"authenticated": "false",
|
||||
"execution_id": "run-1",
|
||||
"organization_id": "org-1",
|
||||
"feature": "someone_else",
|
||||
}
|
||||
|
||||
telemetry.feature_usage_span("cli_usage:eval", offered)
|
||||
telemetry.feature_usage_span("cli_usage:deploy", offered)
|
||||
|
||||
eval_span, deploy_span = exporter.get_finished_spans()
|
||||
assert eval_span.attributes["authenticated"] == "false"
|
||||
assert eval_span.attributes["feature"] == "cli_usage:eval"
|
||||
assert "execution_id" not in eval_span.attributes
|
||||
assert "organization_id" not in eval_span.attributes
|
||||
assert "authenticated" not in deploy_span.attributes
|
||||
assert deploy_span.attributes["feature"] == "cli_usage:deploy"
|
||||
|
||||
|
||||
def test_our_spans_are_unaffected_by_an_application_provider(telemetry_with_exporter):
|
||||
"""An app that installs its own provider must not divert our telemetry.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ resolution-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer = "2026-09-15T05:55:17.52834Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
@@ -1552,7 +1552,7 @@ requires-dist = [
|
||||
{ name = "httpx-auth", marker = "extra == 'a2a'", specifier = "~=0.23.1" },
|
||||
{ name = "httpx-sse", marker = "extra == 'a2a'", specifier = "~=0.4.0" },
|
||||
{ name = "ibm-watsonx-ai", marker = "extra == 'watson'", specifier = "~=1.3.39" },
|
||||
{ name = "instructor", specifier = ">=1.3.3,<1.16" },
|
||||
{ name = "instructor", specifier = ">=1.3.3" },
|
||||
{ name = "json-repair", specifier = "~=0.60.1" },
|
||||
{ name = "json5", specifier = "~=0.10.0" },
|
||||
{ name = "jsonref", specifier = "~=1.1.0" },
|
||||
|
||||
Reference in New Issue
Block a user