Troubleshooting
How to diagnose the most common “why didn’t RunLore do X?” situations. RunLore exposes two diagnostic channels:
- Structured logs — every decision is a one-line
slogevent. Setlogging.format: json(ortext) andlogging.level: debugfor the most detail. Each section below quotes the exactmsg=value to grep for. - Metrics —
runlore_*Prometheus series, exposed whentelemetry.metrics_enabled: true. See Observability for the full catalog and a Grafana dashboard.
Note
Leader-only by design
With leader_election.enabled (the chart default, 2 replicas) only the leader investigates. A
standby logs msg="standby; another replica leads", reports /readyz 200 like the leader (readiness
is catalog warmth, not leadership), and proxies any webhook it receives to the leader — grep
msg="forwarded to leader" (debug) / msg="leader forward failed". runlore_leader == 1 marks the
elected pod; the Lease holder identity is <podName>_<podIP>.
An alert fired but no investigation started
By far the most common case. RunLore has five independent layers that can each decide not to investigate — dedup, debounce, the coalescer’s cooldown, the per-trigger recurrence cooldown, and a human 🔕 silence — and they answer different questions (“is this the same still-firing alert?”, “did it clear before we even started?”, “did this correlation just get investigated?”, “did THIS trigger just get a conclusive answer?”, “did a human say stop?”). Ruling one out does not rule out another, so an operator chasing “why was it quiet” needs to know all five exist before assuming the layer they found is the only candidate. Work from ingress inward.
1. Is there a per-incident decision line? Every admitted alert produces exactly one log event:
msg=incident alert=<name> severity=<sev> namespace=<ns> investigate=<bool> reason="<reason>"reason | meaning | what to do |
|---|---|---|
matched trigger policy | admitted → investigating | nothing — this is the happy path |
filtered by trigger policy | didn’t match triggers.incidents.match, or hit triggers.incidents.ignore | widen match (check severity, environment, namespaces globs, labels); check the alert isn’t in ignore.alertnames |
deduplicated (still-firing) | the same alert is already under investigation within triggers.incidents.dedup.window | expected — wait for the window to pass or the alert to resolve |
Trigger filtering and dedup are not counted by any metric — the
incidentlog line is the only place they surface. Grep it first.
2. No incident line at all? The alert never reached the trigger pipeline:
- Source not enabled. The webhook only mounts when
sources.alertmanager: {}is set. A typo such asalertmanagr:now fails startup withunknown source(s) [alertmanagr] under \sources:` — known sources are [alertmanager gitops pagerduty]` (older builds silently did nothing). Check the startup logs. - Webhook rejected at ingress.
server.webhook_token_envis mandatory once any model is configured (serve fails closed — an anonymous webhook must not bill the model); it is also required byconfig.Validateunderactions.mode=auto. When set, Alertmanager must sendAuthorization: Bearer <token>. A401means the token is missing or wrong. The request body is also capped at 1 MiB. - Metric cross-check.
runlore_alerts_received_totalcounts alerts that passed initial decoding andDecide. Flat while alerts are firing ⇒ they’re being rejected at ingress (auth/parse) or the source isn’t mounted.
3. Admitted, but still nothing ran? Compare runlore_investigations_started_total against
runlore_alerts_received_total, then:
| signal | meaning | fix |
|---|---|---|
runlore_leader == 0 on all pods | no leader elected → nothing runs | check the leases RBAC and leader_election config; look for msg="acquired leadership" |
runlore_investigations_throttled_total rising | rate limiter engaged (investigation.rate_limit); msg="investigation rate limit engaged; throttling new investigations this window" | raise rate_limit.max_per_window / window, or accept the budget |
runlore_investigations_dropped_total rising | dropped by rate_limit.max_requeues or the token-budget hard-stop | see the timeout/budget section below |
runlore_alerts_coalesced_total rising | folded into an existing batch (investigation.coalesce) | expected noise control — one investigation covers the batch |
runlore_alerts_suppressed_total rising | dropped by the coalescer cooldown | expected — a recently-investigated correlation is in cooldown |
runlore_incidents_debounced_total rising | a non-critical alert self-resolved within triggers.incidents.debounce and was dropped before investigating; log: msg="alert resolved within debounce window; dropping self-resolving incident" | expected noise control — lower incidents.debounce if you want faster (but noisier) reactions, or set 0s to disable. (Criticals are never held, so they never appear here) |
runlore_incidents_dropped_on_shutdown_total > 0 | alert LOSS. The process shut down while an alert was still held in its debounce window. Alertmanager already got a 200, so it will not resend until its repeat_interval (often hours) — the alert is simply never investigated. Log: msg="held incident DROPPED: shutting down before its debounce window elapsed" (WARN, names the alert + fingerprint) | expected to be rare, but it rises once per held alert on every restart/helm upgrade that lands mid-hold. The hold window (60s default) exceeds the drain grace period, so draining cannot rescue it. If you cannot tolerate this, shorten triggers.incidents.debounce or set it to 0s. Note criticals are never held, so they are never lost this way |
runlore_investigations_cancelled_total rising | the alert resolved while its investigation was still queued and triggers.incidents.cancel_queued_on_resolve (on by default) dropped it; log: msg="incident resolved before investigation started; cancelling queued investigation" | expected noise control — and the only self-resolving filter criticals get. Set the flag to false if you want post-hoc investigations of self-resolved alerts |
runlore_investigations_completed_total{result="recurrence_suppressed"} rising | this exact TriggerKey was conclusively answered less than investigation.recurrence_cooldown ago and fired again — suppressed: no model call, no notification, no ledger open; log: msg="recurrence cooldown: suppressing re-investigation" | expected noise control (opt-in, off by default) — raise/lower the cooldown, or accept it. A trigger that has never concluded is never suppressed this way; a standing 👎 re-arms it immediately. (A resolve does not re-arm the cooldown — it only clears a silence, a separate mechanism; see the silenced row below) |
runlore_investigations_completed_total{result="silenced"} rising | a human clicked the Slack 🔕 button, reacted 🔕 on Matrix, or typed silence: <duration> in an investigation thread on either transport, and the window hasn’t lapsed — suppressed exactly like the recurrence cooldown: no model call, no notification, no ledger open; log: msg="silenced by a human: skipping re-investigation" | expected — this is exactly what the control is for (opt-in, off by default: notify.slack.silence_button / notify.matrix.silence_reactions). It never suppresses a CRITICAL firing; a colleague’s 👎 (cast after the silence — newest human wins) or the incident resolving lifts it immediately, or wait for the window to expire. The 👎 escape needs a 👍/👎 control enabled on some transport (notify.slack.feedback_buttons / notify.matrix.feedback_reactions); with none, RunLore warns at startup and only the expiry, a CRITICAL firing and a resolve remain |
A GitOps failure didn’t trigger an investigation
The GitOps-failure watcher (sources.gitops: { enabled: true }) debounces before firing, to
filter reconcile-churn transients:
runlore_gitops_failures_debounced_totalrising ⇒ the failure cleared within the debounce window and was dropped as transient. Log:msg="gitops-failure cleared within debounce window; dropping transient".- Tune with
triggers.gitops_failures.debounce(default 60s; explicit0fires immediately on everyReady=False).
The investigation ran but timed out / came back empty
Check runlore_investigations_completed_total{result=…} — the result label tells you how it ended:
result | meaning | log line | lever |
|---|---|---|---|
resolved / unresolved | finished; unresolved = honest “couldn’t determine” | msg="investigation complete" | — |
recall | answered instantly from the catalog | msg="instant recall (catalog hit; skipping the loop)" | — |
timeout | hit investigation.timeout (default 10m) | msg="investigation hit per-investigation deadline" | raise investigation.timeout; check for a hung tool/provider |
budget_exceeded | hit a spend ceiling: investigation.max_tokens_per_investigation (the next request’s size or the run’s cumulative tokens) or investigation.max_cost_per_investigation | msg="investigation hard-stopped at token budget", with reason=tokens_request, tokens_total or cost naming which | raise the ceiling reason names, or accept the cap |
max_steps | hit investigation.max_steps (default 20) without calling submit_findings | msg="investigation hit max steps" | raise max_steps, or the loop is looping — inspect tool calls |
max_steps_degraded | hit max_steps but submit_findings was called mid-loop (degraded answer, not inconclusive) | msg="investigation complete" | the loop ran out of budget but still produced a finding — raise max_steps if you want a complete answer |
inconclusive | model never called submit_findings after a nudge | msg="investigation inconclusive (no submit_findings after nudge)" | often a weak/over-quantized model; try a stronger one |
error | a tool or model call failed | msg="investigation failed; retrying" | inspect the err= field |
Supporting metrics: runlore_tool_calls_total{tool,result} and runlore_model_requests_total{provider,result}
(watch the result="error" slice), runlore_model_responses_truncated_total (completions cut off at the
output-token ceiling — a frequent cause of inconclusive), and runlore_investigation_duration_seconds{result}.
result="budget_exceeded" only counts the runs that died. Watch
runlore_investigation_budget_trips_total{reason,stage} for the rung above it:
stage="nudge" is an investigation that hit a ceiling, was forced to conclude early and still
delivered findings — it looks like a normal resolved run in every other series, so a steadily
rising nudge rate is how you learn your ceilings are cutting work short before any of them start
failing.
The curator didn’t open a PR
RunLore files a KB pull request only for novel, confident findings — by design it does not file
for everything. Check runlore_curations_total{kind="pr",result=…} and the curator log:
| situation | log line | this is… |
|---|---|---|
| recalled answer (cache hit) | msg="skipping curation of a recalled finding (cache hit, not novel)" | expected — not novel |
| below the quality bar | msg="finding below the quality bar; chat-only, no KB artifact" | expected — confidence < forge.min_confidence (default 0.75) |
| duplicates a catalog entry | msg="dedup: duplicates a catalog entry; not filing" | expected — within forge.dup_score |
| coalesced onto an open PR | msg="finding coalesced onto an open PR" (result="coalesced") | expected — added to an existing PR |
| opened | msg="curated as PR" with the url | success |
| error | msg="curate findings" with err= (result="error") | a forge/GitHub-App problem — check App scopes & forge.kb_repo |
If you expected a PR and got chat-only or duplicates, the finding simply wasn’t novel/confident
enough — tune forge.min_confidence / forge.dup_score if the thresholds are wrong for you.
Knowledge-gap issues (opened by the separate
lore curaterecurrence agent, not the live curator) are not counted by any metric — they only logmsg="opened knowledge-gap issue".
The PR opened, but the entry will never be recalled
A KB pull request can merge cleanly and still be dead on arrival: recall matches an entry to an
incident through its resource: frontmatter, so a resource that no workload can ever equal makes
the entry unreachable — worse than omitting it, because a non-empty resource also disables the
scopeless fallback. RunLore checks each draft before filing and warns rather than blocking, so
the PR still opens and a human can fix the frontmatter:
| log line | meaning | fix |
|---|---|---|
msg="drafted KB entry carries a recall index that recall cannot use; filing it anyway" | resource (or alert_resource) is not shaped namespace/name, or reads as a bare namespace so it matches every workload in it | edit the frontmatter on the PR before merging |
msg="drafted KB entry fails RunLore's own merge gate; filing it anyway, but the frontmatter needs a human fix before it can merge" | the draft would be rejected by lore validate-kb — the same gate the catalog repo runs in CI | fix what field=/issue= name; otherwise the PR sits unmergeable |
Both carry field=, issue= and title= so you can find the entry. If an already-merged entry
never matches anything, this is the first thing to check — it shows up in the recall section below
as no_resource_match.
Both are also counted, so you do not have to be watching the log to find out:
runlore_kb_draft_defects_total{defect="unrecallable_resource"} for the first row and
{defect="merge_gate"} for the second, from both entry writers (the curator and the
@runlore note: route). Nothing else would tell you — runlore_curations_total{kind="pr",result="opened"}
counts the pull request as a success whichever line fired. Alert on unrecallable_resource: it is
the half that merges cleanly and stays silent. See
Observability.
Recall never fires (every incident runs the full loop)
Instant recall requires catalog.instant_recall.enabled: true and a confident catalog hit. Check:
runlore_recall_hits_totalis zero, andrunlore_recall_rejections_total{reason=…}shows why candidates were rejected:reasonmeaning lever no_resource_matchno candidate’s stored resource agreed with the incident’s workload — the reranker was never reached instant_recall.require_workload_matchrerank_no_signalretrieval surfaced nothing plausible, so the paid ranking call was skipped instant_recall.rerank_min_score(default 0.1)rerank_low_confidencethe reranker returned no match, a confidence under the bar, or an error instant_recall.rerank_threshold(default 0.7)low_marginlegacy gate only ( rerank: false) — top hit too close to the runner-upinstant_recall.margin_gap(default 1.0)low_outcomethe entry’s real-world resolve-rate decayed below the floor instant_recall.outcome_floor(default 0.5)rerank_over_budgetthe investigation’s spend ceiling was already crossed, so the paid rerank call was declined before it was made — recall falls through to the full loop, which the budget ladder then stops with the same reason investigation.max_tokens_per_investigation/max_cost_per_investigationrerank_over_budgetis not a recall problem — noinstant_recall.*knob fixes it. It means the investigation had already crossed a spend ceiling before recall got to the reranker, so the call was declined rather than paid for. The run then falls through to the full loop, whose first budget check stops it with the same ceiling. If you see this alongsiderunlore_investigation_budget_trips_total, raise the ceiling; recall is behaving correctly.- Check which gate is live before tuning anything. The LLM reranker is on by default once
instant recall is enabled, and it replaces the BM25-magnitude gate — so on a default install
min_score,margin_gapandsolo_floorplay no part in the fire decision and tuning them changes nothing. They apply only underinstant_recall.rerank: false. runlore_recall_score(BM25 at the decision point) is still recorded under the reranker, but there it only ranks candidates: a score too low to spend a call on shows up asrerank_no_signalagainstrerank_min_score(default 0.1), not againstmin_score. Either way a cold catalog legitimately won’t recall — recall compounds as merged PRs accrete.- Decision detail is logged at
msg="instant recall decision"withscore,margin,confidence; under the reranker the fire decision itself is logged atmsg="recall reranker decision"withmatch,entry_id,confidenceand the model’s own one-linereason. - A recalled answer that fails the adversarial verify pass falls through to a full investigation:
msg="instant recall rejected by verify; running full investigation".
Findings were investigated but never delivered to chat
Warning
Delivery has no metric — logs only
There is currently no runlore_* counter for notifier delivery. A failed send logs
msg="delivery failed" err=… (the Slack/Matrix/webhook fan-out is best-effort and joins errors).
Successful sends are not logged. Grep msg="delivery failed" and msg="deliver findings".
Common causes: wrong notify.slack.channel / bot-token scope, an invalid Matrix room_id /
access_token, or a generic-webhook endpoint returning non-2xx. At startup, msg="delivery notifiers"
with count= confirms how many sinks were wired — count=0 means none are configured.
A Slack mention in a thread does nothing
@runlore note: … fails silently in both directions, so first establish that Slack can reach the
endpoint at all: run the
/slack/events pre-flight
(one unsigned curl; 401 is the healthy answer). Everything below assumes it passes.
(Matrix thread capture has the same symptom and no HTTP endpoint — skip the pre-flight, the log lines here are shared.)
| symptom | log line | this is… |
|---|---|---|
| nothing happens, and no log line at all | — | the event was never delivered, or was dropped before anything logs it. In rough order of likelihood: the bot is not a member of the channel; app_mentions:read was added but the app was not reinstalled; the mention was not inside a thread (a top-level @runlore has no root to attribute knowledge to); it was a bot’s own message (loop guard); or the payload was not an app_mention. Confirm delivery from Slack’s side first — Event Subscriptions → Recent Deliveries |
| the thread answers “I don’t have context for this thread” | msg="thread: mention in an unrecognised thread" | the registry has no entry for that root: past registry_ttl (default 168h), evicted past registry_max (default 2000 live threads), or orphaned by a restart / leader failover onto a replica that never saw it. Slack has no per-event fallback, so this is unrecoverable — see Configuration → notify for the persistence shape that prevents the third case |
| “I’m handling too many messages right now” | msg="slack event: mention dropped, handler pool saturated" (ERROR) + runlore_mentions_dropped_on_saturation_total | the note is permanently lost — Slack was already acked, so it will not retry. Send it again |
| a duplicate mention was ignored | msg="slack event: duplicate delivery ignored" (debug) | expected — Slack retried a delivery RunLore had already handled |
| the note reached the KB but the thread stayed quiet | msg="thread: note recorded on KB PR" / msg="thread: note opened a standalone KB PR" and msg="thread: reply failed (best-effort)" | the write succeeded and only the acknowledgement failed — see the bot-token note below |
| the announcement never reached the channel | msg="knowledge-base update delivery failed (swallowed)" (ERROR), or msg="thread: announcement pool saturated; a knowledge-base write was not announced" | notify.thread.announce_kb_updates only: the write landed and the broadcast did not. Per-sink, so other transports may still have received it |
| the write itself failed | msg="thread: knowledge write failed" with err= | a forge problem — same causes as the curator section above |
Note
A stale bot token is a narrow cause, not the usual one. One token serves finding delivery,
progress updates and thread replies alike, so a token that has been rotated out from under the pod
stops everything — which shows up loudly as
findings never delivered, not as a quiet
thread. It explains the pattern above only for threads posted before the rotation: those
messages exist, so a note against them still writes, and only the reply fails —
err="slack chat.postMessage: invalid_auth" (or token_revoked / token_expired).
Reinstalling the Slack app can rotate the bot token. Verify against the value the cluster holds rather than whatever is in your shell, and note that the pod read it once, at startup — so a Secret you have already corrected still needs a restart:
$ TOKEN=$(kubectl -n runlore get secret runlore-secrets \
-o jsonpath='{.data.SLACK_BOT_TOKEN}' | base64 -d)
$ curl -sS -X POST -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test
{"ok":true,…}
not_authed means the token was empty. The signing secret is per-app, not per-install, so a
reinstall never affects it — which is why the pre-flight still answers 401 while replies fail.
/readyz never goes green
/readyz is gated by catalog warmth (internal/app/runtime.go) — deliberately not by
leadership, so every warm replica (leader and standby alike) goes Ready and helm upgrade --wait /
Flux kstatus succeeds with replicaCount > 1. It returns 503 ("not ready") until the pod has
completed its first catalog index/sync.
- Any pod stuck at
503⇒ the catalog never warmed: checkcatalog.dir/catalog.git(clone failing? token wrong?) and the startup logs.runlore_catalog_invalid_entries_totalrising ⇒ malformed OKF entries at load. - The
startupProbeallows ~60s of warm-up; a slow first clone can exceed it — raise the chart’sstartupProbe.failureThresholdif needed. - Who leads is a separate question from readiness: read the Lease
(
kubectl get lease runlore-leader -o jsonpath='{.spec.holderIdentity}'—<podName>_<podIP>) or therunlore_leadergauge.
Quick reference — metric → meaning
The most useful series for triage (full list in Observability):
| metric | use it to see… |
|---|---|
runlore_alerts_received_total | alerts that passed ingress + Decide |
runlore_investigations_started_total | investigations actually begun |
runlore_investigations_throttled_total / _dropped_total | rate-limit / budget pressure |
runlore_alerts_coalesced_total / _suppressed_total | storm-coalescing / cooldown drops |
runlore_investigations_completed_total{result} | how investigations ended (incl. timeout, error) |
runlore_recall_hits_total{result} / _rejections_total{reason} | whether instant recall is working |
runlore_curations_total{kind,result} | KB PRs opened / coalesced / errored |
runlore_leader | which replica is the active leader |
runlore_model_requests_total{provider,result} | LLM call success vs error |