merkhain

Tracing (OpenTelemetry)

merkhain can export OpenTelemetry traces over OTLP/gRPC. Tracing is off by default: with tracing.enabled unset, no exporter is created and nothing is sent anywhere. It follows the Talos conventions: one global tracer provider per process, operation spans with stable error codes, W3C trace-context propagation, parent-based ratio sampling, and a bounded flush on exit.

Configuration

{
  "tracing": {
    "enabled": true,
    "service_name": "merkhain",
    "service_version": "v1.4.0",
    "environment": "laptop",
    "exporter": "otlp",
    "endpoint": "localhost:4317",
    "sample_rate": 1
  }
}
Key Environment variable Default Notes
enabled MERKHAIN_TRACING_ENABLED false true/false; the variable can turn file-enabled tracing off
service_name MERKHAIN_TRACING_SERVICE_NAME merkhain Resource service.name
service_version MERKHAIN_TRACING_SERVICE_VERSION build version Resource service.version
environment MERKHAIN_TRACING_ENVIRONMENT empty Resource deployment.environment.name when set
exporter MERKHAIN_TRACING_EXPORTER otlp Only otlp (gRPC) is supported
endpoint MERKHAIN_TRACING_ENDPOINT localhost:4317 host:port and http://host:port use plaintext gRPC; https://host:port uses TLS with the system roots
sample_rate MERKHAIN_TRACING_SAMPLE_RATE 1 Ratio in [0,1]; an explicit 0 is kept
sql MERKHAIN_TRACING_SQL false true/false; adds SQL statement spans beneath storage transactions (diagnostics, see below)

Precedence is the usual defaults < config.json < environment. Invalid values (unknown exporter, an endpoint without a port or with a path/query, a non-finite or out-of-range rate, a malformed boolean) fail like any other config error. merkhain help and merkhain version never read the configuration.

The standard OTEL_EXPORTER_OTLP_HEADERS / OTEL_RESOURCE_ATTRIBUTES variables are honored by the OpenTelemetry SDK, e.g. to authenticate to a hosted collector. Do not put secrets in OTEL_RESOURCE_ATTRIBUTES: resource attributes are exported with every span.

Collector setup

Any OTLP/gRPC receiver works. For a quick local look, run Jaeger and point merkhain at it:

docker run --rm -p 4317:4317 -p 16686:16686 jaegertracing/jaeger:2.10.0
MERKHAIN_TRACING_ENABLED=true merkhain status
# open http://localhost:16686, service "merkhain"

For serve local as a user service, set tracing.enabled in config.json (the hooks, merkhain mcp, and the CLI read the same file, so every process exports). The exporter is set up when a process starts: running serve and mcp processes reject changes to tracing.* and metrics.* until they restart, except tracing.sql, which applies live (Local memory). For a relay, configure its own config.json or environment; relay spans carry the client's trace context, so a sync from a laptop and the relay's handling of it appear in one trace when both export to the same backend.

Sampling

The sampler is ParentBased(TraceIDRatioBased(sample_rate)): a root span is kept with probability sample_rate, and every child follows its parent's decision. Server spans follow the sampled flag of an incoming traceparent, so a client's decision carries across the local API and the relay. Background work (extraction attempts, capture reconciliation passes, sync cycles) starts new root traces and is sampled independently; trace context is never written to durable queues.

One exception survives sampling: a write transaction that held the writer lock for at least 250 ms, waited for it for at least 1 s, or failed busy is also recorded as a storage.slow_write root span that is always exported, linked to its (possibly unsampled) transaction span, with merkhain.storage.op, merkhain.storage.wait_ms, merkhain.storage.hold_ms, merkhain.storage.rows_changed, and merkhain.storage.wal_frames. See operations for the slow-transaction log.

What is traced

Area Spans
CLI cli.<command> per bounded invocation with merkhain.cli.command, a known merkhain.cli.subcommand, and process.exit.code. serve and mcp have no lifetime span.
MCP mcp.tool/<tool> per tool call (unknown tools are recorded as unknown). Error results count as failures even though the call itself succeeded.
Local API, browser backend API, relay Server spans METHOD /route/{template} with method, route template, and status code; 5xx is a failure. Health checks and static UI assets are not traced.
Outbound HTTP Client spans for relay, local API, and OpenAI-compatible requests, propagating traceparent; relay and OpenAI spans are named by route template. Relay request signatures do not cover headers and are unaffected.
Capture capture.commit, service.reconcile (background pass), capture.reconcile_pass, and capture.reconcile_source per transcript, with counts and merkhain.outcome.
Extraction extract.plan, extract.attempt (claimed jobs only), extract.input, extract.summarize, extract.review, extract.complete, extract.fail; providers add provider.infer (stage draft/review), provider.openai.chunk, provider.codexcli.exec and provider.claudecli.exec (exit class in merkhain.codexcli.exit / merkhain.claudecli.exit). A failure carried in the attempt outcome marks the attempt failed.
Recall recall.search, recall.recall, recall.timeline, recall.get, recall.save, recall.rebuild_index, with result counts.
Sync sync.cycle, sync.pool, sync.membership_refresh, sync.push, sync.pull, sync.retry_deferred, with aggregate counts. A failed pool marks its pool span and the cycle failed.
Storage storage.open, storage.migrate, and storage.read / storage.write per transaction with merkhain.storage.wait_ms (connection and lock acquisition). With sql on, SQL statement, row, and commit/rollback spans beneath them carry the parameterized statement text (never bound values or error text).

Idle background work stays silent: polls that claim no extraction job create no span, storage transactions are traced only inside a traced operation, and a background planning tick is recorded only when it created or finalized jobs or failed. Sync cycles and reconciliation passes are always recorded.

Failures set the span status to a stable code (error.type): an API error code such as not_found or busy, canceled, deadline_exceeded, an HTTP status, panic, or internal. Request and worker logs written with a traced context carry otel.trace_id and otel.span_id for correlation.

Privacy

Spans carry only fixed operation names, known command/tool/route names, counts, sizes, durations, provider kind and stage, and stable codes. They never contain memory content, prompts, model responses, search terms, credentials or tokens, raw error messages, filesystem or database paths, pool/device/session/memory IDs, URLs or query strings, hosts, headers, or request bodies. The one exception is the opt-in sql switch, whose spans carry parameterized SQL statement text (the schema's queries, without bound values). Only the W3C traceparent (and tracestate) header is propagated; baggage is neither sent nor accepted. Panics are recorded as panic without their value and then re-raised.

The HTTP instrumentation is a small wrapper rather than otelhttp, whose defaults record URLs, peer addresses, and error text.

Exit, hooks, and collector outages

Spans are batched and exported asynchronously; exporting never changes a command's output or exit code, and MCP stdout is untouched. When the collector is unreachable or rejects spans, merkhain prints at most one fixed line per minute to stderr (merkhain: tracing: span export failed; …) and drops those spans.

On exit each process flushes pending spans with an uncancelled deadline, so an interrupted command still exports: 5 s for ordinary commands and services, but only 250 ms for capture event, capture protocol, claude hook, and codex hook. Hooks run inline with your agent, so a slow or unreachable collector may cost a hook at most 250 ms; spans still pending when that deadline expires are deliberately dropped.

Metrics

Retrieval metrics are recorded by serve, mcp, recall, and insights. Like tracing, OTLP export is off by default:

{ "metrics": { "enabled": true, "endpoint": "localhost:4317", "interval": "1m" } }
Key Environment variable Default Notes
enabled MERKHAIN_METRICS_ENABLED false Adds a periodic OTLP/gRPC metric exporter
endpoint MERKHAIN_METRICS_ENDPOINT localhost:4317 Same syntax as the tracing endpoint
interval MERKHAIN_METRICS_INTERVAL 1m Export period, 1s–1h

Independently of enabled, the same instruments appear on the opt-in Prometheus endpoint (MERKHAIN_DEBUG_LISTEN=127.0.0.1:9464 serves /metrics) next to the existing process and storage metrics, whose names are unchanged. Scrape it instead of running a collector if you prefer pull:

scrape_configs:
  - job_name: merkhain
    static_configs: [{ targets: ["127.0.0.1:9464"] }]

To receive OTLP, any collector with an otlp receiver works, e.g. an OpenTelemetry Collector forwarding to Prometheus:

receivers:  { otlp: { protocols: { grpc: { endpoint: 127.0.0.1:4317 } } } }
exporters:  { prometheus: { endpoint: 127.0.0.1:8889 } }
service:    { pipelines: { metrics: { receivers: [otlp], exporters: [prometheus] } } }

Instruments

Prometheus renders dots as underscores, appends _total to counters and the unit (_seconds, _bytes) to histograms, e.g. merkhain_memory_retrieval_requests_total.

Instrument Type, unit Attributes Meaning
merkhain.memory.retrieval.requests counter operation, caller, outcome, traffic One per public memory_search / memory_recall (recall's internal search is not counted)
merkhain.memory.retrieval.hits counter operation, caller, traffic, stage Requests with ≥1 candidate / ≥1 returned item
merkhain.memory.retrieval.results histogram, {item} operation, caller, role Items returned per successful request, by role (match, neighbor, history)
merkhain.memory.retrieval.empty counter operation, caller, reason Successful requests that returned nothing: no_match, or budget when candidates existed but none fit
merkhain.memory.retrieval.match counter operation, match Match mode used: all, any, expanded, n/a
merkhain.memory.retrieval.duration histogram, s operation, caller, outcome, stage total, match, expand
merkhain.memory.context.deliveries counter operation, caller, outcome Results actually written to the caller (MCP, API, UI, CLI)
merkhain.memory.context.bytes histogram, By operation, caller Bytes of the model-facing text delivered
merkhain.memory.context.truncations counter operation, caller, reason details, bytes, body, metadata, records, and any once per truncated request
merkhain.memory.feedback counter reporter, verdict, target Agent ratings via memory_feedback
merkhain.memory.engagements counter caller memory_get calls citing a retrieval_id within 24 h
merkhain.telemetry.events.dropped counter reason Ledger events dropped (queue_full, write_failed)

traffic is interactive, page (a cursor follow-up), or benchmark (MERKHAIN_TRAFFIC=benchmark, set by fleetbench); exclude page and benchmark for user-facing rates. Every attribute is a closed enumeration; an unknown value is recorded as other, and each instrument is capped at 1,000 attribute sets.

Useful formulas (per operation, traffic="interactive"):

  • hit rate = hits{stage="returned"} / requests{outcome="ok"}
  • empty rate = sum(empty) / requests{outcome="ok"}; budget share = empty{reason="budget"} / sum(empty)
  • delivery rate = deliveries{outcome="ok"} / requests{outcome="ok"}
  • truncation rate = truncations{reason="any"} / requests{outcome="ok"}
  • engagement rate (search) = engagements / deliveries{operation="search",outcome="ok"}

Metrics carry no query text, memory content or IDs, scopes, pools, projects, or error messages. Exporting never changes command output; metrics share the tracing exit deadline (5 s, 250 ms for hooks) and are flushed concurrently with spans. The local usage ledger behind Insights (merkhain insights, the web UI) is separate: it stays on this device and is configured under Insights → Settings.