1use std::sync::Arc;
32use std::sync::atomic::Ordering::Relaxed;
33use std::time::Duration;
34
35use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
36use mira_proto::common::v1::{AnyValue, KeyValue, any_value};
37use mira_proto::metrics::v1::{
38 AggregationTemporality, Gauge, Metric, NumberDataPoint, ResourceMetrics, ScopeMetrics, Sum,
39 metric, number_data_point,
40};
41use mira_proto::resource::v1::Resource;
42
43use crate::pipeline;
44
45const SCOPE: &str = "mira.self";
49
50fn kv(k: &str, v: &str) -> KeyValue {
51 KeyValue {
52 key: k.into(),
53 value: Some(AnyValue {
54 value: Some(any_value::Value::StringValue(v.into())),
55 }),
56 }
57}
58
59fn sum(name: &str, unit: &str, description: &str, points: Vec<NumberDataPoint>) -> Metric {
62 Metric {
63 name: name.into(),
64 unit: unit.into(),
65 description: description.into(),
66 data: Some(metric::Data::Sum(Sum {
67 data_points: points,
68 aggregation_temporality: AggregationTemporality::Cumulative as i32,
69 is_monotonic: true,
70 })),
71 ..Default::default()
72 }
73}
74
75fn gauge(name: &str, unit: &str, description: &str, points: Vec<NumberDataPoint>) -> Metric {
79 Metric {
80 name: name.into(),
81 unit: unit.into(),
82 description: description.into(),
83 data: Some(metric::Data::Gauge(Gauge {
84 data_points: points,
85 })),
86 ..Default::default()
87 }
88}
89
90pub fn sample(
99 node: &str,
100 now: u64,
101 start: u64,
102 uptime_s: u64,
103 peak_rss: u64,
104 free_fraction: Option<f64>,
105 blocks: [Option<u64>; pipeline::SIGNALS.len()],
106) -> ExportMetricsServiceRequest {
107 let point = |v: f64, attrs: Vec<KeyValue>| NumberDataPoint {
108 attributes: attrs,
109 start_time_unix_nano: start,
110 time_unix_nano: now,
111 value: Some(number_data_point::Value::AsDouble(v)),
112 ..Default::default()
113 };
114 let queries = crate::QUERIES.load(Relaxed);
115
116 let mut metrics = vec![
117 gauge(
118 "mira.uptime",
119 "s",
120 "Seconds since this process started serving",
121 vec![point(uptime_s as f64, Vec::new())],
122 ),
123 gauge(
124 "mira.process.memory.peak",
125 "By",
126 "Peak resident set, page cache included",
127 vec![point(peak_rss as f64, Vec::new())],
128 ),
129 sum(
130 "mira.query.count",
131 "1",
132 "Reads answered since start",
133 vec![point(queries as f64, Vec::new())],
134 ),
135 gauge(
136 "mira.query.duration.max",
137 "ms",
138 "Slowest read since start, queue time included",
139 vec![point(
140 crate::QUERY_MAX_NANOS.load(Relaxed) as f64 / 1e6,
141 Vec::new(),
142 )],
143 ),
144 gauge(
148 "mira.query.duration.mean",
149 "ms",
150 "Mean read latency since start",
151 vec![point(
152 crate::QUERY_NANOS.load(Relaxed) as f64 / queries.max(1) as f64 / 1e6,
153 Vec::new(),
154 )],
155 ),
156 ];
157 if let Some(f) = free_fraction {
158 metrics.push(gauge(
159 "mira.storage.free",
160 "1",
161 "Free fraction of the filesystem holding the block directory",
162 vec![point(f, Vec::new())],
163 ));
164 }
165
166 let by_signal = |f: &dyn Fn(&pipeline::Rejects) -> u64| {
171 pipeline::REJECTS
172 .iter()
173 .map(|r| point(f(r) as f64, vec![kv("signal", r.signal)]))
174 .collect::<Vec<_>>()
175 };
176 metrics.extend([
177 sum(
178 "mira.ingest.rows",
179 "1",
180 "Records written to blocks",
181 by_signal(&|r| r.rows.load(Relaxed)),
182 ),
183 sum(
184 "mira.ingest.bytes",
185 "By",
186 "Bytes those records took on disk",
187 by_signal(&|r| r.bytes.load(Relaxed)),
188 ),
189 sum(
190 "mira.ingest.blocks",
191 "1",
192 "Blocks published",
193 by_signal(&|r| r.published.load(Relaxed)),
194 ),
195 sum(
196 "mira.ingest.shed",
197 "1",
198 "Exports refused with a 503 because the queue was full",
199 by_signal(&|r| r.shed.load(Relaxed)),
200 ),
201 sum(
202 "mira.ingest.failed",
203 "1",
204 "Exports accepted and then NACKed because the write did not land",
205 by_signal(&|r| r.failed.load(Relaxed)),
206 ),
207 sum(
208 "mira.ingest.refused",
209 "1",
210 "Exports refused permanently: the only counter that measures lost data",
211 by_signal(&|r| r.refused.load(Relaxed)),
212 ),
213 gauge(
217 "mira.ingest.open_block.age",
218 "s",
219 "How long the currently open block has been open, 0 if none is",
220 by_signal(&|r| match r.open_since.load(Relaxed) {
221 0 => 0,
222 since => now / 1_000_000_000 - since.min(now / 1_000_000_000),
223 }),
224 ),
225 ]);
226
227 let counted: Vec<NumberDataPoint> = pipeline::SIGNALS
228 .iter()
229 .zip(blocks)
230 .filter_map(|(s, n)| n.map(|n| point(n as f64, vec![kv("signal", s)])))
231 .collect();
232 if !counted.is_empty() {
233 metrics.push(gauge(
234 "mira.storage.blocks",
235 "1",
236 "Blocks currently on disk",
237 counted,
238 ));
239 }
240
241 ExportMetricsServiceRequest {
242 resource_metrics: vec![ResourceMetrics {
243 resource: Some(Resource {
244 attributes: vec![
245 kv("service.name", "mira"),
246 kv("service.instance.id", node),
250 kv("service.version", env!("CARGO_PKG_VERSION")),
251 ],
252 ..Default::default()
253 }),
254 scope_metrics: vec![ScopeMetrics {
255 scope: Some(mira_proto::common::v1::InstrumentationScope {
256 name: SCOPE.into(),
257 version: env!("CARGO_PKG_VERSION").into(),
258 ..Default::default()
259 }),
260 metrics,
261 ..Default::default()
262 }],
263 ..Default::default()
264 }],
265 }
266}
267
268pub async fn run(
286 node: String,
287 data_dir: std::path::PathBuf,
288 interval: Duration,
289 metrics: pipeline::Ingest<ExportMetricsServiceRequest>,
290) -> Option<()> {
291 let dir = Arc::new(data_dir);
292 let start = unix_nanos();
293 loop {
294 tokio::time::sleep(interval).await;
295 let d = Arc::clone(&dir);
296 let disk = tokio::task::spawn_blocking(move || {
299 (
300 mira_core::block::free_fraction(&d).ok(),
301 pipeline::SIGNALS
302 .map(|s| mira_core::block::scan(&d, s).ok().map(|b| b.len() as u64)),
303 )
304 })
305 .await
306 .ok()?;
307 let req = sample(
308 &node,
309 unix_nanos(),
310 start,
311 crate::START.elapsed().as_secs(),
312 crate::peak_rss(),
313 disk.0,
314 disk.1,
315 );
316 if let Err(crate::pipeline::Rejected::Closed) = metrics.submit(req).await {
317 return None;
318 }
319 }
320}
321
322fn unix_nanos() -> u64 {
323 std::time::SystemTime::now()
324 .duration_since(std::time::UNIX_EPOCH)
325 .unwrap_or(Duration::ZERO)
326 .as_nanos() as u64
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
338 fn every_point_carries_the_stream_start_so_a_restart_reads_as_a_reset() {
339 let r = sample("n1", 2_000, 1_000, 7, 4096, Some(0.5), [Some(1); 3]);
340 let ms = &r.resource_metrics[0].scope_metrics[0].metrics;
341 assert!(!ms.is_empty());
342 for m in ms {
343 let points = match &m.data {
344 Some(metric::Data::Sum(s)) => &s.data_points,
345 Some(metric::Data::Gauge(g)) => &g.data_points,
346 other => panic!("{}: unexpected data {other:?}", m.name),
347 };
348 assert!(!points.is_empty(), "{} has no points", m.name);
349 for p in points {
350 assert_eq!(p.start_time_unix_nano, 1_000, "{}", m.name);
351 assert_eq!(p.time_unix_nano, 2_000, "{}", m.name);
352 }
353 }
354 }
355
356 #[test]
360 fn a_measurement_that_failed_is_omitted_rather_than_reported_as_zero() {
361 let r = sample("n1", 2_000, 1_000, 7, 4096, None, [None; 3]);
362 let names: Vec<&str> = r.resource_metrics[0].scope_metrics[0]
363 .metrics
364 .iter()
365 .map(|m| m.name.as_str())
366 .collect();
367 assert!(!names.contains(&"mira.storage.free"), "{names:?}");
368 assert!(!names.contains(&"mira.storage.blocks"), "{names:?}");
369 assert!(names.contains(&"mira.ingest.rows"), "{names:?}");
372 }
373
374 #[test]
378 fn a_signal_is_an_attribute_rather_than_three_metric_names() {
379 let r = sample("n1", 2_000, 1_000, 7, 4096, Some(0.5), [Some(1); 3]);
380 let rows = r.resource_metrics[0].scope_metrics[0]
381 .metrics
382 .iter()
383 .find(|m| m.name == "mira.ingest.rows")
384 .expect("rows is reported");
385 let Some(metric::Data::Sum(s)) = &rows.data else {
386 panic!("rows is a sum")
387 };
388 assert!(s.is_monotonic);
389 let mut signals: Vec<&str> = s
390 .data_points
391 .iter()
392 .map(
393 |p| match p.attributes[0].value.as_ref().unwrap().value.as_ref() {
394 Some(any_value::Value::StringValue(v)) => v.as_str(),
395 other => panic!("signal is a string, got {other:?}"),
396 },
397 )
398 .collect();
399 signals.sort_unstable();
400 assert_eq!(signals, ["logs", "metrics", "traces"]);
401 }
402
403 #[test]
407 fn the_open_block_series_is_an_age_and_a_closed_block_is_zero() {
408 let now = 1_000 * 1_000_000_000;
409 pipeline::REJECTS[0].open_since.store(990, Relaxed);
410 let r = sample("n1", now, 0, 7, 4096, Some(0.5), [Some(1); 3]);
411 let m = r.resource_metrics[0].scope_metrics[0]
412 .metrics
413 .iter()
414 .find(|m| m.name == "mira.ingest.open_block.age")
415 .expect("the open-block age is reported");
416 let Some(metric::Data::Gauge(g)) = &m.data else {
417 panic!("age is a gauge")
418 };
419 let value = |i: usize| match g.data_points[i].value {
420 Some(number_data_point::Value::AsDouble(v)) => v,
421 other => panic!("{other:?}"),
422 };
423 assert_eq!(value(0), 10.0, "an open block reports how long it has been");
424 assert_eq!(value(1), 0.0, "nothing open is 0, not a negative age");
425 pipeline::REJECTS[0].open_since.store(0, Relaxed);
426 }
427
428 #[tokio::test(start_paused = true)]
435 async fn the_sampler_writes_one_export_per_interval_and_stops_when_the_pipe_closes() {
436 let dir = std::env::temp_dir().join(format!("mira-self-{}", std::process::id()));
437 std::fs::create_dir_all(&dir).unwrap();
438 let (tx, mut rx) =
439 tokio::sync::mpsc::channel::<pipeline::Job<ExportMetricsServiceRequest>>(1);
440 let sampler = tokio::spawn(run(
441 "n1".into(),
442 dir.clone(),
443 Duration::from_secs(60),
444 pipeline::Ingest {
445 tx: [tx].into(),
446 turn: std::sync::Arc::default(),
447 rejects: &pipeline::REJECTS[1],
448 wal: None,
449 signal: mira_core::wal::Signal::Metrics,
450 },
451 ));
452
453 let job = rx.recv().await.expect("one sample per interval");
456 drop(job);
457 assert_eq!(
458 sampler.await.unwrap(),
459 None,
460 "a closed pipeline ends the loop rather than spinning on it"
461 );
462 std::fs::remove_dir_all(&dir).ok();
463 }
464}