Skip to main content

zenkey_fleet/model/
alert.rs

1//! The alert projection (#388): one sample on the alert plane → one
2//! [`AlertTransition`], from values in hand.
3//!
4//! RFC 04 §1.2 — *alerts are state*: a `put` on
5//! `…/state/<producer>/alert/<alert_key>` is firing, a `delete` is resolved,
6//! and the key is the identity through both. This module reads that off a
7//! wire key and a sample kind, and lifts what the decoded document says about
8//! itself (severity, rule, labels, a summary) when there is a document to
9//! read. It takes no session — the decode that produces the value is
10//! [`crate::model::decode::decode_sample`]'s, and a `.zrec` replays through
11//! this exactly as live traffic does.
12//!
13//! Routing the transition — which rule it matches, which sink it reaches,
14//! whether it is a duplicate — is the notifier's job, not the engine's.
15
16use std::collections::BTreeMap;
17
18use zenoh::sample::SampleKind;
19
20use crate::report::{AlertState, AlertTransition, RenderSource};
21
22/// Project one sample on the alert plane into an [`AlertTransition`].
23///
24/// `None` when `wire_key` is not an alert key under `base` — another
25/// deployment's key, a key that does not parse, a state subject that is not
26/// `alert/<alert_key>` — which for an observer is the meaningful answer,
27/// not an error.
28///
29/// * `kind`: a [`SampleKind::Put`] is [`AlertState::Firing`], a
30///   [`SampleKind::Delete`] is [`AlertState::Resolved`] (the tombstone).
31/// * `decoded`: the document as a JSON value with where it came from —
32///   `Schema` off the served schema, `Structural` off the bytes. `None` when
33///   nothing structured was read (every `Delete` — a tombstone has no
34///   body — and a `Put` whose bytes read as nothing), which renders as
35///   [`RenderSource::KeyOnly`].
36/// * `stamped`: the sample's HLC as the engine renders it, when it carried
37///   one.
38/// * `at`: the observation's RFC 3339 wall clock.
39///
40/// What is lifted from the document, when present and string-valued:
41/// `severity`, `rule`, `labels` (a string-valued object; the `host` label is
42/// dropped because the origin in the key already names the host, RFC 11
43/// §3.1), and `summary` — or `message`, the incumbent spelling — as the
44/// summary. Anything else in the document is the renderer's to show.
45pub fn alert_transition(
46    base: &str,
47    wire_key: &str,
48    kind: SampleKind,
49    decoded: Option<(RenderSource, &serde_json::Value)>,
50    stamped: Option<&str>,
51    at: &str,
52) -> Option<AlertTransition> {
53    use zenkey::grammar::{Class, ClassOrPlane};
54
55    let parsed = zenkey::grammar::parse_full(base, wire_key)?;
56    if parsed.class != ClassOrPlane::Class(Class::State) {
57        return None;
58    }
59    // `alert/<alert_key>` and nothing else: the family's fixed prefix plus
60    // exactly its one population variable (RFC 04 §1.4).
61    let prefix = zenkey::CommonFamily::Alert.prefix();
62    let alert_key = match parsed.subject.as_slice() {
63        [head, key] if [*head] == prefix[..] && !key.is_empty() => *key,
64        _ => return None,
65    };
66    let origin = parsed.origin.chunk().to_string();
67    // A service origin has no producer chunk — the service *is* the
68    // producer, the same reading `token_identity` gives a liveliness token.
69    let producer = parsed
70        .producer()
71        .map(|p| p.chunk())
72        .unwrap_or_else(|| origin.trim_start_matches('@').to_string());
73    let alert_ref = zenkey::alert::alert_ref(&origin, &producer, alert_key).ok()?;
74
75    let state = match kind {
76        SampleKind::Put => AlertState::Firing,
77        SampleKind::Delete => AlertState::Resolved,
78    };
79    let mut out = AlertTransition {
80        origin,
81        producer,
82        alert_key: alert_key.to_string(),
83        alert_ref,
84        state,
85        severity: None,
86        rule: None,
87        labels: BTreeMap::new(),
88        summary: None,
89        timestamp: stamped.map(str::to_string),
90        at: at.to_string(),
91        rendering: RenderSource::KeyOnly,
92    };
93    // A tombstone carries no document, whatever bytes rode with it.
94    if state == AlertState::Resolved {
95        return Some(out);
96    }
97    if let Some((source, doc)) = decoded {
98        out.rendering = match source {
99            // "Decoded from nothing" is not a source; a value in hand was
100            // read from *somewhere*, and structural is the honest floor.
101            RenderSource::KeyOnly => RenderSource::Structural,
102            s => s,
103        };
104        let field = |name: &str| doc.get(name).and_then(|v| v.as_str()).map(str::to_string);
105        out.severity = field("severity");
106        out.rule = field("rule");
107        out.summary = field("summary").or_else(|| field("message"));
108        if let Some(labels) = doc.get("labels").and_then(|v| v.as_object()) {
109            for (k, v) in labels {
110                if k == "host" {
111                    continue;
112                }
113                // Non-string label values are rendered, not dropped — a
114                // numeric `port: 22` is still a discriminating label.
115                let v = match v {
116                    serde_json::Value::String(s) => s.clone(),
117                    other => other.to_string(),
118                };
119                out.labels.insert(k.clone(), v);
120            }
121        }
122    }
123    Some(out)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    const KEY: &str = "v1/h-3fa9c2d41b7e/state/netlink/alert/a659f813308ad1da";
131
132    /// A `put` with a document: firing, with severity, rule, labels and the
133    /// message lifted; `host` dropped; the ref minted off the key.
134    #[test]
135    fn a_put_is_firing_with_the_documents_fields_lifted() {
136        let doc = serde_json::json!({
137            "severity": "warning",
138            "rule": "link_down",
139            "labels": {"port": "eth0", "host": "h-3fa9c2d41b7e", "vlan": 7},
140            "message": "eth0 is down",
141        });
142        let t = alert_transition(
143            "",
144            KEY,
145            SampleKind::Put,
146            Some((RenderSource::Schema, &doc)),
147            Some("7/…"),
148            "t0",
149        )
150        .expect("an alert key");
151        assert_eq!(t.state, AlertState::Firing);
152        assert_eq!(t.origin, "h-3fa9c2d41b7e");
153        assert_eq!(t.producer, "netlink");
154        assert_eq!(t.alert_key, "a659f813308ad1da");
155        assert_eq!(t.alert_ref, "h-3fa9c2d41b7e.netlink.a659f813308ad1da");
156        assert_eq!(t.severity.as_deref(), Some("warning"));
157        assert_eq!(t.rule.as_deref(), Some("link_down"));
158        assert_eq!(t.summary.as_deref(), Some("eth0 is down"));
159        assert_eq!(t.timestamp.as_deref(), Some("7/…"));
160        assert_eq!(t.rendering, RenderSource::Schema);
161        assert_eq!(
162            t.labels,
163            BTreeMap::from([
164                ("port".to_string(), "eth0".to_string()),
165                ("vlan".to_string(), "7".to_string()),
166            ]),
167            "host is dropped, a numeric label is rendered"
168        );
169    }
170
171    /// A `delete` is resolved and carries no document fields, whatever was
172    /// handed in beside it — a tombstone has no body.
173    #[test]
174    fn a_delete_is_resolved_with_no_fields() {
175        let doc = serde_json::json!({"severity": "error"});
176        let t = alert_transition(
177            "",
178            KEY,
179            SampleKind::Delete,
180            Some((RenderSource::Structural, &doc)),
181            None,
182            "t1",
183        )
184        .expect("an alert key");
185        assert_eq!(t.state, AlertState::Resolved);
186        assert_eq!(t.severity, None);
187        assert!(t.labels.is_empty());
188        assert_eq!(t.rendering, RenderSource::KeyOnly);
189        assert_eq!(t.timestamp, None);
190    }
191
192    /// Under a base the key is stripped first; a key from another deployment,
193    /// a non-state class, or a state subject that is not `alert/<key>` is
194    /// `None`, never a transition.
195    #[test]
196    fn a_non_alert_key_is_none() {
197        let based = format!("zensight/{KEY}");
198        assert!(alert_transition("zensight", &based, SampleKind::Put, None, None, "t").is_some());
199        assert!(alert_transition("other", &based, SampleKind::Put, None, None, "t").is_none());
200        for key in [
201            "v1/h-3fa9c2d41b7e/state/netlink/health",
202            "v1/h-3fa9c2d41b7e/telemetry/netlink/alert/a659f813308ad1da",
203            "v1/h-3fa9c2d41b7e/state/netlink/alert/a659f813308ad1da/extra",
204            "not/a/key",
205        ] {
206            assert!(
207                alert_transition("", key, SampleKind::Put, None, None, "t").is_none(),
208                "{key}"
209            );
210        }
211        // A put with nothing decoded is still firing — key-only.
212        let t = alert_transition("", KEY, SampleKind::Put, None, None, "t").unwrap();
213        assert_eq!(t.state, AlertState::Firing);
214        assert_eq!(t.rendering, RenderSource::KeyOnly);
215    }
216}