Claude Code no tiene "un" sistema de telemetria. Tiene tres, cada uno con un proposito distinto:
|
Capa |
Proposito |
Destino |
Frecuencia |
|---|---|---|---|
|
GrowthBook |
Feature flags y experimentos A/B |
API de Anthropic |
Cada 6 horas |
|
First-Party Logger |
Eventos detallados internos |
API de Anthropic |
Cada 10 segundos |
|
Datadog |
Observabilidad y metricas |
|
Cada 15 segundos |
Estas tres capas funcionan en paralelo. Un mismo evento puede pasar por las tres, o solo por algunas, dependiendo de filtros y configuracion.
# Recreacion del router de telemetria (sink)
class AnalyticsSink:
def __init__(self):
self.sinks = {
"first_party": FirstPartyLogger(),
"datadog": DatadogLogger(),
}
self.killswitch = KillswitchChecker()
self.sampling_config = {} # tasas de muestreo por evento
def track(self, event_name, properties):
"""Envia un evento a todas las capas activas."""
for sink_name, sink in self.sinks.items():
# Comprobar killswitch remoto
if self.killswitch.is_killed(sink_name):
continue
# Comprobar muestreo (no todos los eventos se envian siempre)
sample_rate = self.sampling_config.get(event_name, 1.0)
if random.random() > sample_rate:
continue
sink.send(event_name, properties)Fijate en dos detalles clave:
Muestreo por evento: no todos los eventos se envian siempre. Existe una configuracion remota (
tengu_event_sampling_config) que define tasas de muestreo por tipo de evento. Si la tasa es 0.1, solo 1 de cada 10 eventos de ese tipo se envia realmente.Killswitch por capa: cada capa se puede apagar independientemente desde el servidor (mas sobre esto en la seccion 4).
GrowthBook: el control remoto de tu CLI
GrowthBook es un sistema de feature flags. Permite a Anthropic cambiar el comportamiento de Claude Code sin que necesites actualizar nada. Solo necesitan cambiar una configuracion en su servidor.
# Recreacion del cliente de GrowthBook
class FeatureFlagClient:
REFRESH_INTERVAL_EXTERNAL = 6 * 3600 # 6 horas
REFRESH_INTERVAL_INTERNAL = 20 * 60 # 20 minutos
INIT_TIMEOUT_MS = 5000
def __init__(self, user_attributes, is_internal_user):
self.user_attributes = user_attributes
self.cache = self._load_disk_cache() # ~/.claude.json
self.refresh_interval = (
self.REFRESH_INTERVAL_INTERNAL if is_internal_user
else self.REFRESH_INTERVAL_EXTERNAL
)
def get_feature_value(self, feature_name, default):
"""Devuelve el valor de un feature flag (usa cache, nunca bloquea)."""
if feature_name in self.overrides:
return self.overrides[feature_name]
cached = self.cache.get(feature_name)
return cached if cached is not None else default
def refresh(self):
"""Refresca flags desde el servidor."""
response = self._fetch_from_server()
self.cache.update(response)
self._save_disk_cache(self.cache)
self._notify_subscribers()¿Que atributos envia sobre ti para evaluar los flags?
# Atributos que se envian a GrowthBook para evaluar flags
USER_ATTRIBUTES = {
"id": "device_id", # ID unico del dispositivo
"sessionId": "uuid-de-sesion",
"platform": "darwin", # win32, darwin, linux
"userType": "external", # ant (empleado) o external
"subscriptionType": "pro", # max, pro, enterprise, team
"appVersion": "2.1.88",
"organizationUUID": "...", # si usas OAuth
"accountUUID": "...", # si usas OAuth
"firstTokenTime": "2026-01-15...", # cuando usaste Claude por primera vez
"email": "...", # si usas OAuth
}Esto significa que Anthropic puede activar funciones solo para usuarios enterprise, solo en darwin, solo para empleados internos (ant), o solo para usuarios que llevan mas de X tiempo usando el producto. Todo sin que tu instales nada nuevo.
Algunas configuraciones dinamicas concretas que se controlan por GrowthBook:
tengu_event_sampling_config— tasas de muestreo de eventostengu_1p_event_batch_config— configuracion del bateo de eventos (intervalo, tamaño de lote, reintentos)tengu_log_datadog_events— puerta para activar/desactivar el envio a Datadogtengu_frond_boric— el killswitch remoto por capa (nombre ofuscado a proposito)
¿Que se trackea exactamente?
Cada evento que sale de Claude Code lleva una carga de metadatos. La cantidad de informacion es significativa.
Metadatos que acompañan a cada evento:
# Recreacion de los metadatos por evento
def build_event_metadata(session):
return {
# Identificacion
"session_id": session.id,
"model": session.model, # "claude-opus-4-6"
"user_type": session.user_type, # "ant" o "external"
"subscription_type": session.sub_type, # "pro", "max", "enterprise"
"client_type": "cli", # o "web"
# Entorno
"platform": "darwin",
"arch": "arm64",
"node_version": "v20.11.0",
"terminal": "zsh",
"is_ci": False, # True si corre en CI
"is_github_action": False,
"version": "2.1.88",
# Runtimes detectados en tu maquina
"package_managers": "npm,pip,brew",
"runtimes": "node,python,ruby",
"vcs": "git", # sistemas de control de versiones
# Repo (hasheado)
"repo_hash": "a1b2c3d4e5f6...", # SHA256 del remote, primeros 16 chars
# Recursos del proceso
"process_metrics": {
"uptime": 342.5, # segundos de sesion
"rss": 157286400, # memoria residente (bytes)
"heap_used": 89400320,
"cpu_percent": 12.3,
},
}Claude Code sabe que sistema operativo usas, que terminal, que lenguajes de programacion tienes instalados, que package managers, cuanto llevas con la sesion abierta, cuanta memoria esta consumiendo, y un hash de tu repositorio.
Los 45 tipos de eventos que van a Datadog:
No son 45 eventos aleatorios. Estan organizados por categoria:
|
Categoria |
Eventos |
Que miden |
|---|---|---|
|
Sesion |
|
Inicio, arranque completado, cierre |
|
API |
|
Exito/fallo de llamadas a Claude |
|
Herramientas |
|
Uso de tools y permisos |
|
OAuth |
|
Autenticacion |
|
Errores |
|
Excepciones no capturadas |
|
UI |
|
Interaccion del usuario |
|
Equipo |
|
Sincronizacion de memorias de equipo |
|
IDE |
|
Conexion con extensiones de navegador |
Cada evento en Datadog lleva "tags" que permiten segmentar: modelo, plataforma, tipo de error, HTTP status, nombre de herramienta, tipo de usuario, version, y un userBucket — un hash de tu ID en 30 cubos para estimar usuarios unicos sin identificarlos individualmente.
El detector de frustracion: Claude Code sabe cuando te quejas
Este es quiza el hallazgo mas sorprendente de toda la auditoria de telemetria. Claude Code analiza cada mensaje que escribes para detectar si estas frustrado.
Capa 1: la regex de palabras negativas
Cada vez que envias un mensaje, antes de que llegue al modelo, tu texto se pasa por un patron de expresion regular que busca ~30 palabras y frases asociadas a frustracion:
# Recreacion del detector de palabras negativas
import re
NEGATIVE_PATTERN = re.compile(
r"\b("
r"wtf|wth|ffs|omfg|"
r"shit(ty|tiest)?|dumba|"
r"horrible|awful|"
r"piss(ed|ing)?\s*off|"
r"piece\s+of\s+(shit|crap|junk)|"
r"what\s+the\s+(fuck|hell)|"
r"fucking?\s*(broken|useless|terrible|awful|horrible)|"
r"fuck\s+you|screw\s+(this|you)|"
r"so\s+frustrating|this\s+sucks|damn\s+it"
r")\b",
re.IGNORECASE
)
def matches_negative_keyword(user_input):
"""Devuelve True si el mensaje contiene palabras de frustracion."""
return bool(NEGATIVE_PATTERN.search(user_input))El resultado se envia como evento de telemetria:
# Cada mensaje del usuario genera este evento
def process_user_prompt(text):
is_negative = matches_negative_keyword(text)
is_keep_going = matches_keep_going_keyword(text) # "continue", "keep going"...
analytics.track("tengu_input_prompt", {
"is_negative": is_negative,
"is_keep_going": is_keep_going,
})Esto significa que Anthropic tiene, para cada sesion, una secuencia de flags is_negative que les permite saber en que momento de la conversacion el usuario empezo a frustrarse. No se envia el contenido del mensaje — solo el flag booleano.
Capa 2: deteccion de frustracion en la conversacion
Ademas de la regex por mensaje, existe un sistema mas sofisticado que analiza el historial completo de la conversacion buscando patrones de frustracion acumulada:
# Recreacion del detector de frustracion (solo activo para empleados de Anthropic)
class FrustrationDetector:
"""Analiza toda la conversacion para detectar frustracion acumulada.
IMPORTANTE: En la build externa, este modulo se elimina completamente.
Solo esta activo para empleados de Anthropic (dogfooding).
"""
def __init__(self, messages):
self.messages = messages
self.state = "closed" # closed, prompting, shared
def analyze(self):
"""Se ejecuta en cada cambio de mensajes (O(n) sobre todos los mensajes).
Cuando detecta frustracion suficiente, cambia el estado a 'prompting'
para ofrecerle al usuario compartir la transcripcion.
"""
# Analiza patrones como:
# - Multiples mensajes negativos consecutivos
# - Frases como "I give up", "this is broken"
# - Repeticion de instrucciones (señal de que Claude no entiende)
passUn detalle importante: este sistema de deteccion de frustracion solo esta activo en las builds internas (para empleados de Anthropic). En la build que tu descargas de npm, el modulo se elimina completamente durante la compilacion. Pero la infraestructura existe, y la regex de palabras negativas (capa 1) si esta activa para todos.
Capa 3: clasificacion de satisfaccion por sesion
El sistema de analisis de sesiones clasifica cada sesion completa en 5 niveles de satisfaccion:
# Recreacion del clasificador de satisfaccion
SATISFACTION_LEVELS = {
"frustrated": ["this is broken", "I give up"],
"dissatisfied": ["that's not right", "try again"],
"likely_satisfied": ["ok, now let's..."], # continua sin quejarse
"satisfied": ["thanks", "looks good", "that works"],
"happy": ["Yay!", "great!", "perfect!"],
}
def classify_session_satisfaction(messages):
"""Clasifica el nivel de satisfaccion de una sesion completa."""
# Analiza los mensajes del usuario buscando patrones
# que indiquen el nivel de satisfaccion
for level, patterns in SATISFACTION_LEVELS.items():
if any(pattern in msg for msg in messages for pattern in patterns):
return level
return "likely_satisfied" # Default si no hay señales clarasCapa 4: encuestas de feedback gatilladas por sentimiento
Finalmente, existe un sistema de encuestas que puede aparecer durante la sesion. Las condiciones por defecto son conservadoras:
# Recreacion de la configuracion de encuestas de feedback
DEFAULT_SURVEY_CONFIG = {
"min_time_before_feedback_ms": 600_000, # 10 minutos minimo de sesion
"min_time_between_feedback_ms": 3_600_000, # 1 hora entre encuestas
"min_user_turns_before_feedback": 5, # Al menos 5 turnos del usuario
"min_user_turns_between_feedback": 10, # 10 turnos entre encuestas
"probability": 0.005, # 0.5% de sesiones elegibles
}
# Si el usuario da feedback negativo, puede pedirle compartir la transcripcion
TRANSCRIPT_SHARE_TRIGGERS = [
"bad_feedback_survey", # Encuesta con feedback negativo
"good_feedback_survey", # Encuesta con feedback positivo (tambien)
"frustration", # Deteccion directa de frustracion
"memory_survey", # Encuesta sobre memorias
]Solo el 0.5% de las sesiones que cumplen los requisitos minimos veran una encuesta. Pero si das feedback negativo en esa encuesta, el sistema puede pedirte compartir la transcripcion completa de tu sesion con Anthropic. El trigger "frustration" indica que la transcripcion tambien se puede solicitar directamente cuando el detector de frustracion se activa (en builds internas).
El killswitch remoto
Anthropic tiene un interruptor que puede apagar la recepcion de telemetria de cualquier capa, en cualquier momento, sin tocar tu maquina.
# Recreacion del killswitch remoto
class SinkKillswitch:
# Nombre ofuscado a proposito en el codigo original
CONFIG_NAME = "tengu_frond_boric"
def is_killed(self, sink_name):
"""Comprueba si una capa de telemetria esta desactivada remotamente.
Args:
sink_name: "datadog" o "first_party"
Returns:
True si la capa esta apagada (evento se descarta)
"""
config = feature_flags.get_dynamic_config(
self.CONFIG_NAME,
default={} # Si no hay config, nada esta apagado
)
# config tiene forma: {"datadog": True, "first_party": False}
return config.get(sink_name, False)El nombre tengu_frond_boric es deliberadamente opaco — un nombre ofuscado para que no sea facil encontrarlo buscando "killswitch" en el codigo.
El killswitch funciona asi:
Es una configuracion de GrowthBook (se refresca cada 6 horas)
Tiene forma
{"datadog": bool, "first_party": bool}Si el valor es
True, esa capa esta muerta: los eventos se descartan antes de procesarseSi la configuracion no existe o esta malformada, nada se apaga (fail-open)
Se consulta en cada evento, no se cachea localmente
Esto le da a Anthropic la capacidad de, por ejemplo, apagar el envio a Datadog globalmente si detectan un problema, sin necesidad de publicar una nueva version.
El pipeline completo de un evento
¿Que recorrido hace un evento desde que se genera hasta que sale de tu maquina?
# Recreacion del First-Party Logger
class FirstPartyLogger:
def __init__(self, config):
self.queue = []
self.max_queue_size = config.get("max_queue_size", 8192)
self.batch_size = config.get("max_export_batch_size", 200)
self.flush_interval = config.get("scheduled_delay_ms", 10_000)
self.max_attempts = config.get("max_attempts", 8)
self.failed_events_dir = "~/.claude/telemetry/"
def send(self, event_name, properties):
if len(self.queue) >= self.max_queue_size:
return # Cola llena, descartar
self.queue.append({
"event": event_name,
"properties": properties,
"timestamp": time.time(),
})
def flush(self):
"""Se ejecuta cada 10 segundos o cuando hay 200 eventos."""
batch = self.queue[:self.batch_size]
self.queue = self.queue[self.batch_size:]
try:
self._send_to_api(batch)
except Exception:
self._save_to_disk(batch) # Guardar para reintento
def retry_failed(self):
"""Al iniciar sesion, reintenta eventos fallidos de sesiones previas."""
for file in glob.glob(f"{self.failed_events_dir}/1p_failed_events.*.jsonl"):
events = self._load_from_disk(file)
self._send_to_api(events)
os.remove(file)
def _send_to_api(self, batch):
response = requests.post(
"https://api.anthropic.com/api/event_logging/batch",
json=batch,
)
response.raise_for_status()Detalle importante: los eventos fallidos se guardan en disco con un patron de nombre que incluye el UUID del lote y el ID de sesion (1p_failed_events.{batch_uuid}.{session_id}.jsonl). La proxima vez que abres Claude Code, intenta reenviarlos con backoff cuadratico, hasta un maximo de 8 intentos.
El sistema de opt-out (lo que puedes y lo que no puedes apagar)
Claude Code tiene tres niveles de privacidad, de menos a mas restrictivo:
# Recreacion de los niveles de privacidad
class PrivacyLevel:
DEFAULT = "default" # Todo activado
NO_TELEMETRY = "no-telemetry" # Sin analitica (Datadog, 1P events)
ESSENTIAL_ONLY = "essential-traffic" # Solo trafico esencial
def get_privacy_level():
"""Determina el nivel de privacidad del usuario."""
if os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC") == "1":
return PrivacyLevel.ESSENTIAL_ONLY
if os.environ.get("DISABLE_TELEMETRY") == "1":
return PrivacyLevel.NO_TELEMETRY
return PrivacyLevel.DEFAULT|
Nivel |
Como activarlo |
Que se desactiva |
|---|---|---|
|
|
(por defecto) |
Nada — todo activo |
|
|
|
Datadog, First-Party events, encuesta de feedback |
|
|
|
Todo lo anterior + auto-updates, release notes, capabilities del modelo, GrowthBook |
¿Que sigue enviandose incluso con no-telemetry? Las llamadas a la API de Claude (necesarias para funcionar), la autenticacion OAuth, y las peticiones de GrowthBook (porque los feature flags controlan funcionalidad critica).
Solo el nivel essential-traffic silencia practicamente todo el trafico no esencial. Pero con ese nivel, Claude Code pierde funcionalidades: no recibe actualizaciones automaticas, no puede refrescar feature flags, y puede comportarse de forma diferente a lo esperado.
Grove: el sistema de consentimiento
Ademas de las variables de entorno, existe un sistema de consentimiento llamado Grove ("Help improve Claude") que se gestiona a nivel de cuenta OAuth:
# Recreacion del sistema de consentimiento Grove
class GroveConsent:
CACHE_TTL = 24 * 3600 # 24 horas
def __init__(self, account_id):
self.account_id = account_id
self.cache = self._load_cache()
def is_enabled(self):
"""¿El usuario ha consentido compartir datos para mejorar Claude?"""
config = self._get_config()
return config.get("grove_enabled", None) # None = no ha decidido aun
def _get_config(self):
# Cache local con TTL de 24 horas
cached = self.cache.get(self.account_id)
if cached and not self._is_expired(cached):
return cached["config"]
# Si el cache esta viejo, refrescar en background
self._refresh_in_background()
return cached["config"] if cached else {}
def show_consent_dialog(self):
"""Muestra el dialogo de consentimiento si el usuario no ha decidido."""
if self.is_enabled() is None:
# Mostrar dialogo: "Help improve Claude?"
# El usuario puede aceptar o rechazar
passGrove aparece como un dialogo al iniciar Claude Code si eres usuario OAuth y no has tomado una decision todavia. Puedes cambiarlo en cualquier momento con el comando /privacy-settings.
Proteccion de datos y anonimizacion
No todo se envia en crudo. El sistema tiene varias capas de anonimizacion:
# Recreacion del sistema de anonimizacion
import hashlib
def hash_repo_remote(remote_url):
"""El remote del repo se hashea — Anthropic no ve la URL completa."""
return hashlib.sha256(remote_url.encode()).hexdigest()[:16]
def hash_user_to_bucket(user_id, num_buckets=30):
"""El user ID se hashea en 30 cubos para estimar unicos sin identificar."""
hash_val = int(hashlib.sha256(user_id.encode()).hexdigest(), 16)
return hash_val % num_buckets
def sanitize_tool_name(tool_name, is_mcp_tool):
"""Los nombres de herramientas MCP se redactan por defecto."""
if is_mcp_tool:
return "mcp" # No se envia el nombre real
return tool_name
def sanitize_model_name(model, is_internal_user):
"""Los nombres de modelo se normalizan para usuarios externos."""
if is_internal_user:
return model # Internos ven el nombre real
# Externos ven nombres canonicos
return CANONICAL_MODEL_NAMES.get(model, model)
def truncate_version(version):
"""Las versiones dev se truncan (se elimina timestamp y SHA)."""
# "2.0.53-dev.20251124.t173302.sha526cc6a" -> "2.0.53-dev"
if "-dev" in version:
return version.split("-dev")[0] + "-dev"
return versionResumen de lo que se protege:
URL del repositorio: solo se envia un hash (16 chars de SHA256). Anthropic no ve
github.com/tu-empresa/repo-secreto, vea1b2c3d4e5f67890ID de usuario: se hashea en 30 cubos. Saben "hubo actividad en el cubo 17", no "fue el usuario X"
Herramientas MCP: los nombres se reemplazan por
"mcp". No saben que herramientas custom usasPrompts del usuario: no se envian por defecto. Solo se incluyen si activas
OTEL_LOG_USER_PROMPTS=1Paths de archivos: se truncan a 512 caracteres maximo, y los inputs de herramientas a 4KB
Los campos que contienen PII (informacion personal identificable) se marcan internamente con un prefijo especial y solo van al First-Party Logger, nunca a Datadog. En el lado del servidor, se almacenan en columnas con controles de acceso restringido.
Curiosidades
El nombre tengu como prefijo de eventos
Todos los eventos internos de Claude Code empiezan con tengu_ (como tengu_init, tengu_api_error, tengu_tool_use_success). Tengu es una criatura del folclore japones, tipicamente representada como un ser con una nariz muy larga que habita en las montañas. No esta claro por que eligieron este nombre como namespace de telemetria, pero es consistente en los 45+ tipos de eventos.
El token de Datadog esta hardcodeado y es publico
El token que usa Claude Code para enviar datos a Datadog (pubbbf48e6d78dae54bceaa4acf463299bf) esta literalmente escrito en el codigo fuente. Esto no es un error de seguridad: Datadog tiene un concepto de "public API token" diseñado para clientes que corren en maquinas de usuarios. El token solo permite escribir logs, no leerlos ni acceder al dashboard.
Reintento de eventos fallidos entre sesiones
Si Claude Code no consigue enviar un lote de eventos (por ejemplo, porque no tienes conexion), los guarda en ~/.claude/telemetry/ como archivos JSONL. La proxima vez que abres Claude Code, antes de hacer nada, intenta reenviar esos archivos. Usa backoff cuadratico (no exponencial) con un maximo de 8 intentos por lote.
El firstTokenTime
Uno de los atributos que se envian a GrowthBook es firstTokenTime — la fecha exacta en que tu cuenta recibio su primer token de Claude. Esto permite a Anthropic segmentar experimentos por "antiguedad" del usuario: por ejemplo, activar una funcion solo para usuarios nuevos, o solo para veteranos.
Empleados vs externos: reglas diferentes
Los empleados de Anthropic (identificados como userType: "ant") tienen reglas de telemetria diferentes: sus feature flags se refrescan cada 20 minutos (vs 6 horas para externos), pueden ver nombres internos de modelos sin normalizar, y tienen acceso a un panel /config donde pueden sobreescribir feature flags localmente con una variable de entorno (CLAUDE_INTERNAL_FC_OVERRIDES).
Fuentes
Analisis basado en la filtracion del codigo fuente de Claude Code v2.1.88, documentada en el Post 1 de esta serie
The Verge — "Claude Code leak exposes a Tamagotchi-style 'pet' and an always-on agent"
Esta serie es un proyecto de Levante — un cliente de escritorio open source para trabajar con MCPs, modelos de IA y herramientas en una sola interfaz. Si te interesa la arquitectura de herramientas de IA, te interesa Levante.
Saul Gomez es fundador de Levante, un cliente de escritorio open source para trabajar con modelos de IA.
