Observability
Read-side wiring for OpenTelemetry, Datadog, Honeycomb, and Prometheus — span decorators, lifecycle-event streaming, and the patterns that don't fight the substrate's contracts.
Standing back from the hedge to see what's actually growing where. This page is the read-side observability surface. For the write-side audit ledger that these decorators wrap, see Audit ledger and AuditableCall row.
The substrate emits two structured write streams ready for an
observability stack: every StreamEvent fires on the runtime's
event emitter, and every LLM call writes one AuditableCall
row to the ledger. Both are typed; both are the seams to wire
into your telemetry pipeline.
This page documents the patterns that hold up in production — without breaking determinism, without blocking the turn, and without inventing a second write site for data the substrate is already writing.
import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit";
import type { StreamEvent } from "@pleach/core";Observability is a thematic island. Four distinct surfaces (
lineage,observability,otel-observability,runtime-inspector) — not a three-concept cluster. Readers usually arrive already knowing which one they need. See What lives outside the cluster pattern.
The two write streams
| Stream | Where it fires | What's in it |
|---|---|---|
| Lifecycle events | runtime.on(eventType, handler) | Every StreamEvent — sessions, messages, tools, sync, interrupts, subagents |
| Audit ledger | ProviderDecisionLedger.recordCall | One typed row per LLM call — model, family, tokens, latency, decision payload |
Wire both. The lifecycle stream covers turn-level shape (when did this turn start, how many tool calls fired, did it interrupt); the audit ledger covers per-call detail (which model, which family, how many tokens, how long, what was the decision).
OpenTelemetry pattern
The canonical OTel integration wraps the audit ledger in a span tracer and subscribes turn-level events to a span builder.
// lib/observability/tracedLedger.ts
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit";
const tracer = trace.getTracer("@pleach/core");
export class TracedLedger implements ProviderDecisionLedger {
constructor(private primary: ProviderDecisionLedger) {}
async recordCall(call: AuditableCall): Promise<void> {
const span = tracer.startSpan(`llm.${call.call.callClass}`, {
kind: SpanKind.CLIENT,
attributes: {
"llm.model": call.call.model,
"llm.family": call.call.provider,
"llm.transport": call.call.transport,
"llm.call_class": call.call.callClass,
"llm.session_id": call.sessionId,
"llm.turn_id": call.turnId,
"llm.stage_id": call.stageId,
"llm.tokens.input": call.tokenCost?.inputTokens,
"llm.tokens.output": call.tokenCost?.outputTokens,
"llm.latency_ms": call.outcome.latencyMs,
"llm.fallback": call.decision.selectedReason,
"llm.cache_hit": (call.cacheBreakpoint?.cacheReadTokens ?? 0) > 0,
},
});
if (call.outcome.status === "failed") {
span.setStatus({ code: SpanStatusCode.ERROR, message: call.outcome.finishReason ?? undefined });
}
span.end();
return this.primary.recordCall(call);
}
}Wire it as the active ledger:
import { setProviderDecisionLedgerFactory } from "@pleach/core/runtime";
setProviderDecisionLedgerFactory({
fromSupabase: (client) =>
new TracedLedger(new SupabaseProviderDecisionLedger(client)),
});Per-call spans are the load-bearing telemetry. Don't roll your
own span at the seam call site — the ledger is the seam the
substrate already calls. A span emitted from a stream observer
hook fires per chunk (potentially hundreds per turn) and breaks
the sync-only observer contract; a span emitted from a custom
provider wrapper double-counts against the ledger's own span and
makes the trace tree confusing. One span per recordCall is the
right cardinality — one per LLM call, attributed by turnId and
stageId.
Turn-level spans
Wrap each turn in an outer span via the runtime event stream:
const activeSpans = new Map<string, ReturnType<typeof tracer.startSpan>>();
runtime.on("step.start", (event) => {
if (event.step === "anchor-plan") {
const span = tracer.startSpan("agent.turn", {
attributes: { "session.id": event.sessionId },
});
activeSpans.set(event.sessionId, span);
}
});
runtime.on("step.end", (event) => {
if (event.step === "post-turn") {
activeSpans.get(event.sessionId)?.end();
activeSpans.delete(event.sessionId);
}
});Turn-level spans + per-call child spans give you a complete trace tree per turn — what stages fired, what models were called, where the latency went.
OTEL spans (in-process)
The substrate now emits four span types directly:
session.turn, llm.invocation, graph.stage, and
tool.execution. Parent-threading nests them naturally per turn —
the turn span contains stage spans, which contain LLM and tool
spans.
A runtime.spans facet exposes the exporter lifecycle plus
introspection reads. Its snapshot() returns
{ inFlightCount, isShutdown } — exporter state, not the span
records. To inspect the trace tree itself during debugging, wire the
reference CapturingOtelExporter and read its captured array (no
OTLP collector needed). See
/docs/otel-observability.
The ledger-decorator pattern above still works for per-call span
emission. The four built-in spans complement it; they don't
replace it. Pick the decorator when you want per-recordCall
control; use the built-in spans when you want the full turn tree
for free.
For the auto-flush controls (otelFlushIntervalTurns), the OTLP
wiring recipe, and the pleach.tenant_id attribute, see
/docs/otel-observability.
Datadog pattern
Datadog's dd-trace library auto-instruments many libraries. For
the substrate, the integration shape is the same as OTel — a
ledger decorator emits per-call metrics:
// lib/observability/datadogLedger.ts
import StatsD from "hot-shots";
import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit";
const dogstatsd = new StatsD({ host: process.env.DD_AGENT_HOST });
export class DatadogLedger implements ProviderDecisionLedger {
constructor(private primary: ProviderDecisionLedger) {}
async recordCall(call: AuditableCall): Promise<void> {
const tags = [
`model:${call.call.model}`,
`family:${call.call.provider}`,
`call_class:${call.call.callClass}`,
`outcome:${call.outcome.status}`,
];
dogstatsd.increment("llm.calls", 1, tags);
dogstatsd.histogram("llm.latency_ms", call.outcome.latencyMs, tags);
dogstatsd.histogram("llm.tokens.input", call.tokenCost?.inputTokens ?? 0, tags);
dogstatsd.histogram("llm.tokens.output", call.tokenCost?.outputTokens ?? 0, tags);
if (call.outcome.status === "failed") {
dogstatsd.increment("llm.errors", 1, [...tags, `reason:${call.outcome.finishReason}`]);
}
return this.primary.recordCall(call);
}
}For Datadog APM (distributed tracing), use the
@opentelemetry/exporter-trace-otlp-http exporter pointed at
Datadog's OTLP intake — the OTel pattern above lands in Datadog
APM unchanged.
Honeycomb pattern
Honeycomb consumes OTLP directly. Configure the SDK to point at Honeycomb's OTLP endpoint:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "https://api.honeycomb.io/v1/traces",
headers: { "x-honeycomb-team": process.env.HONEYCOMB_API_KEY! },
}),
serviceName: "my-agent",
});
sdk.start();Configuring the SDK is necessary but not sufficient: @pleach/core
never reads the global tracer provider, so the SDK alone captures no
pleach spans. Pass a bridge implementing OtelExporter to the
runtime via config.otelExporter to forward the four substrate
spans into this pipeline — see the
OTLP bridge recipe.
Honeycomb's strength is high-cardinality field aggregation — the
per-call ledger row's model_id, turn_id, tenant_id make it
a natural fit. Don't pre-aggregate; let Honeycomb do it.
Prometheus pattern
For self-hosted Prometheus, expose a metrics endpoint that reads from an in-memory counter the ledger increments:
// lib/observability/promLedger.ts
import { Counter, Histogram, register } from "prom-client";
const llmCalls = new Counter({
name: "pleach_llm_calls_total",
help: "Total LLM calls",
labelNames: ["model", "family", "call_class", "outcome"],
});
const llmLatency = new Histogram({
name: "pleach_llm_latency_ms",
help: "LLM call latency in ms",
labelNames: ["model", "family", "call_class"],
buckets: [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000],
});
export class PromLedger implements ProviderDecisionLedger {
constructor(private primary: ProviderDecisionLedger) {}
async recordCall(call: AuditableCall): Promise<void> {
const labels = {
model: call.call.model,
family: call.call.provider,
call_class: call.call.callClass,
};
llmCalls.inc({ ...labels, outcome: call.outcome.status });
llmLatency.observe(labels, call.outcome.latencyMs);
return this.primary.recordCall(call);
}
}Mount the /metrics endpoint:
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});For high-throughput deployments, the Counter/Histogram update cost is amortized into the ledger write — both are microsecond-scale.
Structured logging
The substrate's default loggers write event types and ids, not payloads. To stream the full event log into a structured logger:
import pino from "pino";
import type { StreamEvent } from "@pleach/core";
const log = pino({ level: process.env.LOG_LEVEL ?? "info" });
const interestingEvents: StreamEvent["type"][] = [
"error",
"tool.failed",
"sync.conflict",
"stream.truncated",
"interrupt.requested",
];
for (const type of interestingEvents) {
runtime.on(type, (event) => {
log[type === "error" ? "error" : "info"]({
event_type: type,
session_id: event.sessionId,
...event,
});
});
}Don't log every event variant — message.delta fires per chunk
and floods logs. The audit ledger is the per-call surface;
structured logs are for the lifecycle shape.
Forwarding tool.completed to a log sink
Subscribe to the lifecycle stream and push completion events to Loki, Honeycomb, or any sink that takes JSON:
runtime.on("tool.completed", async (event) => {
await fetch(process.env.LOKI_PUSH_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
streams: [{
stream: { app: "knowledge-base", tool: event.toolName },
values: [[String(Date.now() * 1e6), JSON.stringify({
sessionId: event.sessionId,
turnId: event.turnId,
toolName: event.toolName,
durationMs: event.durationMs,
status: event.status,
})]],
}],
}),
}).catch(() => {});
});The .catch(() => {}) matters: telemetry failures must not
break the turn.
Per-tenant dashboards
Tenant attribution is in the ledger row payload. The query layer groups it:
import { getAggregateUsage } from "@pleach/core/query";
const usage = await getAggregateUsage(client, store, {
userId: "tenant-service-account",
groupBy: "model",
dateRange: { from: "2026-06-01", to: "2026-07-01" },
});Feed this into your dashboarding tool (Grafana, Looker, Metabase). The ledger schema is stable; queries written against it survive substrate upgrades.
Joining the ledger to billing
The load-bearing join key is payload->>'tenantId' on
harness_auditable_calls against your billing table's tenant
column (typically tenants.id or organizations.id). A second
join on payload->>'turnId' lets a per-conversation invoice
line-item exist — the same turnId that
runtime.executeMessage stamps onto the ledger row is the one
the React layer surfaces in DevTools, so support requests like
"why was this conversation expensive" map back to a single
WHERE payload->>'turnId' = $1 query.
SELECT
b.tenant_name,
SUM((c.payload->'tokenUsage'->>'in')::int) AS input_tokens,
SUM((c.payload->'tokenUsage'->>'out')::int) AS output_tokens
FROM harness_auditable_calls c
JOIN billing_tenants b ON b.id = c.payload->>'tenantId'
WHERE c.created_at >= date_trunc('month', now())
GROUP BY 1;Per-day fallback rate
The audit row's payload.fallbackReason is the seam. Rolled up
by day, it answers "how often is the primary model failing":
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS total_calls,
COUNT(*) FILTER (WHERE payload->>'fallbackReason' IS NOT NULL
AND payload->>'fallbackReason' <> 'none') AS fallback_calls,
ROUND(
100.0 * COUNT(*) FILTER (WHERE payload->>'fallbackReason' IS NOT NULL
AND payload->>'fallbackReason' <> 'none')
/ NULLIF(COUNT(*), 0),
2
) AS fallback_pct
FROM harness_auditable_calls
WHERE created_at >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1;A rising fallback_pct is the early signal for a primary-provider
incident; pair with the alert thresholds below.
What to alert on
Five signals from the substrate that correlate to user-facing issues:
| Signal | What it means | Threshold |
|---|---|---|
error events with code 5xxx (provider) | Provider outage or rate limit | >1% of calls over 5 min |
family-exhausted events | Every in-family rung failed | >0 is a problem |
sync.conflict rate | Multi-device write contention | Set baseline; alert on deviation |
tool.failed rate per tool | Tool-specific breakage | >5% per tool over 1 hour |
| Audit-ledger write failures | Storage degradation | >0 is a problem |
Don't alert on message.delta rate or token counts — those are
business metrics, not reliability signals. A rising input-token
count on a tenant is usually "they wrote a longer prompt this
week," not "the substrate broke." Wire those into the billing
dashboard, not the on-call paging path.
Extending observability — plugin hooks
The patterns above show the read-side: subscribe to the runtime's existing streams, decorate the ledger, fold the event log. Sometimes the right move is to inject observability into the stream — capture a custom probe per chunk, sanitize the rendered output, attach a span at a specific node. Two plugin hooks are the supported path.
contributeStreamObservers — per-chunk probes
Stream observers fire per stream delta on a specific seam
invocation. They're the hook for per-chunk telemetry — a redaction
gate, a latency probe, a coherence-score sampler. The
registration declares a when filter (which callClass,
stageId, optional nodeId triggers the observer) and a
factory that returns the StreamIterationObserver whose
onChunk runs per delta.
import type { HarnessPlugin } from "@pleach/core"
import type { StreamObserverRegistration } from "@pleach/core/plugins/stream"
const latencyProbe: StreamObserverRegistration = {
when: { callClass: "synthesize", stageId: "synthesize" },
factory: (ctx) => {
let firstChunkAt: number | undefined
return {
onChunk: (chunk) => {
if (firstChunkAt === undefined) {
firstChunkAt = performance.now()
recordTtft(ctx.nodeId, firstChunkAt)
}
return { kind: "continue" }
},
}
},
}
export const myPlugin: HarnessPlugin = {
name: "ttft-probe",
version: "0.1.0",
contributeStreamObservers: () => [latencyProbe],
}Three rules hold for every stream observer:
onChunkis synchronous. Returning aPromiseis rejected for replay-determinism. The observer can hand work off to a background queue, but it can't await.- The
whenfilter is AND-composed. Every set field must match.callClass: "*"matches any class; omittingstageIdmatches any stage. - The factory must not throw at construction.
PluginManagerpre-screens factories viasmokeValidateFactoriesat register time; a throwing factory surfaces asPluginValidationErrorbefore any session uses it.
contributeFinalizationPasses — output sanitization
A SanitizerPass runs on the rendered synthesizer output before
it lands in the audit row. The hook for redaction passes, citation
post-processing, output-shape normalization. Each pass declares an
id and an order (lower runs first; ties resolve by registration
order); the apply(content, ctx) method returns the rewritten
string.
import type { HarnessPlugin } from "@pleach/core"
import type {
SanitizerPass,
FinalizationPassContext,
} from "@pleach/core/plugins"
const redactInternalIds: SanitizerPass = {
id: "redact-internal-ids",
order: 100,
apply(content: string, ctx: FinalizationPassContext): string {
return content.replace(/INTERNAL-\d{6}/g, "[REDACTED]")
},
}
export const redactionPlugin: HarnessPlugin = {
name: "internal-id-redactor",
version: "0.1.0",
contributeFinalizationPasses: () => [redactInternalIds],
}Finalization passes run after the FabricationDetector hooks, so
a detector that flagged a suspect span has already routed before
sanitization runs. See
Fabrication detection
for the pre-finalization guard surface and
Plugin contract for the full hook set.
What not to do
A few patterns that fight the substrate:
- Don't log raw
AuditableCallpayloads without wiringPIIRedactor. The payload carries user input; raw logs are a PII risk. - Don't block the turn on telemetry writes. The ledger contract is fire-and-forget; a telemetry decorator that throws fails the call, which fails the turn.
- Don't add wall-clock timestamps to spans inside the chain. The substrate's IDs (ULIDs) carry timing; spans get their own timing from the tracer. Two clocks disagree about subtle things.
- Don't roll your own per-call instrumentation in plugin observer hooks. Observers are sync-only by contract; making them telemetry sinks risks breaking the sync property.
Where to go next
OTEL observability
The four built-in span types, auto-flush, and the OTLP wiring recipe.
Lineage
Cross-session dependency graph — the other read-side surface in this neighborhood.
Audit ledger
The write-side `ProviderDecisionLedger` seam these decorators wrap.
Stream events
The lifecycle stream — what `runtime.on` subscribers receive.
Query
Server-side analytics over the persisted ledger.
Troubleshooting
Common symptoms, likely causes, and fixes — runtime construction, streaming, tools, sync, storage, and provider failures.
OTEL spans & exporter wiring
Four read-side span types — session.turn, llm.invocation, graph.stage, tool.execution — with parent-threading, auto-flush, the runtime.spans facet, and a minimal OTLP exporter wiring.