Skip to main content

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

ColumnaNotas
idUUID
event_datetimestamptz (default now), indexado
eventenum event_type
call_id / agent_idreferencias de la llamada / agente
user_id / user_emailidentidad del usuario autenticado (solo en acciones de usuario)
entity_id / entity_typereferencia genérica
payloadjsonb 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

EventoCuándoPayload destacado
call_started / call_endedinicio/fin de llamadasource, callerNumber
knowledge_queriedcada recuperación RAG (hit y miss)query, hit, scores, embedMs, queryMs, reason, knowledgeThreshold, knowledgeSources, knowledgeBestScore, knowledgeBestSource, knowledgeBestSection, memoryBestScore
suggestion_generatedse generó una sugerenciachars, llmMs, ragHit, ragScore, tokens
suggestion_skippedun turno del paciente no produjo sugerenciareason (short_turn|model_silent), words, minWords, llmMs, ragHit
feedback_givenel operario marcó una sugerencia; también ediciones/borrados del supervisor desde /logsaction, suggestionId, op (edited|deleted, ausente al crear)
claim_initiatedun agente tomó una llamadasource
call_releasedun agente liberó una llamadasource
transfer_initiatedderivación a humano (Retell)patient, summaryChars
memory_createdse guardó una lección (flywheel)category
prompt_updatedun superadmin editó/revirtió el prompt del agentepromptKey, action (update|rollback), versionId, version
call_reviewedse marcó una llamada como revisada, o un supervisor la reabrióreviewed (true al marcar, false al reabrir)
patient_data_updatedalguien corrigió los datos del paciente de una llamadafields (qué campos se tocaron; no los valores)
transcript_polishedun 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.

  • elevenlabs es la voz por el endpoint Custom LLM; whatsapp es 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, su maxTokens y su umbral de RAG.
  • elevenlabs no se renombró a algo tipo elevenlabs_voice a propósito: hay filas históricas con ese valor y renombrarlo las dejaría huérfanas.
  • En knowledge_queried de cualquier canal, el payload trae knowledgeThreshold junto 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, o null si 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 usuario

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