OpenClaw already exposes useful diagnostic facts such as model usage, queue state, webhook processing, and session health. What it does not provide on its own is a single connected story for one request:
- which inbound message started the work
- which agent handled it
- which tools were called
- whether subagents were spawned or parallel branches occurred
- what the model call cost
- whether the response was actually sent back out
This plugin closes that gap by combining three telemetry paths:
- Typed OpenClaw lifecycle hooks for request, agent, tool, and outbound message flow.
- Event-stream hooks for control-plane events such as session reset and gateway startup.
- OpenClaw diagnostics events for accurate model usage, cost, queue, webhook, and stuck-session signals.
The result is a unified observability layer with connected traces for request forensics, metrics for dashboards and alerts, and optional provider SDK auto-instrumentation for GenAI calls.
The implementation is intentionally split into two layers plus an optional third:
The plugin registers typed hooks through api.on(...) to model the request lifecycle. This layer creates the connected trace hierarchy:
openclaw.request
└── openclaw.agent.turn
├── tool.Read
├── tool.exec
├── tool.sessions_spawn
└── provider SDK spans (optional, via preload instrumentation)
└── openclaw.message.sent
This gives us the workflow structure that stakeholders care about.
The plugin subscribes to OpenClaw diagnostic events through onDiagnosticEvent from the plugin SDK. This is how it receives accurate:
- model token counts
- model/provider identity
- estimated cost
- context window usage
- webhook activity
- queue activity and wait times
- session state and stuck-session indicators
- run attempts and tool-loop warnings
These diagnostics are used in two ways:
- to enrich the active
openclaw.agent.turnspan with accurate model usage and cost - to emit standalone metrics and diagnostic spans for gateway operations
If OpenClaw is started with NODE_OPTIONS=--import ./instrumentation/preload.mjs, the plugin can also include provider SDK spans
for Anthropic, OpenAI, Bedrock, and Vertex AI.
This is optional. The plugin still emits its own hook-based request, agent, tool, and message spans even when SDK auto-instrumentation is not active.
| Hook | What the plugin does | Telemetry emitted |
|---|---|---|
| message_received | Creates or reuses the root request span, captures inbound content when enabled, runs prompt-injection detection, and starts session tracking. | openclaw.request span, openclaw.messages.received counter. |
| message_sent | Creates a producer span for the outbound response and captures response content when enabled. | openclaw.message.sent span, openclaw.messages.sent counter. |
| before_model_resolve | Preferred lifecycle hook for starting the openclaw.agent.turn child span, attaching handoff and join links, seeding child-session handoff state, and registering the active span for diagnostics enrichment. | openclaw.agent.turn span. |
| before_prompt_build | Secondary preferred lifecycle hook for the same agent-turn startup path when model resolution is not the first available signal. | openclaw.agent.turn span when no active agent turn already exists. |
| before_agent_start | Legacy fallback for OpenClaw runtimes that have not moved to the newer lifecycle hooks. | openclaw.agent.turn span when no active agent turn already exists. |
| before_tool_call | Starts the tool span, captures tool input when enabled, records tool invocation count, runs sensitive-file and dangerous-command detection, and marks parallel branches for fork detection. | tool.< toolName > span start, openclaw.tool.calls counter,. |
| tool_result_persist | Looks up the pending tool span, attaches result metadata and captured output, marks tool errors, closes the span, and extracts child-agent IDs from sessions_spawn. | tool.< toolName > span completion, openclaw.tool.errors counter when the result is flagged as an error. |
| agent_end | Finalizes the agent span and the root request span, attaches tokens/model/provider/cost/context, records duration, closes fork groups, clears active state, and cleans up context. | openclaw.agent.turn span completion, openclaw.request span completion, openclaw.agent.turn_duration histogram, token and request counters when diagnostics data was not already available. |
| Event hook | What the plugin does | Telemetry emitted |
|---|---|---|
| command:new | Records the command event and ends the operational session. | openclaw.command.new span, openclaw.session.resets counter, session.end span. |
| command:reset | Records the command event and ends the operational session. | openclaw.command.reset span, openclaw.session.resets counter, session.end span. |
| command:stop | Records the command event. | openclaw.command.stop span. |
| gateway:startup | Records that the gateway started. | openclaw.gateway.startup span. |
The plugin also subscribes to the diagnostics event bus and consumes these event types:
model.usagewebhook.receivedwebhook.processedwebhook.errormessage.queuedmessage.processedqueue.lane.enqueuequeue.lane.dequeuesession.statesession.stuckrun.attemptdiagnostic.heartbeattool.loop
This is not a duplicate hook system. It is the diagnostics path that complements the workflow hooks with accurate usage accounting and control-plane health signals.
| Span | Source | Parenting | When emitted | Why it exists |
|---|---|---|---|---|
| openclaw.request | message_received or fallback creation during agent lifecycle startup | Root span | On the first stable inbound session/conversation event for a request. | Represents the full message-to-response workflow. |
| openclaw.agent.turn | before_model_resolve, before_prompt_build, or legacy before_agent_start | Child of openclaw.request | When an agent begins work on a session. | Represents one agent turn and is the anchor for model usage, handoffs, and tools. |
| tool.< toolName > | before_tool_call + tool_result_persist | Child of openclaw.agent.turn | Starts before the tool runs and ends when the persisted result arrives. | Represents tool execution with input/output, error state. |
| openclaw.message.sent | message_sent | Child of openclaw.request | When OpenClaw emits the outbound message hook. | Confirms response delivery was attempted. |
| session.end | Session watcher / explicit session end | Child of stored session root context | On idle timeout, process exit, or explicit command:new / command:reset. | Marks operational session completion. |
| openclaw.command.new | Event-stream hook | Root or child of active request context if present | On command event. | Makes operator-driven session changes visible in traces. |
| openclaw.command.reset | Event-stream hook | Root or child of active request context if present | On command event. | Same as above. |
| openclaw.command.stop | Event-stream hook | Root or child of active request context if present | On command event. | Records stop operations. |
| openclaw.gateway.startup | Event-stream hook | Root span | On gateway startup. | Proves the plugin saw the control plane start. |
| Span | Source event | Parenting | When emitted | Why it exists |
|---|---|---|---|---|
| openclaw.model.usage | model.usage | Linked to the active openclaw.agent.turn when available | When diagnostics report model usage. | Captures accurate model usage, provider, model, and cost timing. |
| openclaw.webhook.processed | webhook.processed | Root span | When a webhook finishes processing. | Makes ingress processing visible in traces. |
| openclaw.webhook.error | webhook.error | Root span | When ingress processing fails. | Captures webhook failures as error spans. |
| openclaw.message.processed | message.processed | Root span | When a queued message finishes processing. | Exposes downstream processing outcomes and duration. |
| openclaw.session.stuck | session.stuck | Root span | When diagnostics flag a stuck session. | Converts a health issue into a traceable error signal. |
| openclaw.tool.loop | tool.loop | Child of active openclaw.agent.turn when available | When loop detection fires. | Surfaces recursive or blocked tool-loop behavior. |
When preload-based SDK auto-instrumentation is enabled, provider SDK calls produce their own OTel spans under the active agent context. These spans are optional and depend on runtime startup configuration rather than plugin hook registration.
Beyond simple parent-child spans, the plugin adds correlation features that matter in multi-agent systems.
This plugin intentionally defines a session differently from OpenClaw.
OpenClaw emits runtime identifiers such as sessionKey, conversationId, and sometimes a diagnostic sessionId.
We treat those values as correlation keys that tell us which request, agent turn, tool call,
or model call belongs to the same running conversation context.
The plugin then creates its own workflow-level session.id as a UUID.
That session.id is the lifecycle boundary for session.start and session.end spans.
In practice:
openclaw.session.keyis the stable runtime key we use to correlate spans.openclaw.runtime.session.idis only attached when OpenClaw diagnostics provide a separate runtime session identifier.session.idis our session identifier and is generated by the plugin.
So the OpenClaw identifiers tell us which runtime stream an event belongs to, while the plugin session.id tells us
which workflow session lifecycle we are currently tracking for that runtime stream.
Our session starts the first time we observe activity for a stable runtime session key.
Most commonly that happens when we create the root openclaw.request span from an inbound message.
If the inbound hook does not provide enough context early enough, the session can also be created lazily from later activity such as:
before_model_resolve,before_prompt_build, or legacybefore_agent_startbefore_tool_callllm_inputmessage_sent
That means the session start is based on the first traced activity we can reliably associate with a runtime session key, not on a dedicated OpenClaw "session created" event.
Our session does not end when a single request finishes. An openclaw.request span represents one traced request/turn,
while session.id can outlive multiple request spans as long as the same runtime session stays active.
We end the session in three cases:
- Explicit command end: when OpenClaw emits
command:neworcommand:resetfor that runtime session key, we callendSession(...)and emitsession.end. - Idle timeout: if no new activity touches the session for the configured timeout (5 minutes), we emit
session.endautomatically. - Process shutdown: on
beforeExit,SIGINT, orSIGTERM, we flushsession.endfor any still-active sessions.
By default, the idle timeout is 5 minutes and the watcher checks for expired sessions every 30 seconds.
The heuristic is simple:
- every traced activity for a runtime session key updates
lastActivityAt - a background watcher closes sessions whose
lastActivityAtis older than the idle threshold
This makes session end a best-effort workflow boundary rather than a guaranteed business event from OpenClaw itself.
Two implications follow from that design:
session.ended_atreflects the last observed activity time, not necessarily the exact wall-clock time when the watcher ran.agent_endcloses the current request/agent spans, but it does not by itself close the workflow session; a later turn in the same runtime session can continue under the samesession.iduntil one of the end conditions above happens.
Parallel execution does not automatically imply multiple sessions. The plugin uses the runtime session key as the boundary.
If one agent runs multiple tool calls concurrently under the same runtime session key, we keep a single session.id.
The tool branches are annotated with fork metadata and the next joining agent span is annotated with join metadata,
but all of those spans still belong to the same plugin session.
If agent A hands off to agent B while staying in the same runtime session key, both agent turns remain in the same plugin session. We represent the relationship with handoff links and sequence attributes rather than by creating a new session.
If a subagent runs under a different runtime session key, the plugin creates a new session.id for that child runtime session.
We then link the child root/agent spans back to the spawning tool and previous agent using span links (agent_spawn, agent_handoff),
but lifecycle tracking is independent.
This means multiple subagents running in parallel can produce multiple concurrent plugin sessions, each with its own session.id,
as long as OpenClaw gives them distinct runtime session keys.
When one agent turn follows another in the same session, the next openclaw.agent.turn span receives:
- a span link with
link.type=agent_handoff ioa_observe.agent.sequence(emitted whenemitIoaObserveAttributes=true, the default)ioa_observe.agent.previous(emitted whenemitIoaObserveAttributes=true, the default)
This shows the execution chain even when the turns are not simple parent-child nesting.
When the sessions_spawn tool creates a child agent, the child request span is seeded with:
- a span link to the spawning tool span with
link.type=agent_spawn - a span link back to the source agent with
link.type=agent_handoff
This is how the plugin preserves cross-session lineage.
When multiple tools execute within the configured fork window for the same agent turn, the plugin annotates them as fork branches and annotates the next agent turn as the join.
Fork attributes include (emitted when emitIoaObserveAttributes=true, the default):
ioa_observe.fork.idioa_observe.fork.branch_indexioa_observe.fork.parent_nameioa_observe.fork.parent_sequence
Join attributes include (emitted when emitIoaObserveAttributes=true, the default):
ioa_observe.join.fork_idioa_observe.join.branch_count
If captureContent=true, the plugin stores request, agent, tool, and outbound message payloads on spans.
String content is truncated to 4096 characters before embedding, ensuring JSON payload fields are always valid.
Each span type emits both an OTel GenAI semconv payload field and OpenClaw-namespaced attributes:
| Span | OTel GenAI field | OpenClaw field |
|---|---|---|
openclaw.request |
gen_ai.input.messages (input), gen_ai.output.messages (output) |
openclaw.request.input, openclaw.request.output |
openclaw.agent.turn |
gen_ai.input.messages, gen_ai.output.messages |
openclaw.agent.input, openclaw.agent.output |
openclaw.llm.call |
gen_ai.input.messages, gen_ai.output.messages |
openclaw.llm.input, openclaw.llm.output |
tool.<toolName> |
gen_ai.tool.call.arguments (input), gen_ai.tool.call.result (output) |
openclaw.tool.input, openclaw.tool.output |
openclaw.message.sent |
— | openclaw.message.output |
The gen_ai.input.messages and gen_ai.output.messages fields are JSON arrays of schema-compliant message objects
([{"role": "user"|"assistant", "content": ..., "finish_reason": ...}]).
The gen_ai.tool.call.arguments and gen_ai.tool.call.result fields prefer object form and parse JSON strings when possible.
If captureContent=false, the span structure and counters still exist, but payload text is not exported.
The emitIoaObserveAttributes config flag (default true) controls whether IOA-specific semantic attributes
are emitted alongside the primary OTel GenAI semconv fields.
When true (default), the plugin additionally emits:
ioa_observe.entity.input/ioa_observe.entity.outputon all payload-carrying spansioa_observe.agent.sequence,ioa_observe.agent.previous,ioa_observe.agent.previous_sequenceon agent turn spansioa_observe.fork.*on fork-branch tool spansioa_observe.join.*on joining agent turn spansioa_observe.workflow.nameonsession.startandsession.endspans
Set emitIoaObserveAttributes: false to emit only the OTel GenAI semconv fields.
- SDK-level provider spans require preload-based runtime configuration and are not guaranteed in every deployment.