Skip to content

Observability

RunLore self-instruments with structured logs and Prometheus-compatible metrics, and ships a portable Grafana dashboard plus alert rules. Everything here works against either Prometheus or VictoriaMetrics — RunLore is metrics-backend-agnostic.

Logging

RunLore logs via Go’s log/slog. Output format and verbosity are configurable:

logging:
  format: json   # "text" (default, human-readable) | "json" (structured, for log aggregation)
  level: info    # debug | info | warn | error

The Helm chart defaults to JSON in-cluster (so logs flow cleanly into Loki/VictoriaLogs/CloudWatch); the CLI defaults to text. Both are overridable at startup without editing config:

RUNLORE_LOG_FORMAT=json RUNLORE_LOG_LEVEL=debug lore serve --config runlore.yaml

Level guidance: error = an operation failed; warn = a recoverable/degraded condition (a backend unavailable, a provider disabled); info = lifecycle and per-incident milestones; debug = per-step / per-tool tracing (off in production).

Metrics

When telemetry.metrics_enabled: true, RunLore serves the Prometheus exposition format at GET /metrics on the service port. Scrape it with a VMServiceScrape (vmServiceScrape.enabled: true in the chart) or any ServiceMonitor/scrape config.

All series are prefixed runlore_.

The seconds-scale latency histograms (*_duration_seconds, incident_resolution_seconds) carry explicit SLO-aligned bucket boundaries (seconds): 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300. The OTel SDK defaults are millisecond-scale and would collapse most calls into the first bucket, breaking histogram_quantile.

Score histogram buckets are the decision thresholds

The two BM25-score histograms — runlore_recall_score and runlore_curation_dedup_score — carry their own explicit boundaries: 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 5, 7.5, 10. The SDK defaults start at 5, so an entire real corpus (an enriched BM25 score is roughly 0.1–1.2) lands in the first bucket and the panel renders as a single bar.

The rescale is not the interesting part. Every boundary that is also a decision threshold is one deliberately, so a bucket count answers “how much of the corpus clears this gate?” with no arithmetic:

boundarythe gate it isreading le at that boundary tells you
0.1instant_recall.rerank_min_scoreshare of recall attempts too weak to spend a rerank call on — they reject as rerank_no_signal
1instant_recall.min_score / margin_gapshare below the legacy BM25 magnitude gate (live only under instant_recall.rerank: false)
4instant_recall.solo_floorshare below the confident bar for a single-hit recall
5forge.dup_scoreon curation_dedup_score, the share below the dedup bar — i.e. the findings novel enough to file. At or above it, the finding is dropped as duplicating a catalog entry

So runlore_recall_score_bucket{le="0.1"} over _count is precisely the fraction of recall attempts that never reached the reranker, and runlore_curation_dedup_score_bucket{le="5"} over _count the fraction of findings that were novel. Boundaries above 5 exist for deployments that hand-tuned their thresholds to a differently-scaled corpus; +Inf captures anything past 10.

Warning

Existing panels over these two *_bucket series change shape on upgrade. Histogram boundaries are not versioned and the rename-free change is invisible: the series keep their names and simply begin reporting a different set of le values. A panel, recording rule or histogram_quantile written against the old SDK ladder (which began at 5, so nearly everything sat in one bucket) will re-bucket silently and change value across the upgrade — no error, no missing series, just a different number. Re-check anything reading runlore_recall_score_bucket or runlore_curation_dedup_score_bucket, and treat comparisons spanning the upgrade as meaningless rather than as a real shift in corpus quality.

Both ladders are defined in internal/telemetry/setup.go via explicit-bucket-histogram views. The remaining histograms — coalesce_batch_size, investigation_tokens_estimated and the per-investigation call/token/cost distributions — keep the SDK defaults and are read as heatmaps, not percentiles.

Pipeline & investigations

MetricTypeLabelsMeaning
runlore_build_infogaugeversionconstant 1; for absent() liveness + version display
runlore_leadergauge1 on the elected leader, 0 on standbys
runlore_alerts_received_totalcounterincidents passing the trigger gate into the coalescer
runlore_alerts_coalesced_totalcounterincidents folded into an existing batch
runlore_alerts_suppressed_totalcounterincidents dropped by cooldown
runlore_incidents_debounced_totalcounterfiring alerts dropped as self-resolving (a matching resolved webhook arrived within triggers.incidents.debounce)
runlore_incidents_dropped_on_shutdown_totalcounteralert LOSS — firing alerts still held in the debounce window when the process shut down: accepted (200 to Alertmanager) but never investigated, and not retried until Alertmanager’s repeat_interval. Any non-zero value is worth a look; see troubleshooting
runlore_investigations_started_totalcounterinvestigations actually begun
runlore_investigations_completed_totalcounterresultinvestigations finished (resolved/unresolved/recall/timeout/error/max_steps/max_steps_degraded/budget_exceeded/inconclusive/recurrence_suppressed/silenced)
runlore_investigation_duration_secondshistogramresultwall-clock per investigation
runlore_investigations_throttled_totalcounterstarts requeued by the rate limiter
runlore_investigations_dropped_totalcounterdropped (rate-limiter max-requeues or token-budget kill)
runlore_investigations_cancelled_totalcounterqueued (not yet started) investigations cancelled because the incident resolved first (triggers.incidents.cancel_queued_on_resolve)
runlore_gitops_failures_debounced_totalcounterGitOps failures dropped as transient: the failure cleared within the debounce window before an investigation started
runlore_coalesce_batch_sizehistogramincidents per flushed batch (heatmap; SDK default buckets)

Tools & model

MetricTypeLabelsMeaning
runlore_tool_calls_totalcountertool, resultinvestigation tool calls (ok/error)
runlore_tool_call_duration_secondshistogramtoolper-tool latency
runlore_alert_rule_degraded_totalcounterreason, classalert_rule calls that answered with an “unavailable” note instead of the firing rule’s expression. reason = no_capability (the metrics backend serves no alerting-rule endpoint), backend_error (the read failed — 404/500/unparseable body), no_rules (the endpoint answered, with an empty ruleset), unmatched_alert (the ruleset defines no rule under this alertname). class = systemic (the first three) or routine (unmatched_alert)
runlore_model_requests_totalcounterprovider, resultLLM requests (ok/error). provider is the wire protocol for the main model, plus the synthetic tiers rerank (instant recall’s LLM reranker) and embed (the /embeddings endpoint on the hybrid-recall path)
runlore_model_request_duration_secondshistogramproviderLLM request latency, same provider vocabulary
runlore_investigation_tokens_estimatedhistogramper-investigation token estimate (pre-request chars/4 heuristic, investigation loop only — excludes the adversarial verify phase). This is what the RunloreInvestigationCostHigh alert watches
runlore_investigation_model_callshistogramresultmodel completions per investigation (loop + verify)
runlore_investigation_input_tokenshistogramresultprovider-reported input tokens per investigation, including cached (loop + verify)
runlore_investigation_output_tokenshistogramresultprovider-reported output tokens per investigation (loop + verify)
runlore_investigation_cached_input_tokenshistogramresultinput tokens served from cache per investigation (loop + verify)
runlore_investigation_cost_usdhistogramresultestimated per-investigation cost in USD (only when model.pricing is configured)
runlore_model_responses_truncated_totalcounterprovidercompletions cut off at the output-token ceiling (the provider’s stop/finish reason indicated truncation) — a truncated answer is a degraded one, so a rising rate means model.max_tokens is too tight
runlore_model_input_tokens_totalcounterprovidertotal LLM input tokens, including cached. Fleet-wide spend, as opposed to the per-investigation investigation_input_tokens distribution
runlore_model_cached_input_tokens_totalcounterproviderLLM input tokens served from cache. Over model_input_tokens_total this is the cache hit rate, which is the single biggest lever on the bill
runlore_tool_output_truncated_bytes_totalcounterbytes elided by per-call tool-output truncation (investigation.max_tool_output_bytes)
runlore_history_compactions_totalcountermid-loop tool-output history compaction events
runlore_history_elided_bytes_totalcountertool-output bytes elided by mid-loop compaction
runlore_history_summarizations_totalcountercompaction events whose elided batch was replaced by a model-produced digest (compaction: summarize)
runlore_history_summarize_fallbacks_totalcountersummarize-mode compactions that fell back to plain elision after a summariser error, refusal or truncation — the digest was not produced and the bodies were dropped instead
runlore_investigation_budget_trips_totalcounterreason, stagespend ceilings crossed during an investigation. reason = tokens_request (the next request alone exceeded max_tokens_per_investigation), tokens_total (the run’s projected cumulative tokens did), or cost (max_cost_per_investigation). stage = nudge (forced to conclude early; findings still delivered) or kill (hard-stopped, result="budget_exceeded")

One run reports one reason. The ceiling that first engaged the ladder is latched at the nudge and carried to the kill, so the two rungs of a single stop always agree. Without that, the nudged turn’s own spend could push a second ceiling over the line and the kill would name that one instead — telling you to raise a knob that never stopped anything, and splitting one stop across two series in the sum by (reason) recipe below.

budget_trips_total{stage="nudge"} is the one to alert on. A nudged investigation completes and records result="resolved" like any other, so it is invisible in every other series — only this counter distinguishes “the ceiling is comfortable” from “the ceiling has been silently truncating investigations for a week”:

# share of investigations cut short by a ceiling, whether or not they died
sum(rate(runlore_investigation_budget_trips_total{stage="nudge"}[1h]))
  / sum(rate(runlore_investigations_completed_total[1h]))

# which ceiling to raise
sum by (reason) (rate(runlore_investigation_budget_trips_total[1h]))

The five usage histograms carry the same result values as runlore_investigations_completed_total, so {result="recall"} selects exactly the investigations a recall short-circuited and {result!="recall"} exactly those that ran the full loop. That split is what makes the recall saving measurable — see below.

Thread capture & conversational replies

Emitted by the @runlore … thread path (notify.*.thread_capture, plus model.chat for the conversational half). Both halves are member-triggerable — anyone in the channel or room can cause a forge write or a model call — so these are the series that say what that path is costing and where it is being refused.

MetricTypeLabelsMeaning
runlore_mentions_dropped_on_saturation_totalcounternote LOSS — a Slack app_mention or an addressed Matrix thread message that arrived when the handler pool was saturated. Accepted and never processed (Slack was already acked and will not retry; Matrix’s /sync token has advanced), so the human’s note is gone. RunLore tells them to retry with a best-effort in-thread reply
runlore_thread_notes_written_totalcounterrouteknowledge writes that landed from an @runlore note: reply. route = comment (added to the curated PR this thread came from), open_pr (no PR to land on, so a standalone Concept entry PR was opened), or append (appended to the entry file of the standalone PR an earlier note in this same thread opened). comment and append are split deliberately: a comment is discarded when its PR merges, an appended entry becomes catalog knowledge, and with both labelled the same nothing distinguished them
runlore_thread_writes_throttled_totalcounternote writes denied by the global per-hour forge-write window and told to retry. This is the feature’s one global cap and is otherwise invisible to an operator — nothing else reports it
runlore_thread_chat_calls_totalcountermodel calls the chat layer made (granted by the per-hour budget)
runlore_thread_chat_tokens_totalcountertokens the chat layer spent, from provider-reported usage — or a conservative estimate when the provider reported none
runlore_thread_chat_denied_totalcounterceilingchat calls the budget refused. ceiling = calls (notify.thread.chat_calls_per_hour) or tokens (chat_tokens_per_hour). The message falls back to the deterministic reply

Important

There is no cost report for the chat path. thread_chat_tokens_total is a token count and nothing anywhere converts it to currency: per-investigation cost reporting (runlore_investigation_cost_usd, the notification footer) covers the main and verify passes only, and model.chat.pricing is read by nothing at all. To know what conversational replies cost, multiply this counter by your provider’s rate yourself.

thread_chat_denied_total{ceiling="tokens"} firing while call slots remain is the budget working as designed — the token ceiling is derived so that a runaway of maximum-size calls is stopped by cost before it exhausts the call count. Sustained denials mean the ceiling is genuinely too low for your traffic, not that something broke:

# share of chat attempts refused, by which ceiling bound first
sum by (ceiling) (rate(runlore_thread_chat_denied_total[1h]))
  / scalar(sum(rate(runlore_thread_chat_calls_total[1h]))
         + sum(rate(runlore_thread_chat_denied_total[1h])))

# notes that became durable knowledge vs. notes that will vanish at merge
sum by (route) (rate(runlore_thread_notes_written_total[24h]))

class="systemic" is the one to alert on. alert_rule degrades gracefully by design — a rule definition is corroborating context, never the primary evidence, so it must never fail an investigation — and every degraded outcome is therefore a plain string, which the loop records as tool_calls_total{tool="alert_rule",result="ok"}. A backend serving no alerting-rule endpoint is metrically identical to one where the tool works, while every investigation on it silently loses the feature that keeps a threshold alert from being judged against the wrong series. This counter is the only series that separates them:

# the tool is delivering NO rule expressions on this deployment — a config or backend fact
sum(rate(runlore_alert_rule_degraded_total{class="systemic"}[1h]))

# which path fired: a missing capability, a failing read, or an empty ruleset
sum by (reason) (rate(runlore_alert_rule_degraded_total{class="systemic"}[1h]))

# share of alert_rule calls that came back without an expression
sum(rate(runlore_alert_rule_degraded_total{class="systemic"}[1h]))
  / sum(rate(runlore_tool_calls_total{tool="alert_rule"}[1h]))

class="routine" (unmatched_alert) is deliberately excluded from all three: the backend answered with its rules and this incident’s alertname simply is not among them — evaluated elsewhere, or renamed. That is per-incident and normal, and folding it in with the systemic paths would bury the signal an operator must act on under traffic nobody should be paged for. Watch it as a rate if it climbs, though: a sudden step change means alertnames and rules have drifted apart.

Recall, learning loop & curation

MetricTypeLabelsMeaning
runlore_recall_hits_totalcounterresultinstant-recall short-circuits
runlore_recall_tokens_spent_totalcountertokens a delivered recall short-circuit actually cost (LLM reranker + adversarial verify). A measurement, not a saving estimate — see Measuring the recall saving
runlore_recall_rejections_totalcounterreasonrecalls rejected before short-circuit
runlore_recall_scorehistogramBM25 score at the recall decision
runlore_outcomes_opened_totalcounterkindinvestigations recorded as open
runlore_incidents_resolved_totalcounterresolve events matching an open investigation
runlore_recall_outcome_totalcounterresultresolved incidents whose answer was a recall
runlore_incident_resolution_secondshistogramopen→resolve duration
runlore_curations_totalcounterkind, resultcuration outcomes (opened/coalesced/error)
runlore_curation_dedup_scorehistogramcatalog top-hit BM25 score at the dedup decision
runlore_catalog_invalid_entries_totalcounterstructurally-invalid (but parseable) entries surfaced at catalog load. The entry is still indexed and served — one bad entry never empties the catalog — so this is the only signal that the CI merge gate let something through
runlore_kb_draft_defects_totalcounterdefectKB entries filed with a defect found before the PR was opened: unrecallable_resource (the resource/alert_resource recall indexes on is not shaped namespace/name, so recall’s structural match can never agree with it) or merge_gate (the draft fails lore validate-kb, so its PR cannot merge). Counted once per entry per defect, for both entry writers — the curator and the @runlore note: standalone-note route
runlore_catalog_embed_degraded_totalcountercatalog reloads that left hybrid recall without vectors (embed failure — recall degrades to BM25-only until the next successful sync)

runlore_kb_draft_defects_total is the one series that separates a healthy catalog from one silently filling with dead weight. runlore_curations_total{kind="pr",result="opened"} scores the pull request a success whether or not the entry inside it can ever be recalled, so without this the two deployments look identical:

# share of filed entries recall will never be able to match — alert on this one
sum(increase(runlore_kb_draft_defects_total{defect="unrecallable_resource"}[$__range]))
  / sum(increase(runlore_curations_total{kind="pr",result="opened"}[$__range]))

Keep the two defect values apart rather than summing them. merge_gate blocks its own pull request, so a human meets it at review time regardless; unrecallable_resource merges cleanly and is silent forever, and every such entry is permanent catalog weight that never repays its cost. The log lines behind both, and what to do about them, are in The PR opened, but the entry will never be recalled.

Measuring the recall saving

RunLore never asserts a saving — it measures both sides and lets you subtract them. Compare two means, both provider-reported, both per investigation:

# what a delivered recall short-circuit costs
sum(increase(runlore_recall_tokens_spent_total[$__range]))
  / sum(increase(runlore_investigations_completed_total{result="recall"}[$__range]))

# what an investigation that ran the full loop costs
(
    sum(increase(runlore_investigation_input_tokens_sum{result!="recall"}[$__range]))
  + sum(increase(runlore_investigation_output_tokens_sum{result!="recall"}[$__range]))
)
  / sum(increase(runlore_investigation_input_tokens_count{result!="recall"}[$__range]))

The gap between the two is the saving. $__range is the Grafana range variable — substitute a window ([24h]) to run these in Prometheus directly. The dashboard plots exactly these two series in 💸 Cost & efficiency, and the 🧠 Learning loop row reduces them to one number, 1 - recall/full-loop, as the “Recall token savings” stat.

Warning

The {result!="recall"} filter is load-bearing.

Unfiltered, these histograms cover recall short-circuits too — so the “full loop” term would contain the very runs you are subtracting from it, and the measured saving would shrink as recall got better. Never difference recall_tokens_spent_total out of an unfiltered per-investigation total.

Grafana dashboard

A portable dashboard lives at deploy/observability/grafana/runlore.json. It uses a single datasource template variable (type Prometheus), so it works with a Prometheus or a VictoriaMetrics datasource with no edits. Import it via Dashboards → Import → Upload JSON, or provision it. See the grafana README.

It panels most of the runlore_ series above — the learning loop, recall, curation, investigations, tools and model, cost, thread capture and fleet health. Notably it includes the spend-ceiling trips broken out by stage (a nudged investigation completes and records result="resolved", so no other panel shows it) and by reason (which ceiling to raise), the thread knowledge-write routes and the throttle/denial counters, the output-truncation rate (tool_output_truncated_bytes_total), the coalesced-batch-size distribution (coalesce_batch_size, heatmap), and the curation dedup-score distribution (curation_dedup_score, heatmap).

It is not exhaustive: the debounce counters, the history-compaction family and a few per-investigation usage histograms are documented above but have no panel. Query them ad hoc, or add a panel — the metric table is the authority on what exists, the dashboard on what is worth a glance.

Alerting

Alert rules ship as both a Prometheus-Operator PrometheusRule and a VictoriaMetrics-Operator VMRule (identical rules; pick the one your stack uses):

# kube-prometheus-stack
kubectl apply -f deploy/observability/alerts/prometheusrule.yaml
# VictoriaMetrics Operator
kubectl apply -f deploy/observability/alerts/vmrule.yaml

The rule set covers liveness (RunloreAgentDown), HA (RunloreNoActiveLeader, RunloreMultipleLeaders), pipeline health (RunlorePipelineStalled, RunloreInvestigationsDropped, throttling), quality (tool/model error rates, investigation errors), latency (model p95, slow resolution), and cost (RunloreInvestigationCostHigh).

Three of them exist to catch failures that are silent in every other series, which is what makes them worth the alert slot:

AlertWhat would otherwise hide it
RunloreInvestigationsNudgedByCeilinga nudged run completes and records result="resolved", so completion rate, error rate and duration all stay healthy while findings get shallower
RunloreThreadMentionsDroppedthe transport was acked before the work was queued, so a lost note leaves no retry, no error result, and no trace beyond this counter
RunloreCurationWriteErrorsinvestigations keep succeeding; only the write fails, so the learning loop stops compounding with every upstream metric looking fine

Thresholds are starting points — tune to your volume. See the alerts README for the per-alert metric dependencies and operator discovery notes.

Alert runbooks

Every shipped rule’s runbook_url points at its heading below. Each says what the alert means, what to look at first, and what actually fixes it — thresholds are starting points, so “the threshold is wrong for us” is a legitimate outcome for most of them.

RunloreAgentDown

absent(runlore_build_info) for 5m: the series vanished from the TSDB entirely. Either the process is gone or the scrape broke — those need different fixes, so tell them apart first. Check the pod (kubectl get pods -l app.kubernetes.io/name=runlore) and then the scrape target’s health in Prometheus/VMAgent. A VMServiceScrape/ServiceMonitor that stopped matching the Service produces this with a perfectly healthy agent.

RunloreNoActiveLeader

max(runlore_leader) == 0 for 10m: no replica holds the lease, so nothing is investigating and incoming alerts are accumulating or being dropped. Check the lease (kubectl get lease -n <ns>) and the logs of every replica for lease-renewal failures — usually RBAC on coordination.k8s.io/leases, or API-server latency causing repeated lease loss. Standbys are warm, so recovery is fast once one can acquire.

RunloreMultipleLeaders

sum(runlore_leader) > 1 for 5m: two replicas both believe they lead, so incidents can be investigated twice — double model spend and duplicate curation PRs. Brief overlap during failover is normal; five minutes is not. Check for clock skew and for a lease duration tuned shorter than your API-server latency.

RunloreInvestigationsDropped

runlore_investigations_dropped_total increased: an incident was discarded without being investigated — the rate limiter exhausted its requeues, or a spend ceiling hard-killed the run. Split the two before reacting: a token-budget kill also shows on runlore_investigation_budget_trips_total{stage="kill"} and on runlore_investigations_completed_total{result="budget_exceeded"}. Rate-limiter drops mean investigation.rate_limit is too tight for your alert volume; budget kills mean max_tokens_per_investigation is.

RunloreInvestigationThrottlingSustained

Throttling continuously for 30m. Brief throttling under a burst is the rate limiter working; half an hour of it means RunLore is structurally under-provisioned for its load. Either raise investigation.rate_limit.max_per_window and accept the spend, or reduce intake at the trigger gate. Check RunloreInvestigationsDropped alongside — a throttle that never becomes a drop is only latency.

RunlorePipelineStalled

Alerts are arriving and zero investigations are starting: the intake-to-investigation handoff is broken, which no amount of alert volume will fix. Check leadership first (this fires whenever there is no leader, so rule that out via RunloreNoActiveLeader), then the coalescer — incidents sitting in a debounce or cooldown window that never flushes look exactly like this. runlore_alerts_suppressed_total and runlore_incidents_debounced_total tell you if intake is being filtered rather than stalled.

RunloreInvestigationErrors

Investigations finishing with result="error" — the loop is failing mid-run rather than concluding. Read the investigation logs for the failing incidents; the common causes are a tool integration that is down (cross-check RunloreToolErrorRateHigh and runlore_tool_calls_total{result="error"} by tool) and provider errors (RunloreModelErrorRateHigh). A low background rate can be normal; a step change is not.

RunloreToolErrorRateHigh

More than 20% of tool calls are failing over 15m, gated on a minimum call rate so the ratio is not noise at idle. Break down by tool: sum by (tool) (rate(runlore_tool_calls_total{result="error"}[15m])). This is almost always one integration — expired credentials, a datasource that moved, or RBAC narrowed on the cluster reader — not a general fault.

RunloreModelErrorRateHigh

More than 10% of model requests are failing. Model errors stop investigations cold, hence critical. Break down by provider, which distinguishes the main model from the synthetic rerank and embed tiers — an embed failure degrades recall to BM25-only (also visible as runlore_catalog_embed_degraded_total) but leaves investigations working, which is a much smaller problem than the main tier failing. Check quota, credentials, and provider status.

RunloreModelLatencyHigh

p95 model latency above 30s over 15m. Every investigation is dragged out by this, and slow calls interact badly with investigation.timeout. Confirm against your provider’s status and the provider label; if this is your model’s genuine profile at your prompt sizes, raise the threshold rather than living with a permanently firing alert.

RunloreSlowResolution

p95 open-to-resolve above 1h. Informational, not a paging condition — it measures your incident lifecycle, not RunLore’s health, since the resolve event comes from Alertmanager. Useful as an SLO signal; align the threshold with your own target.

RunloreInvestigationsNudgedByCeiling

More than 10% of investigations in the last hour were nudged to conclude early by a spend ceiling. This is the one budget signal worth alerting on, for the reason given under the budget-trip metric: a nudged investigation still delivers findings and records result="resolved", so completion rate, error rate and duration all look healthy while the answers quietly get shallower. Nothing else distinguishes a comfortable ceiling from one that has been truncating investigations for a week.

Find the binding ceiling with sum by (reason) (rate(runlore_investigation_budget_trips_total[1h])). One run reports one reason — the ceiling that first engaged the ladder is latched from the nudge through to the kill — so the label names the knob to raise: tokens_request and tokens_total both point at investigation.max_tokens_per_investigation, cost at max_cost_per_investigation. Before raising either, check whether prompts are being filled with tool output (investigation.max_tool_output_bytes, the compaction settings) — that is the cheaper fix, and RunloreInvestigationCostHigh is the corroborating signal.

RunloreThreadMentionsDropped

An @runlore mention arrived while the concurrent handler pool was saturated. This is data loss, not backpressure: the transport was acked before the work was queued (Slack got its 200, Matrix’s /sync position token advanced), so nothing will be redelivered. Whatever a human was trying to record is gone, and their only notice is a best-effort in-thread reply asking them to send it again.

Any occurrence deserves a look, because it lands during incidents — the moment thread traffic peaks is exactly when someone is recording what actually fixed it. If it recurs, the handler pool is undersized for your channel volume. Cross-check runlore_thread_writes_throttled_total: throttled writes are refused politely and can be retried, dropped mentions cannot, and confusing the two leads to tuning the wrong limit.

RunloreCurationWriteErrors

runlore_curations_total{result="error"} is non-zero: investigations reached findings and the forge write failed. The learning loop stops compounding here, and it does so invisibly — every upstream metric (investigations completed, recall rate, curation attempts) still looks healthy, and recall degrades only slowly as the catalog stops growing.

Almost always a forge credential or scope problem: GitHub App permissions narrowed, forge.kb_repo pointing somewhere the installation cannot reach, or the App uninstalled from the KB repo. Grep msg="curate findings" with err= for the underlying error. Note that result="error" counts only genuine write failures — a finding that was chat-only (below forge.min_confidence) or deduplicated against an existing entry is a different result and is not an error.

RunloreAlertRuleUnavailable

runlore_alert_rule_degraded_total{class="systemic"} is non-zero: the alert_rule tool cannot read the firing alert’s own rule definition, for every investigation, until the backend or the config changes.

This is the one alert on this page for a tool that is working as designed when it fails. alert_rule degrades to a plain string rather than an error, because a rule definition is corroborating context and must never fail an investigation — which means runlore_tool_calls_total records every degradation as result="ok". A deployment whose backend serves no rules endpoint is therefore metrically identical to one where the tool works, and this counter is the only series that separates them.

class="routine" (unmatched_alert) is excluded on purpose: an alert with no matching rule is normal and must never page. Check the backend serves /api/v1/rules and that your metrics provider implements it.

RunloreUnrecallableKBDrafts

runlore_kb_draft_defects_total{defect="unrecallable_resource"} is non-zero: entries are being filed with a resource recall’s structural match can never agree with.

These merge cleanly and then never fire — worse than an entry with no resource at all, because a non-empty one also disables the scopeless fallback. Every such entry is permanent catalog weight that never repays its cost, and nothing else surfaces it: runlore_curations_total{kind="pr",result="opened"} scores the pull request a success either way.

defect="merge_gate" is excluded deliberately — that draft fails lore validate-kb, so its pull request cannot merge and a human meets it at review time regardless. Fix the frontmatter on the open PRs; the two log lines that name the entry are on the troubleshooting page.

RunloreInvestigationCostHigh

p95 of runlore_investigation_tokens_estimated above 100000 over 30m. Read the metric carefully: it records the size of the last request an investigation made, not its cumulative spend, and 100000 is the per-request bound the agent itself enforces (a quarter of the 400000 max_tokens_per_investigation default). p95 at that bound means typical runs are ending on a maximum-size request — evidence that prompts are being filled with tool output. Look at investigation.max_tool_output_bytes and the compaction settings before raising any ceiling, and scale this threshold if you retune max_tokens_per_investigation.