use std::{io, time::Duration};
use anyhow::Error;
use metrics::Unit;
use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshot};
use super::{FileOutcome, record_file, record_run, register_metrics};
use crate::driver::{ExitStatus, Mode};
const MODE_LABEL: &str = "mode";
const OUTCOME_LABEL: &str = "outcome";
const CATEGORY_LABEL: &str = "category";
const RUN_TOTAL: &str = "mdtablefix_run_total";
const FILE_TOTAL: &str = "mdtablefix_file_total";
const FILE_DURATION: &str = "mdtablefix_file_duration_seconds";
const FILE_ERROR_TOTAL: &str = "mdtablefix_file_error_total";
const MODES: [&str; 5] = ["print", "in_place", "check", "diff", "list_files"];
const FILE_OUTCOMES: [&str; 3] = ["changed", "unchanged", "error"];
const RUN_OUTCOMES: [&str; 3] = ["success", "drift", "error"];
const CATEGORIES: [&str; 4] = ["not_found", "permission_denied", "declined", "other"];
#[derive(Debug)]
enum Value {
Counter(u64),
Histogram(Vec<f64>),
}
#[derive(Debug)]
pub(super) struct Recorded {
name: String,
labels: Vec<(String, String)>,
unit: Option<Unit>,
description: Option<String>,
value: Value,
}
pub(super) fn recorded<T>(f: impl FnOnce() -> T) -> (T, Vec<Recorded>) {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let value = metrics::with_local_recorder(&recorder, || {
register_metrics();
f()
});
(value, metrics(snapshotter.snapshot()))
}
fn metrics(snapshot: Snapshot) -> Vec<Recorded> {
snapshot
.into_vec()
.into_iter()
.map(|(composite, unit, description, value)| {
let key = composite.key();
let labels = key
.labels()
.map(|label| (label.key().to_string(), label.value().to_string()))
.collect();
Recorded {
name: key.name().to_string(),
labels,
unit,
description: description.map(|text| text.to_string()),
value: match value {
DebugValue::Counter(count) => Value::Counter(count),
DebugValue::Histogram(samples) => {
Value::Histogram(samples.iter().map(|sample| sample.0).collect())
}
DebugValue::Gauge(gauge) => {
panic!("boundaries must not emit a gauge: {gauge:?}")
}
},
}
})
.collect()
}
fn find<'a>(recorded: &'a [Recorded], name: &str, labels: &[(&str, &str)]) -> Option<&'a Recorded> {
recorded
.iter()
.find(|metric| metric.name == name && same_labels(&metric.labels, labels))
}
fn count(recorded: &[Recorded], name: &str, labels: &[(&str, &str)]) -> u64 {
match find(recorded, name, labels) {
Some(Recorded {
value: Value::Counter(count),
..
}) => *count,
Some(other) => panic!("{name} must be a counter, found {other:?}"),
None => 0,
}
}
fn samples<'a>(recorded: &'a [Recorded], name: &str, labels: &[(&str, &str)]) -> &'a [f64] {
match find(recorded, name, labels) {
Some(Recorded {
value: Value::Histogram(samples),
..
}) => samples,
Some(other) => panic!("{name} must be a histogram, found {other:?}"),
None => &[],
}
}
fn same_labels(recorded: &[(String, String)], expected: &[(&str, &str)]) -> bool {
recorded.len() == expected.len()
&& expected.iter().all(|(key, value)| {
recorded.iter().any(|(recorded_key, recorded_value)| {
recorded_key == key && recorded_value == value
})
})
}
fn outcome_labels<'a>(mode: &'a str, outcome: &'a str) -> [(&'a str, &'a str); 2] {
[(MODE_LABEL, mode), (OUTCOME_LABEL, outcome)]
}
fn count_run(recorded: &[Recorded], mode: &str, outcome: &str) -> u64 {
count(recorded, RUN_TOTAL, &outcome_labels(mode, outcome))
}
pub(super) fn count_file(recorded: &[Recorded], mode: &str, outcome: &str) -> u64 {
count(recorded, FILE_TOTAL, &outcome_labels(mode, outcome))
}
pub(super) fn durations<'a>(recorded: &'a [Recorded], mode: &str, outcome: &str) -> &'a [f64] {
samples(recorded, FILE_DURATION, &outcome_labels(mode, outcome))
}
pub(super) fn count_error(recorded: &[Recorded], category: &str) -> u64 {
count(recorded, FILE_ERROR_TOTAL, &[(CATEGORY_LABEL, category)])
}
pub(super) fn assert_labels_are_bounded(recorded: &[Recorded]) {
for metric in recorded {
let allowed = |key: &str| -> &'static [&'static str] {
match (metric.name.as_str(), key) {
(RUN_TOTAL | FILE_TOTAL | FILE_DURATION | FILE_ERROR_TOTAL, MODE_LABEL) => &MODES,
(RUN_TOTAL, OUTCOME_LABEL) => &RUN_OUTCOMES,
(FILE_TOTAL | FILE_DURATION, OUTCOME_LABEL) => &FILE_OUTCOMES,
(FILE_ERROR_TOTAL, CATEGORY_LABEL) => &CATEGORIES,
(_, other) => panic!("{} must not label metrics with {other}", metric.name),
}
};
for (key, value) in &metric.labels {
assert!(
allowed(key).contains(&value.as_str()),
"{} recorded {key}={value}, which is outside the declared set",
metric.name
);
}
}
}
#[test]
fn a_run_is_counted_by_mode_and_outcome() {
let ((), recorded) = recorded(|| {
for mode in [
Mode::Print,
Mode::InPlace,
Mode::Check,
Mode::Diff,
Mode::ListFiles,
] {
for status in [ExitStatus::Success, ExitStatus::Drift, ExitStatus::Error] {
record_run(mode, status);
}
}
});
for mode in MODES {
for outcome in RUN_OUTCOMES {
assert_eq!(
count_run(&recorded, mode, outcome),
1,
"one run per mode and status must be counted: {mode}/{outcome}"
);
}
}
assert_labels_are_bounded(&recorded);
}
#[test]
fn a_failed_run_is_counted_once() {
for (mode, label) in [
(Mode::Print, "print"),
(Mode::Check, "check"),
(Mode::ListFiles, "list_files"),
] {
let (status, recorded) = recorded(|| crate::failed_run(mode));
assert_eq!(status, ExitStatus::Error);
assert_eq!(count_run(&recorded, label, "error"), 1, "{label}");
assert_eq!(
count_run(&recorded, label, "success") + count_run(&recorded, label, "drift"),
0,
"one failed run, one outcome: {label}"
);
assert_labels_are_bounded(&recorded);
}
}
#[test]
fn run_labels_take_the_declared_names() {
let ((), recorded) = recorded(|| {
record_run(Mode::InPlace, ExitStatus::Drift);
record_run(Mode::Diff, ExitStatus::Error);
});
assert_eq!(count_run(&recorded, "in_place", "drift"), 1);
assert_eq!(count_run(&recorded, "diff", "error"), 1);
assert_eq!(
count_run(&recorded, "InPlace", "Drift"),
0,
"a label is a fixed name rather than the variant's own spelling"
);
}
#[test]
fn every_metric_is_described() {
let ((), recorded) = recorded(|| {
record_run(Mode::Check, ExitStatus::Drift);
record_file(Mode::Check, &FileOutcome::Changed, Duration::from_millis(1));
record_file(
Mode::Check,
&FileOutcome::Failed(&Error::new(io::Error::new(
io::ErrorKind::NotFound,
"fixture",
))),
Duration::from_millis(1),
);
});
let mut names: Vec<&str> = recorded.iter().map(|metric| metric.name.as_str()).collect();
names.sort_unstable();
names.dedup();
assert_eq!(
names,
[FILE_DURATION, FILE_ERROR_TOTAL, FILE_TOTAL, RUN_TOTAL],
"the four boundaries emit exactly the declared metrics"
);
for metric in &recorded {
assert!(
metric
.description
.as_deref()
.is_some_and(|text| !text.is_empty()),
"{} must declare a description, found {:?}",
metric.name,
metric.description
);
}
let duration = recorded
.iter()
.find(|metric| metric.name == FILE_DURATION)
.expect("the duration histogram must be recorded");
assert_eq!(
duration.unit,
Some(Unit::Seconds),
"the duration histogram must declare its unit"
);
}