Skip to main content
A bot does a lot of work for each message: access lists, persistence, the chat’s queue, hooks, routing, a conversation step, an AI call with tools, the provider’s send. Tracing shows that work as a tree of spans per message, so you can see where the time goes and where things fail, in Jaeger, Grafana Tempo or any OpenTelemetry backend. Tracing is opt-in. Without BotConfig.telemetry the bot runs exactly the code it ran before: no spans, no extra awaits.
  • @botmesh/core has the tracing hooks: a minimal BotTracer interface, a no-op default and bot.tracing. No new dependency.
  • @botmesh/otel adapts them to OpenTelemetry and also turns bot.metrics() into OpenTelemetry metrics.
  • @botmesh/ai adds model, tool and retrieval spans to the same trace.
No message contentSpans carry kinds, stages, outcomes and ids, never message bodies, captions, prompts, completions, tool arguments or results, c.state or error messages. Chat, user and message ids are hashed by default. See Personal data.

Quick start

createOtelTracer() loads @opentelemetry/api lazily and rejects with an install hint when it is missing. When the bot must be built synchronously, use lazyOtelTracer(): its spans are no-ops until the API is loaded, and a load failure is reported through its ready promise (never thrown into the bot).

Local backends

Jaeger

Point the exporters at http://localhost:4318 and open http://localhost:16686. Jaeger stores traces only: send the metrics to a Prometheus or an OpenTelemetry Collector, or drop the metric reader while you try it.

Grafana Tempo + Grafana

docker-compose.yml:
tempo.yaml:
grafana-datasources.yaml:
docker compose up, then open Grafana at http://localhost:3000 → Explore → Tempo and search by service name (sales-bot) or by span name (bot.send, chat claude-…). Tempo only takes traces: for the metrics, add a Prometheus with its OTLP receiver (--web.enable-otlp-receiver, OTLP path /api/v1/otlp/v1/metrics) or an OpenTelemetry Collector in front of both. Pin the image versions in production.

What a message looks like

The user’s answer arrives as another message: its own bot.inbound → bot.dispatch (stage conversation) with the next turn of the same conversation (bot.conversation.id is the same) as a child.

Span catalogue

Attributes

Every name is exported as TRACE_ATTR / SPAN_NAMES from @botmesh/core.

Conversation turns

A conversation can last days, so it is not one span. Each stretch of work between two waits is a bot.conversation.turn:
  • the first turn starts under the handler (or bot.trigger) that entered it — reason: start or dormant (a persistent flow resumed from its checkpoint); a c.goto() target starts under the turn that ran the goto — reason: goto;
  • parking on a wait ends the turn with bot.conversation.parked = true;
  • a reply opens the next turn under the dispatch span of the message that answered — reason: reply;
  • a wait timeout opens a root turn linked to the first one — reason: timeout, since no message caused it; a c.idle() callback that fires while the conversation is parked runs in a root turn too (reason: idle), which parks again when the callback only replied, or carries the outcome when it ended or redirected the conversation;
  • an error thrown by the conversation (or by an idle callback) is recorded on the turn it happened in;
  • a conversation resumed from a checkpoint written by another replica or an earlier process (a persistent flow after a restart, a flow forwarded by coordination.handover, a hand-over stub restarted with fallback: 'restart') starts under the dispatch of the waking message and is linked to the turn that wrote the checkpoint — see Across replicas and restarts;
  • the end of the conversation sets bot.conversation.outcome (completed, ended, guarded, cancelled, timedOut, retriesExceeded, error) on the open turn; a conversation aborted while parked (/reset, handoff, stop()) gets a short closing turn (reason: end).
Flow steps are flow.step events on the turn (bot.flow.step.index, bot.flow.step.kind).

Across replicas and restarts

With telemetry on, every checkpoint a conversation writes (persistent flows, ephemeral checkpoints of handover.flows: 'forward', hand-over stubs) also stores the W3C trace context of the turn that wrote it, in FlowCheckpoint.trace ({ traceparent, tracestate? }; stores keep it opaquely, like the rest of the checkpoint). The replica — or the restarted process — that resumes the conversation gives its first turn (reason: dormant, or start for a stub restarted by fallback: 'restart') a span link to that context, marked bot.link.reason = resume and isRemote, with the tracestate restored. Link, not parent (deliberate): the resumed turn’s parent stays the bot.dispatch of the message that woke it, so that message’s trace (inbound → dispatch → turn → sends) is whole; making the previous replica’s span its parent would cut the waking message’s trace in two, and a conversation parked for hours would stretch one trace across processes and exports that were flushed long ago. Your backend shows the link as “follows from” and lets you jump to the earlier trace. Notes:
  • Only W3C-shaped ids are carried (32/16 lowercase hex, which OpenTelemetry produces). A tracer with other ids stores nothing and the turn simply has no link. A malformed stored value is ignored.
  • The stored context is always the latest turn that wrote or parked: a hand-over stub is rewritten when a later turn parks (only with telemetry on; otherwise it is refreshed only before it expires), and the parked marker of a forwarded step refreshes it too. A turn whose context cannot be carried keeps the one already stored instead of erasing it.
  • A custom handover.fallback function receives the context in info.trace, to link spans of its own.
  • formatTraceparent(spanContext) and parseTraceparent(traceparent, tracestate?) are exported from @botmesh/core/spi for your own propagation.

Scheduled tasks

Each run of a task registered with bot.schedule(), bot.every() or bot.delay() is a bot.task span: plain in-memory tasks per run, durable tasks per occurrence (one span per slot of a catch-up). The span is always a root span — a task has no causing message, even when it was registered inside a handler — and the handler’s sends and AI calls nest under it. A throwing handler sets status error with error.type, as anywhere else. Durable occurrences add the slot (bot.task.scheduled_at), bot.task.missed, the claim’s bot.task.fencing_token, and leadership: bot.scheduler.role is leader (this replica holds the scheduler lease), follower (it stepped in for an occurrence the leader left overdue) or single (no lease configured), plus bot.scheduler.owner (scheduler.owner, a random id per process by default). Task ids go through the ids policy (hashed by default), since durable ids often embed a user or chat id. The bot’s internal timers (per-chat onIdle watchers) are not task spans.

AI spans

aiPlugin passes bot.tracing to its AIClient, so with telemetry on every model call is a chat {model} span (kind client) with 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 and bot.ai.purpose (reply, ask, generate, memory, tool_step). A timeout is status error with error.type = timeout; a refusal is finish_reasons = [refusal] with the status unset. Streams are one span from the first chunk to the end (bot.ai.streamed, bot.ai.cancelled when stopped early); Anthropic and Gemini report streamed token usage, OpenAI-compatible and Ollama do not. Tool calls are execute_tool {name} spans with gen_ai.tool.name, gen_ai.tool.call.id and bot.ai.tool.outcome (ok, error, timeout, invalid_args, unknown, duplicate, confirm.denied, confirm.replied, confirm.cancelled). A tool waiting for the user’s confirmation keeps its span open (with a confirm.requested event) until it is answered. A standalone AIClient traces only when you pass tracing (e.g. bot.tracing) in its options.

Your own spans

bot.tracing is the bot’s tracing facade (a disabled no-op without telemetry). Spans you start inside a handler nest under the bot’s:
With @botmesh/otel, bot spans are also the active OpenTelemetry context, so tracer.startActiveSpan() and the SDK’s auto-instrumentations (http, undici, pg…) nest under them too. Plugins use ctx.bot.tracing the same way; bot.tracing.idAttribute(id) applies the ids policy to an id of your own.

Metrics

otelMetricsPlugin() (or await registerOtelMetrics(bot)) reads bot.metrics() on every collection: Every point carries bot.id plus your attributes. bot.resetMetrics() makes the cumulative counters drop, which OpenTelemetry and Prometheus treat as a counter reset. The tracer also derives histograms from finished spans (spanMetrics, on by default), recorded even for unsampled traces: 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 and gen_ai.client.token.usage{gen_ai.token.type}. No metric ever carries a chat, user or message id. The histograms come with bucket boundaries that fit their unit (sent as instrument advice, so a View in your SDK still wins): 5 ms to 10 s for the bot latencies, the GenAI semantic-convention buckets for model calls and tokens, and 1 s to 1 day for botmesh.conversation.duration. A prefix must start with a letter and contain only letters, digits, ., _, - or /; anything else throws when the tracer or the bridge is built.

Personal data

  • Never recorded: message bodies, captions, prompts, completions, tool arguments and results, c.state, ask answers, unknown command text, raw provider payloads.
  • Error messages may contain user text: only the error class and code are recorded unless you set telemetry.errorMessages: true.
  • Ids follow telemetry.ids:
    • 'hash' (default): HMAC-SHA256, base64url, 16 characters. The key is telemetry.idKey; without it a random key per process is used, so hashes do not correlate across replicas or restarts. Set the same secret everywhere to search a chat’s traces.
    • 'raw': the id as the provider gives it — on WhatsApp that is a phone number, personal data.
    • 'omit': no ids at all.

Configuration

Sampling

Sample at the root with a parent-based sampler (new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.1) }) in the NodeSDK options): a message’s spans share its decision. When a span is not recorded, the core skips its id hashing. The span-derived histograms are recorded anyway, so latency dashboards stay complete at any sampling rate.

Other backends and tests

BotTracer is deliberately small: startSpan(name, options) returning a span with setAttribute(s), addEvent, recordException, setStatus, end, isRecording and spanContext, plus an optional withSpan(span, fn) to make a span the backend’s active context. Any tracing library can implement it. A tracer that throws never breaks the bot: the call falls back to a no-op span and a warning is logged at most once a minute. In tests, createRecordingTracer() from @botmesh/core/testing records every span in memory:

Limitations

  • Durable replays: an inbound record replayed by the durability engine (after a crash or a retry) has no bot.inbound span; its bot.dispatch is a root span.
  • Multi-replica: a conversation resumed on another replica (or after a restart) starts a new trace linked to the previous one, not a continuation of it (see Across replicas and restarts). A conversation handed over before telemetry was enabled, or by a replica without it, has no link.
  • Third-party OpenTelemetry spans inside a resumed conversation (code after await c.ask()) parent to OpenTelemetry’s own context, which is still the first turn’s; the bot’s own spans parent correctly.
  • Flushing is the SDK’s job: call sdk.shutdown() after bot.stop().