use super::DiscoveryOutcome;
use crate::cli::{DISCOVERY_DURATION, DISCOVERY_TOTAL};
use metrics::{counter, describe_counter, describe_histogram, histogram};
use monotony::MonotonicClock;
use ortho_config::OrthoError;
use std::sync::Once;
use std::time::Instant;
use tracing::field;
pub(super) fn describe_metrics() {
static DESCRIBE: Once = Once::new();
DESCRIBE.call_once(|| {
describe_counter!(
DISCOVERY_TOTAL,
"Counts configuration discovery passes by bounded outcome."
);
describe_histogram!(
DISCOVERY_DURATION,
"Measures configuration discovery duration in seconds."
);
});
}
const fn error_category(error: &OrthoError) -> &'static str {
match error {
OrthoError::File { .. } => "file",
OrthoError::Validation { .. } => "validation",
OrthoError::CyclicExtends { .. } => "cyclic_extends",
OrthoError::CliParsing(_) => "cli_parsing",
OrthoError::Gathering(_) => "gathering",
OrthoError::Merge { .. } => "merge",
OrthoError::Aggregate(_) => "aggregate",
_ => "other",
}
}
pub fn record_discovery_outcome<C: MonotonicClock>(
clock: &C,
started: Instant,
outcome: &DiscoveryOutcome,
) {
describe_metrics();
let span = tracing::trace_span!(
"collect_diag_file_layers",
outcome = field::Empty,
error_category = field::Empty,
);
let _guard = span.enter();
histogram!(DISCOVERY_DURATION).record(clock.now().duration_since(started));
if let Some(error) = outcome.first_error() {
let category = error_category(error);
span.record("outcome", "error");
span.record("error_category", category);
tracing::debug!(error_category = category, "configuration discovery failed");
counter!(DISCOVERY_TOTAL, "outcome" => "error").increment(1);
} else {
span.record("outcome", "success");
counter!(DISCOVERY_TOTAL, "outcome" => "success").increment(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::discovery::{DiscoveredLayers, DiscoveryDiagnostics, DiscoveryOutcome};
use crate::test_tracing_capture::with_test_subscriber;
use metrics_util::{
CompositeKey, MetricKind,
debugging::{DebugValue, DebuggingRecorder},
};
use std::sync::Arc;
use tracing_subscriber::filter::LevelFilter;
type Snapshot = Vec<(
CompositeKey,
Option<metrics::Unit>,
Option<metrics::SharedString>,
DebugValue,
)>;
fn assert_discovery_counter(snapshot: &Snapshot, expected_outcome: &str) {
let matches = snapshot
.iter()
.filter(|(key, _, _, value)| {
key.kind() == MetricKind::Counter
&& key.key().name() == DISCOVERY_TOTAL
&& key
.key()
.labels()
.any(|label| label.key() == "outcome" && label.value() == expected_outcome)
&& matches!(value, DebugValue::Counter(1))
})
.count();
assert_eq!(
matches, 1,
"discovery counter should record {expected_outcome:?} exactly once"
);
}
fn assert_discovery_duration(snapshot: &Snapshot) {
let matches = snapshot
.iter()
.filter(|(key, _, _, value)| {
key.kind() == MetricKind::Histogram
&& key.key().name() == DISCOVERY_DURATION
&& matches!(value, DebugValue::Histogram(samples) if samples.len() == 1)
})
.count();
assert_eq!(matches, 1, "duration histogram should record one sample");
}
fn assert_discovery_span(
fields: &[String],
expected_outcome: &str,
expected_category: Option<&str>,
) {
assert!(
fields
.iter()
.all(|field| field.starts_with("outcome=") || field.starts_with("error_category=")),
"discovery span must record only bounded fields: {fields:?}"
);
assert!(
fields.contains(&format!("outcome={expected_outcome:?}")),
"discovery span must record {expected_outcome:?}: {fields:?}"
);
if let Some(category) = expected_category {
assert!(
fields.contains(&format!("error_category={category:?}")),
"discovery span must record {category:?}: {fields:?}"
);
} else {
assert!(
fields
.iter()
.all(|field| !field.starts_with("error_category=")),
"successful discovery must not record an error category: {fields:?}"
);
}
}
fn snapshot_recorded_outcome(outcome: &DiscoveryOutcome) -> (Snapshot, Vec<String>) {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let clock = monotony::StdMonotonicClock;
let started = clock.now();
let fields = metrics::with_local_recorder(&recorder, || {
with_test_subscriber(LevelFilter::TRACE, |captured| {
record_discovery_outcome(&clock, started, outcome);
captured.discovery_span_fields()
})
});
(snapshotter.snapshot().into_vec(), fields)
}
fn outcome_without_error() -> DiscoveryOutcome {
let (layers, errors, diagnostics) = empty_parts();
DiscoveryOutcome {
layers: DiscoveredLayers {
layers,
json_preference: false,
errors,
diagnostics,
},
}
}
fn outcome_with_error() -> DiscoveryOutcome {
let error = Arc::new(ortho_config::OrthoError::File {
path: "/nonexistent/netsuke-telemetry.toml".into(),
source: Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"missing config",
)),
});
let (layers, _errors, diagnostics) = empty_parts();
DiscoveryOutcome {
layers: DiscoveredLayers {
layers,
json_preference: false,
errors: vec![error],
diagnostics,
},
}
}
fn empty_parts() -> (
Vec<ortho_config::MergeLayer<'static>>,
Vec<Arc<ortho_config::OrthoError>>,
DiscoveryDiagnostics,
) {
let resolution = super::super::ConfigPathResolution {
selector: "none",
path: None,
environment_lookups: Vec::new(),
};
let diagnostics = DiscoveryDiagnostics::new(
super::super::trace::DiscoveryTrace::new(
&resolution,
super::super::trace::FileLayerTrace::Automatic {
project_scope: None,
},
),
None,
);
(Vec::new(), Vec::new(), diagnostics)
}
#[test]
fn error_category_is_closed_and_bounded() {
let outcome = outcome_with_error();
let error = outcome.first_error().expect("error outcome has an error");
let category = error_category(error);
assert!(
matches!(
category,
"file"
| "validation"
| "cyclic_extends"
| "cli_parsing"
| "gathering"
| "merge"
| "aggregate"
| "other"
),
"category must be from the closed set, got {category}"
);
}
#[test]
fn record_discovery_outcome_records_success_metrics_and_span() {
let outcome = outcome_without_error();
let (snapshot, fields) = snapshot_recorded_outcome(&outcome);
assert_discovery_counter(&snapshot, "success");
assert_discovery_duration(&snapshot);
assert_discovery_span(&fields, "success", None);
}
#[test]
fn record_discovery_outcome_records_file_error_metrics_and_span() {
let outcome = outcome_with_error();
let (snapshot, fields) = snapshot_recorded_outcome(&outcome);
assert_discovery_counter(&snapshot, "error");
assert_discovery_duration(&snapshot);
assert_discovery_span(&fields, "error", Some("file"));
}
}