Managoat.ACP.Tracer (managoat_acp v0.1.0)

Copy Markdown View Source

Turns session/update notifications into OTel tool spans, for every runtime.

Fountain's claude-only stream tracer bridged one dialect into the turn span; three of four runtimes produced no tool-level traces at all, and nobody noticed because the gap is invisible from inside a conversation (#637). ACP's tool_call / tool_call_update carry an id and a status for every runtime, which is exactly what a tracer keys on — so this module is dialect-free and one per protocol, not one per agent.

Span and attribute names carry a prefix, new/2's :prefix option, "acp" by default. The names below are written with it.

Span mapping:

  • tool_call opens a <prefix>.tool_use child span, keyed by toolCallId, named by the same title-then-kind preference the render path uses (Blocks).
  • tool_call_update with a terminal status closes the matching span; failed and cancelled mark it as an error. Non-terminal updates are progress, not an outcome, and touch nothing.
  • agent_message_chunk / agent_thought_chunk accumulate byte counts, surfaced at finalize/1 as <prefix>.text_bytes / <prefix>.thinking_bytes on the turn span. Chunks are per-delta and a turn produces hundreds; one span event each — what the legacy tracer did per assistant message — would blow through OTel's default event limit on the first real turn.
  • finalize/1 closes any span still open as abandoned (the runtime exited or was interrupted before the matching update) and writes the accumulated totals.

What the legacy tracer had that this one drops, on purpose

Cost and token usage came from claude's proprietary result event. ACP's session/prompt response carries a stop reason and, at protocol v1, an unstable usage block that Managoat.ACP.Usage reads at turn end; the tracer does not put it on the span — dropped explicitly rather than silently (#637). If a dashboard needs it back, the source is the :done report, not this module.

All functions no-op on nil, so the caller keeps a nil tracer for turns that trace nothing, without branching. The OpenTelemetry API is the only dependency: with no SDK started every span call is a no-op.

Summary

Functions

Close abandoned tool spans and write the accumulated totals to the turn span.

Feed one stored protocol line — the same ndjson the acp log stream holds.

Create a tracer attached to turn_span_ctx.

Types

t()

@type t() :: %Managoat.ACP.Tracer{
  open_tool_spans: term(),
  prefix: term(),
  text_bytes: term(),
  thinking_bytes: term(),
  tool_calls: term(),
  turn_span_ctx: term()
}

Functions

finalize(tracer)

@spec finalize(t() | nil) :: :ok

Close abandoned tool spans and write the accumulated totals to the turn span.

Call on every way a turn can end, before the turn span itself is ended.

handle_line(tracer, line)

@spec handle_line(t() | nil, binary()) :: t() | nil

Feed one stored protocol line — the same ndjson the acp log stream holds.

Anything that is not a session/update notification (responses, requests, an adapter's stray non-JSON output) traces nothing.

new(turn_span_ctx, opts \\ [])

@spec new(
  term(),
  keyword()
) :: t()

Create a tracer attached to turn_span_ctx.

prefix: names the spans and attributes (<prefix>.tool_use, <prefix>.tool_name, …); the default is "acp". A host with dashboards built on another prefix passes its own.