event_log (logging estructurado)
Toda la observabilidad se apoya en una única tabla append-only, event_log
(src/event-log/). Es a la vez herramienta de debugging y combustible del flywheel.
La tabla
| Columna | Notas |
|---|---|
id | UUID |
event_date | timestamptz (default now), indexado |
event | enum event_type |
call_id / agent_id | referencias de la llamada / agente |
user_id / user_email | identidad del usuario autenticado (solo en acciones de usuario) |
entity_id / entity_type | referencia genérica |
payload | jsonb flexible por tipo de evento |
EventLogService.log(event, context) es fire-and-forget (void ...): si falla,
loguea el error pero nunca rompe el flujo de la llamada.
Tipos de evento
| Evento | Cuándo | Payload destacado |
|---|---|---|
call_started / call_ended | inicio/fin de llamada | source, callerNumber |
knowledge_queried | cada recuperación RAG (hit y miss) | query, hit, scores, embedMs, queryMs, reason, knowledgeThreshold, knowledgeSources, knowledgeBestScore, knowledgeBestSource, knowledgeBestSection, memoryBestScore |
suggestion_generated | se generó una sugerencia | chars, llmMs, ragHit, ragScore, tokens |
suggestion_skipped | un turno del paciente no produjo sugerencia | reason (short_turn|model_silent), words, minWords, llmMs, ragHit |
feedback_given | el operario marcó una sugerencia; también ediciones/borrados del supervisor desde /logs | action, suggestionId, op (edited|deleted, ausente al crear) |
claim_initiated | un agente tomó una llamada | source |
call_released | un agente liberó una llamada | source |
transfer_initiated | derivación a humano (Retell) | patient, summaryChars |
memory_created | se guardó una lección (flywheel) | category |
prompt_updated | un superadmin editó/revirtió el prompt del agente | promptKey, action (update|rollback), versionId, version |
call_reviewed | se marcó una llamada como revisada, o un supervisor la reabrió | reviewed (true al marcar, false al reabrir) |
patient_data_updated | alguien corrigió los datos del paciente de una llamada | fields (qué campos se tocaron; no los valores) |
transcript_polished | un operador pidió la corrección del transcript con IA (ya no es automática) | polished, changed, turns |
Por qué suggestion_skipped es un evento aparte
Un turno silenciado por el gate de palabras mínimas ya escribía una fila, pero era un
knowledge_queried con reason: 'short_turn' — idéntica a la que escribe el engine cuando
saltea el RAG en un turno que sí generó sugerencia. Las dos situaciones eran indistinguibles en
la tabla, y el caso en que el modelo elegía callarse (—) no dejaba rastro alguno más allá de una
línea de log. Con eso, un copiloto que dejaba de hablar a mitad de la llamada era invisible.
Un turno silenciado por el gate escribe las dos filas: el knowledge_queried se mantiene para
que la cobertura de RAG siga comparable con los turnos que sí pasaron, y el suggestion_skipped
responde la pregunta nueva — cuántas veces el panel del operador quedó vacío, y quién lo decidió.
El panel de operaciones lo expone como KPI "Se calló", separando byGate (el gate silenció al
copiloto sin que el modelo opine) de byModel (el modelo no tenía nada que agregar, que es
comportamiento esperado).
El turno de datos no escribe knowledge_queried
Hay un tercer caso, y es el que no deja fila. Los turnos del guion (isScriptDataTurn: un DNI
dictado, un teléfono) esquivan el gate de palabras a propósito, porque ahí el operador necesita el
próximo paso del guion. Antes llegaban igual al engine, que los volvía a gatear por longitud y
escribía un knowledge_queried con reason: 'short_turn'. En una semana de producción fueron ~91
filas que contaban como fallo de cobertura del RAG: un paciente deletreando su documento no es un
hueco en los manuales.
Hoy el adaptador manda skipRag y el engine ni consulta ni loguea. La señal no se pierde: viaja en
banda con la sugerencia, que es otro transporte (ticket 4.0 de la Fase 4). Y el bypass de
alwaysRagOnEquipment se evalúa antes que el skipRag, para que un "error H08" corto siga
llegando al manual.
El score crudo de un miss
knowledge_queried traía knowledgeScore: null cuando no había hit, así que un miss por umbral
(0.52, casi) era indistinguible de un miss por corpus inexistente (0.05) sin grepear los logs de la
app. Ahora el payload trae, hit o miss, el top-1 real de cada store: knowledgeBestScore con su
knowledgeBestSource / knowledgeBestSection, y memoryBestScore. Son null solo cuando no hubo
candidatos (corpus vacío, filtro sin match) o la query falló — distinto de un miss, que sí trae score.
Con eso, la distribución de los misses es una query:
SELECT width_bucket((payload->>'knowledgeBestScore')::numeric, 0, 1, 20) AS bucket, count(*)
FROM event_log
WHERE event = 'knowledge_queried' AND payload->>'reason' = 'no_hit'
AND payload ? 'knowledgeBestScore'
GROUP BY 1 ORDER BY 1;
El campo source
knowledge_queried y suggestion_generated etiquetan el canal que originó el turno:
retell, audio_fork, elevenlabs o whatsapp.
elevenlabses la voz por el endpoint Custom LLM;whatsappes el texto por el mismo endpoint. Son dos canales distintos con el mismo transporte: los separa el bearer token, y cada uno trae su prompt, sumaxTokensy su umbral de RAG.elevenlabsno se renombró a algo tipoelevenlabs_voicea propósito: hay filas históricas con ese valor y renombrarlo las dejaría huérfanas.- En
knowledge_queriedde cualquier canal, el payload traeknowledgeThresholdjunto a los scores — necesario porque el umbral ya no es el mismo para todos (ver Pipeline RAG). - Y trae
knowledgeSources: los manuales a los que se restringió la búsqueda por el equipo del paciente, onullsi vio el corpus entero. Es la única forma de distinguir después un miss del umbral de un miss del filtro (ver Filtro por equipo).
:::caution ElevenLabs, WhatsApp y el call_id
En los canales de ElevenLabs (voz y WhatsApp) el
call_id sale del header x-conversation-id, y solo porque el agente está configurado para
inyectarlo desde la variable dinámica system__conversation_id. Un agente sin ese header hace que
cada turno se sintetice su propio id: los conteos agregados por source siguen siendo válidos,
pero agrupar por call_id no. Cuando pasa, el backend loguea un warn.
Los filtros por fuente de las pantallas de métricas y feedback todavía no ofrecen elevenlabs ni
whatsapp; los agregados sin filtro sí los incluyen.
:::
Identidad del usuario
user_id / user_email solo se pueblan en acciones que entran por HTTP
autenticado (feedback, claim, release, edición de prompt, revisión de llamada), tomadas de @CurrentUser() en el
controller.
agent_id NO es la identidad del usuarioagent_id es el último segmento de la URL del WebSocket del copiloto
(VITE_COPILOT_WS_URL), frecuentemente 'unknown'. Para auditoría (quién hizo qué) usar
user_id / user_email. Los eventos del bot (RAG, sugerencias, inicio/fin) no tienen
usuario.
Qué alimenta
- Métricas operativas (cobertura, se calló/derivó, latencia) → panel
/operations. - Auditoría de acciones de usuario → panel
/logs.
Ver Métricas y paneles.