Saltar al contenido principal
Por cada mensaje el bot hace mucho trabajo: listas de acceso, persistencia, la cola del chat, hooks, enrutado, un paso de conversación, una llamada a la IA con herramientas, el envío del provider. Las trazas muestran ese trabajo como un árbol de spans por mensaje, para ver dónde se va el tiempo y dónde falla algo, en Jaeger, Grafana Tempo o cualquier backend de OpenTelemetry. Las trazas son opt-in. Sin BotConfig.telemetry el bot ejecuta exactamente el mismo código que antes: sin spans y sin await extra.
  • @botmesh/core trae los hooks de trazas: una interfaz mínima BotTracer, un no-op por defecto y bot.tracing. Sin dependencias nuevas.
  • @botmesh/otel los adapta a OpenTelemetry y además publica bot.metrics() como métricas de OpenTelemetry.
  • @botmesh/ai añade spans de modelo, herramientas y recuperación a la misma traza.
Sin contenido de mensajesLos spans llevan tipos, etapas, resultados e ids, nunca cuerpos de mensaje, captions, prompts, respuestas del modelo, argumentos o resultados de herramientas, c.state ni mensajes de error. Los ids de chat, usuario y mensaje van con hash por defecto. Mira Datos personales.

Inicio rápido

createOtelTracer() carga @opentelemetry/api en diferido y rechaza con un mensaje de instalación si falta. Si el bot se tiene que construir de forma síncrona, usa lazyOtelTracer(): sus spans son no-op hasta que la API carga, y un fallo de carga se informa por su promesa ready (nunca se lanza dentro del bot).

Backends locales

Jaeger

Apunta los exporters a http://localhost:4318 y abre http://localhost:16686. Jaeger solo guarda trazas: manda las métricas a un Prometheus o a un OpenTelemetry Collector, o quita el lector de métricas mientras pruebas.

Grafana Tempo + Grafana

docker-compose.yml:
tempo.yaml:
grafana-datasources.yaml:
docker compose up, abre Grafana en http://localhost:3000 → Explore → Tempo y busca por nombre de servicio (bot-ventas) o de span (bot.send, chat claude-…). Tempo solo recibe trazas: para las métricas añade un Prometheus con su receptor OTLP (--web.enable-otlp-receiver, ruta OTLP /api/v1/otlp/v1/metrics) o un OpenTelemetry Collector delante de ambos. En producción fija las versiones de las imágenes.

Cómo se ve un mensaje

La respuesta del usuario llega como otro mensaje: su propio bot.inbound → bot.dispatch (etapa conversation), con el siguiente turno de la misma conversación (mismo bot.conversation.id) como hijo.

Catálogo de spans

Atributos

Todos los nombres se exportan como TRACE_ATTR / SPAN_NAMES desde @botmesh/core.

Turnos de conversación

Una conversación puede durar días, así que no es un único span. Cada tramo de trabajo entre dos esperas es un bot.conversation.turn:
  • el primer turno empieza bajo el handler (o bot.trigger) que la inició: reason: start o dormant (un flujo persistente retomado desde su checkpoint); el destino de un c.goto() empieza bajo el turno que hizo el goto: reason: goto;
  • quedarse esperando en un ask cierra el turno con bot.conversation.parked = true;
  • una respuesta abre el siguiente turno bajo el span de dispatch del mensaje que respondió: reason: reply;
  • el timeout de una espera abre un turno raíz enlazado al primero (reason: timeout), porque ningún mensaje lo causó; un callback de c.idle() que salta mientras la conversación espera corre también en un turno raíz (reason: idle), que vuelve a quedar en espera si el callback solo respondió, o lleva el resultado si terminó o redirigió la conversación;
  • un error lanzado por la conversación (o por un callback de inactividad) se registra en el turno donde ocurrió;
  • una conversación retomada desde un checkpoint que escribió otra réplica o un proceso anterior (un flujo persistente tras un reinicio, un flujo reenviado por coordination.handover, un stub de hand-over reiniciado con fallback: 'restart') empieza bajo el dispatch del mensaje que la despertó y queda enlazada al turno que escribió el checkpoint; ver Entre réplicas y reinicios;
  • el final de la conversación pone bot.conversation.outcome (completed, ended, guarded, cancelled, timedOut, retriesExceeded, error) en el turno abierto; una conversación abortada mientras esperaba (/reset, handoff, stop()) recibe un turno corto de cierre (reason: end).
Los pasos de un flujo son eventos flow.step del turno (bot.flow.step.index, bot.flow.step.kind).

Entre réplicas y reinicios

Con telemetría activa, cada checkpoint que escribe una conversación (flujos persistentes, checkpoints efímeros de handover.flows: 'forward', stubs de hand-over) guarda también el contexto de traza W3C del turno que lo escribió, en FlowCheckpoint.trace ({ traceparent, tracestate? }; los stores lo guardan de forma opaca, como el resto del checkpoint). La réplica (o el proceso reiniciado) que retoma la conversación le da a su primer turno (reason: dormant, o start para un stub reiniciado con fallback: 'restart') un enlace de span a ese contexto, marcado con bot.link.reason = resume e isRemote, con el tracestate restaurado. Enlace, no padre (a propósito): el padre del turno retomado sigue siendo el bot.dispatch del mensaje que lo despertó, así que la traza de ese mensaje (inbound → dispatch → turno → envíos) queda completa; colgarlo del span de la réplica anterior partiría en dos la traza del mensaje que lo despertó, y una conversación en espera durante horas estiraría una sola traza entre procesos y exportaciones vaciadas hace mucho. Tu backend muestra el enlace como “follows from” y te deja saltar a la traza anterior. Notas:
  • Solo se transportan ids con forma W3C (32/16 hex en minúsculas, lo que produce OpenTelemetry). Un tracer con otros ids no guarda nada y el turno simplemente no tiene enlace. Un valor guardado mal formado se ignora.
  • El contexto guardado es siempre el del último turno que escribió o quedó en espera: un stub de hand-over se reescribe cuando un turno posterior queda en espera (solo con telemetría; si no, solo se refresca antes de caducar), y el marcador de espera de un paso reenviado también lo actualiza. Un turno cuyo contexto no se puede transportar conserva el que ya estaba guardado en vez de borrarlo.
  • Una función handover.fallback propia recibe el contexto en info.trace, para enlazar sus propios spans.
  • formatTraceparent(spanContext) y parseTraceparent(traceparent, tracestate?) se exportan desde @botmesh/core/spi para tu propia propagación.

Tareas programadas

Cada ejecución de una tarea registrada con bot.schedule(), bot.every() o bot.delay() es un span bot.task: las tareas simples en memoria, por ejecución; las durables, por ocurrencia (un span por slot en un catch-up). El span es siempre raíz (una tarea no la causa ningún mensaje, aunque se haya registrado dentro de un handler) y los envíos y llamadas de IA del handler cuelgan de él. Un handler que lanza pone el estado en error con error.type, como en cualquier otro sitio. Las ocurrencias durables añaden el slot (bot.task.scheduled_at), bot.task.missed, el bot.task.fencing_token del claim y el liderazgo: bot.scheduler.role es leader (esta réplica tiene el lease del scheduler), follower (cubrió una ocurrencia que el líder dejó vencer) o single (sin lease configurado), más bot.scheduler.owner (scheduler.owner; por defecto, un id aleatorio por proceso). Los ids de tarea pasan por la política de ids (hash por defecto), porque los ids durables suelen llevar dentro un id de usuario o de chat. Los temporizadores internos del bot (los vigilantes onIdle por chat) no son spans de tarea.

Spans de IA

aiPlugin pasa bot.tracing a su AIClient, así que con telemetría activa cada llamada al modelo es un span chat {model} (kind client) con gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.request.max_tokens, gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.response.finish_reasons y bot.ai.purpose (reply, ask, generate, memory, tool_step). Un timeout es estado error con error.type = timeout; un rechazo es finish_reasons = [refusal] con el estado sin marcar. Un stream es un span desde el primer fragmento hasta el final (bot.ai.streamed, bot.ai.cancelled si se cortó antes); Anthropic y Gemini informan el uso de tokens en streaming, los compatibles con OpenAI y Ollama no. Las herramientas son spans execute_tool {name} con gen_ai.tool.name, gen_ai.tool.call.id y bot.ai.tool.outcome (ok, error, timeout, invalid_args, unknown, duplicate, confirm.denied, confirm.replied, confirm.cancelled). Una herramienta que espera la confirmación del usuario mantiene su span abierto (con un evento confirm.requested) hasta que responde. Un AIClient suelto solo traza si le pasas tracing (p. ej. bot.tracing) en sus opciones.

Tus propios spans

bot.tracing es la fachada de trazas del bot (un no-op desactivado sin telemetría). Los spans que inicies dentro de un handler cuelgan de los del bot:
Con @botmesh/otel, los spans del bot también son el contexto activo de OpenTelemetry, así que tracer.startActiveSpan() y las auto-instrumentaciones del SDK (http, undici, pg…) cuelgan de ellos. Los plugins usan ctx.bot.tracing igual; bot.tracing.idAttribute(id) aplica la política de ids a un id tuyo.

Métricas

otelMetricsPlugin() (o await registerOtelMetrics(bot)) lee bot.metrics() en cada recolección: Cada punto lleva bot.id más tus attributes. bot.resetMetrics() hace caer los contadores acumulados, y OpenTelemetry y Prometheus lo tratan como un reinicio de contador. El tracer además deriva histogramas de los spans terminados (spanMetrics, activo por defecto), que se registran aunque la traza no se muestree: botmesh.inbound.duration{bot.event.kind, bot.inbound.outcome, bot.provider.id}, botmesh.dispatch.duration{bot.dispatch.stage}, botmesh.queue.wait, botmesh.send.duration{bot.provider.id, bot.send.outcome}, botmesh.lifecycle.duration{bot.lifecycle.step, bot.lifecycle.phase}, gen_ai.client.operation.duration y gen_ai.client.token.usage{gen_ai.token.type}. Ninguna métrica lleva ids de chat, usuario o mensaje. Los histogramas traen límites de cubeta acordes a su unidad (como advice del instrumento, así que una View de tu SDK sigue mandando): de 5 ms a 10 s para las latencias del bot, las cubetas de las convenciones semánticas de GenAI para llamadas al modelo y tokens, y de 1 s a 1 día para botmesh.conversation.duration. Un prefix debe empezar por letra y contener solo letras, dígitos, ., _, - o /; cualquier otro lanza al construir el tracer o el puente.

Datos personales

  • Nunca se registran: cuerpos de mensaje, captions, prompts, respuestas del modelo, argumentos y resultados de herramientas, c.state, respuestas de ask, texto de comandos desconocidos ni payloads crudos del provider.
  • Los mensajes de error pueden contener texto del usuario: solo se registran la clase y el código del error, salvo que pongas telemetry.errorMessages: true.
  • Los ids siguen telemetry.ids:
    • 'hash' (por defecto): HMAC-SHA256, base64url, 16 caracteres. La clave es telemetry.idKey; sin ella se usa una clave aleatoria por proceso, y los hashes no coinciden entre réplicas ni reinicios. Pon el mismo secreto en todas partes para buscar las trazas de un chat.
    • 'raw': el id tal cual lo da el provider; en WhatsApp es un número de teléfono, un dato personal.
    • 'omit': sin ids.

Configuración

Muestreo

Muestrea en la raíz con un sampler basado en el padre (new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.1) }) en las opciones del NodeSDK): los spans de un mensaje comparten su decisión. Cuando un span no se graba, el core se salta el hash de sus ids. Los histogramas derivados de spans se registran igual, así que los paneles de latencia siguen completos con cualquier tasa de muestreo.

Otros backends y tests

BotTracer es pequeño a propósito: startSpan(name, options), que devuelve un span con setAttribute(s), addEvent, recordException, setStatus, end, isRecording y spanContext, y un withSpan(span, fn) opcional para hacer de un span el contexto activo del backend. Cualquier librería de trazas puede implementarlo. Un tracer que lanza errores nunca rompe el bot: la llamada cae a un span no-op y se registra un aviso como mucho una vez por minuto. En tests, createRecordingTracer() de @botmesh/core/testing guarda cada span en memoria:

Límites

  • Reproducciones durables: un registro entrante que reproduce el motor de durabilidad (tras una caída o un reintento) no tiene span bot.inbound; su bot.dispatch es un span raíz.
  • Varias réplicas: una conversación retomada en otra réplica (o tras un reinicio) empieza una traza nueva enlazada a la anterior, no una continuación (ver Entre réplicas y reinicios). Una conversación traspasada antes de activar la telemetría, o por una réplica sin ella, no tiene enlace.
  • Spans de OpenTelemetry de terceros dentro de una conversación retomada (código después de await c.ask()) cuelgan del contexto propio de OpenTelemetry, que sigue siendo el del primer turno; los spans del bot sí cuelgan bien.
  • Vaciar el buffer es cosa del SDK: llama a sdk.shutdown() después de bot.stop().