use alloc::string::String;
use core::fmt::{self, Write as _};
#[cfg(feature = "metrics-build")]
pub mod build;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuildInfo {
pub version: &'static str,
pub git: &'static str,
pub dirty: &'static str,
pub lock: &'static str,
}
#[macro_export]
macro_rules! build_info {
() => {
$crate::metrics::BuildInfo {
version: env!("CARGO_PKG_VERSION"),
git: env!(
"KICKSTART_BUILD_GIT",
"call rpi_kickstart::metrics::build::emit() from build.rs"
),
dirty: env!(
"KICKSTART_BUILD_DIRTY",
"call rpi_kickstart::metrics::build::emit() from build.rs"
),
lock: env!(
"KICKSTART_BUILD_LOCK",
"call rpi_kickstart::metrics::build::emit() from build.rs"
),
}
};
}
pub const CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Counter,
Gauge,
}
impl Kind {
fn as_str(self) -> &'static str {
match self {
Kind::Counter => "counter",
Kind::Gauge => "gauge",
}
}
}
pub trait Value: sealed::Sealed {
fn write(&self, out: &mut String);
}
mod sealed {
pub trait Sealed {}
}
macro_rules! integer_values {
($($t:ty),*) => {$(
impl sealed::Sealed for $t {}
impl Value for $t {
fn write(&self, out: &mut String) {
let _ = write!(out, "{self}");
}
}
)*};
}
integer_values!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize);
impl sealed::Sealed for bool {}
impl Value for bool {
fn write(&self, out: &mut String) {
out.push(if *self { '1' } else { '0' });
}
}
pub type Labels<'a> = &'a [(&'a str, &'a dyn fmt::Display)];
#[derive(Debug, Default)]
pub struct Exposition {
text: String,
}
impl Exposition {
pub fn new() -> Self {
Exposition::default()
}
pub fn with_capacity(bytes: usize) -> Self {
Exposition {
text: String::with_capacity(bytes),
}
}
pub fn family(&mut self, name: &str, kind: Kind, help: &str) {
let _ = write!(self.text, "# HELP {name} ");
for c in help.chars() {
match c {
'\\' => self.text.push_str("\\\\"),
'\n' => self.text.push_str("\\n"),
c => self.text.push(c),
}
}
let _ = writeln!(self.text, "\n# TYPE {name} {}", kind.as_str());
}
pub fn sample(&mut self, name: &str, labels: Labels<'_>, value: impl Value) {
self.head(name, labels);
value.write(&mut self.text);
self.text.push('\n');
}
pub fn sample_fixed(&mut self, name: &str, labels: Labels<'_>, value: i64, decimals: u32) {
self.head(name, labels);
if value < 0 {
self.text.push('-');
}
let magnitude = value.unsigned_abs();
match 10u64.checked_pow(decimals) {
Some(scale) if decimals > 0 => {
let _ = writeln!(
self.text,
"{}.{:0width$}",
magnitude / scale,
magnitude % scale,
width = decimals as usize
);
}
Some(_) => {
let _ = writeln!(self.text, "{magnitude}");
}
None => {
let _ = writeln!(
self.text,
"0.{:0width$}",
magnitude,
width = decimals as usize
);
}
}
}
pub fn build_info(&mut self, prefix: &str, info: &BuildInfo) {
let name = alloc::format!("{prefix}_build_info");
self.family(
&name,
Kind::Gauge,
"The running firmware: its version, the commit it was built from, whether that tree had uncommitted changes, and a hash of its Cargo.lock, as labels on a constant 1.",
);
self.sample(
&name,
&[
("version", &info.version),
("git", &info.git),
("dirty", &info.dirty),
("lock", &info.lock),
],
1u8,
);
}
pub fn uptime(&mut self, prefix: &str) {
let name = alloc::format!("{prefix}_uptime_seconds");
self.family(&name, Kind::Counter, "Seconds since this board booted.");
self.sample(&name, &[], embassy_time::Instant::now().as_secs());
}
#[cfg(feature = "heap")]
pub fn heap(&mut self, prefix: &str) {
let usage = crate::heap::usage();
let used = alloc::format!("{prefix}_heap_used_bytes");
self.family(
&used,
Kind::Gauge,
"Heap bytes not available to allocate: live allocations and the allocator's own bookkeeping.",
);
self.sample(&used, &[], usage.used);
let free = alloc::format!("{prefix}_heap_free_bytes");
self.family(
&free,
Kind::Gauge,
"Heap bytes in free blocks. Falling over weeks and never recovering means something is never freed.",
);
self.sample(&free, &[], usage.free);
}
#[cfg(feature = "storage")]
pub fn card(&mut self, prefix: &str, counters: &resident_fat::counted::Counters) {
let counts = counters.now();
let commands = alloc::format!("{prefix}_card_commands_total");
self.family(
&commands,
Kind::Counter,
"Commands sent to the card since boot. A card charges per command, not per block.",
);
self.sample(&commands, &[("op", &"read")], counts.read_calls);
self.sample(&commands, &[("op", &"write")], counts.write_calls);
let blocks = alloc::format!("{prefix}_card_blocks_total");
self.family(
&blocks,
Kind::Counter,
"512-byte blocks moved to or from the card since boot.",
);
self.sample(&blocks, &[("op", &"read")], counts.read_blocks);
self.sample(&blocks, &[("op", &"write")], counts.write_blocks);
}
pub fn as_str(&self) -> &str {
&self.text
}
pub fn into_string(self) -> String {
self.text
}
#[cfg(feature = "web")]
pub fn into_body(self) -> crate::web::TextBody {
crate::web::TextBody {
body: self.text,
content_type: CONTENT_TYPE,
}
}
fn head(&mut self, name: &str, labels: Labels<'_>) {
self.text.push_str(name);
if !labels.is_empty() {
self.text.push('{');
for (index, (label, value)) in labels.iter().enumerate() {
if index > 0 {
self.text.push(',');
}
let _ = write!(self.text, "{label}=\"");
let _ = write!(Escaped(&mut self.text), "{value}");
self.text.push('"');
}
self.text.push('}');
}
self.text.push(' ');
}
}
struct Escaped<'a>(&'a mut String);
impl fmt::Write for Escaped<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
match c {
'\\' => self.0.push_str("\\\\"),
'"' => self.0.push_str("\\\""),
'\n' => self.0.push_str("\\n"),
c => self.0.push(c),
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_family_and_its_samples() {
let mut out = Exposition::new();
out.family("board_zone_wet", Kind::Gauge, "1 while a zone reads wet.");
out.sample("board_zone_wet", &[("zone", &1)], true);
out.sample("board_zone_wet", &[("zone", &2)], false);
out.family("board_joins_total", Kind::Counter, "Joins.");
out.sample("board_joins_total", &[], u64::MAX);
assert_eq!(
out.as_str(),
"# HELP board_zone_wet 1 while a zone reads wet.\n\
# TYPE board_zone_wet gauge\n\
board_zone_wet{zone=\"1\"} 1\n\
board_zone_wet{zone=\"2\"} 0\n\
# HELP board_joins_total Joins.\n\
# TYPE board_joins_total counter\n\
board_joins_total 18446744073709551615\n"
);
}
#[test]
fn label_values_are_escaped() {
let mut out = Exposition::new();
let name = "back\\slash \"quoted\"\nnext";
out.sample("board_zone_info", &[("zone", &3), ("name", &name)], 1u8);
assert_eq!(
out.as_str(),
"board_zone_info{zone=\"3\",name=\"back\\\\slash \\\"quoted\\\"\\nnext\"} 1\n"
);
}
#[cfg(feature = "storage")]
#[test]
fn card_counts_are_split_by_operation() {
use resident_fat::BlockDevice;
use resident_fat::counted::{Counted, Counters};
struct Nothing;
impl BlockDevice for Nothing {
type Error = ();
fn read(&mut self, _: u64, _: &mut [u8]) -> Result<(), ()> {
Ok(())
}
fn write(&mut self, _: u64, _: &[u8]) -> Result<(), ()> {
Ok(())
}
fn block_count(&mut self) -> Result<Option<u64>, ()> {
Ok(None)
}
}
let counters = Counters::new();
let mut card = Counted::new(Nothing, &counters);
card.read(0, &mut [0; 512 * 3]).unwrap();
card.write(0, &[0; 512 * 2]).unwrap();
card.write(8, &[0; 512]).unwrap();
let mut out = Exposition::new();
out.card("board", &counters);
let samples: alloc::vec::Vec<&str> = out
.as_str()
.lines()
.filter(|line| !line.starts_with('#'))
.collect();
assert_eq!(
samples,
[
"board_card_commands_total{op=\"read\"} 1",
"board_card_commands_total{op=\"write\"} 2",
"board_card_blocks_total{op=\"read\"} 3",
"board_card_blocks_total{op=\"write\"} 3",
]
);
assert!(
out.as_str()
.contains("# TYPE board_card_blocks_total counter\n")
);
}
#[test]
fn help_is_escaped() {
let mut out = Exposition::new();
out.family("x", Kind::Gauge, "a \\ b\nc");
assert_eq!(out.as_str(), "# HELP x a \\\\ b\\nc\n# TYPE x gauge\n");
}
#[test]
fn fixed_point_keeps_the_sign_between_minus_one_and_zero() {
let cases: &[(i64, u32, &str)] = &[
(223, 1, "22.3"),
(-5, 1, "-0.5"),
(-15, 1, "-1.5"),
(0, 1, "0.0"),
(1_234_567, 6, "1.234567"),
(7, 3, "0.007"),
(42, 0, "42"),
(-42, 0, "-42"),
(i64::MIN, 1, "-922337203685477580.8"),
(5, 20, "0.00000000000000000005"),
];
for &(value, decimals, text) in cases {
let mut out = Exposition::new();
out.sample_fixed("t", &[], value, decimals);
assert_eq!(
out.as_str(),
alloc::format!("t {text}\n"),
"{value} {decimals}"
);
}
}
#[test]
fn build_info_labels_the_build() {
let mut out = Exposition::new();
let info = BuildInfo {
version: "1.2.3",
git: "a1b2c3d",
dirty: "false",
lock: "9f8e7d6c",
};
out.build_info("board", &info);
assert!(
out.as_str().ends_with(
"# TYPE board_build_info gauge\n\
board_build_info{version=\"1.2.3\",git=\"a1b2c3d\",dirty=\"false\",lock=\"9f8e7d6c\"} 1\n"
),
"{}",
out.as_str()
);
}
}