Skip to main content

dynomite/stats/
snapshot.rs

1//! Snapshot type and JSON serialization for the stats subsystem.
2
3use std::fmt::{self, Write};
4
5use crate::stats::codec::{StatsMetricType, POOL_CODEC, SERVER_CODEC};
6use crate::stats::failure::FailureSnapshot;
7use crate::stats::histogram::Histogram;
8
9/// Engine-wide identifying strings included in every snapshot.
10///
11/// # Examples
12///
13/// ```
14/// use dynomite::stats::ServiceInfo;
15/// let info = ServiceInfo {
16///     source: "node-a".into(),
17///     version: "0.0.1".into(),
18///     rack: "r1".into(),
19///     dc: "dc1".into(),
20/// };
21/// assert_eq!(info.source, "node-a");
22/// ```
23#[derive(Clone, Debug, Default)]
24pub struct ServiceInfo {
25    /// Hostname or address of the local node.
26    pub source: String,
27    /// Engine version reported as `version` in the JSON.
28    pub version: String,
29    /// Logical rack of the local node.
30    pub rack: String,
31    /// Logical datacenter of the local node.
32    pub dc: String,
33}
34
35/// Pre-computed quantile summary derived from a [`Histogram`].
36///
37/// # Examples
38///
39/// ```
40/// use dynomite::stats::HistogramSummary;
41/// let s = HistogramSummary::default();
42/// assert_eq!(s.max, 0);
43/// ```
44#[derive(Clone, Copy, Debug, Default)]
45pub struct HistogramSummary {
46    /// Maximum observation in the window.
47    pub max: u64,
48    /// 99.9th percentile.
49    pub p999: u64,
50    /// 99th percentile.
51    pub p99: u64,
52    /// 95th percentile.
53    pub p95: u64,
54    /// Arithmetic mean of all observations.
55    pub mean: u64,
56}
57
58impl HistogramSummary {
59    /// Compute the standard quantile summary from a histogram.
60    ///
61    /// When the histogram is in overflow (a value larger than the
62    /// largest bucket offset has been recorded), the summary is
63    /// zeroed: percentiles are not published in that state.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use dynomite::stats::{Histogram, HistogramSummary};
69    /// let mut h = Histogram::new();
70    /// for v in 0..100 { h.record(v); }
71    /// let s = HistogramSummary::from_histogram(&h);
72    /// assert!(s.p99 >= s.p95);
73    /// ```
74    pub fn from_histogram(h: &Histogram) -> Self {
75        if h.is_overflowing() {
76            return Self::default();
77        }
78        let mean_f = h.mean();
79        let mean = if mean_f.is_finite() && mean_f > 0.0 {
80            // Round mean up to the nearest integer.
81            ceil_f64_to_u64(mean_f)
82        } else {
83            0
84        };
85        Self {
86            max: h.max(),
87            p999: h.percentile(0.999),
88            p99: h.percentile(0.99),
89            p95: h.percentile(0.95),
90            mean,
91        }
92    }
93}
94
95/// Computes `ceil(x)` for non-negative finite `f64` values without an
96/// `as` cast, returning `u64::MAX` on overflow.
97fn ceil_f64_to_u64(x: f64) -> u64 {
98    if !x.is_finite() || x <= 0.0 {
99        return 0;
100    }
101    let ceil = x.ceil();
102    let bits = ceil.to_bits();
103    let exp = u32::try_from((bits >> 52) & 0x7FF).expect("11-bit field");
104    let mant = bits & ((1u64 << 52) - 1);
105    if exp < 1023 {
106        // 0.0 < ceil < 1.0 cannot occur after ceil(); fall back safely.
107        return 1;
108    }
109    let unbiased = exp - 1023;
110    if unbiased >= 64 {
111        return u64::MAX;
112    }
113    let m = (1u64 << 52) | mant;
114    if unbiased >= 52 {
115        let shift = unbiased - 52;
116        m.checked_shl(shift).unwrap_or(u64::MAX)
117    } else {
118        m >> (52 - unbiased)
119    }
120}
121
122/// Per-pool collected metrics.
123///
124/// # Examples
125///
126/// ```
127/// use dynomite::stats::PoolStats;
128/// let pool = PoolStats::new("dyn_o_mite");
129/// assert_eq!(pool.name, "dyn_o_mite");
130/// assert!(!pool.metrics.is_empty());
131/// ```
132#[derive(Clone, Debug)]
133pub struct PoolStats {
134    /// Pool name as declared in the YAML configuration.
135    pub name: String,
136    /// Counter/gauge values, indexed by `PoolField::index()`.
137    pub metrics: Vec<i64>,
138}
139
140impl Default for PoolStats {
141    fn default() -> Self {
142        Self::new(String::new())
143    }
144}
145
146impl PoolStats {
147    /// Construct a fresh `PoolStats` with all metrics zeroed.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use dynomite::stats::PoolStats;
153    /// let p = PoolStats::new("dyn_o_mite");
154    /// assert!(p.metrics.iter().all(|&v| v == 0));
155    /// ```
156    pub fn new(name: impl Into<String>) -> Self {
157        Self {
158            name: name.into(),
159            metrics: vec![0; POOL_CODEC.len()],
160        }
161    }
162}
163
164/// Per-datastore-server collected metrics.
165///
166/// # Examples
167///
168/// ```
169/// use dynomite::stats::ServerStats;
170/// let s = ServerStats::new("redis_local");
171/// assert_eq!(s.name, "redis_local");
172/// ```
173#[derive(Clone, Debug)]
174pub struct ServerStats {
175    /// Server name (the host name from the YAML).
176    pub name: String,
177    /// Counter/gauge values, indexed by `ServerField::index()`.
178    pub metrics: Vec<i64>,
179}
180
181impl Default for ServerStats {
182    fn default() -> Self {
183        Self::new(String::new())
184    }
185}
186
187impl ServerStats {
188    /// Construct a fresh `ServerStats` with all metrics zeroed.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use dynomite::stats::ServerStats;
194    /// let s = ServerStats::new("redis_local");
195    /// assert!(s.metrics.iter().all(|&v| v == 0));
196    /// ```
197    pub fn new(name: impl Into<String>) -> Self {
198        Self {
199            name: name.into(),
200            metrics: vec![0; SERVER_CODEC.len()],
201        }
202    }
203}
204
205/// Per-peer collected metrics. Mirrors `ServerStats` for cluster peers.
206///
207/// # Examples
208///
209/// ```
210/// use dynomite::stats::PeerStats;
211/// let p = PeerStats::new("peer-a");
212/// assert_eq!(p.name, "peer-a");
213/// ```
214#[derive(Clone, Debug)]
215pub struct PeerStats {
216    /// Peer name.
217    pub name: String,
218    /// Counter/gauge values indexed by `ServerField::index()`.
219    pub metrics: Vec<i64>,
220}
221
222impl PeerStats {
223    /// Construct a fresh `PeerStats` with all metrics zeroed.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use dynomite::stats::PeerStats;
229    /// let p = PeerStats::new("peer-a");
230    /// assert!(p.metrics.iter().all(|&v| v == 0));
231    /// ```
232    pub fn new(name: impl Into<String>) -> Self {
233        Self {
234            name: name.into(),
235            metrics: vec![0; SERVER_CODEC.len()],
236        }
237    }
238}
239
240/// Aggregate snapshot of the stats subsystem at a point in time.
241///
242/// This is the value rendered by [`Snapshot::to_json`] and exposed
243/// through the REST endpoint. It is `Send + Sync` and cheap to clone.
244#[derive(Clone, Debug, Default)]
245pub struct Snapshot {
246    /// Static identification strings.
247    pub info: ServiceInfo,
248    /// Seconds since the engine started.
249    pub uptime: i64,
250    /// Wall-clock seconds since UNIX epoch.
251    pub timestamp: i64,
252    /// Latency histogram summary.
253    pub latency: HistogramSummary,
254    /// Payload size histogram summary.
255    pub payload_size: HistogramSummary,
256    /// Cross-region RTT histogram summary.
257    pub cross_region_latency: HistogramSummary,
258    /// Cross-zone latency histogram summary.
259    pub cross_zone_latency: HistogramSummary,
260    /// Per-server latency summary.
261    pub server_latency: HistogramSummary,
262    /// Cross-region queue wait time summary.
263    pub cross_region_queue_wait: HistogramSummary,
264    /// Cross-zone queue wait time summary.
265    pub cross_zone_queue_wait: HistogramSummary,
266    /// Server queue wait time summary.
267    pub server_queue_wait: HistogramSummary,
268    /// 99th percentile of the client outbound queue length.
269    pub client_out_queue_p99: u64,
270    /// 99th percentile of the server inbound queue length.
271    pub server_in_queue_p99: u64,
272    /// 99th percentile of the server outbound queue length.
273    pub server_out_queue_p99: u64,
274    /// 99th percentile of the dnode client outbound queue length.
275    pub dnode_client_out_queue_p99: u64,
276    /// 99th percentile of the local-DC peer inbound queue length.
277    pub peer_in_queue_p99: u64,
278    /// 99th percentile of the local-DC peer outbound queue length.
279    pub peer_out_queue_p99: u64,
280    /// 99th percentile of the remote-DC peer inbound queue length.
281    pub remote_peer_in_queue_p99: u64,
282    /// 99th percentile of the remote-DC peer outbound queue length.
283    pub remote_peer_out_queue_p99: u64,
284    /// Number of message structs allocated.
285    pub alloc_msgs: i64,
286    /// Number of message structs on the free list.
287    pub free_msgs: i64,
288    /// Number of mbuf chunks allocated.
289    pub alloc_mbufs: i64,
290    /// Number of mbuf chunks on the free list.
291    pub free_mbufs: i64,
292    /// Resident set size in bytes.
293    pub dyn_memory: i64,
294    /// Aggregated pool counters.
295    pub pool: PoolStats,
296    /// Aggregated server counters.
297    pub server: ServerStats,
298    /// Aggregated failure-cause metrics.
299    pub failure: FailureSnapshot,
300}
301
302impl Snapshot {
303    /// Serialize the snapshot to a JSON string.
304    ///
305    /// The layout is a single JSON object with flat top-level fields
306    /// followed by a nested pool object containing the per-pool metric
307    /// counters and a per-server sub-object.
308    ///
309    /// # Examples
310    ///
311    /// ```
312    /// use dynomite::stats::{Snapshot, PoolStats, ServerStats};
313    ///
314    /// let mut snap = Snapshot::default();
315    /// snap.pool = PoolStats::new("dyn_o_mite");
316    /// snap.server = ServerStats::new("redis_local");
317    /// let s = snap.to_json();
318    /// assert!(s.starts_with('{'));
319    /// assert!(s.contains("\"dyn_o_mite\""));
320    /// ```
321    pub fn to_json(&self) -> String {
322        let mut out = String::new();
323        self.write_json(&mut out)
324            .expect("writing to a String never fails");
325        out
326    }
327
328    /// Render the snapshot as JSON into any [`fmt::Write`] sink.
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// use dynomite::stats::Snapshot;
334    /// let snap = Snapshot::default();
335    /// let mut s = String::new();
336    /// snap.write_json(&mut s).expect("writing into String never fails");
337    /// assert!(s.starts_with('{'));
338    /// ```
339    pub fn write_json<W: Write>(&self, w: &mut W) -> fmt::Result {
340        w.write_char('{')?;
341        self.write_header(w)?;
342        self.write_pool(w)?;
343        w.write_char('}')?;
344        Ok(())
345    }
346
347    fn write_header<W: Write>(&self, w: &mut W) -> fmt::Result {
348        write_string(w, "service", "dynomite")?;
349        write_string(w, "source", &self.info.source)?;
350        write_string(w, "version", &self.info.version)?;
351        write_num(w, "uptime", self.uptime)?;
352        write_num(w, "timestamp", self.timestamp)?;
353        write_string(w, "rack", &self.info.rack)?;
354        write_string(w, "dc", &self.info.dc)?;
355
356        write_num_u64(w, "latency_max", self.latency.max)?;
357        write_num_u64(w, "latency_999th", self.latency.p999)?;
358        write_num_u64(w, "latency_99th", self.latency.p99)?;
359        write_num_u64(w, "latency_95th", self.latency.p95)?;
360        write_num_u64(w, "latency_mean", self.latency.mean)?;
361
362        write_num_u64(w, "payload_size_max", self.payload_size.max)?;
363        write_num_u64(w, "payload_size_999th", self.payload_size.p999)?;
364        write_num_u64(w, "payload_size_99th", self.payload_size.p99)?;
365        write_num_u64(w, "payload_size_95th", self.payload_size.p95)?;
366        write_num_u64(w, "payload_size_mean", self.payload_size.mean)?;
367
368        self.write_cross_region_latency(w)?;
369        self.write_queue_wait(w)?;
370        self.write_queue_p99s(w)?;
371        self.write_resource_usage(w)?;
372        Ok(())
373    }
374
375    fn write_cross_region_latency<W: Write>(&self, w: &mut W) -> fmt::Result {
376        write_num_u64(
377            w,
378            "average_cross_region_rtt",
379            self.cross_region_latency.mean,
380        )?;
381        write_num_u64(w, "99_cross_region_rtt", self.cross_region_latency.p99)?;
382        write_num_u64(
383            w,
384            "average_cross_zone_latency",
385            self.cross_zone_latency.mean,
386        )?;
387        write_num_u64(w, "99_cross_zone_latency", self.cross_zone_latency.p99)?;
388        write_num_u64(w, "average_server_latency", self.server_latency.mean)?;
389        write_num_u64(w, "99_server_latency", self.server_latency.p99)?;
390        Ok(())
391    }
392
393    fn write_queue_wait<W: Write>(&self, w: &mut W) -> fmt::Result {
394        write_num_u64(
395            w,
396            "average_cross_region_queue_wait",
397            self.cross_region_queue_wait.mean,
398        )?;
399        write_num_u64(
400            w,
401            "99_cross_region_queue_wait",
402            self.cross_region_queue_wait.p99,
403        )?;
404        write_num_u64(
405            w,
406            "average_cross_zone_queue_wait",
407            self.cross_zone_queue_wait.mean,
408        )?;
409        write_num_u64(
410            w,
411            "99_cross_zone_queue_wait",
412            self.cross_zone_queue_wait.p99,
413        )?;
414        write_num_u64(w, "average_server_queue_wait", self.server_queue_wait.mean)?;
415        write_num_u64(w, "99_server_queue_wait", self.server_queue_wait.p99)?;
416        Ok(())
417    }
418
419    fn write_queue_p99s<W: Write>(&self, w: &mut W) -> fmt::Result {
420        write_num_u64(w, "client_out_queue_99", self.client_out_queue_p99)?;
421        write_num_u64(w, "server_in_queue_99", self.server_in_queue_p99)?;
422        write_num_u64(w, "server_out_queue_99", self.server_out_queue_p99)?;
423        write_num_u64(
424            w,
425            "dnode_client_out_queue_99",
426            self.dnode_client_out_queue_p99,
427        )?;
428        write_num_u64(w, "peer_in_queue_99", self.peer_in_queue_p99)?;
429        write_num_u64(w, "peer_out_queue_99", self.peer_out_queue_p99)?;
430        write_num_u64(
431            w,
432            "remote_peer_out_queue_99",
433            self.remote_peer_out_queue_p99,
434        )?;
435        write_num_u64(w, "remote_peer_in_queue_99", self.remote_peer_in_queue_p99)?;
436        Ok(())
437    }
438
439    fn write_resource_usage<W: Write>(&self, w: &mut W) -> fmt::Result {
440        write_num(w, "alloc_msgs", self.alloc_msgs)?;
441        write_num(w, "free_msgs", self.free_msgs)?;
442        write_num(w, "alloc_mbufs", self.alloc_mbufs)?;
443        write_num(w, "free_mbufs", self.free_mbufs)?;
444        write_num(w, "dyn_memory", self.dyn_memory)?;
445        Ok(())
446    }
447
448    fn write_pool<W: Write>(&self, w: &mut W) -> fmt::Result {
449        write!(w, "\"{}\":{{", escape_str(&self.pool.name))?;
450        for (i, spec) in POOL_CODEC.iter().enumerate() {
451            if !is_visible_metric(spec.kind) {
452                continue;
453            }
454            let value = self.pool.metrics.get(i).copied().unwrap_or(0);
455            write_num(w, spec.name, value)?;
456        }
457        self.write_server(w)?;
458        w.write_str("}")?;
459        Ok(())
460    }
461
462    fn write_server<W: Write>(&self, w: &mut W) -> fmt::Result {
463        write!(w, "\"{}\":{{", escape_str(&self.server.name))?;
464        let server_visible: Vec<usize> = SERVER_CODEC
465            .iter()
466            .enumerate()
467            .filter(|(_, s)| is_visible_metric(s.kind))
468            .map(|(i, _)| i)
469            .collect();
470        for (j, idx) in server_visible.iter().copied().enumerate() {
471            let spec = &SERVER_CODEC[idx];
472            let value = self.server.metrics.get(idx).copied().unwrap_or(0);
473            if j + 1 == server_visible.len() {
474                write_num_no_comma(w, spec.name, value)?;
475            } else {
476                write_num(w, spec.name, value)?;
477            }
478        }
479        w.write_str("}")?;
480        Ok(())
481    }
482}
483
484/// Whether a metric kind appears in the JSON output. Counters, gauges,
485/// and timestamps are all rendered as numbers; invalid/string metric
486/// kinds are omitted entirely.
487fn is_visible_metric(kind: StatsMetricType) -> bool {
488    matches!(
489        kind,
490        StatsMetricType::Counter | StatsMetricType::Gauge | StatsMetricType::Timestamp
491    )
492}
493
494fn write_string<W: Write>(w: &mut W, key: &str, value: &str) -> fmt::Result {
495    write!(w, "\"{}\":\"{}\",", escape_str(key), escape_str(value))
496}
497
498fn write_num<W: Write>(w: &mut W, key: &str, value: i64) -> fmt::Result {
499    write!(w, "\"{}\":{value},", escape_str(key))
500}
501
502fn write_num_no_comma<W: Write>(w: &mut W, key: &str, value: i64) -> fmt::Result {
503    write!(w, "\"{}\":{value}", escape_str(key))
504}
505
506fn write_num_u64<W: Write>(w: &mut W, key: &str, value: u64) -> fmt::Result {
507    write!(w, "\"{}\":{value},", escape_str(key))
508}
509
510/// Minimal JSON string escaping. Backslashes, quotes, and control
511/// characters below 0x20 are escaped; everything else passes through.
512fn escape_str(s: &str) -> EscapedStr<'_> {
513    EscapedStr(s)
514}
515
516struct EscapedStr<'a>(&'a str);
517
518impl fmt::Display for EscapedStr<'_> {
519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520        for ch in self.0.chars() {
521            match ch {
522                '\\' => f.write_str("\\\\")?,
523                '"' => f.write_str("\\\"")?,
524                '\n' => f.write_str("\\n")?,
525                '\r' => f.write_str("\\r")?,
526                '\t' => f.write_str("\\t")?,
527                c if (c as u32) < 0x20 => write!(f, "\\u{:04x}", c as u32)?,
528                c => f.write_char(c)?,
529            }
530        }
531        Ok(())
532    }
533}
534
535/// Returns the human-readable description block printed by the `-D`
536/// command-line flag.
537///
538/// # Examples
539///
540/// ```
541/// let text = dynomite::stats::describe_stats();
542/// assert!(text.contains("pool stats:"));
543/// assert!(text.contains("server stats:"));
544/// ```
545pub fn describe_stats() -> String {
546    let mut out = String::new();
547    out.push_str("pool stats:\n");
548    for spec in POOL_CODEC {
549        let _ = writeln!(out, "  {:<20}\"{}\"", spec.name, spec.description);
550    }
551    out.push('\n');
552    out.push_str("server stats:\n");
553    for spec in SERVER_CODEC {
554        let _ = writeln!(out, "  {:<20}\"{}\"", spec.name, spec.description);
555    }
556    out
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn ceil_helper_matches_known_values() {
565        assert_eq!(ceil_f64_to_u64(0.0), 0);
566        assert_eq!(ceil_f64_to_u64(1.0), 1);
567        assert_eq!(ceil_f64_to_u64(1.5), 2);
568        assert_eq!(ceil_f64_to_u64(2.0), 2);
569        assert_eq!(ceil_f64_to_u64(99.99), 100);
570        assert_eq!(ceil_f64_to_u64(f64::NAN), 0);
571        assert_eq!(ceil_f64_to_u64(f64::INFINITY), 0);
572        assert_eq!(ceil_f64_to_u64(-1.0), 0);
573    }
574
575    #[test]
576    fn empty_snapshot_renders_to_valid_json_skeleton() {
577        let snap = Snapshot {
578            pool: PoolStats::new("dyn_o_mite"),
579            server: ServerStats::new("redis"),
580            ..Snapshot::default()
581        };
582        let s = snap.to_json();
583        assert!(s.starts_with('{'));
584        assert!(s.ends_with('}'));
585        assert!(s.contains("\"service\":\"dynomite\""));
586        assert!(s.contains("\"dyn_o_mite\":{"));
587        assert!(s.contains("\"redis\":{"));
588    }
589
590    #[test]
591    fn describe_lists_every_metric() {
592        let text = describe_stats();
593        for spec in POOL_CODEC {
594            assert!(
595                text.contains(spec.name),
596                "pool metric {} missing",
597                spec.name
598            );
599            assert!(text.contains(spec.description));
600        }
601        for spec in SERVER_CODEC {
602            assert!(
603                text.contains(spec.name),
604                "server metric {} missing",
605                spec.name
606            );
607        }
608    }
609
610    #[test]
611    fn escape_handles_quotes_and_controls() {
612        let s = EscapedStr("a\"b\nc").to_string();
613        assert_eq!(s, r#"a\"b\nc"#);
614    }
615}