Skip to main content

mira/
telemetry.rs

1//! Mira storing Mira's telemetry, in Mira.
2//!
3//! Every counter here already existed and is already served, in one shape, by
4//! `/api/v1/stats`. What that endpoint cannot do is answer "when did the shed
5//! rate start climbing", because it is an instant and an instant has no
6//! yesterday. The gap between a node that knows everything about itself right
7//! now and a node that remembers is a time series database, and this process is
8//! one — so the whole feature is a timer, a translation into OTLP, and a call
9//! to the same [`pipeline::Ingest::submit`] a collector would have used.
10//!
11//! No exporter, no scrape endpoint, no second port. That is the point rather
12//! than a shortcut: the reason self-monitoring is normally somebody else's
13//! Prometheus is that the thing being monitored cannot be trusted to store its
14//! own data, and the reason it can be trusted here is that when Mira is too
15//! broken to store this, the operator's evidence is `/health` and the process
16//! exit code, which do not depend on it. A node that cannot write its own
17//! metrics is a node whose missing metrics *are* the signal.
18//!
19//! It is off by default. See [`crate::config::Config::self_telemetry`] for why that is the
20//! honest default rather than a timid one.
21//!
22//! ## The reflexive bit
23//!
24//! Storing these rows increments `mira.ingest.rows`, which is reported in the
25//! next sample. That is not a bug to be corrected out: the sampler's own cost is
26//! part of the node's cost, and subtracting it would make the number disagree
27//! with the block directory. At the default interval it is three-figures of rows
28//! an hour against a floor of millions, so it is visible in arithmetic and
29//! invisible on a chart.
30
31use 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
45/// The instrumentation scope every series below is published under, so a reader
46/// can separate what Mira says about itself from what was sent to it by a
47/// service that happens to also be called `mira`.
48const 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
59/// One cumulative counter: a value that only goes up for the life of the
60/// process, which is what `is_monotonic` promises a reader.
61fn 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
75/// One instantaneous reading. Not a `Sum` with `is_monotonic: false`, which is
76/// the other legal spelling: a gauge is what every consumer's default renderer
77/// expects for "the value right now", and being unusual here buys nothing.
78fn 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
90/// Everything this node currently knows about itself, as OTLP.
91///
92/// `start` is the process start in Unix nanoseconds and goes on every point:
93/// without it a cumulative counter has no way to say "this stream began here",
94/// and a consumer computing a rate across a restart reads the reset as a
95/// enormous negative delta. `blocks` is `None` for a signal whose directory
96/// would not list, and a point that cannot be measured is omitted rather than
97/// sent as zero — the same rule `/api/v1/stats` follows with `null`.
98pub 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        // Mean rather than a histogram because the source is a running total and
145        // a count, and inventing buckets from those two numbers would be
146        // inventing the distribution. The max above is what catches the tail.
147        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    // One series per counter with the signal as an attribute, rather than one
167    // metric per signal: `sum(mira.ingest.rows)` is then the node's total and
168    // `by signal` is the breakdown, which is the shape every query language
169    // already knows how to ask for.
170    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        // Age, not the timestamp: a chart of "seconds the open block has been
214        // open" has a ceiling an operator can reason about (`max_block_age`),
215        // and a chart of Unix seconds is a diagonal line.
216        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                    // The node name, so that replicas sharing a block directory
247                    // are separable series rather than one sawtooth. Same
248                    // reasoning as the block filename hashing it in.
249                    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
268/// Sample forever, until the metrics pipeline is gone.
269///
270/// "Gone" in practice means aborted: this task holds an `Ingest` clone, so the
271/// flusher cannot close the channel underneath it while it is alive. `serve_with`
272/// cancels it as the first step of a stop, and the `Closed` arm below is what
273/// covers the other order — a flusher that stopped on its own.
274///
275/// The first sample waits a whole interval rather than firing at zero. A sample
276/// taken during boot is all zeroes and a `free_fraction` read against a
277/// directory the flushers have not touched yet; it is not wrong, it is just the
278/// least informative point in the series and it is the one a chart's y-axis
279/// would scale to.
280///
281/// Errors are dropped on purpose. A shed self-sample means the node is busy
282/// storing real telemetry, which is the correct thing for it to be doing with a
283/// full queue, and a warning per interval about it would be this module making
284/// noise about its own unimportance.
285pub 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        // `scan` and `statfs` are filesystem work, and they go where filesystem
297        // work goes for the same reason `/api/v1/stats` sends them there.
298        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    /// Every point carries the process start, and a restart is therefore
334    /// readable as a reset rather than as a counter going backwards for no
335    /// reason. This is the whole value of `start_time_unix_nano` and it is one
336    /// field, so it is exactly the kind of thing that gets dropped in a refactor.
337    #[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    /// A reading the node could not take is an absent series, not a zero. A
357    /// filesystem that will not answer `statfs` and a filesystem that is full
358    /// are opposite operational facts, and a zero says the second one.
359    #[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        // And the counters that are always readable are still there, so the
370        // absence above is selective rather than the whole sample collapsing.
371        assert!(names.contains(&"mira.ingest.rows"), "{names:?}");
372    }
373
374    /// One series per counter, with `signal` as an attribute. The alternative —
375    /// `mira.ingest.rows.logs` and two siblings — cannot be summed to a node
376    /// total without the reader knowing all three names.
377    #[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    /// The open-block series is an age in seconds, and an age is bounded by
404    /// `max_block_age` where a Unix timestamp is a diagonal line no axis can
405    /// share with anything else.
406    #[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    /// The loop, driven through one interval against a queue the test owns.
429    ///
430    /// Paused time rather than a short interval: tokio advances the clock once
431    /// every task is parked, which is exactly the state "the sampler is waiting
432    /// for its next tick" is, so the whole interval costs nothing in wall clock
433    /// and the test cannot go flaky on a loaded machine.
434    #[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        // One tick, one export. Dropping the job drops its ack channel, which is
454        // what a shut-down flusher looks like from here.
455        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}