Skip to content

Commit d1a6bdd

Browse files
joaomdmouraclaude
andcommitted
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
1 parent 3932d3f commit d1a6bdd

9 files changed

Lines changed: 362 additions & 3 deletions

File tree

lib/cli/src/crewai_cli/create_crew.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from crewai_cli.utils import (
1616
copy_template,
17+
get_or_create_project_id,
1718
is_dmn_mode_enabled,
1819
load_env_vars,
1920
write_env_file,
@@ -320,6 +321,8 @@ def create_crew(
320321
copy_template(src_file, dst_file, name, class_name, folder_name)
321322

322323
if not parent_folder:
324+
# Minted at creation so the project has a stable identity from run one.
325+
get_or_create_project_id(folder_path / "pyproject.toml")
323326
initialize_if_git_available(folder_path)
324327

325328
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)

lib/cli/src/crewai_cli/create_flow.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from crewai_core.telemetry import Telemetry
66

77
from crewai_cli.git import initialize_if_git_available
8+
from crewai_cli.utils import get_or_create_project_id
89
from crewai_cli.version import get_crewai_tools_dependency
910

1011

@@ -31,6 +32,8 @@ def create_flow(name: str, *, declarative: bool = False) -> None:
3132
else:
3233
_create_python_flow(name, class_name, folder_name, project_root)
3334

35+
# Minted at creation so the project has a stable identity from run one.
36+
get_or_create_project_id(project_root / "pyproject.toml")
3437
initialize_if_git_available(project_root)
3538

3639
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)

lib/cli/src/crewai_cli/run_crew.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from crewai_cli.utils import (
2222
build_env_with_all_tool_credentials,
23+
ensure_project_id,
2324
is_dmn_mode_enabled,
2425
)
2526
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
@@ -617,6 +618,10 @@ def run_crew(
617618
or declarative (JSON) crew. Layered over the definition's own
618619
defaults; missing required values are prompted for interactively.
619620
"""
621+
# Backfills projects created before project_id existed. Only here, in a
622+
# command the user explicitly invoked - never from the SDK during kickoff.
623+
ensure_project_id()
624+
620625
# --definition is a pure override: run that flow directly.
621626
if definition is not None:
622627
_run_explicit_declarative_flow(

lib/cli/src/crewai_cli/tools/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
1717
from crewai_cli.utils import (
1818
build_env_with_tool_repository_credentials,
19+
ensure_project_id,
1920
get_project_description,
2021
get_project_name,
2122
get_project_version,
@@ -229,7 +230,8 @@ def install(self, handle: str) -> None:
229230
def login(self) -> None:
230231
get_user_id = _require_get_user_id()
231232
login_response = self.plus_api_client.login_to_tool_repository(
232-
user_identifier=get_user_id()
233+
user_identifier=get_user_id(),
234+
project_id=ensure_project_id(),
233235
)
234236

235237
if login_response.status_code != 200:

lib/cli/src/crewai_cli/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
import click
1111
from crewai_core.project import (
12+
get_or_create_project_id as get_or_create_project_id,
1213
get_project_description as get_project_description,
14+
get_project_id as get_project_id,
1315
get_project_name as get_project_name,
1416
get_project_version as get_project_version,
1517
parse_toml as parse_toml,
@@ -29,8 +31,11 @@
2931
"build_env_with_tool_repository_credentials",
3032
"copy_template",
3133
"enable_prompt_line_editing",
34+
"ensure_project_id",
3235
"fetch_and_json_env_file",
36+
"get_or_create_project_id",
3337
"get_project_description",
38+
"get_project_id",
3439
"get_project_name",
3540
"get_project_version",
3641
"is_dmn_mode_enabled",
@@ -48,6 +53,32 @@
4853
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
4954

5055

56+
def ensure_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None:
57+
"""Return the project's id, minting one and telling the user if it was added.
58+
59+
Safe to call from any CLI command: returns None rather than raising when
60+
there is no project, or when ``pyproject.toml`` is not writable.
61+
62+
Args:
63+
pyproject_path: Path to the project's ``pyproject.toml``.
64+
65+
Returns:
66+
The project id, or None if one could not be read or created.
67+
"""
68+
project_id, created = get_or_create_project_id(pyproject_path)
69+
70+
if created:
71+
console.print(
72+
f"Added [bold]project_id[/bold] to {pyproject_path} under "
73+
# Escaped: Rich would otherwise parse [tool.crewai] as a style tag.
74+
r"[bold]\[tool.crewai][/bold] so this project's runs and traces stay "
75+
"linked. Commit it to share that link with your team.",
76+
style="dim",
77+
)
78+
79+
return project_id
80+
81+
5182
def is_dmn_mode_enabled() -> bool:
5283
"""Return True when the enterprise non-interactive mode is enabled."""
5384
value = os.environ.get("CREWAI_DMN")

lib/crewai-core/src/crewai_core/plus_api.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class _WithUserIdentifier(TypedDict):
6969

7070

7171
class LoginPayload(_WithUserIdentifier):
72-
pass
72+
project_id: NotRequired[str]
7373

7474

7575
class TraceExecutionContext(TypedDict):
@@ -78,6 +78,7 @@ class TraceExecutionContext(TypedDict):
7878
flow_name: str | None
7979
crewai_version: str
8080
privacy_level: str
81+
project_id: NotRequired[str | None]
8182

8283

8384
class TraceExecutionMetadata(TypedDict):
@@ -229,11 +230,24 @@ def _make_multipart_request(
229230
return client.request(method, url, files=files, **request_kwargs)
230231

231232
def login_to_tool_repository(
232-
self, user_identifier: str | None = None
233+
self, user_identifier: str | None = None, project_id: str | None = None
233234
) -> httpx.Response:
235+
"""Log in to the tool repository.
236+
237+
This request is authenticated, so sending user_identifier and project_id
238+
alongside it links the account to the local pseudonymous user id and to
239+
the project the command was run from - letting prior anonymous usage of
240+
that project be attributed after signup.
241+
242+
Args:
243+
user_identifier: Local pseudonymous user id.
244+
project_id: ``[tool.crewai].project_id`` of the current project.
245+
"""
234246
payload: LoginPayload = {}
235247
if user_identifier:
236248
payload["user_identifier"] = user_identifier
249+
if project_id:
250+
payload["project_id"] = project_id
237251
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
238252

239253
def get_tool(self, handle: str) -> httpx.Response:

lib/crewai-core/src/crewai_core/project.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path, PureWindowsPath
77
import sys
88
from typing import Any
9+
import uuid
910

1011
from rich.console import Console
1112
import tomli
@@ -221,3 +222,121 @@ def get_project_description(
221222
return _get_project_attribute(
222223
pyproject_path, ["project", "description"], require=require
223224
)
225+
226+
227+
_PROJECT_ID_KEY = "project_id"
228+
229+
230+
def get_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None:
231+
"""Return ``[tool.crewai].project_id`` if the project has one.
232+
233+
Read-only and safe to call from library code: it never creates or modifies
234+
anything. Use this everywhere except the CLI commands that are allowed to
235+
mint an id (see :func:`get_or_create_project_id`).
236+
237+
Args:
238+
pyproject_path: Path to the project's ``pyproject.toml``.
239+
240+
Returns:
241+
The project id, or None when the file is missing, unreadable, or has
242+
no id configured.
243+
"""
244+
try:
245+
pyproject_data = read_toml(pyproject_path)
246+
except (OSError, tomli.TOMLDecodeError):
247+
return None
248+
249+
project_id = get_crewai_project_config(pyproject_data).get(_PROJECT_ID_KEY)
250+
return project_id if isinstance(project_id, str) and project_id else None
251+
252+
253+
def get_or_create_project_id(
254+
pyproject_path: str | Path = "pyproject.toml",
255+
) -> tuple[str | None, bool]:
256+
"""Return the project's id, minting and persisting one if absent.
257+
258+
Writes ``project_id`` into the ``[tool.crewai]`` table so it is committed
259+
with the repository. That makes it stable across machines, teammates, CI,
260+
and containers - unlike a machine- or user-derived identifier.
261+
262+
Only CLI commands the user explicitly invoked should call this. Library
263+
code must use :func:`get_project_id` instead; silently rewriting a user's
264+
``pyproject.toml`` during ``Crew.kickoff()`` would be surprising.
265+
266+
Args:
267+
pyproject_path: Path to the project's ``pyproject.toml``.
268+
269+
Returns:
270+
A ``(project_id, created)`` tuple. ``created`` is True only when an id
271+
was minted and written on this call, so callers can tell the user. Both
272+
values are ``(None, False)`` when the file is missing or not writable -
273+
this is best-effort and never raises.
274+
"""
275+
existing = get_project_id(pyproject_path)
276+
if existing:
277+
return existing, False
278+
279+
path = Path(pyproject_path)
280+
if not path.is_file():
281+
return None, False
282+
283+
try:
284+
content = path.read_text(encoding="utf-8")
285+
except OSError:
286+
return None, False
287+
288+
project_id = str(uuid.uuid4())
289+
updated = _insert_project_id(content, project_id)
290+
if updated is None:
291+
return None, False
292+
293+
try:
294+
path.write_text(updated, encoding="utf-8")
295+
except OSError:
296+
# Read-only checkout, permissions, container FS - not worth failing over.
297+
return None, False
298+
299+
return project_id, True
300+
301+
302+
def _insert_project_id(content: str, project_id: str) -> str | None:
303+
"""Add ``project_id`` to the ``[tool.crewai]`` table in TOML source text.
304+
305+
Edits the raw text rather than round-tripping through a TOML writer so
306+
formatting, ordering, and comments in the rest of the file are preserved.
307+
308+
Args:
309+
content: Full contents of a ``pyproject.toml``.
310+
project_id: The id to insert.
311+
312+
Returns:
313+
Updated file contents, or None if the edit could not be made safely.
314+
"""
315+
lines = content.splitlines(keepends=True)
316+
entry = f'{_PROJECT_ID_KEY} = "{project_id}"\n'
317+
318+
for index, line in enumerate(lines):
319+
if line.strip() != "[tool.crewai]":
320+
continue
321+
322+
# Insert at the end of the table, before the next table header, so the
323+
# key cannot land inside a different section.
324+
insert_at = len(lines)
325+
for offset in range(index + 1, len(lines)):
326+
if lines[offset].lstrip().startswith("["):
327+
insert_at = offset
328+
break
329+
330+
# Step back over trailing blank lines so the key stays in the table.
331+
while insert_at > index + 1 and not lines[insert_at - 1].strip():
332+
insert_at -= 1
333+
334+
if insert_at > 0 and not lines[insert_at - 1].endswith("\n"):
335+
lines[insert_at - 1] += "\n"
336+
337+
lines.insert(insert_at, entry)
338+
return "".join(lines)
339+
340+
# No [tool.crewai] table: append one rather than guessing where it belongs.
341+
suffix = "" if content.endswith("\n") or not content else "\n"
342+
return f"{content}{suffix}\n[tool.crewai]\n{entry}"

lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
TraceExecutionMetadata,
1515
TraceFinalizePayload,
1616
)
17+
from crewai_core.project import get_project_id
1718
from crewai_core.settings import Settings
1819
from rich.console import Console
1920
from rich.panel import Panel
@@ -145,6 +146,10 @@ def _initialize_backend_batch(
145146
"flow_name": execution_metadata.get("flow_name", None),
146147
"crewai_version": self.current_batch.version,
147148
"privacy_level": user_context.get("privacy_level", "standard"),
149+
# Read-only: never mints an id. Sent on both the ephemeral and
150+
# authenticated paths, so a project's traces stay attributable
151+
# to it before and after the user creates an account.
152+
"project_id": get_project_id(),
148153
}
149154
execution_metadata_payload: TraceExecutionMetadata = {
150155
"expected_duration_estimate": execution_metadata.get(

0 commit comments

Comments
 (0)