Compare commits

...

2 Commits

Author SHA1 Message Date
Joao Moura
39e2cacb2f fix(cli): classify deploy create failures by status and by stage
Review fixes on the failure span. Check the HTTP class before the body so a
gateway's HTML page counts as api_4xx / api_5xx with its code. Treat a 2xx
whose body is not a JSON object carrying uuid and status as invalid_response
and exit cleanly, instead of emitting a success span and crashing in the
display step. Recognise archive failures by a dedicated ArchiveError
(a ValueError) raised from create_project_zip, so the git helpers' own
ValueErrors no longer read as zip_error; a failed ZIP write is wrapped and
its partial file removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 00:41:28 -07:00
Joao Moura
22ed8a638c feat(cli): record why a deployment create failed
`crewai deploy create` counts every attempt (`Create Crew Deployment`) and every
success (`Crew Deployment Created`), but the gap between them carried no cause:
among clients able to emit the success span, the CLI succeeds 96.7% of the time
and the run TUI 36.4%, and nothing said why. A third span, `Crew Deployment
Failed`, now fires for every failure after the attempt is counted, with a closed
vocabulary `reason` (api_4xx, api_5xx, invalid_response, network_error,
zip_error, user_declined, unexpected), the HTTP `status_code` when the API
answered, and the existing `source`. Never the error message.

The request path is factored into `_request_crew_creation`; every exception is
classified, reported and re-raised unchanged, so CLI and TUI behaviour is the
same as before. HTTP failures are classified before `_validate_response`, which
still prints and exits as it did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 00:15:18 -07:00
10 changed files with 504 additions and 25 deletions

View File

@@ -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. |

View File

@@ -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. |

View File

@@ -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 편집 및 보존 설정을 검토하세요. |

View File

@@ -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. |

View File

@@ -36,6 +36,15 @@ _EXCLUDED_SUFFIXES = {
}
class ArchiveError(ValueError):
"""The project ZIP could not be built.
A ``ValueError`` so existing callers keep working; a distinct type so the
deploy command can tell an archive failure from the git helpers' own
``ValueError``s when it classifies why a create failed.
"""
def create_project_zip(
project_name: str,
*,
@@ -46,7 +55,7 @@ def create_project_zip(
root = (project_dir or Path.cwd()).resolve()
files = _project_files(root, repository)
if not files:
raise ValueError("No deployable project files were found.")
raise ArchiveError("No deployable project files were found.")
staged_root = _stage_project(root, files)
archive_handle = tempfile.NamedTemporaryFile(
@@ -62,6 +71,9 @@ def create_project_zip(
for relative_path in _walk_files(staged_root):
absolute_path = staged_root / relative_path
zip_file.write(absolute_path, relative_path.as_posix())
except (OSError, zipfile.BadZipFile) as exc:
archive_path.unlink(missing_ok=True)
raise ArchiveError(f"Could not build the project ZIP: {exc}") from exc
finally:
shutil.rmtree(staged_root, ignore_errors=True)

View File

@@ -1,3 +1,4 @@
import json
from pathlib import Path
import subprocess
from typing import Any
@@ -5,13 +6,14 @@ from urllib.parse import quote
import webbrowser
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
from crewai_cli.command import BaseCommand, PlusAPIMixin
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_cli.deploy.archive import create_project_zip
from crewai_cli.deploy.archive import ArchiveError, create_project_zip
from crewai_cli.deploy.validate import DeployValidator, Severity, render_report
from crewai_cli.utils import fetch_and_json_env_file, get_project_name
@@ -126,6 +128,46 @@ 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.
Archive failures are recognised by type (``ArchiveError``), not by base
class: the git helpers raise plain ``ValueError`` too, and those are not
ZIP problems.
"""
if isinstance(exc, (KeyboardInterrupt, EOFError)):
return "user_declined"
if isinstance(exc, httpx.HTTPError):
return "network_error"
if isinstance(exc, ArchiveError):
return "zip_error"
return "unexpected"
def _response_failure_reason(response: httpx.Response) -> DeployFailureReason | None:
"""Classify a create response that cannot become a created deployment.
Status first, so a gateway's HTML error page counts as the API class it is;
then the body, which must be a JSON object carrying ``uuid`` and ``status``
for the success path to use. ``None`` means the response is a creation.
"""
if response.status_code >= 500:
return "api_5xx"
if not response.is_success:
return "api_4xx"
try:
payload = response.json()
except (json.JSONDecodeError, ValueError):
return "invalid_response"
if (
not isinstance(payload, dict)
or not payload.get("uuid")
or "status" not in payload
):
return "invalid_response"
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,6 +484,43 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
return
self._telemetry.create_crew_deployment_span(source=source)
console.print("Creating deployment...", style="bold blue")
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
)
# Prints the API's own details and exits for every non-2xx and for
# a body that is not JSON. Only a 2xx that parsed but is not a
# creation payload gets past it.
self._validate_response(response)
console.print(
"Unexpected response from the Enterprise API: no deployment uuid was returned.",
style="bold red",
)
raise SystemExit(1)
json_response = response.json()
# Only here, after the response has been classified as a creation: this
# is the first point at which the uuid exists -- the pre-flight span at
# the top of this method counts the attempt and cannot carry it.
self._telemetry.crew_deployment_created_span(
uuid=str(json_response["uuid"]), source=source
)
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
@@ -449,22 +528,10 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
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)
return self.plus_api_client.create_crew(payload)
self._validate_response(response)
json_response = response.json()
# After _validate_response, not before: it raises SystemExit on a failed
# create, so the span cannot fire for a deployment that was not made. This
# is the first point at which the uuid exists -- the pre-flight span at the
# top of this method counts the attempt and cannot carry it.
created_uuid = json_response.get("uuid")
self._telemetry.crew_deployment_created_span(
uuid=str(created_uuid) if created_uuid else None, source=source
)
self._display_creation_success(json_response)
_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."""
@@ -544,7 +611,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")

View File

@@ -4,7 +4,7 @@ import zipfile
import pytest
from crewai_cli.deploy.archive import create_project_zip
from crewai_cli.deploy.archive import ArchiveError, create_project_zip
def test_create_project_zip_excludes_local_artifacts(tmp_path: Path):
@@ -305,3 +305,31 @@ type = "crew"
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_with_nothing_to_deploy_raises_archive_error(
tmp_path: Path,
):
"""Still a ValueError for existing callers, and a distinct type for the deploy command."""
with pytest.raises(ArchiveError, match="No deployable project files were found"):
create_project_zip("demo", project_dir=tmp_path)
assert issubclass(ArchiveError, ValueError)
def test_create_project_zip_wraps_a_write_failure_and_removes_the_partial_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
created: list[Path] = []
def failing_zipfile(path, *args, **kwargs):
created.append(Path(path))
raise OSError("disk full")
monkeypatch.setattr("crewai_cli.deploy.archive.zipfile.ZipFile", failing_zipfile)
with pytest.raises(ArchiveError, match="Could not build the project ZIP: disk full"):
create_project_zip("demo", project_dir=tmp_path)
assert created and not created[0].exists()

View File

@@ -9,6 +9,7 @@ import pytest
import json
import crewai_cli.deploy.main as deploy_main
from crewai_cli.deploy.archive import ArchiveError
import httpx
from crewai_cli.deploy.validate import Severity, ValidationResult
from crewai_cli.utils import parse_toml
@@ -825,6 +826,266 @@ 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 = ArchiveError(
"No deployable project files were found."
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(ArchiveError, 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_a_git_helper_error_is_not_a_zip_error(
self, mock_input, mock_repository, mock_fetch_env
):
"""The git helpers raise plain ValueError; only ArchiveError is a ZIP problem."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
mock_repository.return_value.origin_url.side_effect = ValueError(
"Git remote lookup failed"
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(ValueError, match="Git remote lookup failed"):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"unexpected", source="cli"
)
@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_error_page_keeps_its_api_class(
self, mock_input, mock_repository, mock_fetch_env
):
"""A gateway's HTML 502 is an api_5xx, not an invalid response."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
502, 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(
"api_5xx", source="cli", status_code=502
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_success_body_that_is_not_a_creation_reports_invalid_response(
self, mock_input, mock_repository, mock_fetch_env
):
"""A 2xx without a deployment uuid is not a success and must not crash."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
for body in ([{"uuid": "in-a-list"}], {"status": "created"}, {}):
with self.subTest(body=body):
self.mock_client.create_crew.return_value = self._api_response(
200, body
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()) as fake_out:
with self.assertRaises(SystemExit) as exit_info:
self.deploy_command.create_crew(skip_validate=True)
assert exit_info.exception.code == 1
assert "no deployment uuid was returned" in fake_out.getvalue()
telemetry.crew_deployment_failed_span.assert_called_once_with(
"invalid_response", source="cli", status_code=200
)
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_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")

View File

@@ -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:

View File

@@ -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",)),