Trazas (OpenTelemetry)
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/coretrae los hooks de trazas: una interfaz mínimaBotTracer, un no-op por defecto ybot.tracing. Sin dependencias nuevas.@botmesh/otel(privado por ahora) los adapta a OpenTelemetry y además publicabot.metrics()como métricas de OpenTelemetry.@botmesh/aiañade spans de modelo, herramientas y recuperación a la misma traza.
Sin contenido de mensajes
Los 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
pnpm add @opentelemetry/api @opentelemetry/sdk-node \
@opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/sdk-metrics// telemetry.ts — cárgalo antes que el bot (p. ej. `node --import ./telemetry.js bot.js`)
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
export const sdk = new NodeSDK({
serviceName: 'bot-ventas',
traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics' }),
}),
})
sdk.start()// bot.ts
import { Bot } from '@botmesh/core'
import { createOtelTracer, otelMetricsPlugin } from '@botmesh/otel'
const bot = new Bot({
provider,
telemetry: {
tracer: await createOtelTracer(),
// La misma clave en todas las réplicas: los ids con hash coinciden entre procesos y reinicios.
idKey: process.env.TRACE_ID_KEY,
},
})
bot.plug(otelMetricsPlugin()) // bot.metrics() como métricas de OpenTelemetry
bot.plug(aiPlugin({ /* … */ })) // los spans de IA entran solos en la misma traza
process.on('SIGTERM', async () => {
await bot.stop()
await sdk.shutdown() // vacía los spans y métricas que siguen en buffer
})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
docker run --rm --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:latestApunta 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:
services:
tempo:
image: grafana/tempo:latest
command: ['-config.file=/etc/tempo.yaml']
volumes: ['./tempo.yaml:/etc/tempo.yaml']
ports: ['4318:4318', '3200:3200']
grafana:
image: grafana/grafana:latest
environment:
GF_AUTH_ANONYMOUS_ENABLED: 'true'
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
volumes: ['./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml']
ports: ['3000:3000']tempo.yaml:
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
storage:
trace:
backend: local
local: { path: /var/tempo/traces }
wal: { path: /var/tempo/wal }grafana-datasources.yaml:
apiVersion: 1
datasources:
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
isDefault: truedocker 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
bot.inbound consumer · bot.event.kind=message · bot.inbound.outcome=dispatched
├─ bot.persist escrituras de chat / contacto / mensaje
└─ bot.dispatch bot.queue.wait_ms · bot.dispatch.stage=matcher
└─ bot.handler bot.handler.stage=matcher · bot.matcher.kind=text · bot.flow.id=pedido
└─ bot.conversation.turn bot.conversation.reason=start · parked=true
├─ chat claude-… gen_ai.* (modelo, tokens, motivo de fin)
│ └─ (span http de la llamada al SDK, de su propia instrumentación)
├─ execute_tool buscar bot.ai.tool.outcome=ok
└─ bot.pace retraso + escribiendo…
└─ bot.send client · bot.send.outcome=ok
└─ (span http de la llamada al provider)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
| Span | Kind | Dónde |
|---|---|---|
bot.inbound | consumer | Un evento de mensaje, interacción o llamada del provider (conexión, presencia y estados no tienen span). Padre: el contexto activo de OpenTelemetry, p. ej. el span HTTP de un webhook. |
bot.persist | internal | Escrituras de chat, contacto y mensaje de un mensaje entrante (spans.persist). |
bot.dispatch | internal | El trabajo del mensaje en la cola FIFO del chat, cuando le llega el turno. |
bot.handler | internal | Cada handler que corre el dispatcher: comando, matcher, catch-all, trigger de evento (llamada). |
bot.conversation.turn | internal | Un tramo de conversación entre dos esperas (ver abajo). |
bot.trigger / bot.broadcast | internal | bot.trigger() / bot.broadcast(). |
bot.pace | internal | Una respuesta con ritmo: retraso + indicador de escritura + su envío (spans.pace). |
bot.send | client | Cada envío y edición del pipeline de salida. La llamada al provider corre dentro. |
bot.stream.update | client | Un borrador/edición de ctx.stream(): solo con spans.streamUpdates; si no, un evento en el span activo. |
chat {model} | client | Una llamada al modelo de @botmesh/ai (convenciones GenAI de OpenTelemetry). |
execute_tool {name} | internal | Una llamada a herramienta del bucle de herramientas de la IA. |
bot.ai.retrieve | internal | Recuperación RAG (bot.ai.rag.chunks). |
bot.task | internal | Una ejecución de una tarea programada (bot.schedule/every/delay, simple o durable). Siempre es un span raíz; los envíos de la tarea cuelgan de él. Ver Tareas programadas. |
bot.lifecycle | internal | Un start/stop/rollback de un paso del ciclo de vida: storage, coordination, restore, durability, transport, jobs. Spans raíz tomados durante bot.start()/bot.stop(); el de transport mide el handshake con el provider (conexión + QR de Baileys, polling de Telegram, login IMAP), que es donde se nota un arranque lento. |
Atributos
| Atributo | En | Valores |
|---|---|---|
bot.provider.id, bot.id | inbound, send | Id del provider, bot id del namespace del store. |
bot.event.kind, bot.message.type, bot.chat.kind | inbound, dispatch | message/interaction/call; text, image…; private/group. |
bot.chat.id, bot.sender.id, bot.message.id | inbound, send | Según la política de ids. |
bot.inbound.outcome | inbound | dispatched, blocked, duplicate, group_filtered, stopped, from_me, handoff (llamadas). |
bot.queue.wait_ms | dispatch | Espera por el turno del chat (incluye el turno entre réplicas con coordinación). |
bot.dispatch.stage | dispatch | aborted_by_hook, handoff, command, command_unknown, conversation, dormant, matcher, catch_all, unhandled, event. |
bot.handler.stage, bot.matcher.kind | handler | command/matcher/catch_all/event; welcome/text/function/type. |
bot.command.name | handler | Solo para un comando registrado (el texto de un comando desconocido nunca se registra). |
bot.flow.id | handler | El flujo/conversación en el que entra un matcher. |
bot.conversation.id, .name, .reason, .parked, .outcome, .goto | turno | Ver abajo. |
bot.send.outcome, bot.content.kind, bot.send.origin, bot.send.edit | send | ok, failed, skipped, deduplicated (outbox durable), deferred; text, image…; ctx/api. |
bot.task.id, .kind, .durable, .cron, .interval_ms, .time_zone | tarea | Id de la tarea (según la política de ids), cron/interval/once, si es durable, su especificación. |
bot.task.scheduled_at, .missed, .fencing_token, bot.scheduler.role, bot.scheduler.owner | tarea (durable) | El slot (ISO 8601), si corre con retraso, el fencing token del claim, leader/follower/single, el owner del scheduler de la réplica. |
bot.link.reason | enlace de span | resume: el enlace de una conversación retomada desde un checkpoint al turno que lo escribió. |
bot.lifecycle.step, bot.lifecycle.phase | lifecycle | El nombre del paso y lo que hacía: start, stop (tras el drenado) o rollback (un start() fallido deshaciendo lo que arrancó). |
error.type | cualquier span fallido | El nombre de la clase de error (RateLimitedError, timeout…). |
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: startodormant(un flujo persistente retomado desde su checkpoint); el destino de unc.goto()empieza bajo el turno que hizo el goto:reason: goto; - quedarse esperando en un
askcierra el turno conbot.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 dec.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 confallback: '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.fallbackpropia recibe el contexto eninfo.trace, para enlazar sus propios spans. formatTraceparent(spanContext)yparseTraceparent(traceparent, tracestate?)se exportan desde@botmesh/core/spipara 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:
bot.when('pedido', async (ctx) => {
const pedido = await bot.tracing.trace('erp.lookup', { kind: 'client' }, () => erp.buscar(ctx.body))
await ctx.reply(`Pedido ${pedido.id}`)
})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:
| Métrica | Tipo | Atributos |
|---|---|---|
botmesh.messages.inbound | counter | |
botmesh.messages.outbound | counter | result = ok/failed |
botmesh.dropped | counter | reason = duplicate/blocked/handoff |
botmesh.presence.skipped | counter | kind = typing/mark_read |
botmesh.conversations.started / .ended | counter | outcome (ended) |
botmesh.flow.conversations.started / .ended | counter | flow, outcome (perFlow) |
botmesh.triggered, botmesh.errors | counter | |
botmesh.conversations.active, botmesh.uptime (s) | gauge | |
botmesh.conversation.duration (s) | histogram | flow, outcome |
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.
Datos personales
- Nunca se registran: cuerpos de mensaje, captions, prompts, respuestas del modelo, argumentos y resultados de herramientas,
c.state, respuestas deask, 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 estelemetry.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
new Bot({
telemetry: {
tracer, // BotTracer (obligatorio)
ids: 'hash', // 'hash' | 'raw' | 'omit'
idKey: process.env.TRACE_ID_KEY, // string | Uint8Array
errorMessages: false,
spans: { persist: true, pace: true, streamUpdates: false },
},
})Opción de @botmesh/otel | Por defecto | |
|---|---|---|
tracerProvider | el global (trace.getTracerProvider()) | |
tracerName | '@botmesh/otel' | Scope de instrumentación. |
spanMetrics | true | false, o { meterProvider, prefix }. |
otelMetricsPlugin({ meterProvider, prefix, attributes, perFlow, conversationDuration }) | global, 'botmesh' ('botwa' conserva los nombres de la alpha), {}, true, true |
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:
import { createRecordingTracer, createTestBot } from '@botmesh/core/testing'
const rec = createRecordingTracer()
const { bot, chat } = await createTestBot({ telemetry: { tracer: rec.tracer, ids: 'raw' } })
bot.when('hola', (ctx) => ctx.reply('buenas'))
await chat.send('hola')
expect(rec.parentOf(rec.find('bot.send')!)?.name).toBe('bot.pace')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; subot.dispatches 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 debot.stop().