use yaml_rust2::Yaml;
use mira_core::json::Json;
use mira_core::query::{Op, Search, Signal, Target, Term, Value};
use crate::api;
const UNREADABLE: usize = usize::MAX;
#[derive(Debug)]
pub(crate) struct Rca {
title: String,
from: i64,
to: i64,
summary: String,
impact: Vec<String>,
timeline: Vec<(i64, String)>,
root_cause: String,
pub(crate) evidence: Vec<Fact>,
ruled_out: Vec<String>,
contributing: Vec<String>,
remediation: Vec<String>,
verification: Vec<String>,
prevention: Option<String>,
pub(crate) emit: bool,
}
#[derive(Debug)]
pub(crate) struct Fact {
claim: String,
pub(crate) cite: Search,
shown: String,
pub(crate) found: Option<usize>,
}
impl Fact {
pub(crate) fn seen(&mut self, rows: Option<usize>) {
self.found = Some(rows.unwrap_or(UNREADABLE));
}
}
pub(crate) fn trace_query(id: &str, limit: usize) -> Result<Search, String> {
let id = id.trim();
if mira_core::query::unhex(id).is_none_or(|b| b.len() != 16) {
return Err(format!("{id:?} is not a 16-byte hex trace id"));
}
Ok(Search {
signal: Signal::Traces,
from: 0,
to: i64::MAX,
terms: vec![Term {
target: Target::Field("trace_id".into()),
op: Op::Eq,
value: Value::Str(id.to_owned()),
}],
limit,
after: None,
cursors: false,
})
}
pub(crate) fn doc(args: &Yaml, now: i64) -> Result<Rca, String> {
api::known(
args,
&[
"title",
"from",
"to",
"summary",
"impact",
"timeline",
"root_cause",
"evidence",
"ruled_out",
"contributing",
"remediation",
"verification",
"prevention",
"emit",
],
)?;
let (from, to) = api::bounds(args, now)?;
let mut timeline = Vec::new();
for e in list(args, "timeline")? {
api::known(e, &["at", "what"])?;
let at = api::time_field(&e["at"], now, i64::MIN)?;
if at == i64::MIN {
return Err("each timeline entry needs `at`, as '-15m' or nanoseconds".into());
}
timeline.push((at, text(e, "what", "a timeline entry")?));
}
timeline.sort_by_key(|(at, _)| *at);
let mut evidence = Vec::new();
for e in list(args, "evidence")? {
evidence.push(fact(e, now, from, to)?);
}
let prevention = match &args["prevention"] {
Yaml::BadValue | Yaml::Null => None,
y => {
crate::alert::rule(y, &[]).map_err(|e| format!("prevention: {e}"))?;
Some(kyaml(y))
}
};
Ok(Rca {
title: text(args, "title", "an RCA")?,
from,
to,
summary: text(args, "summary", "an RCA")?,
impact: strings(args, "impact")?,
timeline,
root_cause: text(args, "root_cause", "an RCA")?,
evidence,
ruled_out: strings(args, "ruled_out")?,
contributing: strings(args, "contributing")?,
remediation: strings(args, "remediation")?,
verification: strings(args, "verification")?,
prevention,
emit: flag(args, "emit")?,
})
}
fn fact(y: &Yaml, now: i64, from: i64, to: i64) -> Result<Fact, String> {
api::known(y, &["claim", "trace_id", "query"])?;
let claim = text(y, "claim", "an evidence item")?;
let empty = |v: &Yaml| matches!(v, Yaml::BadValue | Yaml::Null);
let (mut cite, shown) = match (&y["trace_id"], &y["query"]) {
(t, q) if empty(t) && empty(q) => {
return Err(format!(
"evidence {claim:?} cites nothing: give it `trace_id` or `query`. \
A claim Mira cannot re-run is not evidence — put it in `root_cause` \
or `contributing` instead."
));
}
(t, q) if !empty(t) && !empty(q) => {
return Err(format!(
"evidence {claim:?} cites both `trace_id` and `query`; pick one"
));
}
(t, _) if !empty(t) => {
let id = t.as_str().ok_or("`trace_id` must be a quoted string")?;
(trace_query(id, 0)?, format!("trace `{}`", id.trim()))
}
(_, q) => {
let mut s = api::search_doc(q, now)?;
if empty(&q["from"]) && empty(&q["to"]) {
(s.from, s.to) = (from, to);
}
let shown = match filter(&s.terms) {
f if f.is_empty() => format!("all {}", s.signal.dir()),
f => format!("{} where `{f}`", s.signal.dir()),
};
(s, shown)
}
};
cite.limit = 0;
Ok(Fact {
claim,
cite,
shown,
found: None,
})
}
impl Rca {
pub(crate) fn verified(&self) -> bool {
self.evidence.iter().all(|f| f.found.is_some())
}
pub(crate) fn dead(&self) -> Option<String> {
let dead: Vec<&Fact> = self
.evidence
.iter()
.filter(|f| matches!(f.found, Some(0) | Some(UNREADABLE) | None))
.collect();
if dead.is_empty() {
return None;
}
let mut out = format!(
"nothing was rendered and nothing was stored: {} of {} citations do not \
hold against this store.\n",
dead.len(),
self.evidence.len()
);
for f in dead {
let why = match f.found {
Some(UNREADABLE) | None => "could not be read",
_ => "matches no records",
};
out.push_str(&format!(" - {} ({}) {why}\n", f.claim, f.shown));
}
out.push_str(
"Widen the window, fix the filter, or drop the claim — but do not publish \
a write-up citing evidence this node cannot produce.",
);
Some(out)
}
pub(crate) fn render(&self) -> String {
assert!(self.verified(), "render before the citations were checked");
let mut m = String::new();
m.push_str(&format!("# {}\n\n", self.title));
m.push_str(&format!(
"*{} → {} ({}). Written by an agent against Mira; every citation below was \
re-run at render time and returned the record count beside it.*\n\n",
utc(self.from),
utc(self.to),
span(self.to - self.from)
));
m.push_str("## Summary\n\n");
m.push_str(&self.summary);
m.push_str("\n\n");
bullets(&mut m, "Impact", &self.impact);
if !self.timeline.is_empty() {
m.push_str("## Timeline\n\n| When (UTC) | What |\n| --- | --- |\n");
for (at, what) in &self.timeline {
m.push_str(&format!("| {} | {} |\n", utc(*at), cell(what)));
}
m.push('\n');
}
m.push_str("## Root cause\n\n");
m.push_str(&self.root_cause);
m.push_str("\n\n");
if !self.evidence.is_empty() {
m.push_str("## Evidence\n\n");
for f in &self.evidence {
m.push_str(&format!(
"- {} — {}, {} records\n",
f.claim,
f.shown,
f.found.unwrap_or_default()
));
}
m.push('\n');
}
bullets(&mut m, "Ruled out", &self.ruled_out);
bullets(&mut m, "Contributing factors", &self.contributing);
if !self.remediation.is_empty() {
bullets(&mut m, "Remediation", &self.remediation);
m.push_str(
"> Mira did not apply any of this and cannot: it has no verb that \
changes a cluster.\n\n",
);
}
bullets(&mut m, "Verification", &self.verification);
if let Some(rule) = &self.prevention {
m.push_str(
"## Prevention\n\nThe rule that would have caught this, ready for \
`alerts.kyaml` — add your own `notify` targets:\n\n```yaml\nrules:\n - ",
);
m.push_str(&rule.replace('\n', "\n "));
m.push_str("\n```\n\n");
}
m
}
pub(crate) fn export(
&self,
md: &str,
now: i64,
) -> mira_proto::collector::logs::v1::ExportLogsServiceRequest {
use mira_proto::collector::logs::v1::ExportLogsServiceRequest;
use mira_proto::common::v1::{AnyValue, InstrumentationScope, KeyValue, any_value};
use mira_proto::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
use mira_proto::resource::v1::Resource;
let kv = |k: &str, v: String| KeyValue {
key: k.into(),
value: Some(AnyValue {
value: Some(any_value::Value::StringValue(v)),
}),
};
ExportLogsServiceRequest {
resource_logs: vec![ResourceLogs {
resource: Some(Resource {
attributes: vec![kv("service.name", "mira".into())],
..Default::default()
}),
scope_logs: vec![ScopeLogs {
scope: Some(InstrumentationScope {
name: "mira.rca".into(),
..Default::default()
}),
log_records: vec![LogRecord {
time_unix_nano: now.max(0) as u64,
severity_number: 9,
severity_text: "INFO".into(),
event_name: "rca".into(),
body: Some(AnyValue {
value: Some(any_value::Value::StringValue(md.to_owned())),
}),
attributes: vec![
kv("rca.title", self.title.clone()),
kv("rca.window.from", utc(self.from)),
kv("rca.window.to", utc(self.to)),
],
..Default::default()
}],
..Default::default()
}],
..Default::default()
}],
}
}
}
fn bullets(m: &mut String, heading: &str, items: &[String]) {
if items.is_empty() {
return;
}
m.push_str(&format!("## {heading}\n\n"));
for i in items {
m.push_str(&format!("- {i}\n"));
}
m.push('\n');
}
fn cell(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
fn text(doc: &Yaml, key: &str, what: &str) -> Result<String, String> {
doc[key]
.as_str()
.map(str::to_owned)
.ok_or(format!("{what} needs a quoted `{key}`"))
}
fn strings(doc: &Yaml, key: &str) -> Result<Vec<String>, String> {
list(doc, key)?
.iter()
.map(|y| {
y.as_str()
.map(str::to_owned)
.ok_or(format!("every entry of `{key}` must be a quoted string"))
})
.collect()
}
fn list<'a>(doc: &'a Yaml, key: &str) -> Result<&'a [Yaml], String> {
match &doc[key] {
Yaml::BadValue | Yaml::Null => Ok(&[]),
Yaml::Array(a) => Ok(a),
_ => Err(format!("`{key}` must be a list")),
}
}
fn flag(doc: &Yaml, key: &str) -> Result<bool, String> {
match &doc[key] {
Yaml::BadValue | Yaml::Null => Ok(false),
Yaml::Boolean(b) => Ok(*b),
y => y
.as_str()
.and_then(|s| s.parse().ok())
.ok_or(format!("{key}: expected \"true\" or \"false\"")),
}
}
fn filter(terms: &[Term]) -> String {
crate::alert::filter_of(terms)
}
fn utc(ns: i64) -> String {
let secs = ns.div_euclid(1_000_000_000) as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
unsafe { libc::gmtime_r(&secs, &mut tm) };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min,
tm.tm_sec
)
}
fn span(ns: i64) -> String {
match ns.max(0) / 1_000_000_000 {
s if s >= 86_400 => format!("{}d{}h", s / 86_400, s % 86_400 / 3_600),
s if s >= 3_600 => format!("{}h{:02}m", s / 3_600, s % 3_600 / 60),
s if s >= 60 => format!("{}m{:02}s", s / 60, s % 60),
s => format!("{s}s"),
}
}
fn kyaml(y: &Yaml) -> String {
match y.as_hash() {
Some(h) => h
.iter()
.map(|(k, v)| format!("{}: {}", one(k), one(v)))
.collect::<Vec<_>>()
.join("\n"),
None => one(y),
}
}
fn one(y: &Yaml) -> String {
let mut j = Json::new();
write(y, &mut j);
j.into_string()
}
fn write(y: &Yaml, j: &mut Json) {
match y {
Yaml::Hash(h) => j.obj(|j| {
for (k, v) in h {
j.key(k.as_str().unwrap_or_default());
write(v, j);
}
}),
Yaml::Array(a) => j.arr(|j| {
for v in a {
write(v, j);
}
}),
Yaml::String(s) => j.str(s),
Yaml::Integer(n) => j.i64(*n),
Yaml::Boolean(b) => j.bool(*b),
Yaml::Real(r) => j.f64(r.parse().unwrap_or_default()),
_ => j.null(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Yaml {
api::parse(s).unwrap()
}
const T: i64 = 1_789_812_000_000_000_000;
#[test]
fn a_claim_mira_cannot_re_run_is_not_evidence() {
let base = r#"{"title":"t","summary":"s","root_cause":"r","#;
let uncited = doc(
&parse(&format!(
r#"{base}"evidence":[{{"claim":"the pods were fine"}}]}}"#
)),
T,
)
.unwrap_err();
assert!(uncited.contains("cites nothing"), "{uncited}");
assert!(uncited.contains("the pods were fine"), "{uncited}");
assert!(uncited.contains("root_cause"), "{uncited}");
let both = doc(
&parse(&format!(
r#"{base}"evidence":[{{"claim":"c","query":{{}},
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}]}}"#
)),
T,
)
.unwrap_err();
assert!(both.contains("pick one"), "{both}");
let mut r = doc(
&parse(&format!(
r#"{base}"evidence":[
{{"claim":"checkout 5xx","query":{{"where":[{{"attr":"service.name","eq":"checkout"}}]}}}},
{{"claim":"one bad trace","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}]}}"#
)),
T,
)
.unwrap();
assert!(!r.verified());
r.evidence[0].seen(Some(0));
r.evidence[1].seen(None);
assert!(r.verified());
let dead = r.dead().unwrap();
assert!(dead.contains("2 of 2"), "{dead}");
assert!(dead.contains("matches no records"), "{dead}");
assert!(dead.contains("could not be read"), "{dead}");
assert!(dead.contains("attr:service.name=checkout"), "{dead}");
assert!(dead.contains("nothing was stored"), "{dead}");
r.evidence[0].seen(Some(12));
r.evidence[1].seen(Some(1));
assert!(r.dead().is_none());
}
#[test]
fn a_citation_inherits_the_incidents_window_unless_it_names_one() {
let r = doc(
&parse(
r#"{"title":"t","summary":"s","root_cause":"r",
"from":"-6h","to":"-5h","evidence":[
{"claim":"a","query":{}},
{"claim":"b","query":{"from":"-30m"}}]}"#,
),
T,
)
.unwrap();
assert_eq!(
(r.evidence[0].cite.from, r.evidence[0].cite.to),
(r.from, r.to)
);
assert_eq!(r.from, T - 6 * 3_600_000_000_000);
assert_eq!(r.evidence[1].cite.from, T - 30 * 60_000_000_000);
assert!(r.evidence.iter().all(|f| f.cite.limit == 0));
let t = doc(
&parse(
r#"{"title":"t","summary":"s","root_cause":"r","evidence":[
{"claim":"a","trace_id":" 4BF92F3577B34DA6A3CE929D0E0E4736 "}]}"#,
),
T,
)
.unwrap();
assert_eq!(
(t.evidence[0].cite.from, t.evidence[0].cite.to),
(0, i64::MAX)
);
assert_eq!(t.evidence[0].cite.signal, Signal::Traces);
let bad = doc(
&parse(
r#"{"title":"t","summary":"s","root_cause":"r","evidence":[
{"claim":"a","trace_id":"0102030405060708"}]}"#,
),
T,
)
.unwrap_err();
assert!(bad.contains("16-byte"), "{bad}");
}
#[test]
fn a_proposed_rule_is_one_the_alert_loader_accepts() {
let with = |rule: &str| {
doc(
&parse(&format!(
r#"{{"title":"t","summary":"s","root_cause":"r","prevention":{rule}}}"#
)),
T,
)
};
let good = with(
r#"{"name":"checkout-5xx","over":"5m","for":"2m","when":"count > 20",
"query":{"signal":"logs","where":[{"attr":"service.name","eq":"checkout"},
{"field":"severity_number","gte":17}]}}"#,
)
.unwrap();
let md = {
let mut g = good;
g.evidence.clear();
g.render()
};
assert!(md.contains("```yaml\nrules:\n - \"name\""), "{md}");
assert!(md.contains(r#""name": "checkout-5xx""#), "{md}");
assert!(md.contains(r#""gte":17"#), "{md}");
let windowed =
with(r#"{"name":"r","when":"count > 1","query":{"signal":"logs","from":"-1h"}}"#)
.unwrap_err();
assert!(windowed.contains("prevention:"), "{windowed}");
assert!(windowed.contains("over"), "{windowed}");
let typo =
with(r#"{"name":"r","when":"count > 1","query":{},"notify":["oncall"]}"#).unwrap_err();
assert!(typo.contains("prevention:"), "{typo}");
}
#[test]
fn the_rendered_document_is_timezone_free_ordered_and_honest_about_what_it_did() {
let mut r = doc(
&parse(
r#"{"title":"Checkout 5xx","summary":"Checkout returned 502 for 31 minutes.",
"from":1789812000000000000,"to":1789813860000000000,
"impact":["4,102 requests failed"],
"timeline":[{"at":1789813800000000000,"what":"rollback | complete"},
{"at":1789812060000000000,"what":"first 502"}],
"root_cause":"The 1.4.0 image raised the pool ceiling.",
"evidence":[{"claim":"the pool was exhausted",
"query":{"where":[{"attr":"service.name","eq":"checkout"}]}}],
"ruled_out":["Not the database: no slow queries."],
"contributing":["No alert on pool saturation."],
"remediation":["Roll back to 1.3.9."],
"verification":["502 rate back to zero."]}"#,
),
T,
)
.unwrap();
r.evidence[0].seen(Some(4_102));
let md = r.render();
assert!(md.starts_with("# Checkout 5xx\n"), "{md}");
assert!(
md.contains("2026-09-19T10:00:00Z → 2026-09-19T10:31:00Z (31m00s)"),
"{md}"
);
let first = md.find("first 502").unwrap();
let second = md.find("rollback").unwrap();
assert!(first < second, "timeline out of order:\n{md}");
assert!(md.contains(r"rollback \| complete"), "{md}");
assert!(md.contains(", 4102 records"), "{md}");
assert!(
md.contains("> Mira did not apply any of this and cannot"),
"{md}"
);
assert!(!md.contains("## Prevention"), "{md}");
let req = r.export(&md, T);
let rl = &req.resource_logs[0];
let rec = &rl.scope_logs[0].log_records[0];
assert_eq!(rec.severity_number, 9);
assert_eq!(rec.event_name, "rca");
assert_eq!(rec.time_unix_nano, T as u64);
assert_eq!(
rl.resource.as_ref().unwrap().attributes[0].key,
"service.name"
);
assert!(rec.attributes.iter().any(|a| a.key == "rca.title"));
}
#[test]
fn a_misspelled_section_is_refused_rather_than_dropped() {
let bad = [
(r#"{"summary":"s","root_cause":"r"}"#, "`title`"),
(r#"{"title":"t","root_cause":"r"}"#, "`summary`"),
(r#"{"title":"t","summary":"s"}"#, "`root_cause`"),
(
r#"{"title":"t","summary":"s","root_cause":"r","timelines":[]}"#,
"unknown query key",
),
(
r#"{"title":"t","summary":"s","root_cause":"r","impact":"one thing"}"#,
"`impact` must be a list",
),
(
r#"{"title":"t","summary":"s","root_cause":"r","impact":[7]}"#,
"quoted string",
),
(
r#"{"title":"t","summary":"s","root_cause":"r","timeline":[{"what":"x"}]}"#,
"needs `at`",
),
(
r#"{"title":"t","summary":"s","root_cause":"r","timeline":[{"at":"-5m"}]}"#,
"`what`",
),
(
r#"{"title":"t","summary":"s","root_cause":"r","emit":"yes"}"#,
"emit",
),
(
r#"{"title":"t","summary":"s","root_cause":"r","from":"-1h","to":"-2h"}"#,
"is after",
),
];
for (args, want) in bad {
let e = doc(&parse(args), T).unwrap_err();
assert!(e.contains(want), "{args}\n wanted {want:?}, got {e:?}");
}
let r = doc(&parse(r#"{"title":"t","summary":"s","root_cause":"r"}"#), T).unwrap();
assert!(r.verified());
assert!(r.dead().is_none());
assert!(!r.emit);
assert!(r.render().contains("## Root cause"));
}
#[test]
fn the_rule_fence_keeps_a_number_a_number_and_one_key_per_line() {
let y = parse(
r#"{"name":"x","when":"count >= 1","query":{"where":[{"gte":17},{"on":true},
{"ratio":0.5},{"nil":null}]}}"#,
);
let out = kyaml(&y);
let lines: Vec<_> = out.lines().collect();
assert_eq!(lines[0], r#""name": "x""#, "{out}");
assert_eq!(lines.len(), 3, "one top-level key per line:\n{out}");
assert!(out.contains(r#"{"gte":17}"#), "{out}");
assert!(out.contains(r#"{"on":true}"#), "{out}");
assert!(out.contains(r#"{"ratio":0.5}"#), "{out}");
assert!(out.contains(r#"{"nil":null}"#), "{out}");
assert_eq!(kyaml(&parse("[1, 2]")), "[1,2]");
}
}