1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value, json};
4
5const LATENCY_MARGIN: f64 = 0.2;
6const ERROR_RATE_MARGIN: f64 = 0.01;
7
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Measurement {
10 pub p95_ms: f64,
11 pub error_rate: f64,
12}
13
14pub type Measurements = BTreeMap<String, Measurement>;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum Verdict {
18 New,
19 Held,
20 Lowered,
21 Regressed(Vec<String>),
22}
23
24#[derive(Clone, Debug, PartialEq)]
25pub struct RouteResult {
26 pub endpoint: String,
27 pub measured: Measurement,
28 pub baseline: Option<Measurement>,
29 pub verdict: Verdict,
30}
31
32#[derive(Clone, Debug, PartialEq)]
33pub struct Comparison {
34 pub routes: Vec<RouteResult>,
35 pub next_baseline: Measurements,
36}
37
38impl Comparison {
39 #[must_use]
40 pub fn regressed(&self) -> bool {
41 self.routes
42 .iter()
43 .any(|route| matches!(route.verdict, Verdict::Regressed(_)))
44 }
45}
46
47#[must_use]
50pub fn compare(baseline: &Measurements, measured: &Measurements) -> Comparison {
51 let mut routes = Vec::new();
52 let mut next_baseline = Measurements::new();
53 for (endpoint, &measured) in measured {
54 let previous = baseline.get(endpoint).copied();
55 let (verdict, ceiling) = previous.map_or((Verdict::New, measured), |previous| {
56 judge(previous, measured)
57 });
58 next_baseline.insert(endpoint.clone(), ceiling);
59 routes.push(RouteResult {
60 endpoint: endpoint.clone(),
61 measured,
62 baseline: previous,
63 verdict,
64 });
65 }
66 Comparison {
67 routes,
68 next_baseline,
69 }
70}
71
72fn judge(previous: Measurement, measured: Measurement) -> (Verdict, Measurement) {
73 let mut regressions = Vec::new();
74 if measured.p95_ms > previous.p95_ms * (1.0 + LATENCY_MARGIN) {
75 regressions.push(format!(
76 "p95 {:.2} ms is above {:.2} ms plus {:.0}%",
77 measured.p95_ms,
78 previous.p95_ms,
79 LATENCY_MARGIN * 100.0
80 ));
81 }
82 if measured.error_rate > previous.error_rate + ERROR_RATE_MARGIN {
83 regressions.push(format!(
84 "error rate {:.2}% is above {:.2}% plus {:.0} points",
85 measured.error_rate * 100.0,
86 previous.error_rate * 100.0,
87 ERROR_RATE_MARGIN * 100.0
88 ));
89 }
90 if !regressions.is_empty() {
91 return (Verdict::Regressed(regressions), previous);
92 }
93 let ceiling = Measurement {
94 p95_ms: previous.p95_ms.min(measured.p95_ms),
95 error_rate: previous.error_rate.min(measured.error_rate),
96 };
97 let verdict = if ceiling == previous {
98 Verdict::Held
99 } else {
100 Verdict::Lowered
101 };
102 (verdict, ceiling)
103}
104
105pub fn parse(text: &str) -> Result<Measurements, String> {
108 let document: Value = serde_json::from_str(text).map_err(|error| error.to_string())?;
109 let routes = document
110 .get("routes")
111 .and_then(Value::as_object)
112 .ok_or("no `routes` object")?;
113 routes
114 .iter()
115 .map(|(endpoint, values)| {
116 let number = |key: &str| {
117 values
118 .get(key)
119 .and_then(Value::as_f64)
120 .ok_or_else(|| format!("`{endpoint}` has no number `{key}`"))
121 };
122 let measurement = Measurement {
123 p95_ms: round(number("p95_ms")?, 100.0),
124 error_rate: round(number("error_rate")?, 10_000.0),
125 };
126 Ok((endpoint.clone(), measurement))
127 })
128 .collect()
129}
130
131#[must_use]
132pub fn render(measurements: &Measurements) -> String {
133 let routes: Map<String, Value> = measurements
134 .iter()
135 .map(|(endpoint, measurement)| {
136 let values =
137 json!({ "p95_ms": measurement.p95_ms, "error_rate": measurement.error_rate });
138 (endpoint.clone(), values)
139 })
140 .collect();
141 let text = serde_json::to_string_pretty(&json!({ "routes": routes }))
142 .expect("a map of numbers serializes");
143 format!("{text}\n")
144}
145
146fn round(value: f64, scale: f64) -> f64 {
147 (value * scale).round() / scale
148}
149
150#[cfg(test)]
151mod tests {
152 use super::{Measurement, Measurements, Verdict, compare, parse, render};
153
154 fn measurements(entries: &[(&str, f64, f64)]) -> Measurements {
155 entries
156 .iter()
157 .map(|&(endpoint, p95_ms, error_rate)| {
158 let measurement = Measurement { p95_ms, error_rate };
159 (endpoint.to_owned(), measurement)
160 })
161 .collect()
162 }
163
164 #[test]
165 fn a_regression_beyond_the_margin_fails_and_keeps_the_old_ceiling() {
166 let baseline = measurements(&[("list html", 10.0, 0.0)]);
167
168 let comparison = compare(&baseline, &measurements(&[("list html", 12.5, 0.0)]));
169
170 assert!(comparison.regressed());
171 assert!(
172 matches!(&comparison.routes[0].verdict, Verdict::Regressed(reasons) if reasons[0].contains("p95 12.50 ms"))
173 );
174 assert_eq!(comparison.next_baseline, baseline);
175 }
176
177 #[test]
178 fn an_error_rate_above_the_margin_is_a_regression() {
179 let baseline = measurements(&[("list json", 10.0, 0.0)]);
180
181 let comparison = compare(&baseline, &measurements(&[("list json", 9.0, 0.02)]));
182
183 assert!(comparison.regressed());
184 }
185
186 #[test]
187 fn noise_within_the_margin_holds_and_an_improvement_lowers_the_ceiling() {
188 let baseline = measurements(&[("list html", 10.0, 0.0), ("list json", 8.0, 0.01)]);
189
190 let comparison = compare(
191 &baseline,
192 &measurements(&[("list html", 11.5, 0.0), ("list json", 6.0, 0.0)]),
193 );
194
195 assert!(!comparison.regressed());
196 assert_eq!(comparison.routes[0].verdict, Verdict::Held);
197 assert_eq!(comparison.routes[1].verdict, Verdict::Lowered);
198 assert_eq!(
199 comparison.next_baseline,
200 measurements(&[("list html", 10.0, 0.0), ("list json", 6.0, 0.0)])
201 );
202 }
203
204 #[test]
205 fn a_new_endpoint_sets_its_ceiling_and_a_removed_one_drops_out() {
206 let baseline = measurements(&[("gone json", 3.0, 0.0)]);
207
208 let comparison = compare(&baseline, &measurements(&[("list json", 4.0, 0.0)]));
209
210 assert_eq!(comparison.routes[0].verdict, Verdict::New);
211 assert_eq!(
212 comparison.next_baseline,
213 measurements(&[("list json", 4.0, 0.0)])
214 );
215 }
216
217 #[test]
218 fn the_baseline_file_reads_back_what_it_wrote_rounded() {
219 let summary = r#"{"routes": {"list html": {"p95_ms": 12.3456, "error_rate": 0.000049}}}"#;
220
221 let read = parse(summary).unwrap();
222
223 assert_eq!(read, measurements(&[("list html", 12.35, 0.0)]));
224 assert_eq!(parse(&render(&read)).unwrap(), read);
225 }
226}