vibe/vibe/core/telemetry/send.py
Clément Sirieix b23f49e5f4
v2.9.5 (#676)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-05-07 13:55:56 +02:00

362 lines
13 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import Callable
import os
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urljoin
import httpx
from vibe import __version__
from vibe.core.config import ProviderConfig, VibeConfig
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.telemetry.build_metadata import build_base_metadata
from vibe.core.telemetry.types import (
AgentEntrypoint,
EntrypointMetadata,
TelemetryCallType,
TeleportCompletedPayload,
TeleportFailedPayload,
TeleportFailureStage,
)
from vibe.core.utils import get_server_url_from_api_base, get_user_agent
if TYPE_CHECKING:
from vibe.core.agent_loop import ToolDecision
_DEFAULT_TELEMETRY_BASE_URL = "https://api.mistral.ai"
_DATALAKE_EVENTS_PATH = "/v1/datalake/events"
class TelemetryClient:
def __init__(
self,
config_getter: Callable[[], VibeConfig],
session_id_getter: Callable[[], str | None] | None = None,
parent_session_id_getter: Callable[[], str | None] | None = None,
entrypoint_metadata_getter: Callable[[], EntrypointMetadata | None]
| None = None,
) -> None:
self._config_getter = config_getter
self._session_id_getter = session_id_getter
self._parent_session_id_getter = parent_session_id_getter
self._entrypoint_metadata_getter = entrypoint_metadata_getter
self._client: httpx.AsyncClient | None = None
self._pending_tasks: set[asyncio.Task[Any]] = set()
self.last_correlation_id: str | None = None
def _get_telemetry_url(self, api_base: str) -> str:
base = get_server_url_from_api_base(api_base) or _DEFAULT_TELEMETRY_BASE_URL
return urljoin(base.rstrip("/"), _DATALAKE_EVENTS_PATH)
def _get_mistral_api_key(self) -> str | None:
"""Get the API key from the active provider if it's Mistral,
otherwise the first Mistral provider.
Only returns an API key if the provider is a Mistral provider
to avoid leaking third-party credentials to the telemetry endpoint.
"""
provider_and_api_key = self._get_mistral_provider_and_api_key()
if provider_and_api_key is None:
return None
_, api_key = provider_and_api_key
return api_key
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
try:
provider = self._config_getter().get_mistral_provider()
except Exception:
return None
if provider is None:
return None
env_var = provider.api_key_env_var
api_key = os.getenv(env_var) if env_var else None
if api_key is None:
return None
return provider, api_key
def _is_enabled(self) -> bool:
"""Check if telemetry is enabled in the current config."""
try:
return self._config_getter().enable_telemetry
except Exception:
return False
def is_active(self) -> bool:
return self._is_enabled() and self._get_mistral_api_key() is not None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
)
return self._client
@property
def session_id(self) -> str | None:
if self._session_id_getter is None:
return None
return self._session_id_getter()
@property
def parent_session_id(self) -> str | None:
if self._parent_session_id_getter is None:
return None
return self._parent_session_id_getter()
def build_client_event_metadata(self) -> dict[str, str]:
return build_base_metadata(
entrypoint_metadata=(
self._entrypoint_metadata_getter()
if self._entrypoint_metadata_getter is not None
else None
),
session_id=self.session_id,
parent_session_id=self.parent_session_id,
)
def send_telemetry_event(
self,
event_name: str,
properties: dict[str, Any],
*,
correlation_id: str | None = None,
) -> None:
if not self._is_enabled():
return
provider_and_api_key = self._get_mistral_provider_and_api_key()
if provider_and_api_key is None:
return
provider, mistral_api_key = provider_and_api_key
telemetry_url = self._get_telemetry_url(provider.api_base)
user_agent = get_user_agent(provider.backend)
properties = self.build_client_event_metadata() | properties
payload: dict[str, Any] = {"event": event_name, "properties": properties}
if correlation_id:
payload["correlation_id"] = correlation_id
async def _send() -> None:
try:
await self.client.post(
telemetry_url,
json=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {mistral_api_key}",
"User-Agent": user_agent,
},
)
except Exception:
pass # Silently swallow all exceptions for fire-and-forget telemetry
task = asyncio.create_task(_send())
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
async def aclose(self) -> None:
if self._pending_tasks:
await asyncio.gather(*self._pending_tasks, return_exceptions=True)
if self._client is not None:
await self._client.aclose()
self._client = None
def _calculate_file_metrics(
self,
tool_call: ResolvedToolCall,
status: Literal["success", "failure", "skipped"],
result: dict[str, Any] | None = None,
) -> tuple[int, int]:
nb_files_created = 0
nb_files_modified = 0
if status == "success" and result is not None:
if tool_call.tool_name == "write_file":
file_existed = result.get("file_existed", False)
if file_existed:
nb_files_modified = 1
else:
nb_files_created = 1
elif tool_call.tool_name == "search_replace":
nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0
return nb_files_created, nb_files_modified
def send_tool_call_finished(
self,
*,
tool_call: ResolvedToolCall,
status: Literal["success", "failure", "skipped"],
decision: ToolDecision | None,
agent_profile_name: str,
model: str,
result: dict[str, Any] | None = None,
message_id: str | None = None,
) -> None:
verdict_value = decision.verdict.value if decision else None
approval_type_value = decision.approval_type.value if decision else None
nb_files_created, nb_files_modified = self._calculate_file_metrics(
tool_call, status, result
)
payload = {
"tool_name": tool_call.tool_name,
"status": status,
"decision": verdict_value,
"approval_type": approval_type_value,
"agent_profile_name": agent_profile_name,
"model": model,
"nb_files_created": nb_files_created,
"nb_files_modified": nb_files_modified,
"message_id": message_id,
}
self.send_telemetry_event("vibe.tool_call_finished", payload)
def send_user_copied_text(self, text: str) -> None:
payload = {"text_length": len(text)}
self.send_telemetry_event("vibe.user_copied_text", payload)
def send_user_cancelled_action(self, action: str) -> None:
payload = {"action": action}
self.send_telemetry_event("vibe.user_cancelled_action", payload)
def send_auto_compact_triggered(
self,
*,
nb_context_tokens_before: int,
nb_context_tokens_after: int,
auto_compact_threshold: int,
status: Literal["success", "failure", "cancelled"],
session_id: str | None = None,
parent_session_id: str | None = None,
) -> None:
payload = {
"nb_context_tokens_before": nb_context_tokens_before,
"nb_context_tokens_after": nb_context_tokens_after,
"auto_compact_threshold": auto_compact_threshold,
"status": status,
}
if session_id is not None:
payload["session_id"] = session_id
payload["parent_session_id"] = parent_session_id
self.send_telemetry_event("vibe.auto_compact_triggered", payload)
def send_slash_command_used(
self, command: str, command_type: Literal["builtin", "skill"]
) -> None:
payload = {"command": command.lstrip("/"), "command_type": command_type}
self.send_telemetry_event("vibe.slash_command_used", payload)
def send_new_session(
self,
has_agents_md: bool,
nb_skills: int,
nb_mcp_servers: int,
nb_models: int,
entrypoint: AgentEntrypoint,
client_name: str | None,
client_version: str | None,
terminal_emulator: str | None = None,
) -> None:
payload = {
"has_agents_md": has_agents_md,
"nb_skills": nb_skills,
"nb_mcp_servers": nb_mcp_servers,
"nb_models": nb_models,
"entrypoint": entrypoint,
"version": __version__,
"client_name": client_name,
"client_version": client_version,
"terminal_emulator": terminal_emulator,
}
self.send_telemetry_event("vibe.new_session", payload)
def send_session_closed(self) -> None:
self.send_telemetry_event("vibe.session_closed", {})
def send_onboarding_api_key_added(self) -> None:
self.send_telemetry_event(
"vibe.onboarding_api_key_added", {"version": __version__}
)
def send_request_sent(
self,
*,
model: str,
nb_context_chars: int,
nb_context_messages: int,
nb_prompt_chars: int,
call_type: TelemetryCallType,
message_id: str | None = None,
) -> None:
payload = {
"model": model,
"nb_context_chars": nb_context_chars,
"nb_context_messages": nb_context_messages,
"nb_prompt_chars": nb_prompt_chars,
"call_source": "vibe_code",
"call_type": call_type,
"message_id": message_id,
}
self.send_telemetry_event("vibe.request_sent", payload)
def send_ready(self, *, init_duration_ms: int) -> None:
payload = {"init_duration_ms": init_duration_ms}
self.send_telemetry_event("vibe.ready", payload)
def send_at_mention_inserted(
self,
*,
nb_mentions: int,
context_types: dict[str, int],
file_extensions: dict[str, int] | None,
message_id: str | None,
) -> None:
payload: dict[str, Any] = {
"nb_mentions": nb_mentions,
"context_types": context_types,
"file_extensions": file_extensions,
"message_id": message_id,
}
self.send_telemetry_event("vibe.at_mention_inserted", payload)
def send_user_rating_feedback(self, rating: int, model: str) -> None:
self.send_telemetry_event(
"vibe.user_rating_feedback",
{"rating": rating, "version": __version__, "model": model},
correlation_id=self.last_correlation_id,
)
def send_teleport_completed(
self,
*,
push_required: bool,
github_auth_required: bool,
nb_session_messages: int,
) -> None:
payload: TeleportCompletedPayload = {
"push_required": push_required,
"github_auth_required": github_auth_required,
"nb_session_messages": nb_session_messages,
}
self.send_telemetry_event("vibe.teleport_completed", dict(payload))
def send_teleport_failed(
self,
*,
stage: TeleportFailureStage,
error_class: str,
push_required: bool,
github_auth_required: bool,
nb_session_messages: int,
) -> None:
payload: TeleportFailedPayload = {
"stage": stage,
"error_class": error_class,
"push_required": push_required,
"github_auth_required": github_auth_required,
"nb_session_messages": nb_session_messages,
}
self.send_telemetry_event("vibe.teleport_failed", dict(payload))