BotConfig.telemetry the bot runs exactly the code it ran before: no spans, no extra awaits.
@botmesh/corehas the tracing hooks: a minimalBotTracerinterface, a no-op default andbot.tracing. No new dependency.@botmesh/oteladapts them to OpenTelemetry and also turnsbot.metrics()into OpenTelemetry metrics.@botmesh/aiadds model, tool and retrieval spans to the same trace.
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
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
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 abot.conversation.turn:
- the first turn starts under the handler (or
bot.trigger) that entered it —reason: startordormant(a persistent flow resumed from its checkpoint); ac.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; ac.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 withfallback: '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.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 ofhandover.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.fallbackfunction receives the context ininfo.trace, to link spans of its own. formatTraceparent(spanContext)andparseTraceparent(traceparent, tracestate?)are exported from@botmesh/core/spifor your own propagation.
Scheduled tasks
Each run of a task registered withbot.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:
@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 istelemetry.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.inboundspan; itsbot.dispatchis 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()afterbot.stop().