Skip to content

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 slog event. Set logging.format: json (or text) and logging.level: debug for the most detail. Each section below quotes the exact msg= value to grep for.
  • Metricsrunlore_* Prometheus series, exposed when telemetry.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>"
reasonmeaningwhat to do
matched trigger policyadmitted → investigatingnothing — this is the happy path
filtered by trigger policydidn’t match triggers.incidents.match, or hit triggers.incidents.ignorewiden 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.windowexpected — wait for the window to pass or the alert to resolve

Trigger filtering and dedup are not counted by any metric — the incident log 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 as alertmanagr: now fails startup with unknown 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_env is mandatory once any model is configured (serve fails closed — an anonymous webhook must not bill the model); it is also required by config.Validate under actions.mode=auto. When set, Alertmanager must send Authorization: Bearer <token>. A 401 means the token is missing or wrong. The request body is also capped at 1 MiB.
  • Metric cross-check. runlore_alerts_received_total counts alerts that passed initial decoding and Decide. 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:

signalmeaningfix
runlore_leader == 0 on all podsno leader elected → nothing runscheck the leases RBAC and leader_election config; look for msg="acquired leadership"
runlore_investigations_throttled_total risingrate 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 risingdropped by rate_limit.max_requeues or the token-budget hard-stopsee the timeout/budget section below
runlore_alerts_coalesced_total risingfolded into an existing batch (investigation.coalesce)expected noise control — one investigation covers the batch
runlore_alerts_suppressed_total risingdropped by the coalescer cooldownexpected — a recently-investigated correlation is in cooldown
runlore_incidents_debounced_total risinga 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 > 0alert 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 risingthe 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"} risingthis 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"} risinga 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_total rising ⇒ 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; explicit 0 fires immediately on every Ready=False).

The investigation ran but timed out / came back empty

Check runlore_investigations_completed_total{result=…} — the result label tells you how it ended:

resultmeaninglog linelever
resolved / unresolvedfinished; unresolved = honest “couldn’t determine”msg="investigation complete"
recallanswered instantly from the catalogmsg="instant recall (catalog hit; skipping the loop)"
timeouthit investigation.timeout (default 10m)msg="investigation hit per-investigation deadline"raise investigation.timeout; check for a hung tool/provider
budget_exceededhit a spend ceiling: investigation.max_tokens_per_investigation (the next request’s size or the run’s cumulative tokens) or investigation.max_cost_per_investigationmsg="investigation hard-stopped at token budget", with reason=tokens_request, tokens_total or cost naming whichraise the ceiling reason names, or accept the cap
max_stepshit investigation.max_steps (default 20) without calling submit_findingsmsg="investigation hit max steps"raise max_steps, or the loop is looping — inspect tool calls
max_steps_degradedhit 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
inconclusivemodel never called submit_findings after a nudgemsg="investigation inconclusive (no submit_findings after nudge)"often a weak/over-quantized model; try a stronger one
errora tool or model call failedmsg="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:

situationlog linethis is…
recalled answer (cache hit)msg="skipping curation of a recalled finding (cache hit, not novel)"expected — not novel
below the quality barmsg="finding below the quality bar; chat-only, no KB artifact"expected — confidence < forge.min_confidence (default 0.75)
duplicates a catalog entrymsg="dedup: duplicates a catalog entry; not filing"expected — within forge.dup_score
coalesced onto an open PRmsg="finding coalesced onto an open PR" (result="coalesced")expected — added to an existing PR
openedmsg="curated as PR" with the urlsuccess
errormsg="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 curate recurrence agent, not the live curator) are not counted by any metric — they only log msg="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 linemeaningfix
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 itedit 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 CIfix 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_total is zero, and runlore_recall_rejections_total{reason=…} shows why candidates were rejected:
    reasonmeaninglever
    no_resource_matchno candidate’s stored resource agreed with the incident’s workload — the reranker was never reachedinstant_recall.require_workload_match
    rerank_no_signalretrieval surfaced nothing plausible, so the paid ranking call was skippedinstant_recall.rerank_min_score (default 0.1)
    rerank_low_confidencethe reranker returned no match, a confidence under the bar, or an errorinstant_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 floorinstant_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 reasoninvestigation.max_tokens_per_investigation / max_cost_per_investigation
  • rerank_over_budget is not a recall problem — no instant_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 alongside runlore_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_gap and solo_floor play no part in the fire decision and tuning them changes nothing. They apply only under instant_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 as rerank_no_signal against rerank_min_score (default 0.1), not against min_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" with score, margin, confidence; under the reranker the fire decision itself is logged at msg="recall reranker decision" with match, entry_id, confidence and the model’s own one-line reason.
  • 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.)

symptomlog linethis is…
nothing happens, and no log line at allthe 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_totalthe note is permanently lost — Slack was already acked, so it will not retry. Send it again
a duplicate mention was ignoredmsg="slack event: duplicate delivery ignored" (debug)expected — Slack retried a delivery RunLore had already handled
the note reached the KB but the thread stayed quietmsg="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 channelmsg="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 failedmsg="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: check catalog.dir / catalog.git (clone failing? token wrong?) and the startup logs. runlore_catalog_invalid_entries_total rising ⇒ malformed OKF entries at load.
  • The startupProbe allows ~60s of warm-up; a slow first clone can exceed it — raise the chart’s startupProbe.failureThreshold if needed.
  • Who leads is a separate question from readiness: read the Lease (kubectl get lease runlore-leader -o jsonpath='{.spec.holderIdentity}'<podName>_<podIP>) or the runlore_leader gauge.

Quick reference — metric → meaning

The most useful series for triage (full list in Observability):

metricuse it to see…
runlore_alerts_received_totalalerts that passed ingress + Decide
runlore_investigations_started_totalinvestigations actually begun
runlore_investigations_throttled_total / _dropped_totalrate-limit / budget pressure
runlore_alerts_coalesced_total / _suppressed_totalstorm-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_leaderwhich replica is the active leader
runlore_model_requests_total{provider,result}LLM call success vs error