diff --git a/docs/edge/ar/telemetry.mdx b/docs/edge/ar/telemetry.mdx index ae734669f..b7e3967a0 100644 --- a/docs/edge/ar/telemetry.mdx +++ b/docs/edge/ar/telemetry.mdx @@ -63,7 +63,7 @@ os.environ['OTEL_SDK_DISABLED'] = 'true' | نعم | بيانات دورة حياة المهمة | تشمل: أوقات الإنشاء وبدء/انتهاء التنفيذ، معرّفات الطاقم والمهمة، وما إذا نجحت المهمة أو فشلت. وعند فشل المهمة، يُسجَّل **اسم صنف** الاستثناء (مثل `TimeoutError`) بحيث يمكن عدّ حالات الفشل وتشخيصها — وليس رسالة الخطأ أبدًا، فهي قد تحتوي على مطالبات أو مخرجات نموذج أو مسارات ملفات أو بيانات اعتماد. مخزنة كنطاقات مع طوابع زمنية. لا بيانات شخصية. | | نعم | سمات LLM | تشمل: الاسم، model_name، model، top_k، temperature، واسم فئة LLM. كلها بيانات تقنية غير شخصية. | | نعم | إنشاء مشروع باستخدام CLI الخاص بـ CrewAI | تشمل: أن مشروعًا جديدًا أُنشئ عبر `crewai create`، ونوعه (`crew` أو `json_crew` أو `flow`)، ومعرّف المشروع الذي تم توليده لهذا المشروع الجديد وكُتب في ملف `pyproject.toml` الخاص به. وهو معرّف المشروع الجديد نفسه، ويُسجَّل بشكل منفصل عن `project_id` الخاص بالمجلد الذي شُغّل منه الأمر — وقد يختلفان. لا اسم مشروع، ولا محتويات ملفات، ولا شيفرة. لا بيانات شخصية. | -| نعم | محاولة نشر الطاقم باستخدام CLI الخاص بـ CrewAI | تشمل: حقيقة إجراء النشر ومعرّف الطاقم، وما إذا كان يحاول سحب السجلات، وما إذا بدأ النشر من أمر CLI أو من واجهة التشغيل TUI. لا تُسجَّل محتويات المشروع أو الطاقم. لا توجد بيانات شخصية. | +| نعم | محاولة نشر الطاقم باستخدام CLI الخاص بـ CrewAI | تشمل: حقيقة إجراء النشر ومعرّف الطاقم، وما إذا كان يحاول سحب السجلات، وما إذا بدأ النشر من أمر CLI أو من واجهة التشغيل TUI. إذا فشل إنشاء النشر، تُسجَّل فئة الفشل (واحدة من قائمة ثابتة مثل `api_4xx` أو `network_error` أو `user_declined`) ورمز حالة HTTP لاستجابة API إن وُجدت — ولا تُسجَّل رسالة الخطأ أبدًا. لا تُسجَّل محتويات المشروع أو الطاقم. لا توجد بيانات شخصية. | | نعم | بيئة التنفيذ | تشمل: مساعد البرمجة بالذكاء الاصطناعي الذي يشغّل العملية إن وُجد (واحد من قائمة ثابتة مثل `claude_code` أو `codex` أو `cursor` أو `unknown`)، ومكان تشغيل العملية (واحد من قائمة ثابتة مثل `ci` أو `container` أو `serverless` أو `interactive`)، و`project_id` من ملف `pyproject.toml` عند ضبطه، ونطاقًا تقريبيًا لحجم الجهاز (واحد من `1-2` أو `3-4` أو `5-8` أو `9-16` أو `17-32` أو `33+` أو `unknown`). النطاق مجال وليس العدد الدقيق للأنوية أبدًا — العدد الدقيق اختياري فقط، ضمن «معلومات البيئة» أدناه. تأتي فئة الحجم من عدد أنوية المضيف؛ ويتحقق اكتشاف المساعد وموقع التشغيل فقط مما إذا كانت متغيرات البيئة المعروفة مضبوطة، ولا يقرأ قيمها أبدًا. لا بيانات شخصية. | | نعم | إشارات دورة حياة التدفق | تشمل: بدء التدفق، وما إذا اكتمل أو فشل، وما إذا فشلت إحدى طرقه، وما إذا توقف مؤقتًا لانتظار إدخال أو ملاحظات بشرية، وما إذا كان البدء تشغيلًا مستأنفًا، وما إذا فشل دور محادثة، ومدة تشغيل التدفق، وما إذا كان التدفق مما تشغّله CrewAI داخليًا أو مما كتبته أنت. ويُسجَّل اسم التدفق، كما هو الحال بالفعل لإنشاء التدفق وتنفيذه. وعند فشل تدفق أو إحدى طرقه، يُسجَّل **اسم فئة** الاستثناء (مثل `TimeoutError`) لتشخيص الأعطال — ولا تُسجَّل أبدًا رسالة الخطأ، التي قد تحتوي على مطالبات أو مخرجات النموذج أو مسارات ملفات أو بيانات اعتماد. ولا تُسجَّل أبدًا أسماء الطرق أو حالة التدفق. لا توجد بيانات شخصية. | | نعم | إشارة مشاركة التتبع | تشمل: نجاح مشاركة دفعة من عمليات التتبع مع CrewAI AMP، وما إذا تمت المشاركة بشكل مجهول (قبل إنشاء حساب) أو مرتبطة بحسابك. ومثل كل span، تحمل أيضًا سمات بيئة التنفيذ الموضحة أعلاه (`project_id` عند تكوينه، ومساعد البرمجة، وبيئة التشغيل). يصف هذا الصف بيانات القياس عن بُعد الخاصة بالمشاركة فقط — وليس محتويات التتبع أو الوصول الذي تمنحه روابط التتبع المشتركة. لا تُسجَّل محتويات التتبع أو المدخلات أو المخرجات في هذه الإشارة. قبل مشاركة التتبعات، راجع الأسرار والبيانات الشخصية وإعدادات التنقيح والاحتفاظ في AMP. | diff --git a/docs/edge/en/telemetry.mdx b/docs/edge/en/telemetry.mdx index f9e7abf78..9801c44c7 100644 --- a/docs/edge/en/telemetry.mdx +++ b/docs/edge/en/telemetry.mdx @@ -63,7 +63,7 @@ own tracer provider, which is independent of the one described here. | Yes | Task Lifecycle Data | Includes: creation and execution start/end times, crew and task identifiers, and whether the task succeeded or failed. When a task fails, the **class name** of the exception is recorded (for example `TimeoutError`) so failures can be counted and diagnosed — never the error message, which can contain prompts, model output, file paths or credentials. Stored as spans with timestamps. No personal data. | | Yes | LLM Attributes | Includes: name, model_name, model, top_k, temperature, and class name of the LLM. All technical, non-personal data. | | Yes | Project Creation using crewAI CLI | Includes: that a new project was scaffolded by `crewai create`, which kind it was (`crew`, `json_crew` or `flow`), and the project ID minted for that new project and written into its own `pyproject.toml`. That is the new project's own ID, recorded separately from the `project_id` of the directory the command was run from — the two can differ. No project name, no file contents, no code. No personal data. | -| Yes | Crew Deployment attempt using crewAI CLI | Includes: The fact a deploy is being made and crew id, whether it's trying to pull logs, and whether the deploy was started from a CLI command or from the run TUI. No project or crew contents. No personal data. | +| Yes | Crew Deployment attempt using crewAI CLI | Includes: The fact a deploy is being made and crew id, whether it's trying to pull logs, and whether the deploy was started from a CLI command or from the run TUI. If creating a deployment fails, the failure category (one of a fixed list such as `api_4xx`, `network_error` or `user_declined`) and the HTTP status code of the API response, when there was one, are recorded — never the error message. No project or crew contents. No personal data. | | Yes | Execution Environment | Includes: which AI coding assistant is running the process, if any (one of a fixed list such as `claude_code`, `codex`, `cursor`, or `unknown`), where the process runs (one of a fixed list such as `ci`, `container`, `serverless`, `interactive`), the `project_id` from your `pyproject.toml` when one is configured, and a coarse size band for the machine (one of `1-2`, `3-4`, `5-8`, `9-16`, `17-32`, `33+`, or `unknown`). The band is a range, never the exact core count — the exact count is opt-in only, under Environment Information below. The size band comes from the host CPU count; assistant and location detection reads only whether known environment variables are set, never their values. No personal data. | | Yes | Flow Lifecycle Signals | Includes: that a flow started, whether it completed or failed, whether one of its methods failed, whether it paused for human input or feedback, whether the start was a resumed run, whether a conversation turn failed, how long the flow ran, and whether the flow is one CrewAI runs internally or one you wrote. The flow name is recorded, as it already is for flow creation and execution. When a flow or one of its methods fails, the **class name** of the exception is recorded (for example `TimeoutError`) so that failures can be diagnosed — never the error message, which can contain prompts, model output, file paths or credentials. Method names and flow state are never recorded. No personal data. | | Yes | Trace Sharing Signal | Includes: that a batch of traces was successfully shared with CrewAI AMP, and whether it was shared anonymously (before you have an account) or linked to your account. Like every span, it also carries the Execution Environment attributes described above (`project_id` when configured, the coding assistant, and the runtime). This row describes sharing telemetry only — not the trace contents or access granted by shared trace links. Trace contents, inputs, and outputs are never recorded on this signal. Before sharing traces, review secrets, personal data, and AMP redaction and retention settings. | diff --git a/docs/edge/ko/telemetry.mdx b/docs/edge/ko/telemetry.mdx index 15de481bd..223a3c6f4 100644 --- a/docs/edge/ko/telemetry.mdx +++ b/docs/edge/ko/telemetry.mdx @@ -61,7 +61,7 @@ provider로 등록하지 않습니다. 이를 통해 양방향이 분리됩니 | 예 | 작업 라이프사이클 데이터 | 생성 및 실행 시작/종료 시각, crew 및 작업 식별자, 그리고 작업의 성공 또는 실패 여부가 포함됩니다. 작업이 실패하면 실패를 집계하고 진단할 수 있도록 예외의 **클래스 이름**(예: `TimeoutError`)이 기록되며, 프롬프트·모델 출력·파일 경로·자격 증명이 포함될 수 있는 오류 메시지는 결코 기록되지 않습니다. 타임스탬프를 포함한 span으로 저장됩니다. 개인 정보 없음. | | 예 | LLM 속성 | LLM의 이름, model_name, 모델, top_k, temperature 및 클래스명이 포함됩니다. 모두 기술적이고 비개인 정보입니다. | | 예 | crewAI CLI를 통한 프로젝트 생성 | 포함 항목: `crewai create`로 새 프로젝트가 생성되었다는 사실, 그 종류(`crew`, `json_crew` 또는 `flow`), 그리고 그 새 프로젝트에 발급되어 해당 프로젝트의 `pyproject.toml`에 기록된 프로젝트 ID. 이는 새 프로젝트 자체의 ID이며, 명령을 실행한 디렉터리의 `project_id`와는 별개로 기록됩니다 — 두 값은 다를 수 있습니다. 프로젝트 이름, 파일 내용, 코드는 기록되지 않습니다. 개인 정보 없음. | -| 예 | crewAI CLI를 통한 Crew 배포 시도 | 포함 항목: 배포가 시도되고 있다는 사실과 crew id, 로그를 가져오려고 하는지 여부, 그리고 배포가 CLI 명령에서 시작되었는지 실행 TUI에서 시작되었는지 여부. 프로젝트나 crew의 내용은 기록되지 않습니다. 개인 정보 없음. | +| 예 | crewAI CLI를 통한 Crew 배포 시도 | 포함 항목: 배포가 시도되고 있다는 사실과 crew id, 로그를 가져오려고 하는지 여부, 그리고 배포가 CLI 명령에서 시작되었는지 실행 TUI에서 시작되었는지 여부. 배포 생성이 실패하면 실패 범주(`api_4xx`, `network_error`, `user_declined` 등 고정된 목록 중 하나)와 API 응답의 HTTP 상태 코드(있는 경우)가 기록되며, 오류 메시지는 절대 기록되지 않습니다. 프로젝트나 crew의 내용은 기록되지 않습니다. 개인 정보 없음. | | 예 | 실행 환경 | 포함: 프로세스를 실행 중인 AI 코딩 어시스턴트(있는 경우, `claude_code`, `codex`, `cursor`, `unknown` 등 고정 목록 중 하나), 프로세스가 실행되는 위치(`ci`, `container`, `serverless`, `interactive` 등 고정 목록 중 하나), `pyproject.toml`에 설정된 경우 `project_id`, 그리고 머신 크기의 대략적인 구간(`1-2`, `3-4`, `5-8`, `9-16`, `17-32`, `33+`, `unknown` 중 하나). 구간은 범위이며 정확한 코어 수는 절대 포함하지 않습니다 — 정확한 코어 수는 아래 환경 정보에서 옵트인한 경우에만 수집됩니다. 크기 구간은 호스트 CPU 수에서 가져오며, 어시스턴트와 실행 위치 감지는 알려진 환경 변수의 설정 여부만 확인하고 값은 읽지 않음. 개인 데이터 없음. | | 예 | Flow 라이프사이클 신호 | 포함 항목: flow의 시작, 완료 또는 실패 여부, 해당 메서드의 실패 여부, 사람의 입력이나 피드백을 위해 일시 중지되었는지 여부, 해당 시작이 재개된 실행이었는지 여부, 대화 턴의 실패 여부, flow 실행 시간, 그리고 해당 flow가 CrewAI가 내부적으로 실행하는 것인지 사용자가 작성한 것인지 여부. flow 이름은 flow 생성 및 실행에서와 마찬가지로 기록됩니다. flow 또는 해당 메서드가 실패하면 장애 진단을 위해 예외의 **클래스 이름**(예: `TimeoutError`)이 기록되며, 프롬프트·모델 출력·파일 경로·자격 증명이 포함될 수 있는 오류 메시지는 절대 기록되지 않습니다. 메서드 이름과 flow 상태는 절대 기록되지 않습니다. 개인 정보 없음. | | 예 | 트레이스 공유 신호 | 포함 항목: 트레이스 배치가 CrewAI AMP에 성공적으로 공유되었는지 여부와, 익명으로(계정 생성 전) 공유되었는지 또는 계정에 연결되어 공유되었는지 여부. 모든 span과 마찬가지로 위에서 설명한 실행 환경 속성(구성된 경우 `project_id`, 코딩 어시스턴트, 런타임)도 함께 기록됩니다. 이 행은 공유 텔레메트리만 설명하며 — 트레이스 내용이나 공유된 트레이스 링크로 부여되는 접근 권한은 설명하지 않습니다. 트레이스 내용, 입력, 출력은 이 신호에는 기록되지 않습니다. 트레이스를 공유하기 전에 비밀 정보, 개인 데이터, AMP 편집 및 보존 설정을 검토하세요. | diff --git a/docs/edge/pt-BR/telemetry.mdx b/docs/edge/pt-BR/telemetry.mdx index cf4b1c450..21faa9223 100644 --- a/docs/edge/pt-BR/telemetry.mdx +++ b/docs/edge/pt-BR/telemetry.mdx @@ -63,7 +63,7 @@ por meio do próprio tracer provider, que é independente do descrito aqui. | Sim | Dados do Ciclo de Vida da Tarefa | Inclui: horários de criação, início/fim de execução, identificadores de crew e tarefa, e se a tarefa foi bem-sucedida ou falhou. Quando uma tarefa falha, o **nome da classe** da exceção é registrado (por exemplo `TimeoutError`) para que as falhas possam ser contadas e diagnosticadas — nunca a mensagem de erro, que pode conter prompts, saída do modelo, caminhos de arquivos ou credenciais. Armazenado como spans com timestamps. Sem dados pessoais. | | Sim | Atributos do LLM | Inclui: nome, model_name, model, top_k, temperatura e nome da classe do LLM. Todos técnicos, sem dados pessoais. | | Sim | Criação de Projeto pelo CLI do crewAI | Inclui: o fato de um novo projeto ter sido criado por `crewai create`, de qual tipo ele é (`crew`, `json_crew` ou `flow`) e o ID de projeto gerado para esse novo projeto e gravado no `pyproject.toml` dele. É o ID do próprio projeto novo, registrado separadamente do `project_id` do diretório de onde o comando foi executado — os dois podem diferir. Sem nome de projeto, sem conteúdo de arquivos, sem código. Sem dados pessoais. | -| Sim | Tentativa de Deploy do Crew pelo CLI do crewAI | Inclui: O fato de um deploy estar sendo realizado e o crew id, se está tentando buscar logs, e se o deploy foi iniciado por um comando do CLI ou pela TUI de execução. Não inclui conteúdo do projeto ou do crew nem dados pessoais. | +| Sim | Tentativa de Deploy do Crew pelo CLI do crewAI | Inclui: O fato de um deploy estar sendo realizado e o crew id, se está tentando buscar logs, e se o deploy foi iniciado por um comando do CLI ou pela TUI de execução. Se a criação de um deploy falhar, são registradas a categoria da falha (uma de uma lista fixa, como `api_4xx`, `network_error` ou `user_declined`) e o código de status HTTP da resposta da API, quando houver — nunca a mensagem de erro. Não inclui conteúdo do projeto ou do crew nem dados pessoais. | | Sim | Ambiente de Execução | Inclui: qual assistente de código com IA está executando o processo, se houver (um valor de uma lista fixa como `claude_code`, `codex`, `cursor` ou `unknown`), onde o processo é executado (um valor de uma lista fixa como `ci`, `container`, `serverless`, `interactive`), o `project_id` do seu `pyproject.toml` quando houver um configurado e uma faixa aproximada de tamanho da máquina (uma de `1-2`, `3-4`, `5-8`, `9-16`, `17-32`, `33+` ou `unknown`). A faixa é um intervalo, nunca a contagem exata de núcleos — a contagem exata é opcional, em Informações de Ambiente abaixo. A faixa de tamanho vem da contagem de núcleos do host; a detecção do assistente e do local de execução lê apenas se variáveis de ambiente conhecidas estão definidas, nunca seus valores. Sem dados pessoais. | | Sim | Sinais de Ciclo de Vida do Flow | Inclui: que um flow iniciou, se foi concluído ou falhou, se um de seus métodos falhou, se pausou para entrada ou feedback humano, se o início foi uma execução retomada, se um turno de conversa falhou, quanto tempo o flow executou, e se o flow é um que a CrewAI executa internamente ou um que você escreveu. O nome do flow é registrado, como já é para criação e execução de flow. Quando um flow ou um de seus métodos falha, o **nome da classe** da exceção é registrado (por exemplo `TimeoutError`) para permitir o diagnóstico de falhas — nunca a mensagem de erro, que pode conter prompts, saída do modelo, caminhos de arquivo ou credenciais. Nomes de métodos e estado do flow nunca são registrados. Nenhum dado pessoal. | | Sim | Sinal de Compartilhamento de Trace | Inclui: que um lote de traces foi compartilhado com sucesso com o CrewAI AMP, e se foi compartilhado anonimamente (antes de você ter uma conta) ou vinculado à sua conta. Como todo span, também carrega os atributos de Ambiente de Execução descritos acima (`project_id` quando configurado, o assistente de programação e o runtime). Esta linha descreve apenas a telemetria do compartilhamento — não o conteúdo dos traces nem o acesso concedido por links de traces compartilhados. O conteúdo dos traces, entradas e saídas nunca são registrados neste sinal. Antes de compartilhar traces, revise segredos, dados pessoais e as configurações de redação e retenção do AMP. | diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index c42f9be2b..6806b50d6 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -1,11 +1,14 @@ +import json from pathlib import Path import subprocess from typing import Any from urllib.parse import quote import webbrowser +import zipfile from crewai_core.plus_api import CreateCrewPayload -from crewai_core.telemetry import DeploySource +from crewai_core.telemetry import DeployFailureReason, DeploySource +import httpx from rich.console import Console from crewai_cli import git @@ -126,6 +129,37 @@ def _deployment_page_url(base_url: str, json_response: dict[str, Any]) -> str | ) +def _creation_failure_reason(exc: BaseException) -> DeployFailureReason: + """Classify an exception raised while requesting a deployment, for telemetry. + + Only the archive step raises ``ValueError`` / ``OSError`` inside that request + (the project name is validated at construction), so those read as zip errors. + """ + if isinstance(exc, (KeyboardInterrupt, EOFError)): + return "user_declined" + if isinstance(exc, httpx.HTTPError): + return "network_error" + if isinstance(exc, (ValueError, OSError, zipfile.BadZipFile)): + return "zip_error" + return "unexpected" + + +def _response_failure_reason(response: httpx.Response) -> DeployFailureReason | None: + """Classify a create response that ``_validate_response`` will reject. + + Mirrors its checks in the same order; ``None`` means the response will pass. + """ + try: + response.json() + except (json.JSONDecodeError, ValueError): + return "invalid_response" + if response.status_code >= 500: + return "api_5xx" + if not response.is_success: + return "api_4xx" + return None + + def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool: """Return True when deploy should create the project's first lockfile.""" root = project_root or Path.cwd() @@ -442,18 +476,22 @@ class DeployCommand(BaseCommand, PlusAPIMixin): return self._telemetry.create_crew_deployment_span(source=source) console.print("Creating deployment...", style="bold blue") - env_vars = fetch_and_json_env_file() - repository = self._prepare_git_repository() - remote_repo_url = repository.origin_url() if repository else None - - if remote_repo_url: - self._confirm_input(env_vars, remote_repo_url, confirm) - payload = self._create_payload(env_vars, remote_repo_url) - response = self.plus_api_client.create_crew(payload) - else: - _display_git_remote_help() - response = self._create_crew_from_zip(env_vars, repository, confirm) + try: + response = self._request_crew_creation(confirm) + except BaseException as exc: + # Report and re-raise unchanged: the CLI and the run TUI already + # decide how each failure is shown, this only explains the gap + # between attempts and successes. + self._telemetry.crew_deployment_failed_span( + _creation_failure_reason(exc), source=source + ) + raise + failure_reason = _response_failure_reason(response) + if failure_reason is not None: + self._telemetry.crew_deployment_failed_span( + failure_reason, source=source, status_code=response.status_code + ) self._validate_response(response) json_response = response.json() # After _validate_response, not before: it raises SystemExit on a failed @@ -466,6 +504,20 @@ class DeployCommand(BaseCommand, PlusAPIMixin): ) self._display_creation_success(json_response) + def _request_crew_creation(self, confirm: bool) -> httpx.Response: + """Ask the Enterprise API to create the deployment, from git or from a ZIP.""" + env_vars = fetch_and_json_env_file() + repository = self._prepare_git_repository() + remote_repo_url = repository.origin_url() if repository else None + + if remote_repo_url: + self._confirm_input(env_vars, remote_repo_url, confirm) + payload = self._create_payload(env_vars, remote_repo_url) + return self.plus_api_client.create_crew(payload) + + _display_git_remote_help() + return self._create_crew_from_zip(env_vars, repository, confirm) + def _prepare_git_repository(self) -> git.Repository | None: """Prepare Git for deploy while preserving remote deploy when possible.""" try: @@ -544,7 +596,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): env_vars: dict[str, str], repository: git.Repository | None, confirm: bool, - ) -> Any: + ) -> httpx.Response: """Create a deployment by uploading a project ZIP archive.""" if not self.project_name: raise ValueError("project_name is required to create a ZIP deployment") diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 0a3dc4b4d..142fe7d6d 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -825,6 +825,199 @@ class TestDeployCommand(unittest.TestCase): telemetry.create_crew_deployment_span.assert_called_once_with(source="cli") telemetry.crew_deployment_created_span.assert_not_called() + # --- why a create failed (the Crew Deployment Failed span) --------------- + + def _git_path(self, mock_input, mock_repository, mock_fetch_env): + mock_fetch_env.return_value = {"ENV_VAR": "value"} + mock_repository.return_value.origin_url.return_value = ( + "https://github.com/test/repo.git" + ) + mock_repository.return_value.create_initial_commit_if_needed.return_value = ( + False + ) + mock_input.return_value = "" + + @staticmethod + def _api_response(status_code: int, body: object) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.is_success = 200 <= status_code < 300 + if isinstance(body, Exception): + response.json.side_effect = body + else: + response.json.return_value = body + return response + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_a_rejected_create_reports_the_api_class_and_status( + self, mock_input, mock_repository, mock_fetch_env + ): + """A 4xx names the class and carries the exact code; the message never leaves.""" + self._git_path(mock_input, mock_repository, mock_fetch_env) + self.mock_client.create_crew.return_value = self._api_response( + 422, {"name": ["has already been taken"]} + ) + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with self.assertRaises(SystemExit): + self.deploy_command.create_crew(skip_validate=True) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "api_4xx", source="cli", status_code=422 + ) + telemetry.crew_deployment_created_span.assert_not_called() + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_a_server_error_reports_api_5xx( + self, mock_input, mock_repository, mock_fetch_env + ): + self._git_path(mock_input, mock_repository, mock_fetch_env) + self.mock_client.create_crew.return_value = self._api_response( + 503, {"error": "upstream unavailable"} + ) + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with self.assertRaises(SystemExit): + self.deploy_command.create_crew(skip_validate=True) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "api_5xx", source="cli", status_code=503 + ) + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_a_non_json_success_body_reports_invalid_response( + self, mock_input, mock_repository, mock_fetch_env + ): + """_validate_response rejects it, so it is a failure with a 2xx attached.""" + self._git_path(mock_input, mock_repository, mock_fetch_env) + self.mock_client.create_crew.return_value = self._api_response( + 200, ValueError("not json") + ) + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with self.assertRaises(SystemExit): + self.deploy_command.create_crew(skip_validate=True) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "invalid_response", source="cli", status_code=200 + ) + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_a_transport_failure_reports_network_error_and_propagates( + self, mock_input, mock_repository, mock_fetch_env + ): + """No response, so no status; the exception reaches the caller unchanged.""" + self._git_path(mock_input, mock_repository, mock_fetch_env) + self.mock_client.create_crew.side_effect = httpx.ConnectError("refused") + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with pytest.raises(httpx.ConnectError, match="refused"): + self.deploy_command.create_crew(skip_validate=True) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "network_error", source="cli" + ) + telemetry.crew_deployment_created_span.assert_not_called() + + @patch("crewai_cli.deploy.main.create_project_zip") + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + def test_a_failed_archive_reports_zip_error( + self, mock_repository, mock_fetch_env, mock_create_project_zip + ): + mock_fetch_env.return_value = {"ENV_VAR": "value"} + mock_repository.side_effect = ValueError("not a Git repository") + initialized_repository = MagicMock() + initialized_repository.origin_url.return_value = None + mock_repository.initialize.return_value = initialized_repository + mock_create_project_zip.side_effect = ValueError( + "No deployable project files were found." + ) + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with pytest.raises(ValueError, match="No deployable project files"): + self.deploy_command.create_crew(skip_validate=True, confirm=True) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "zip_error", source="cli" + ) + self.mock_client.create_crew_from_zip.assert_not_called() + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_an_abort_at_the_prompt_reports_user_declined( + self, mock_input, mock_repository, mock_fetch_env + ): + self._git_path(mock_input, mock_repository, mock_fetch_env) + mock_input.side_effect = KeyboardInterrupt + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with self.assertRaises(KeyboardInterrupt): + self.deploy_command.create_crew(skip_validate=True) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "user_declined", source="cli" + ) + self.mock_client.create_crew.assert_not_called() + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_a_failure_from_the_run_tui_keeps_its_source( + self, mock_input, mock_repository, mock_fetch_env + ): + """The TUI succeeds far less often than the CLI; the split must survive.""" + self._git_path(mock_input, mock_repository, mock_fetch_env) + self.mock_client.create_crew.return_value = self._api_response( + 403, {"error": "forbidden"} + ) + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + with self.assertRaises(SystemExit): + self.deploy_command.create_crew( + skip_validate=True, confirm=True, source="tui" + ) + + telemetry.crew_deployment_failed_span.assert_called_once_with( + "api_4xx", source="tui", status_code=403 + ) + + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("builtins.input") + def test_a_successful_create_reports_no_failure( + self, mock_input, mock_repository, mock_fetch_env + ): + self._git_path(mock_input, mock_repository, mock_fetch_env) + self.mock_client.create_crew.return_value = self._api_response( + 201, {"uuid": "new-uuid", "status": "created"} + ) + + with patch.object(self.deploy_command, "_telemetry") as telemetry: + with patch("sys.stdout", new=StringIO()): + self.deploy_command.create_crew(skip_validate=True) + + telemetry.crew_deployment_failed_span.assert_not_called() + telemetry.crew_deployment_created_span.assert_called_once_with( + uuid="new-uuid", source="cli" + ) + @patch("crewai_cli.deploy.main.create_project_zip") @patch("crewai_cli.deploy.main.fetch_and_json_env_file") @patch("crewai_cli.deploy.main.git.Repository") diff --git a/lib/crewai-core/src/crewai_core/telemetry.py b/lib/crewai-core/src/crewai_core/telemetry.py index bb39d4265..7173d56d7 100644 --- a/lib/crewai-core/src/crewai_core/telemetry.py +++ b/lib/crewai-core/src/crewai_core/telemetry.py @@ -50,6 +50,25 @@ TRACER_NAME: Final[str] = "crewai.telemetry" DeploySource = Literal["cli", "tui"] """Where a deployment was initiated from: a direct CLI command, or the run TUI.""" +DeployFailureReason = Literal[ + "api_4xx", + "api_5xx", + "invalid_response", + "network_error", + "zip_error", + "user_declined", + "unexpected", +] +"""Why ``crewai deploy create`` failed after the attempt was counted. + +A closed vocabulary, so the warehouse can group on it. ``api_4xx`` / ``api_5xx`` +classify the Enterprise API's response (the exact code rides separately as +``status_code``); ``invalid_response`` is a 2xx whose body is not JSON; +``network_error`` a transport failure before any response; ``zip_error`` a +failure building the project archive; ``user_declined`` an abort at a +confirmation prompt; ``unexpected`` anything else. Never the error message. +""" + def close_span(span: Span) -> None: """Set span status to OK and end it.""" @@ -418,6 +437,41 @@ class Telemetry: self._safe_telemetry_procedure(_operation) + def crew_deployment_failed_span( + self, + reason: DeployFailureReason, + source: DeploySource = "cli", + status_code: int | None = None, + ) -> None: + """Records that ``crewai deploy create`` failed after the attempt was counted. + + :meth:`create_crew_deployment_span` counts attempts and + :meth:`crew_deployment_created_span` counts successes; the gap between + them was measurable but had no cause attached. This span carries the + cause from a closed vocabulary, plus the HTTP status when the API + answered. Emits no feature count, for the same reason as + :meth:`crew_deployment_created_span`. + + Args: + reason: Why the create failed. + source: Where the deployment was initiated from. + status_code: HTTP status of the API response, when there was one. + """ + + from crewai_core.version import get_crewai_version + + def _operation() -> None: + tracer = self.provider.get_tracer(TRACER_NAME) + span = tracer.start_span("Crew Deployment Failed") + self._add_attribute(span, "crewai_version", get_crewai_version()) + self._add_attribute(span, "reason", reason) + if status_code is not None: + self._add_attribute(span, "status_code", status_code) + self._add_attribute(span, "source", source) + close_span(span) + + self._safe_telemetry_procedure(_operation) + def get_crew_logs_span( self, uuid: str | None, log_type: str = "deployment" ) -> None: diff --git a/lib/crewai-core/tests/test_telemetry_deploy.py b/lib/crewai-core/tests/test_telemetry_deploy.py index e44497e26..fa1c8aa41 100644 --- a/lib/crewai-core/tests/test_telemetry_deploy.py +++ b/lib/crewai-core/tests/test_telemetry_deploy.py @@ -170,6 +170,60 @@ class TestStartDeployment: feature.assert_called_once_with("deploy:pushed") +class TestCrewDeploymentFailed: + """The third deployment span: why an attempt did not become a success.""" + + def test_records_the_reason_and_defaults_to_cli( + self, telemetry: tuple[Telemetry, MagicMock] + ) -> None: + instance, span = telemetry + instance.crew_deployment_failed_span("network_error") + attributes = _attributes(span) + assert attributes["reason"] == "network_error" + assert attributes["source"] == "cli" + + def test_carries_the_http_status_when_the_api_answered( + self, telemetry: tuple[Telemetry, MagicMock] + ) -> None: + instance, span = telemetry + instance.crew_deployment_failed_span("api_4xx", status_code=422) + assert _attributes(span)["status_code"] == 422 + + def test_omits_the_status_key_when_there_was_no_response( + self, telemetry: tuple[Telemetry, MagicMock] + ) -> None: + """A missing key means "no response"; a 0 would read as a status.""" + instance, span = telemetry + instance.crew_deployment_failed_span("user_declined", source="tui") + attributes = _attributes(span) + assert "status_code" not in attributes + assert attributes["source"] == "tui" + + def test_is_a_separate_span_from_the_attempt_and_the_success( + self, telemetry: tuple[Telemetry, MagicMock] + ) -> None: + instance, _span = telemetry + # The attempt also emits its deploy:created feature span; that count is + # covered elsewhere and only clutters the sequence asserted here. + with patch.object(instance, "feature_usage_span"): + instance.create_crew_deployment_span() + instance.crew_deployment_failed_span("api_5xx", status_code=500) + provider = cast(MagicMock, instance.provider) + assert _span_names(provider) == [ + "Create Crew Deployment", + "Crew Deployment Failed", + ] + + def test_does_not_emit_a_feature_count( + self, telemetry: tuple[Telemetry, MagicMock] + ) -> None: + """deploy:created is already counted by the attempt span.""" + instance, _span = telemetry + with patch.object(instance, "feature_usage_span") as feature: + instance.crew_deployment_failed_span("zip_error") + feature.assert_not_called() + + class TestDisabledTelemetry: def test_opted_out_users_emit_nothing(self) -> None: """No span and no feature count when telemetry is off.""" @@ -183,6 +237,7 @@ class TestDisabledTelemetry: ): instance.create_crew_deployment_span(source="tui") instance.start_deployment_span("dep-123", source="tui") + instance.crew_deployment_failed_span("api_4xx", status_code=401) assert _span_names(provider) == [] # feature_usage_span is itself gated, so it is still called; it is the @@ -198,7 +253,7 @@ class TestReleaseAttribution: A span without ``crewai_version`` cannot be attributed to a version, so a version-filtered question returns nothing for it rather than something - visibly wrong. Covers all eight spans this module emits, not only the ones + visibly wrong. Covers all ten spans this module emits, not only the ones that were missing it, so a regression on the others is caught too. """ @@ -208,6 +263,8 @@ class TestReleaseAttribution: ("deploy_signup_error_span", ()), ("start_deployment_span", ("dep-123",)), ("create_crew_deployment_span", ()), + ("crew_deployment_created_span", ("dep-123",)), + ("crew_deployment_failed_span", ("api_5xx",)), ("get_crew_logs_span", ("dep-123", "deployment")), ("remove_crew_span", ("dep-123",)), ("feature_usage_span", ("memory:query",)),