use crate::ir::IrGenError;
use crate::ninja_gen::NinjaGenError;
use crate::recipe_shell::RecipeShell;
use anyhow::Result;
use metrics::{counter, describe_counter, describe_histogram, histogram};
use std::{sync::Once, time::Instant};
use tracing::{field, info};
use super::RunnerError;
pub const RECIPE_SHELL_RESOLUTIONS_TOTAL: &str = "netsuke_runner_recipe_shell_resolutions_total";
pub const BASH_PREFLIGHT_TOTAL: &str = "netsuke_runner_recipe_shell_bash_preflight_total";
pub const LEGACY_RECIPE_EXECUTIONS_TOTAL: &str = "netsuke_runner_legacy_recipe_executions_total";
pub const LEGACY_RECIPE_EXECUTION_DURATION: &str =
"netsuke_runner_legacy_recipe_execution_duration_seconds";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum LegacyRecipeOperation {
Build,
NinjaTool,
}
impl LegacyRecipeOperation {
const fn label(self) -> &'static str {
match self {
Self::Build => "build",
Self::NinjaTool => "ninja_tool",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum BashProbeOutcome {
Success,
NotFound,
LaunchFailed,
NonZeroExit,
}
impl BashProbeOutcome {
const fn label(self) -> &'static str {
match self {
Self::Success => "success",
Self::NotFound => "not_found",
Self::LaunchFailed => "launch_failed",
Self::NonZeroExit => "non_zero_exit",
}
}
}
pub(super) fn instrument_recipe_shell_resolution(
resolve: impl FnOnce() -> Result<RecipeShell>,
) -> Result<RecipeShell> {
describe_metrics();
let span = tracing::info_span!(
"runner.recipe_shell.resolve",
recipe_shell = field::Empty,
outcome = field::Empty,
error_category = field::Empty,
);
let _guard = span.enter();
let result = resolve();
let (shell, outcome, error_category) = result.as_ref().map_or_else(
|_| {
(
shell_label(RecipeShell::host_default()),
"error",
"invalid_selection",
)
},
|shell| (shell_label(*shell), "success", "none"),
);
span.record("recipe_shell", shell);
span.record("outcome", outcome);
span.record("error_category", error_category);
info!(
recipe_shell = shell,
outcome, error_category, "Resolved recipe shell"
);
counter!(
RECIPE_SHELL_RESOLUTIONS_TOTAL,
"recipe_shell" => shell,
"outcome" => outcome,
"error_category" => error_category,
)
.increment(1);
result
}
pub(super) fn instrument_bash_preflight<T>(
probe_outcome: BashProbeOutcome,
preflight: impl FnOnce() -> Result<T>,
) -> Result<T> {
let probe_outcome_label = probe_outcome.label();
describe_metrics();
let span = tracing::info_span!(
"runner.recipe_shell.bash_preflight",
recipe_shell = "bash",
outcome = field::Empty,
probe_outcome = probe_outcome_label,
);
let _guard = span.enter();
let result = preflight();
let outcome = if result.is_ok() { "success" } else { "error" };
span.record("outcome", outcome);
info!(
recipe_shell = "bash",
outcome,
probe_outcome = probe_outcome_label,
"Completed Bash preflight"
);
counter!(
BASH_PREFLIGHT_TOTAL,
"recipe_shell" => "bash",
"outcome" => outcome,
"probe_outcome" => probe_outcome_label,
)
.increment(1);
result
}
pub(super) fn instrument_legacy_recipe_operation<T>(
operation: LegacyRecipeOperation,
shell: RecipeShell,
execute: impl FnOnce() -> Result<T>,
) -> Result<T> {
describe_metrics();
let operation_label = operation.label();
let recipe_shell = shell_label(shell);
let span = tracing::info_span!(
"runner.legacy_recipe.operation",
operation = operation_label,
recipe_shell,
outcome = field::Empty,
failure_category = field::Empty,
);
let _guard = span.enter();
let started = Instant::now();
let result = execute();
let (outcome, failure_category) = match &result {
Ok(_) => ("success", "none"),
Err(error) => ("error", legacy_recipe_failure_category(error)),
};
span.record("outcome", outcome);
span.record("failure_category", failure_category);
info!(
operation = operation_label,
recipe_shell, outcome, failure_category, "Completed generated-recipe runner operation"
);
counter!(
LEGACY_RECIPE_EXECUTIONS_TOTAL,
"operation" => operation_label,
"recipe_shell" => recipe_shell,
"outcome" => outcome,
"failure_category" => failure_category,
)
.increment(1);
histogram!(
LEGACY_RECIPE_EXECUTION_DURATION,
"operation" => operation_label,
"recipe_shell" => recipe_shell,
"outcome" => outcome,
"failure_category" => failure_category,
)
.record(started.elapsed());
result
}
fn legacy_recipe_failure_category(error: &anyhow::Error) -> &'static str {
if error
.chain()
.any(|cause| cause.downcast_ref::<RunnerError>().is_some())
{
"manifest"
} else if error
.chain()
.any(|cause| cause.downcast_ref::<IrGenError>().is_some())
{
"graph"
} else if error
.chain()
.any(|cause| cause.downcast_ref::<NinjaGenError>().is_some())
{
"ninja_generation"
} else if error
.chain()
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some())
{
"ninja_io"
} else {
"other"
}
}
const fn shell_label(shell: RecipeShell) -> &'static str {
match shell {
RecipeShell::Posix => "posix",
RecipeShell::PowerShell => "powershell",
RecipeShell::Bash => "bash",
}
}
fn describe_metrics() {
static DESCRIBE: Once = Once::new();
DESCRIBE.call_once(|| {
describe_counter!(
RECIPE_SHELL_RESOLUTIONS_TOTAL,
"Counts legacy recipe-shell resolution outcomes by bounded labels."
);
describe_counter!(
BASH_PREFLIGHT_TOTAL,
"Counts explicit Bash compatibility preflight outcomes by bounded labels."
);
describe_counter!(
LEGACY_RECIPE_EXECUTIONS_TOTAL,
"Counts complete runner build and Ninja-tool operations by bounded labels."
);
describe_histogram!(
LEGACY_RECIPE_EXECUTION_DURATION,
"Measures complete runner build and Ninja-tool operation duration in seconds by bounded labels."
);
});
}
#[cfg(test)]
mod tests {
use super::{BASH_PREFLIGHT_TOTAL, RECIPE_SHELL_RESOLUTIONS_TOTAL, *};
use crate::test_tracing_capture::with_test_subscriber;
use anyhow::bail;
use metrics_util::{
CompositeKey, MetricKind,
debugging::{DebugValue, DebuggingRecorder},
};
use tracing_subscriber::filter::LevelFilter;
type Snapshot = Vec<(
CompositeKey,
Option<metrics::Unit>,
Option<metrics::SharedString>,
DebugValue,
)>;
fn record<T>(operation: impl FnOnce() -> T) -> (T, Snapshot, Vec<String>) {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let (result, events) = metrics::with_local_recorder(&recorder, || {
with_test_subscriber(LevelFilter::INFO, |captured| {
let result = operation();
(result, captured.snapshot())
})
});
(result, snapshotter.snapshot().into_vec(), events)
}
fn assert_counter(snapshot: &Snapshot, name: &str, labels: &[(&str, &str)]) {
assert!(
snapshot.iter().any(|(key, _, _, value)| {
let recorded = key
.key()
.labels()
.map(|label| (label.key(), label.value()))
.collect::<Vec<_>>();
key.kind() == MetricKind::Counter
&& key.key().name() == name
&& recorded.len() == labels.len()
&& labels.iter().all(|label| recorded.contains(label))
&& matches!(value, DebugValue::Counter(1))
}),
"expected {name} with bounded labels {labels:?}: {snapshot:?}"
);
}
#[test]
fn recipe_shell_resolution_records_bounded_success_telemetry() {
let (result, snapshot, events) =
record(|| instrument_recipe_shell_resolution(|| Ok(RecipeShell::PowerShell)));
assert_eq!(
result.expect("resolution should succeed"),
RecipeShell::PowerShell
);
assert_counter(
&snapshot,
RECIPE_SHELL_RESOLUTIONS_TOTAL,
&[
("recipe_shell", "powershell"),
("outcome", "success"),
("error_category", "none"),
],
);
assert!(events.iter().any(|event| {
event.contains("recipe_shell=\"powershell\"")
&& event.contains("outcome=\"success\"")
&& event.contains("error_category=\"none\"")
}));
}
#[test]
fn bash_preflight_records_bounded_failure_telemetry() {
let (result, snapshot, events) = record(|| {
instrument_bash_preflight(BashProbeOutcome::NotFound, || -> anyhow::Result<()> {
bail!("do not expose this process detail")
})
});
assert!(result.is_err(), "the injected preflight should fail");
assert_counter(
&snapshot,
BASH_PREFLIGHT_TOTAL,
&[
("recipe_shell", "bash"),
("outcome", "error"),
("probe_outcome", "not_found"),
],
);
assert!(events.iter().any(|event| {
event.contains("recipe_shell=\"bash\"")
&& event.contains("outcome=\"error\"")
&& event.contains("probe_outcome=\"not_found\"")
&& !event.contains("do not expose this process detail")
}));
}
#[path = "recipe_shell_telemetry_operation_tests.rs"]
mod legacy_recipe_operation_tests;
}