mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-10 08:21:54 +00:00
feat: add project_id to link OSS usage to an enterprise account
Adds a stable per-project identifier so a project's OSS traces and runs can be attributed to an account after signup. There was no such identifier before: [tool.crewai] held only `type`, the deploy UUID was printed to the console but never persisted, Settings.org_uuid is global rather than per-project, and trace batches carried only crew_fingerprint/crew_name. The id lives in the project's pyproject.toml, so it is committed with the repository and stays stable across machines, teammates, CI, and containers - unlike a machine- or user-derived identifier, which is unstable in exactly the containerized production environments that matter most. crewai-core: - get_project_id(): read-only lookup of [tool.crewai].project_id. Safe for library code; never creates or modifies anything. - get_or_create_project_id(): mints a uuid4 and persists it, returning (id, created) so callers can tell the user. Best-effort - returns (None, False) for a missing, malformed, or read-only pyproject.toml rather than raising. - Insertion edits the raw TOML text instead of round-tripping through a writer, so comments, key order, and formatting elsewhere survive. The key is placed at the end of the [tool.crewai] table, before the next table header, so it cannot land in a neighbouring section. - LoginPayload and TraceExecutionContext gain optional project_id. Sent on two paths: - Traces: project_id is added to execution_context, which is sent on both the ephemeral and authenticated paths, so a project's traces remain attributable before and after the user creates an account. - Login: `crewai login` already sends the pseudonymous user_identifier on an authenticated request; adding project_id means one request carries account + user + project, which is the link itself. Minting is restricted to CLI commands the user explicitly invoked - `crewai create` for new projects and `crewai run` to backfill existing ones - and is announced when it happens. Library code only ever reads. Silently rewriting a user's pyproject.toml during Crew.kickoff() would be surprising. Privacy: project_id is a random uuid4 in a file the user commits. It is visible in a diff, contains nothing personal, and identifies a project rather than a person - so this needs none of the notice changes that attaching a user identifier to all telemetry would require. Tests: 18 new tests covering minting, stability, table placement, comment and formatting preservation, five pyproject layouts, the neighbouring-table regression, and graceful handling of missing/malformed/read-only files. Verified end-to-end that both create paths mint distinct ids, that the trace payload carries project_id on both the ephemeral and authenticated paths, and that the login payload carries user_identifier and project_id together. Follow-ups, deliberately not included: adding project_id to telemetry spans, and backend persistence of the (account, user_identifier, project_id) triple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t
This commit is contained in:
@@ -14,6 +14,7 @@ from crewai_cli.provider import (
|
||||
)
|
||||
from crewai_cli.utils import (
|
||||
copy_template,
|
||||
get_or_create_project_id,
|
||||
is_dmn_mode_enabled,
|
||||
load_env_vars,
|
||||
write_env_file,
|
||||
@@ -320,6 +321,8 @@ def create_crew(
|
||||
copy_template(src_file, dst_file, name, class_name, folder_name)
|
||||
|
||||
if not parent_folder:
|
||||
# Minted at creation so the project has a stable identity from run one.
|
||||
get_or_create_project_id(folder_path / "pyproject.toml")
|
||||
initialize_if_git_available(folder_path)
|
||||
|
||||
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ import click
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
from crewai_cli.git import initialize_if_git_available
|
||||
from crewai_cli.utils import get_or_create_project_id
|
||||
from crewai_cli.version import get_crewai_tools_dependency
|
||||
|
||||
|
||||
@@ -31,6 +32,8 @@ def create_flow(name: str, *, declarative: bool = False) -> None:
|
||||
else:
|
||||
_create_python_flow(name, class_name, folder_name, project_root)
|
||||
|
||||
# Minted at creation so the project has a stable identity from run one.
|
||||
get_or_create_project_id(project_root / "pyproject.toml")
|
||||
initialize_if_git_available(project_root)
|
||||
|
||||
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
|
||||
|
||||
@@ -20,6 +20,7 @@ from crewai_cli.input_prompt import (
|
||||
)
|
||||
from crewai_cli.utils import (
|
||||
build_env_with_all_tool_credentials,
|
||||
ensure_project_id,
|
||||
is_dmn_mode_enabled,
|
||||
)
|
||||
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
|
||||
@@ -617,6 +618,10 @@ def run_crew(
|
||||
or declarative (JSON) crew. Layered over the definition's own
|
||||
defaults; missing required values are prompted for interactively.
|
||||
"""
|
||||
# Backfills projects created before project_id existed. Only here, in a
|
||||
# command the user explicitly invoked - never from the SDK during kickoff.
|
||||
ensure_project_id()
|
||||
|
||||
# --definition is a pure override: run that flow directly.
|
||||
if definition is not None:
|
||||
_run_explicit_declarative_flow(
|
||||
|
||||
@@ -16,6 +16,7 @@ from crewai_cli.config import Settings
|
||||
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
from crewai_cli.utils import (
|
||||
build_env_with_tool_repository_credentials,
|
||||
ensure_project_id,
|
||||
get_project_description,
|
||||
get_project_name,
|
||||
get_project_version,
|
||||
@@ -229,7 +230,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
def login(self) -> None:
|
||||
get_user_id = _require_get_user_id()
|
||||
login_response = self.plus_api_client.login_to_tool_repository(
|
||||
user_identifier=get_user_id()
|
||||
user_identifier=get_user_id(),
|
||||
project_id=ensure_project_id(),
|
||||
)
|
||||
|
||||
if login_response.status_code != 200:
|
||||
|
||||
@@ -9,7 +9,9 @@ from typing import Any
|
||||
|
||||
import click
|
||||
from crewai_core.project import (
|
||||
get_or_create_project_id as get_or_create_project_id,
|
||||
get_project_description as get_project_description,
|
||||
get_project_id as get_project_id,
|
||||
get_project_name as get_project_name,
|
||||
get_project_version as get_project_version,
|
||||
parse_toml as parse_toml,
|
||||
@@ -29,8 +31,11 @@ __all__ = [
|
||||
"build_env_with_tool_repository_credentials",
|
||||
"copy_template",
|
||||
"enable_prompt_line_editing",
|
||||
"ensure_project_id",
|
||||
"fetch_and_json_env_file",
|
||||
"get_or_create_project_id",
|
||||
"get_project_description",
|
||||
"get_project_id",
|
||||
"get_project_name",
|
||||
"get_project_version",
|
||||
"is_dmn_mode_enabled",
|
||||
@@ -48,6 +53,32 @@ console = Console()
|
||||
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
|
||||
|
||||
|
||||
def ensure_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None:
|
||||
"""Return the project's id, minting one and telling the user if it was added.
|
||||
|
||||
Safe to call from any CLI command: returns None rather than raising when
|
||||
there is no project, or when ``pyproject.toml`` is not writable.
|
||||
|
||||
Args:
|
||||
pyproject_path: Path to the project's ``pyproject.toml``.
|
||||
|
||||
Returns:
|
||||
The project id, or None if one could not be read or created.
|
||||
"""
|
||||
project_id, created = get_or_create_project_id(pyproject_path)
|
||||
|
||||
if created:
|
||||
console.print(
|
||||
f"Added [bold]project_id[/bold] to {pyproject_path} under "
|
||||
# Escaped: Rich would otherwise parse [tool.crewai] as a style tag.
|
||||
r"[bold]\[tool.crewai][/bold] so this project's runs and traces stay "
|
||||
"linked. Commit it to share that link with your team.",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
return project_id
|
||||
|
||||
|
||||
def is_dmn_mode_enabled() -> bool:
|
||||
"""Return True when the enterprise non-interactive mode is enabled."""
|
||||
value = os.environ.get("CREWAI_DMN")
|
||||
|
||||
@@ -69,7 +69,7 @@ class _WithUserIdentifier(TypedDict):
|
||||
|
||||
|
||||
class LoginPayload(_WithUserIdentifier):
|
||||
pass
|
||||
project_id: NotRequired[str]
|
||||
|
||||
|
||||
class TraceExecutionContext(TypedDict):
|
||||
@@ -78,6 +78,7 @@ class TraceExecutionContext(TypedDict):
|
||||
flow_name: str | None
|
||||
crewai_version: str
|
||||
privacy_level: str
|
||||
project_id: NotRequired[str | None]
|
||||
|
||||
|
||||
class TraceExecutionMetadata(TypedDict):
|
||||
@@ -229,11 +230,24 @@ class PlusAPI:
|
||||
return client.request(method, url, files=files, **request_kwargs)
|
||||
|
||||
def login_to_tool_repository(
|
||||
self, user_identifier: str | None = None
|
||||
self, user_identifier: str | None = None, project_id: str | None = None
|
||||
) -> httpx.Response:
|
||||
"""Log in to the tool repository.
|
||||
|
||||
This request is authenticated, so sending user_identifier and project_id
|
||||
alongside it links the account to the local pseudonymous user id and to
|
||||
the project the command was run from - letting prior anonymous usage of
|
||||
that project be attributed after signup.
|
||||
|
||||
Args:
|
||||
user_identifier: Local pseudonymous user id.
|
||||
project_id: ``[tool.crewai].project_id`` of the current project.
|
||||
"""
|
||||
payload: LoginPayload = {}
|
||||
if user_identifier:
|
||||
payload["user_identifier"] = user_identifier
|
||||
if project_id:
|
||||
payload["project_id"] = project_id
|
||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
|
||||
|
||||
def get_tool(self, handle: str) -> httpx.Response:
|
||||
|
||||
@@ -6,6 +6,7 @@ from functools import reduce
|
||||
from pathlib import Path, PureWindowsPath
|
||||
import sys
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from rich.console import Console
|
||||
import tomli
|
||||
@@ -221,3 +222,121 @@ def get_project_description(
|
||||
return _get_project_attribute(
|
||||
pyproject_path, ["project", "description"], require=require
|
||||
)
|
||||
|
||||
|
||||
_PROJECT_ID_KEY = "project_id"
|
||||
|
||||
|
||||
def get_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None:
|
||||
"""Return ``[tool.crewai].project_id`` if the project has one.
|
||||
|
||||
Read-only and safe to call from library code: it never creates or modifies
|
||||
anything. Use this everywhere except the CLI commands that are allowed to
|
||||
mint an id (see :func:`get_or_create_project_id`).
|
||||
|
||||
Args:
|
||||
pyproject_path: Path to the project's ``pyproject.toml``.
|
||||
|
||||
Returns:
|
||||
The project id, or None when the file is missing, unreadable, or has
|
||||
no id configured.
|
||||
"""
|
||||
try:
|
||||
pyproject_data = read_toml(pyproject_path)
|
||||
except (OSError, tomli.TOMLDecodeError):
|
||||
return None
|
||||
|
||||
project_id = get_crewai_project_config(pyproject_data).get(_PROJECT_ID_KEY)
|
||||
return project_id if isinstance(project_id, str) and project_id else None
|
||||
|
||||
|
||||
def get_or_create_project_id(
|
||||
pyproject_path: str | Path = "pyproject.toml",
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Return the project's id, minting and persisting one if absent.
|
||||
|
||||
Writes ``project_id`` into the ``[tool.crewai]`` table so it is committed
|
||||
with the repository. That makes it stable across machines, teammates, CI,
|
||||
and containers - unlike a machine- or user-derived identifier.
|
||||
|
||||
Only CLI commands the user explicitly invoked should call this. Library
|
||||
code must use :func:`get_project_id` instead; silently rewriting a user's
|
||||
``pyproject.toml`` during ``Crew.kickoff()`` would be surprising.
|
||||
|
||||
Args:
|
||||
pyproject_path: Path to the project's ``pyproject.toml``.
|
||||
|
||||
Returns:
|
||||
A ``(project_id, created)`` tuple. ``created`` is True only when an id
|
||||
was minted and written on this call, so callers can tell the user. Both
|
||||
values are ``(None, False)`` when the file is missing or not writable -
|
||||
this is best-effort and never raises.
|
||||
"""
|
||||
existing = get_project_id(pyproject_path)
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
path = Path(pyproject_path)
|
||||
if not path.is_file():
|
||||
return None, False
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None, False
|
||||
|
||||
project_id = str(uuid.uuid4())
|
||||
updated = _insert_project_id(content, project_id)
|
||||
if updated is None:
|
||||
return None, False
|
||||
|
||||
try:
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
except OSError:
|
||||
# Read-only checkout, permissions, container FS - not worth failing over.
|
||||
return None, False
|
||||
|
||||
return project_id, True
|
||||
|
||||
|
||||
def _insert_project_id(content: str, project_id: str) -> str | None:
|
||||
"""Add ``project_id`` to the ``[tool.crewai]`` table in TOML source text.
|
||||
|
||||
Edits the raw text rather than round-tripping through a TOML writer so
|
||||
formatting, ordering, and comments in the rest of the file are preserved.
|
||||
|
||||
Args:
|
||||
content: Full contents of a ``pyproject.toml``.
|
||||
project_id: The id to insert.
|
||||
|
||||
Returns:
|
||||
Updated file contents, or None if the edit could not be made safely.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
entry = f'{_PROJECT_ID_KEY} = "{project_id}"\n'
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() != "[tool.crewai]":
|
||||
continue
|
||||
|
||||
# Insert at the end of the table, before the next table header, so the
|
||||
# key cannot land inside a different section.
|
||||
insert_at = len(lines)
|
||||
for offset in range(index + 1, len(lines)):
|
||||
if lines[offset].lstrip().startswith("["):
|
||||
insert_at = offset
|
||||
break
|
||||
|
||||
# Step back over trailing blank lines so the key stays in the table.
|
||||
while insert_at > index + 1 and not lines[insert_at - 1].strip():
|
||||
insert_at -= 1
|
||||
|
||||
if insert_at > 0 and not lines[insert_at - 1].endswith("\n"):
|
||||
lines[insert_at - 1] += "\n"
|
||||
|
||||
lines.insert(insert_at, entry)
|
||||
return "".join(lines)
|
||||
|
||||
# No [tool.crewai] table: append one rather than guessing where it belongs.
|
||||
suffix = "" if content.endswith("\n") or not content else "\n"
|
||||
return f"{content}{suffix}\n[tool.crewai]\n{entry}"
|
||||
|
||||
@@ -14,6 +14,7 @@ from crewai_core.plus_api import (
|
||||
TraceExecutionMetadata,
|
||||
TraceFinalizePayload,
|
||||
)
|
||||
from crewai_core.project import get_project_id
|
||||
from crewai_core.settings import Settings
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -145,6 +146,10 @@ class TraceBatchManager:
|
||||
"flow_name": execution_metadata.get("flow_name", None),
|
||||
"crewai_version": self.current_batch.version,
|
||||
"privacy_level": user_context.get("privacy_level", "standard"),
|
||||
# Read-only: never mints an id. Sent on both the ephemeral and
|
||||
# authenticated paths, so a project's traces stay attributable
|
||||
# to it before and after the user creates an account.
|
||||
"project_id": get_project_id(),
|
||||
}
|
||||
execution_metadata_payload: TraceExecutionMetadata = {
|
||||
"expected_duration_estimate": execution_metadata.get(
|
||||
|
||||
177
lib/crewai/tests/telemetry/test_project_id.py
Normal file
177
lib/crewai/tests/telemetry/test_project_id.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Tests for the project_id used to link OSS usage to an enterprise account."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_core.project import (
|
||||
get_or_create_project_id,
|
||||
get_project_id,
|
||||
parse_toml,
|
||||
)
|
||||
|
||||
|
||||
CREW_PYPROJECT = """\
|
||||
[project]
|
||||
name = "my_crew"
|
||||
version = "0.1.0"
|
||||
dependencies = ["crewai"]
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pyproject(tmp_path):
|
||||
path = tmp_path / "pyproject.toml"
|
||||
path.write_text(CREW_PYPROJECT)
|
||||
return path
|
||||
|
||||
|
||||
def test_returns_none_when_no_id_configured(pyproject):
|
||||
assert get_project_id(pyproject) is None
|
||||
|
||||
|
||||
def test_mints_and_persists_an_id(pyproject):
|
||||
project_id, created = get_or_create_project_id(pyproject)
|
||||
|
||||
assert created is True
|
||||
assert uuid.UUID(project_id)
|
||||
assert get_project_id(pyproject) == project_id
|
||||
|
||||
|
||||
def test_id_is_stable_across_calls(pyproject):
|
||||
first, created_first = get_or_create_project_id(pyproject)
|
||||
second, created_second = get_or_create_project_id(pyproject)
|
||||
|
||||
assert created_first is True
|
||||
assert created_second is False, "must not mint a second id"
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_id_lands_in_the_tool_crewai_table(pyproject):
|
||||
project_id, _ = get_or_create_project_id(pyproject)
|
||||
|
||||
data = parse_toml(pyproject.read_text())
|
||||
assert data["tool"]["crewai"]["project_id"] == project_id
|
||||
assert data["tool"]["crewai"]["type"] == "crew", "existing keys must survive"
|
||||
|
||||
|
||||
def test_other_tables_are_preserved(pyproject):
|
||||
get_or_create_project_id(pyproject)
|
||||
|
||||
data = parse_toml(pyproject.read_text())
|
||||
assert data["project"]["name"] == "my_crew"
|
||||
assert data["project"]["dependencies"] == ["crewai"]
|
||||
assert data["build-system"]["build-backend"] == "hatchling.build"
|
||||
|
||||
|
||||
def test_comments_and_formatting_are_preserved(tmp_path):
|
||||
"""Raw-text editing rather than a TOML round-trip, so comments survive."""
|
||||
path = tmp_path / "pyproject.toml"
|
||||
path.write_text(
|
||||
'# top comment\n[project]\nname = "x" # inline comment\n\n[tool.crewai]\ntype = "flow"\n'
|
||||
)
|
||||
|
||||
get_or_create_project_id(path)
|
||||
|
||||
content = path.read_text()
|
||||
assert "# top comment" in content
|
||||
assert "# inline comment" in content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "label"),
|
||||
[
|
||||
('[project]\nname = "x"\n\n[tool.crewai]\ntype = "crew"\n', "table then EOF"),
|
||||
('[tool.crewai]\ntype = "crew"', "no trailing newline"),
|
||||
('[project]\nname = "x"\n', "no tool.crewai table"),
|
||||
('[project]\nname = "x"\n[tool.crewai]\n[other]\na = 1\n', "empty table"),
|
||||
(
|
||||
'[tool.crewai]\ntype = "crew"\n\n\n[build-system]\nrequires = []\n',
|
||||
"blank lines before next table",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_produces_valid_toml_for_varied_layouts(tmp_path, source, label):
|
||||
path = tmp_path / "pyproject.toml"
|
||||
path.write_text(source)
|
||||
|
||||
project_id, created = get_or_create_project_id(path)
|
||||
|
||||
assert created is True, label
|
||||
data = parse_toml(path.read_text())
|
||||
assert data["tool"]["crewai"]["project_id"] == project_id, label
|
||||
|
||||
|
||||
def test_id_does_not_leak_into_a_neighbouring_table(tmp_path):
|
||||
"""The key must never land under [build-system]."""
|
||||
path = tmp_path / "pyproject.toml"
|
||||
path.write_text(
|
||||
'[tool.crewai]\ntype = "crew"\n\n[build-system]\nrequires = ["hatchling"]\n'
|
||||
)
|
||||
|
||||
get_or_create_project_id(path)
|
||||
|
||||
data = parse_toml(path.read_text())
|
||||
assert "project_id" in data["tool"]["crewai"]
|
||||
assert "project_id" not in data["build-system"]
|
||||
|
||||
|
||||
def test_missing_file_is_not_an_error(tmp_path):
|
||||
project_id, created = get_or_create_project_id(tmp_path / "nope.toml")
|
||||
|
||||
assert project_id is None
|
||||
assert created is False
|
||||
|
||||
|
||||
def test_malformed_toml_is_not_an_error(tmp_path):
|
||||
path = tmp_path / "pyproject.toml"
|
||||
path.write_text("this is not [valid toml")
|
||||
|
||||
assert get_project_id(path) is None
|
||||
|
||||
|
||||
def test_read_only_file_is_not_an_error(pyproject):
|
||||
"""A read-only checkout must not break the command that called this."""
|
||||
pyproject.chmod(0o444)
|
||||
try:
|
||||
project_id, created = get_or_create_project_id(pyproject)
|
||||
finally:
|
||||
pyproject.chmod(0o644)
|
||||
|
||||
assert project_id is None
|
||||
assert created is False
|
||||
|
||||
|
||||
def test_get_project_id_never_creates_anything(pyproject):
|
||||
"""Library code calls the read-only variant; it must not mutate the file."""
|
||||
before = pyproject.read_text()
|
||||
|
||||
assert get_project_id(pyproject) is None
|
||||
|
||||
assert pyproject.read_text() == before
|
||||
|
||||
|
||||
def test_blank_id_is_treated_as_absent(tmp_path):
|
||||
path = tmp_path / "pyproject.toml"
|
||||
path.write_text('[tool.crewai]\ntype = "crew"\nproject_id = ""\n')
|
||||
|
||||
assert get_project_id(path) is None
|
||||
|
||||
|
||||
def test_ids_are_unique_across_projects(tmp_path):
|
||||
ids = set()
|
||||
for name in ("a", "b", "c"):
|
||||
path = tmp_path / name / "pyproject.toml"
|
||||
path.parent.mkdir()
|
||||
path.write_text(CREW_PYPROJECT)
|
||||
project_id, _ = get_or_create_project_id(path)
|
||||
ids.add(project_id)
|
||||
|
||||
assert len(ids) == 3
|
||||
Reference in New Issue
Block a user