Files
crewAI/lib/crewai-core/tests/test_runtime_env.py
João Moura 1f6e327b3c feat(events): report machine size as a coarse band, not a core count (#7117)
* feat(events): report machine size as a coarse band, not a core count

`runtime_context` says where a process runs but carries no capacity axis, and
its largest bucket is a catch-all: a gunicorn worker on a VM and
`python main.py > out.log` on a MacBook both report `non_interactive`. Docker
Desktop on a laptop reports `container` via /.dockerenv, and a Remote-SSH shell
on a server reports `vscode_terminal`. So "server or laptop" is not answerable
from it today.

Adds `cpu_band` to the common span attributes, so it rides every span the way
`runtime_context` does rather than sitting on `Crew Created` alone - which would
answer nothing for Flow-only, CLI-only or standalone-agent runs.

Six bands, powers of two, top one open-ended: 1-2, 3-4, 5-8, 9-16, 17-32, 33+.
Open-ended because the exact count is the fingerprint - the observed fleet
maximum is 512, and a span reporting 512 identifies one machine. The vocabulary
is closed and asserted, like KNOWN_CODING_AGENTS and KNOWN_RUNTIME_CONTEXTS.

The share_crew-gated exact `cpus` attribute and the four platform* attributes
are untouched. That gating was a deliberate 2024 classification of machine
fingerprint as shareable content (44e38b1d5), and this does not reverse it: a
band is a range, the gated attribute remains the precise value.

Documents a trap in the docstring rather than leaving it to be rediscovered:
os.cpu_count() reports HOST cores, not the cgroup quota, so a 1-vCPU pod on a
96-core node lands in 33+. Right for "what kind of machine", wrong for "what did
this run get" - os.process_cpu_count() gives the latter but needs 3.13, above
this package's floor.

Docs updated in en/ar/ko/pt-BR, in the default-on Execution Environment row,
stating explicitly that the band is a range and the exact count stays opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(telemetry): say where the cpu band comes from

The Execution Environment row ended "Detection reads only whether known
environment variables are set, never their values". True for the assistant
and runtime-context fields, and wrong for the band this PR adds:
detect_cpu_band() reads os.cpu_count() (runtime_env.py:306) and touches no
environment variable. In a privacy disclosure table that is the kind of
inaccuracy worth a line.

Names the source explicitly and scopes the env-var sentence to the two
fields it actually describes. All four locales at parity.

pt-BR also takes the reviewer's wording fix: "um de uma lista fixa" ->
"um valor de uma lista fixa", and the missing comma before o `project_id`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:50:10 -07:00

211 lines
7.5 KiB
Python

"""crewai-core must report the same process context as crewai.
The CLI emits deployment, template and flow-creation spans through
``crewai_core.telemetry`` in processes that never import ``crewai``. Those spans
previously carried ``coding_agent``/``runtime_context`` only by accident - they
rode the global TracerProvider that ``crewai`` installed at import - so a
CLI-only process reported neither.
The detection and common-attribute behaviour itself is covered against the
shared implementation in ``lib/crewai/tests/telemetry/``; only what is specific
to crewai-core standing alone is tested here.
"""
from __future__ import annotations
from collections.abc import Iterator
from crewai_core.runtime_env import (
CODING_AGENT_ENV_MARKERS,
GENERIC_AGENT_ENV_VARS,
KNOWN_CPU_BANDS,
RUNTIME_CONTEXT_ENV_MARKERS,
detect_coding_agent,
detect_cpu_band,
detect_runtime_context,
)
from crewai_core.telemetry import Telemetry, common_span_attributes
from opentelemetry.sdk.trace.export import SpanExportResult
import pytest
class _NullExporter:
"""Stands in for the OTLP exporter so no test attempts a real export."""
def export(self, spans: object) -> SpanExportResult:
return SpanExportResult.SUCCESS
def shutdown(self) -> None:
pass
def force_flush(self, timeout_millis: int = 30000) -> bool:
return True
@pytest.fixture(autouse=True)
def _reset_cache() -> Iterator[None]:
common_span_attributes.cache_clear()
yield
common_span_attributes.cache_clear()
@pytest.fixture
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
"""Remove every marker the detectors read, so results are deterministic."""
for _, env_vars in (*CODING_AGENT_ENV_MARKERS, *RUNTIME_CONTEXT_ENV_MARKERS):
for var in env_vars:
monkeypatch.delenv(var, raising=False)
for var in (*GENERIC_AGENT_ENV_VARS, "TERM_PROGRAM", "TERMINAL_EMULATOR"):
monkeypatch.delenv(var, raising=False)
return monkeypatch
def test_detection_works_from_crewai_core(clean_env: pytest.MonkeyPatch) -> None:
"""The detectors resolve here, not only through the crewai re-export."""
clean_env.setenv("CLAUDECODE", "1")
clean_env.setenv("GITHUB_ACTIONS", "true")
assert detect_coding_agent() == "claude_code"
assert detect_runtime_context() == "ci"
def test_runtime_env_does_not_import_crewai() -> None:
"""crewai-core is the leaf package; importing crewai here would invert it.
Asserted on the module's imports rather than on ``sys.modules``, which
reflects whatever else the test session has already loaded and would make
this depend on ordering.
"""
import ast
import inspect
from crewai_core import runtime_env
tree = ast.parse(inspect.getsource(runtime_env))
imported: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported |= {alias.name for alias in node.names}
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module)
assert not [
module
for module in imported
if module == "crewai" or module.startswith("crewai.")
]
def test_cli_spans_carry_the_process_context(
clean_env: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The regression: CLI spans must carry coding_agent and runtime_context."""
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
clean_env.setenv("CLAUDECODE", "1")
clean_env.setenv("GITHUB_ACTIONS", "true")
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
Telemetry._instance = None
monkeypatch.setattr(Telemetry, "_register_shutdown_handlers", lambda self: None)
# Patched before construction: __init__ wires the real OTLP exporter.
monkeypatch.setattr(
"crewai_core.telemetry.SafeOTLPSpanExporter",
lambda **_kwargs: _NullExporter(),
)
telemetry = Telemetry()
exporter = InMemorySpanExporter()
telemetry.provider.add_span_processor(SimpleSpanProcessor(exporter))
try:
telemetry.feature_usage_span("cli_usage:deploy")
telemetry.template_installed_span("crew-template")
finally:
telemetry.provider.shutdown()
Telemetry._instance = None
exported = exporter.get_finished_spans()
assert [span.name for span in exported] == ["Feature Usage", "Template Installed"]
for span in exported:
attributes = span.attributes
assert attributes is not None
assert attributes["coding_agent"] == "claude_code"
assert attributes["runtime_context"] == "ci"
class TestCpuBand:
"""A coarse capacity signal: enough to tell a server from a laptop, no more."""
@pytest.mark.parametrize(
("cores", "expected"),
[
(1, "1-2"),
(2, "1-2"),
(3, "3-4"),
(4, "3-4"),
(5, "5-8"),
(8, "5-8"),
(9, "9-16"),
(16, "9-16"),
(17, "17-32"),
(32, "17-32"),
(33, "33+"),
(96, "33+"),
(512, "33+"),
],
)
def test_bands_are_inclusive_at_every_boundary(
self, cores: int, expected: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("crewai_core.runtime_env.os.cpu_count", lambda: cores)
assert detect_cpu_band() == expected
def test_the_largest_band_is_open_ended(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The exact count is the fingerprint: 512 cores identifies one machine.
The observed fleet maximum is 512, so the top band must absorb it rather
than the value reaching a span.
"""
monkeypatch.setattr("crewai_core.runtime_env.os.cpu_count", lambda: 512)
band = detect_cpu_band()
assert band == "33+"
assert "512" not in band
@pytest.mark.parametrize("unavailable", [None, 0])
def test_an_unavailable_count_is_unknown_not_a_band(
self, unavailable: int | None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`os.cpu_count()` returns None when it cannot tell; that is not "1-2"."""
monkeypatch.setattr("crewai_core.runtime_env.os.cpu_count", lambda: unavailable)
assert detect_cpu_band() == "unknown"
def test_the_real_machine_lands_in_the_closed_vocabulary(self) -> None:
"""Unmocked: whatever this host reports must still be a known literal."""
assert detect_cpu_band() in KNOWN_CPU_BANDS
def test_no_band_can_carry_a_precise_core_count(self) -> None:
"""Every emittable value is a short opaque label, as for the sibling signals."""
for band in KNOWN_CPU_BANDS:
assert len(band) <= 32, band
assert band.replace("-", "").replace("+", "").replace("_", "").isalnum(), (
band
)
def test_it_rides_every_span_rather_than_only_crew_created(self) -> None:
"""The gated `cpus` sits on Crew Created alone, which answers nothing for
Flow-only, CLI-only or standalone-agent runs. This one is a common
attribute, so it is on all of them."""
common_span_attributes.cache_clear()
try:
assert common_span_attributes()["cpu_band"] in KNOWN_CPU_BANDS
finally:
common_span_attributes.cache_clear()