Skip to main content

caboodle/
observability.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs,
4    path::Path,
5};
6
7use anyhow::{bail, Context, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11use crate::model::{Plan, ToolName};
12
13const CONTRACT_VERSION: u32 = 1;
14
15#[derive(Debug, Deserialize)]
16pub struct Targets {
17    pub targets: BTreeMap<String, String>,
18}
19
20#[derive(Debug, Serialize)]
21struct Contract<'a> {
22    contract_version: u32,
23    tool: &'a str,
24    metrics_path: Option<&'a str>,
25    required_metrics: &'a [&'a str],
26    unavailable_reason: Option<&'a str>,
27}
28
29fn contract(tool: ToolName) -> Contract<'static> {
30    match tool {
31        ToolName::Quipu => Contract {
32            contract_version: CONTRACT_VERSION,
33            tool: "quipu",
34            metrics_path: Some("/metrics"),
35            required_metrics: &["quipu_graph_facts", "quipu_http_requests_total"],
36            unavailable_reason: None,
37        },
38        ToolName::Bobbin => Contract {
39            contract_version: CONTRACT_VERSION,
40            tool: "bobbin",
41            metrics_path: Some("/metrics"),
42            required_metrics: &["bobbin_up", "bobbin_index_chunks_total"],
43            unavailable_reason: None,
44        },
45        ToolName::Camayoc => unavailable("camayoc", "no standalone metrics exporter"),
46        ToolName::Yupana => unavailable("yupana", "no metrics endpoint in the pinned release"),
47        ToolName::DesirePath => unavailable(
48            "desire-path",
49            "no metrics endpoint in the pinned source revision",
50        ),
51        ToolName::Member(name) => unavailable(name, "a stack member declares no metrics contract"),
52    }
53}
54
55fn unavailable(tool: &'static str, reason: &'static str) -> Contract<'static> {
56    Contract {
57        contract_version: CONTRACT_VERSION,
58        tool,
59        metrics_path: None,
60        required_metrics: &[],
61        unavailable_reason: Some(reason),
62    }
63}
64
65fn validate_target(tool: &str, target: &str) -> Result<()> {
66    if target.is_empty()
67        || target.contains('/')
68        || target.contains(char::is_whitespace)
69        || !target
70            .chars()
71            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ':' | '-' | '_' | '[' | ']'))
72    {
73        bail!("invalid Prometheus target for {tool}: {target}");
74    }
75    Ok(())
76}
77
78pub fn render(plan: &Plan, targets_path: &Path, output: &Path) -> Result<()> {
79    let targets: Targets = toml::from_str(
80        &fs::read_to_string(targets_path)
81            .with_context(|| format!("read observability targets {}", targets_path.display()))?,
82    )
83    .context("parse observability targets")?;
84    let contracts: Vec<_> = plan.tools.iter().copied().map(contract).collect();
85
86    for item in &contracts {
87        match item.metrics_path {
88            Some(_) => {
89                let target = targets
90                    .targets
91                    .get(item.tool)
92                    .with_context(|| format!("missing metrics target for {}", item.tool))?;
93                validate_target(item.tool, target)?;
94            }
95            None if targets.targets.contains_key(item.tool) => {
96                bail!(
97                    "{} has a target but its versioned contract has no exporter",
98                    item.tool
99                )
100            }
101            None => {}
102        }
103    }
104    for name in targets.targets.keys() {
105        if !contracts.iter().any(|item| item.tool == name) {
106            bail!("metrics target {name} is not selected by the reviewed plan");
107        }
108    }
109
110    fs::create_dir_all(output)?;
111    fs::write(
112        output.join("contracts.json"),
113        serde_json::to_vec_pretty(&json!({"schema_version": 1, "contracts": contracts}))?,
114    )?;
115
116    let observable: Vec<_> = contracts
117        .iter()
118        .filter(|item| item.metrics_path.is_some())
119        .collect();
120    let mut prometheus =
121        String::from("# Generated by Tambo; review before deployment.\nscrape_configs:\n");
122    let mut alerts = String::from("# Generated by Tambo; review before deployment.\ngroups:\n  - name: tambo-starter\n    rules:\n");
123    let mut panels = Vec::new();
124    for (index, item) in observable.iter().enumerate() {
125        let target = &targets.targets[item.tool];
126        prometheus.push_str(&format!(
127            "  - job_name: 'tambo-{}'\n    metrics_path: '{}'\n    static_configs:\n      - targets: ['{}']\n",
128            item.tool,
129            item.metrics_path.unwrap(),
130            target
131        ));
132        let metric = item.required_metrics[0];
133        alerts.push_str(&format!(
134            "      - alert: Tambo{}MetricsAbsent\n        expr: absent({})\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: '{} metrics contract is not observable'\n",
135            title(item.tool), metric, item.tool
136        ));
137        panels.push(json!({
138            "id": index + 1,
139            "title": format!("{} contract signal", title(item.tool)),
140            "type": "timeseries",
141            "targets": [{"expr": metric, "refId": "A"}]
142        }));
143    }
144    fs::write(output.join("prometheus.yml"), prometheus)?;
145    fs::write(output.join("alerts.yml"), alerts)?;
146    fs::write(
147        output.join("dashboard.json"),
148        serde_json::to_vec_pretty(&json!({
149            "schemaVersion": 39,
150            "title": "Tambo selected corpus",
151            "panels": panels
152        }))?,
153    )?;
154    validate(output)?;
155    Ok(())
156}
157
158pub fn validate(output: &Path) -> Result<()> {
159    let contracts: serde_json::Value =
160        serde_json::from_slice(&fs::read(output.join("contracts.json"))?)?;
161    let entries = contracts["contracts"]
162        .as_array()
163        .context("contracts.json omitted contracts array")?;
164    let mut metrics = BTreeSet::new();
165    let mut observable = 0;
166    for entry in entries {
167        if entry["contract_version"].as_u64() != Some(CONTRACT_VERSION.into()) {
168            bail!("unsupported or missing observability contract version");
169        }
170        if !entry["required_metrics"]
171            .as_array()
172            .context("contract omitted required_metrics")?
173            .is_empty()
174        {
175            observable += 1;
176        }
177        for metric in entry["required_metrics"].as_array().unwrap() {
178            metrics.insert(
179                metric
180                    .as_str()
181                    .context("contract metric is not a string")?
182                    .to_owned(),
183            );
184        }
185    }
186    let prometheus = fs::read_to_string(output.join("prometheus.yml"))?;
187    if prometheus.matches("job_name:").count() != observable {
188        bail!("scrape config does not cover every observable contract");
189    }
190    let alerts = fs::read_to_string(output.join("alerts.yml"))?;
191    for line in alerts
192        .lines()
193        .filter(|line| line.trim_start().starts_with("expr: absent("))
194    {
195        let metric = line
196            .trim()
197            .strip_prefix("expr: absent(")
198            .and_then(|value| value.strip_suffix(')'))
199            .context("malformed generated alert expression")?;
200        if !metrics.contains(metric) {
201            bail!("alert references uncovered metric {metric}");
202        }
203    }
204    let dashboard: serde_json::Value =
205        serde_json::from_slice(&fs::read(output.join("dashboard.json"))?)?;
206    for panel in dashboard["panels"]
207        .as_array()
208        .context("dashboard omitted panels")?
209    {
210        let metric = panel["targets"][0]["expr"]
211            .as_str()
212            .context("dashboard panel omitted expression")?;
213        if !metrics.contains(metric) {
214            bail!("dashboard references uncovered metric {metric}");
215        }
216    }
217    Ok(())
218}
219
220fn title(name: &str) -> String {
221    name.split(['-', '_'])
222        .map(|part| {
223            let mut chars = part.chars();
224            chars
225                .next()
226                .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
227                .unwrap_or_default()
228        })
229        .collect()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::model::Profile;
236
237    #[test]
238    fn render_is_deterministic_and_covers_every_selected_tool() {
239        let root = tempfile::tempdir().unwrap();
240        let targets = root.path().join("targets.toml");
241        fs::write(
242            &targets,
243            "[targets]\nquipu='127.0.0.1:8080'\nbobbin='127.0.0.1:3030'\n",
244        )
245        .unwrap();
246        let first = root.path().join("first");
247        let second = root.path().join("second");
248        let plan = Plan::for_profile(Profile::Everything);
249        render(&plan, &targets, &first).unwrap();
250        render(&plan, &targets, &second).unwrap();
251        for name in [
252            "contracts.json",
253            "prometheus.yml",
254            "alerts.yml",
255            "dashboard.json",
256        ] {
257            assert_eq!(
258                fs::read(first.join(name)).unwrap(),
259                fs::read(second.join(name)).unwrap()
260            );
261        }
262        let contracts = fs::read_to_string(first.join("contracts.json")).unwrap();
263        for tool in ["quipu", "camayoc", "bobbin", "yupana", "desire-path"] {
264            assert!(contracts.contains(tool));
265        }
266    }
267
268    #[test]
269    fn missing_or_malformed_contract_targets_fail() {
270        let root = tempfile::tempdir().unwrap();
271        let targets = root.path().join("targets.toml");
272        let plan = Plan::for_profile(Profile::Retrieval);
273        fs::write(&targets, "[targets]\nquipu='127.0.0.1:8080'\n").unwrap();
274        assert!(render(&plan, &targets, &root.path().join("missing")).is_err());
275        fs::write(
276            &targets,
277            "[targets]\nquipu='http://host'\nbobbin='127.0.0.1:3030'\n",
278        )
279        .unwrap();
280        assert!(render(&plan, &targets, &root.path().join("malformed")).is_err());
281    }
282
283    #[test]
284    fn uncovered_dashboard_metric_fails_validation() {
285        let root = tempfile::tempdir().unwrap();
286        let targets = root.path().join("targets.toml");
287        let output = root.path().join("output");
288        fs::write(
289            &targets,
290            "[targets]\nquipu='127.0.0.1:8080'\nbobbin='127.0.0.1:3030'\n",
291        )
292        .unwrap();
293        render(&Plan::for_profile(Profile::Retrieval), &targets, &output).unwrap();
294        let path = output.join("dashboard.json");
295        let mut dashboard: serde_json::Value =
296            serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
297        dashboard["panels"][0]["targets"][0]["expr"] = json!("invented_metric");
298        fs::write(&path, serde_json::to_vec_pretty(&dashboard).unwrap()).unwrap();
299        assert!(validate(&output).is_err());
300    }
301}