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}