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:
Joao Moura
2026-09-28 00:21:56 -07:00
co-authored by Claude Opus 5.5
parent 58bc1de74a
commit 52fa5f3ae0
9 changed files with 302 additions and 82 deletions
+39 -2
View File
@@ -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}"
+87 -17
View File
@@ -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)
+38 -9
View File
@@ -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:
+15 -11
View File
@@ -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)
+42
View File
@@ -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:
+15 -11
View File
@@ -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.
Generated
+2 -2
View File
@@ -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" },