use std::sync::Arc;
use std::sync::atomic::Ordering::Relaxed;
use std::time::Duration;
use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
use mira_proto::common::v1::{AnyValue, KeyValue, any_value};
use mira_proto::metrics::v1::{
AggregationTemporality, Gauge, Metric, NumberDataPoint, ResourceMetrics, ScopeMetrics, Sum,
metric, number_data_point,
};
use mira_proto::resource::v1::Resource;
use crate::pipeline;
const SCOPE: &str = "mira.self";
fn kv(k: &str, v: &str) -> KeyValue {
KeyValue {
key: k.into(),
value: Some(AnyValue {
value: Some(any_value::Value::StringValue(v.into())),
}),
}
}
fn sum(name: &str, unit: &str, description: &str, points: Vec<NumberDataPoint>) -> Metric {
Metric {
name: name.into(),
unit: unit.into(),
description: description.into(),
data: Some(metric::Data::Sum(Sum {
data_points: points,
aggregation_temporality: AggregationTemporality::Cumulative as i32,
is_monotonic: true,
})),
..Default::default()
}
}
fn gauge(name: &str, unit: &str, description: &str, points: Vec<NumberDataPoint>) -> Metric {
Metric {
name: name.into(),
unit: unit.into(),
description: description.into(),
data: Some(metric::Data::Gauge(Gauge {
data_points: points,
})),
..Default::default()
}
}
pub fn sample(
node: &str,
now: u64,
start: u64,
uptime_s: u64,
peak_rss: u64,
free_fraction: Option<f64>,
blocks: [Option<u64>; pipeline::SIGNALS.len()],
) -> ExportMetricsServiceRequest {
let point = |v: f64, attrs: Vec<KeyValue>| NumberDataPoint {
attributes: attrs,
start_time_unix_nano: start,
time_unix_nano: now,
value: Some(number_data_point::Value::AsDouble(v)),
..Default::default()
};
let queries = crate::QUERIES.load(Relaxed);
let mut metrics = vec![
gauge(
"mira.uptime",
"s",
"Seconds since this process started serving",
vec![point(uptime_s as f64, Vec::new())],
),
gauge(
"mira.process.memory.peak",
"By",
"Peak resident set, page cache included",
vec![point(peak_rss as f64, Vec::new())],
),
sum(
"mira.query.count",
"1",
"Reads answered since start",
vec![point(queries as f64, Vec::new())],
),
gauge(
"mira.query.duration.max",
"ms",
"Slowest read since start, queue time included",
vec![point(
crate::QUERY_MAX_NANOS.load(Relaxed) as f64 / 1e6,
Vec::new(),
)],
),
gauge(
"mira.query.duration.mean",
"ms",
"Mean read latency since start",
vec![point(
crate::QUERY_NANOS.load(Relaxed) as f64 / queries.max(1) as f64 / 1e6,
Vec::new(),
)],
),
];
if let Some(f) = free_fraction {
metrics.push(gauge(
"mira.storage.free",
"1",
"Free fraction of the filesystem holding the block directory",
vec![point(f, Vec::new())],
));
}
let by_signal = |f: &dyn Fn(&pipeline::Rejects) -> u64| {
pipeline::REJECTS
.iter()
.map(|r| point(f(r) as f64, vec![kv("signal", r.signal)]))
.collect::<Vec<_>>()
};
metrics.extend([
sum(
"mira.ingest.rows",
"1",
"Records written to blocks",
by_signal(&|r| r.rows.load(Relaxed)),
),
sum(
"mira.ingest.bytes",
"By",
"Bytes those records took on disk",
by_signal(&|r| r.bytes.load(Relaxed)),
),
sum(
"mira.ingest.blocks",
"1",
"Blocks published",
by_signal(&|r| r.published.load(Relaxed)),
),
sum(
"mira.ingest.shed",
"1",
"Exports refused with a 503 because the queue was full",
by_signal(&|r| r.shed.load(Relaxed)),
),
sum(
"mira.ingest.failed",
"1",
"Exports accepted and then NACKed because the write did not land",
by_signal(&|r| r.failed.load(Relaxed)),
),
sum(
"mira.ingest.refused",
"1",
"Exports refused permanently: the only counter that measures lost data",
by_signal(&|r| r.refused.load(Relaxed)),
),
gauge(
"mira.ingest.open_block.age",
"s",
"How long the currently open block has been open, 0 if none is",
by_signal(&|r| match r.open_since.load(Relaxed) {
0 => 0,
since => now / 1_000_000_000 - since.min(now / 1_000_000_000),
}),
),
]);
let counted: Vec<NumberDataPoint> = pipeline::SIGNALS
.iter()
.zip(blocks)
.filter_map(|(s, n)| n.map(|n| point(n as f64, vec![kv("signal", s)])))
.collect();
if !counted.is_empty() {
metrics.push(gauge(
"mira.storage.blocks",
"1",
"Blocks currently on disk",
counted,
));
}
ExportMetricsServiceRequest {
resource_metrics: vec![ResourceMetrics {
resource: Some(Resource {
attributes: vec![
kv("service.name", "mira"),
kv("service.instance.id", node),
kv("service.version", env!("CARGO_PKG_VERSION")),
],
..Default::default()
}),
scope_metrics: vec![ScopeMetrics {
scope: Some(mira_proto::common::v1::InstrumentationScope {
name: SCOPE.into(),
version: env!("CARGO_PKG_VERSION").into(),
..Default::default()
}),
metrics,
..Default::default()
}],
..Default::default()
}],
}
}
pub async fn run(
node: String,
data_dir: std::path::PathBuf,
interval: Duration,
metrics: pipeline::Ingest<ExportMetricsServiceRequest>,
) -> Option<()> {
let dir = Arc::new(data_dir);
let start = unix_nanos();
loop {
tokio::time::sleep(interval).await;
let d = Arc::clone(&dir);
let disk = tokio::task::spawn_blocking(move || {
(
mira_core::block::free_fraction(&d).ok(),
pipeline::SIGNALS
.map(|s| mira_core::block::scan(&d, s).ok().map(|b| b.len() as u64)),
)
})
.await
.ok()?;
let req = sample(
&node,
unix_nanos(),
start,
crate::START.elapsed().as_secs(),
crate::peak_rss(),
disk.0,
disk.1,
);
if let Err(crate::pipeline::Rejected::Closed) = metrics.submit(req).await {
return None;
}
}
}
fn unix_nanos() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_nanos() as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_point_carries_the_stream_start_so_a_restart_reads_as_a_reset() {
let r = sample("n1", 2_000, 1_000, 7, 4096, Some(0.5), [Some(1); 3]);
let ms = &r.resource_metrics[0].scope_metrics[0].metrics;
assert!(!ms.is_empty());
for m in ms {
let points = match &m.data {
Some(metric::Data::Sum(s)) => &s.data_points,
Some(metric::Data::Gauge(g)) => &g.data_points,
other => panic!("{}: unexpected data {other:?}", m.name),
};
assert!(!points.is_empty(), "{} has no points", m.name);
for p in points {
assert_eq!(p.start_time_unix_nano, 1_000, "{}", m.name);
assert_eq!(p.time_unix_nano, 2_000, "{}", m.name);
}
}
}
#[test]
fn a_measurement_that_failed_is_omitted_rather_than_reported_as_zero() {
let r = sample("n1", 2_000, 1_000, 7, 4096, None, [None; 3]);
let names: Vec<&str> = r.resource_metrics[0].scope_metrics[0]
.metrics
.iter()
.map(|m| m.name.as_str())
.collect();
assert!(!names.contains(&"mira.storage.free"), "{names:?}");
assert!(!names.contains(&"mira.storage.blocks"), "{names:?}");
assert!(names.contains(&"mira.ingest.rows"), "{names:?}");
}
#[test]
fn a_signal_is_an_attribute_rather_than_three_metric_names() {
let r = sample("n1", 2_000, 1_000, 7, 4096, Some(0.5), [Some(1); 3]);
let rows = r.resource_metrics[0].scope_metrics[0]
.metrics
.iter()
.find(|m| m.name == "mira.ingest.rows")
.expect("rows is reported");
let Some(metric::Data::Sum(s)) = &rows.data else {
panic!("rows is a sum")
};
assert!(s.is_monotonic);
let mut signals: Vec<&str> = s
.data_points
.iter()
.map(
|p| match p.attributes[0].value.as_ref().unwrap().value.as_ref() {
Some(any_value::Value::StringValue(v)) => v.as_str(),
other => panic!("signal is a string, got {other:?}"),
},
)
.collect();
signals.sort_unstable();
assert_eq!(signals, ["logs", "metrics", "traces"]);
}
#[test]
fn the_open_block_series_is_an_age_and_a_closed_block_is_zero() {
let now = 1_000 * 1_000_000_000;
pipeline::REJECTS[0].open_since.store(990, Relaxed);
let r = sample("n1", now, 0, 7, 4096, Some(0.5), [Some(1); 3]);
let m = r.resource_metrics[0].scope_metrics[0]
.metrics
.iter()
.find(|m| m.name == "mira.ingest.open_block.age")
.expect("the open-block age is reported");
let Some(metric::Data::Gauge(g)) = &m.data else {
panic!("age is a gauge")
};
let value = |i: usize| match g.data_points[i].value {
Some(number_data_point::Value::AsDouble(v)) => v,
other => panic!("{other:?}"),
};
assert_eq!(value(0), 10.0, "an open block reports how long it has been");
assert_eq!(value(1), 0.0, "nothing open is 0, not a negative age");
pipeline::REJECTS[0].open_since.store(0, Relaxed);
}
#[tokio::test(start_paused = true)]
async fn the_sampler_writes_one_export_per_interval_and_stops_when_the_pipe_closes() {
let dir = std::env::temp_dir().join(format!("mira-self-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (tx, mut rx) =
tokio::sync::mpsc::channel::<pipeline::Job<ExportMetricsServiceRequest>>(1);
let sampler = tokio::spawn(run(
"n1".into(),
dir.clone(),
Duration::from_secs(60),
pipeline::Ingest {
tx: [tx].into(),
turn: std::sync::Arc::default(),
rejects: &pipeline::REJECTS[1],
wal: None,
signal: mira_core::wal::Signal::Metrics,
},
));
let job = rx.recv().await.expect("one sample per interval");
drop(job);
assert_eq!(
sampler.await.unwrap(),
None,
"a closed pipeline ends the loop rather than spinning on it"
);
std::fs::remove_dir_all(&dir).ok();
}
}