Debug a request
Loom tags everything one request does with the same id, so tracing a request is a join. This page follows one request from its response headers to the component that decided the answer. When the deployment itself is unhealthy, start from troubleshooting; when a worker has not run, see worker status.
flowchart TD
A["Response headers<br/>X-Request-Id, X-Trace-Id, Server-Timing"] --> B["loom.agent_events<br/>request_id"]
B --> C["system.query_log<br/>log_comment = 'op request_id'"]
B --> D["default.otel_traces<br/>loom.request_id"]
B --> E["Langfuse trace metadata<br/>request_id"]
B --> F["loom.audit_log<br/>writes only"]
G["memory.search explain=true<br/>per-component scores"] -.-> B
1. Capture the request's ids
Every HTTP response carries:
| Header | Meaning |
|---|---|
X-Request-Id |
The request's id. Send your own (1 to 64 letters, digits, ., _ or -) and Loom echoes it, so your log and Loom's rows share one key; any other value is replaced by a generated id. |
X-Trace-Id |
The OpenTelemetry trace id of the request's span tree. |
Server-Timing |
app (handler time) and ch (ClickHouse time, statement count, bytes read) on success. |
X-Loom-Hint, X-Loom-Hint-Code |
The mentor's hint for this call, when it has one; the SDKs log it. |
curl -si "$LOOM_BASE_URL/v1/memory.search" -H "Authorization: Bearer $LOOM_API_KEY" \
-H 'Content-Type: application/json' -H 'X-Request-Id: dbg-0001' \
-d '{"org":"acme","namespace":"support","agent":"bot","query":"refund window"}' \
| grep -iE '^(x-request-id|x-trace-id|server-timing)'
Server-Timing often answers "why was it slow". A large ch duration or byte count
points at the query. A large app with a small ch points at a provider call
(embedding, rerank, LLM).
2. Join the request across systems
The same id is in loom.agent_events.request_id, the Langfuse trace metadata, and the
span attribute loom.request_id in default.otel_traces.
-- the ledger row: operation, identity, latency, success, error_code, tokens, cost, model
SELECT * FROM loom.agent_events WHERE request_id = 'dbg-0001';
-- every ClickHouse statement the request issued
SELECT query_duration_ms, read_rows, read_bytes, substring(query, 1, 200) AS q
FROM system.query_log
WHERE type = 'QueryFinish' AND log_comment LIKE '% dbg-0001%'
ORDER BY event_time_microseconds;
-- its spans
SELECT SpanName, Duration, SpanAttributes['loom.operation'] AS op
FROM default.otel_traces
WHERE SpanAttributes['loom.request_id'] = 'dbg-0001'
ORDER BY Timestamp;
The log_comment tags are listed in
query attribution.
Spans go to default.otel_traces (in LOOM_CLICKSTACK_DATABASE when it is set) when
LOOM_OTEL_EXPORTER resolves to ch, or to the OTLP endpoint. With Langfuse configured they also go to
Langfuse.
Note: the ledger never stores query text.
loom.agent_eventsreplaces every free-text argument with asha256:<16 hex>/<length>shape. To see what was asked, use the search trace log below or your own client log.
3. Explain a search answer
Why did this rank here? memory.search with explain=true attaches score_components
to each served hit: the ranking signals and, after a reranker, its move (rerank_from,
rerank_pos, rerank_delta) and, when the reranker changed the order, its own fused score
(rank_score_pre_rerank).
out = await agent.call("memory.search", {"query": "refund window", "explain": True})
for h in out["results"]:
c = h["score_components"]
print(
h["memory_key"],
h["similarity"],
h["rank_score"],
c.get("rerank_from"),
c.get("rank_score_pre_rerank"),
)
memory.search_trace takes the arguments of memory.search and scores the vector leg
alone (no fusion, assembly or reranker), returning, per hit, similarity, distance,
entity_boost, rank_score, rank and source.
Why did it abstain? Read the memory.search answer itself:
| Field | Tells you |
|---|---|
abstain, abstain_code |
Which gate fired: no_match, below_threshold, entity_absent, low_calibrated_confidence, page_expired. |
retrieval_risk, retrieval_confidence |
How far the best hit is from the gates. |
calibration_explanation |
Which similarity gates were in force (calibrated from the embedder, or the scalar fallbacks). |
degraded |
Providers that fell back on this read, and what the response did instead. |
pending_extraction |
Deferred set_from_messages batches not yet extracted; above 0, an empty answer is not proof of absence. |
namespaces_searched, candidates_total |
Whether the search reached the namespaces and rows you expected. |
calibration.get shows the thresholds in effect for a namespace and which tier
(namespace, cluster, scalar) produced them.
Retrieval and ranking explains each term.
What did an agent search, over time? Needs LOOM_SEARCH_TRACE_LOG_ENABLED=true
(default off). memory.search_trace_log(since_hours=, agent=, run_id=) returns one row
per search: returned_keys, returned_scores, abstain, retrieval_risk,
latency_ms, and the query when LOOM_LANGFUSE_CAPTURE_IO_LEVEL is not off (its
default is off, so by default the rows carry keys and scores without the question).
4. Explain a write
- The write's own answer.
memory.setreturnsstatus,conflict_verdict,peers,duplicate_of,merged_into_key,noopandadvisories. A write that "disappeared" was usually merged into a canonical match or marked a duplicate.advisoriesnames a double-encoded or oversized value. - Its history.
memory.historylists a key's versions fromloom.memory_versions.memory.provenancesays who wrote it, from which source, session and run. - What extraction dropped.
memory.extraction_statuslists deferredset_from_messagesbatches (pending,failed).loom drops --namespace <ns>prints what the pipeline discarded, truncated or gave up on, by stage and reason (memory ingest drops). Aset_from_messagesresponse carriespipeline_errorsand per-stepstage_ms(extraction pipeline). - The audit trail.
audit.list(event_type=, since=)readsloom.audit_log: who changed what, with a per-row content hash. Every write that creates or changes memory leaves a hash-only row (audit). - A run's view.
run.get(run_id)returns what a run recalled (recalled_keys), what it wrote, its turns and its cost (trace and resume).