diff --git a/sentry-core/src/cadence.rs b/sentry-core/src/cadence.rs index 691026fd..754469db 100644 --- a/sentry-core/src/cadence.rs +++ b/sentry-core/src/cadence.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use cadence::MetricSink; +use crate::metrics::{Metric, MetricType, MetricValue}; +use crate::units::MetricUnit; use crate::{Client, Hub}; /// A [`cadence`] compatible [`MetricSink`]. @@ -28,9 +30,12 @@ impl MetricSink for SentryMetricSink where S: MetricSink, { - fn emit(&self, metric: &str) -> std::io::Result { - self.client.add_metric(metric); - self.sink.emit(metric) + fn emit(&self, string: &str) -> std::io::Result { + if let Some(metric) = parse_metric(string) { + self.client.add_metric(metric); + } + + self.sink.emit(string) } fn flush(&self) -> std::io::Result<()> { @@ -45,6 +50,41 @@ where } } +fn parse_metric(string: &str) -> Option { + let mut components = string.split('|'); + + let (mri_str, value_str) = components.next()?.split_once(':')?; + let (name, unit) = match mri_str.split_once('@') { + Some((name, unit_str)) => (name, unit_str.parse().ok()?), + None => (mri_str, MetricUnit::None), + }; + + let ty = components.next().and_then(|s| s.parse().ok())?; + let value = match ty { + MetricType::Counter => MetricValue::Counter(value_str.parse().ok()?), + MetricType::Distribution => MetricValue::Distribution(value_str.parse().ok()?), + MetricType::Set => MetricValue::Set(value_str.parse().ok()?), + MetricType::Gauge => MetricValue::Gauge(value_str.parse().ok()?), + }; + + let mut builder = Metric::build(name.to_owned(), value).with_unit(unit); + + for component in components { + if let Some('#') = component.chars().next() { + for pair in string.get(1..)?.split(',') { + let mut key_value = pair.splitn(2, ':'); + + let key = key_value.next()?.to_owned(); + let value = key_value.next().unwrap_or_default().to_owned(); + + builder = builder.with_tag(key, value); + } + } + } + + Some(builder.finish()) +} + #[cfg(test)] mod tests { use cadence::{Counted, Distributed}; diff --git a/sentry-core/src/client.rs b/sentry-core/src/client.rs index 8c674b01..a67295f4 100644 --- a/sentry-core/src/client.rs +++ b/sentry-core/src/client.rs @@ -12,7 +12,7 @@ use sentry_types::random_uuid; use crate::constants::SDK_INFO; #[cfg(feature = "UNSTABLE_metrics")] -use crate::metrics::MetricAggregator; +use crate::metrics::{self, MetricAggregator}; use crate::protocol::{ClientSdkInfo, Event}; use crate::session::SessionFlusher; use crate::types::{Dsn, Uuid}; @@ -323,7 +323,7 @@ impl Client { } #[cfg(feature = "UNSTABLE_metrics")] - pub(crate) fn add_metric(&self, metric: &str) { + pub fn add_metric(&self, metric: metrics::Metric) { if let Some(ref aggregator) = *self.metric_aggregator.read().unwrap() { aggregator.add(metric) } diff --git a/sentry-core/src/lib.rs b/sentry-core/src/lib.rs index a1a1afdb..5b1f5cd4 100644 --- a/sentry-core/src/lib.rs +++ b/sentry-core/src/lib.rs @@ -147,6 +147,8 @@ mod hub_impl; mod metrics; #[cfg(feature = "client")] mod session; +#[cfg(all(feature = "client", feature = "UNSTABLE_metrics"))] +mod units; #[cfg(all(feature = "client", feature = "UNSTABLE_cadence"))] pub use crate::cadence::SentryMetricSink; #[cfg(feature = "client")] diff --git a/sentry-core/src/metrics.rs b/sentry-core/src/metrics.rs index cad640ba..822a2fc7 100644 --- a/sentry-core/src/metrics.rs +++ b/sentry-core/src/metrics.rs @@ -1,7 +1,8 @@ -use std::collections::btree_map::Entry; -use std::collections::{BTreeMap, BTreeSet}; +use std::borrow::Cow; +use std::collections::hash_map::{DefaultHasher, Entry}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; -use std::sync::mpsc; +use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -9,11 +10,15 @@ use sentry_types::protocol::latest::{Envelope, EnvelopeItem}; use crate::client::TransportArc; +use crate::units::DurationUnit; +pub use crate::units::MetricUnit; + const BUCKET_INTERVAL: Duration = Duration::from_secs(10); -const FLUSH_INTERVAL: Duration = Duration::from_secs(10); +const FLUSH_INTERVAL: Duration = Duration::from_secs(5); +const MAX_WEIGHT: usize = 100_000; -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum MetricType { +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum MetricType { Counter, Distribution, Set, @@ -52,253 +57,474 @@ impl std::str::FromStr for MetricType { } } -struct GaugeValue { - last: f64, - min: f64, - max: f64, - sum: f64, - count: u64, -} - -enum BucketValue { - Counter(f64), - Distribution(Vec), - Set(BTreeSet), - Gauge(GaugeValue), +/// Type used for Counter metric +pub type CounterType = f64; + +/// Type of distribution entries +pub type DistributionType = f64; + +/// Type used for set elements in Set metric +pub type SetType = u32; + +/// Type used for Gauge entries +pub type GaugeType = f64; + +/// A snapshot of values. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GaugeValue { + /// The last value reported in the bucket. + /// + /// This aggregation is not commutative. + pub last: GaugeType, + /// The minimum value reported in the bucket. + pub min: GaugeType, + /// The maximum value reported in the bucket. + pub max: GaugeType, + /// The sum of all values reported in the bucket. + pub sum: GaugeType, + /// The number of times this bucket was updated with a new value. + pub count: u64, } -impl BucketValue { - fn distribution(val: f64) -> BucketValue { - Self::Distribution(vec![val]) - } - - fn gauge(val: f64) -> BucketValue { - Self::Gauge(GaugeValue { - last: val, - min: val, - max: val, - sum: val, +impl GaugeValue { + /// Creates a gauge snapshot from a single value. + pub fn single(value: GaugeType) -> Self { + Self { + last: value, + min: value, + max: value, + sum: value, count: 1, - }) + } } - fn set_from_str(value: &str) -> BucketValue { - BucketValue::Set([value.into()].into()) + /// Inserts a new value into the gauge. + pub fn insert(&mut self, value: GaugeType) { + self.last = value; + self.min = self.min.min(value); + self.max = self.max.max(value); + self.sum += value; + self.count += 1; } +} - fn merge(&mut self, other: BucketValue) -> Result<(), ()> { - match (self, other) { - (BucketValue::Counter(c1), BucketValue::Counter(c2)) => { +enum BucketValue { + Counter(CounterType), + Distribution(Vec), + Set(BTreeSet), + Gauge(GaugeValue), +} + +impl BucketValue { + pub fn insert(&mut self, value: MetricValue) -> usize { + match (self, value) { + (Self::Counter(c1), MetricValue::Counter(c2)) => { *c1 += c2; + 0 } - (BucketValue::Distribution(d1), BucketValue::Distribution(d2)) => { - d1.extend(d2); + (Self::Distribution(d1), MetricValue::Distribution(d2)) => { + d1.push(d2); + 1 } - (BucketValue::Set(s1), BucketValue::Set(s2)) => { - s1.extend(s2); + (Self::Set(s1), MetricValue::Set(s2)) => { + if s1.insert(s2) { + 1 + } else { + 0 + } } - (BucketValue::Gauge(g1), BucketValue::Gauge(g2)) => { - g1.last = g2.last; - g1.min = g1.min.min(g2.min); - g1.max = g1.max.max(g2.max); - g1.sum += g2.sum; - g1.count += g2.count; + (Self::Gauge(g1), MetricValue::Gauge(g2)) => { + g1.insert(g2); + 0 } - _ => return Err(()), + _ => panic!("invalid metric type"), } + } - Ok(()) + pub fn weight(&self) -> usize { + match self { + BucketValue::Counter(_) => 1, + BucketValue::Distribution(v) => v.len(), + BucketValue::Set(v) => v.len(), + BucketValue::Gauge(_) => 5, + } } } -#[derive(PartialEq, Eq, PartialOrd, Ord)] -struct BucketKey { - timestamp: u64, - ty: MetricType, - name: String, - tags: String, +impl From for BucketValue { + fn from(value: MetricValue) -> Self { + match value { + MetricValue::Counter(v) => Self::Counter(v), + MetricValue::Distribution(v) => Self::Distribution(vec![v]), + MetricValue::Gauge(v) => Self::Gauge(GaugeValue::single(v)), + MetricValue::Set(v) => Self::Set(std::iter::once(v).collect()), + } + } } -type AggregateMetrics = BTreeMap; +pub type MetricStr = Cow<'static, str>; + +type Timestamp = u64; -enum Task { - RecordMetric((BucketKey, BucketValue)), - Flush, - Shutdown, +#[derive(PartialEq, Eq, Hash)] +struct BucketKey { + timestamp: Timestamp, + ty: MetricType, + name: MetricStr, + unit: MetricUnit, + tags: BTreeMap, } -pub struct MetricAggregator { - sender: mpsc::SyncSender, - handle: Option>, +#[derive(Debug)] +pub enum MetricValue { + Counter(CounterType), + Distribution(DistributionType), + Gauge(GaugeType), + Set(SetType), } -impl MetricAggregator { - pub fn new(transport: TransportArc) -> Self { - let (sender, receiver) = mpsc::sync_channel(30); - let handle = thread::Builder::new() - .name("sentry-metrics".into()) - .spawn(move || Self::worker_thread(receiver, transport)) - .ok(); +impl MetricValue { + /// Returns a bucket value representing a set with a single given string value. + pub fn set_from_str(string: &str) -> Self { + Self::Set(hash_set_value(string)) + } - Self { sender, handle } + /// Returns a bucket value representing a set with a single given value. + pub fn set_from_display(display: impl fmt::Display) -> Self { + Self::Set(hash_set_value(&display.to_string())) } - pub fn add(&self, metric: &str) { - fn mk_value(ty: MetricType, value: &str) -> Option { - Some(match ty { - MetricType::Counter => BucketValue::Counter(value.parse().ok()?), - MetricType::Distribution => BucketValue::distribution(value.parse().ok()?), - MetricType::Set => BucketValue::set_from_str(value), - MetricType::Gauge => BucketValue::gauge(value.parse().ok()?), - }) + fn ty(&self) -> MetricType { + match self { + Self::Counter(_) => MetricType::Counter, + Self::Distribution(_) => MetricType::Distribution, + Self::Gauge(_) => MetricType::Gauge, + Self::Set(_) => MetricType::Set, } + } +} - fn parse(metric: &str) -> Option<(BucketKey, BucketValue)> { - let mut components = metric.split('|'); - let mut values = components.next()?.split(':'); - let name = values.next()?; +/// Hashes the given set value. +/// +/// Sets only guarantee 32-bit accuracy, but arbitrary strings are allowed on the protocol. Upon +/// parsing, they are hashed and only used as hashes subsequently. +fn hash_set_value(string: &str) -> u32 { + use std::hash::Hasher; + let mut hasher = DefaultHasher::default(); + hasher.write(string.as_bytes()); + hasher.finish() as u32 +} - let ty: MetricType = components.next().and_then(|s| s.parse().ok())?; - let mut value = mk_value(ty, values.next()?)?; +type BucketMap = BTreeMap>; - for value_s in values { - value.merge(mk_value(ty, value_s)?).ok()?; - } +struct AggregatorInner { + buckets: BucketMap, + weight: usize, + running: bool, + force_flush: bool, +} + +impl AggregatorInner { + pub fn new() -> Self { + Self { + buckets: BTreeMap::new(), + weight: 0, + running: true, + force_flush: false, + } + } - let mut tags = ""; - let mut timestamp = SystemTime::now() + pub fn add(&mut self, mut key: BucketKey, value: MetricValue) { + // Floor timestamp to bucket interval + key.timestamp /= BUCKET_INTERVAL.as_secs(); + key.timestamp *= BUCKET_INTERVAL.as_secs(); + + match self.buckets.entry(key.timestamp).or_default().entry(key) { + Entry::Occupied(mut e) => self.weight += e.get_mut().insert(value), + Entry::Vacant(e) => self.weight += e.insert(value.into()).weight(), + } + } + + pub fn take_buckets(&mut self) -> BucketMap { + if self.force_flush || !self.running { + self.weight = 0; + self.force_flush = false; + std::mem::take(&mut self.buckets) + } else { + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() + .saturating_sub(FLUSH_INTERVAL) .as_secs(); - for component in components { - if let Some(component_tags) = component.strip_prefix('#') { - tags = component_tags; - } else if let Some(component_timestamp) = component.strip_prefix('T') { - timestamp = component_timestamp.parse().ok()?; - } - } + // Split all buckets after the cutoff time. `split` contains newer buckets, which should + // remain, so swap them. After the swap, `split` contains all older buckets. + let mut split = self.buckets.split_off(×tamp); + std::mem::swap(&mut split, &mut self.buckets); + + self.weight -= split + .values() + .flat_map(|map| map.values()) + .map(|bucket| bucket.weight()) + .sum::(); - Some(( - BucketKey { - timestamp, - ty, - name: name.into(), - tags: tags.into(), - }, - value, - )) + split } + } + + pub fn weight(&self) -> usize { + self.weight + } +} + +pub struct Metric { + name: MetricStr, + unit: MetricUnit, + value: MetricValue, + tags: BTreeMap, + time: Option, +} + +impl Metric { + pub fn build(name: impl Into, value: MetricValue) -> MetricBuilder { + let metric = Metric { + name: name.into(), + unit: MetricUnit::None, + value, + tags: BTreeMap::new(), + time: None, + }; + + MetricBuilder { metric } + } + + pub fn incr(name: impl Into) -> MetricBuilder { + Self::build(name, MetricValue::Counter(1.0)) + } + + pub fn timing(name: impl Into, timing: Duration) -> MetricBuilder { + Self::build(name, MetricValue::Distribution(timing.as_secs_f64())) + .with_unit(MetricUnit::Duration(DurationUnit::Second)) + } + + pub fn distribution(name: impl Into, value: f64) -> MetricBuilder { + Self::build(name, MetricValue::Distribution(value)) + } - if let Some(parsed_metric) = parse(metric) { - let _ = self.sender.send(Task::RecordMetric(parsed_metric)); + pub fn set(name: impl Into, string: &str) -> MetricBuilder { + Self::build(name, MetricValue::set_from_str(string)) + } + + pub fn gauge(name: impl Into, value: f64) -> MetricBuilder { + Self::build(name, MetricValue::Gauge(value)) + } +} + +#[must_use] +pub struct MetricBuilder { + metric: Metric, +} + +impl MetricBuilder { + pub fn with_unit(mut self, unit: MetricUnit) -> Self { + self.metric.unit = unit; + self + } + + pub fn with_tag(mut self, name: impl Into, value: impl Into) -> Self { + self.metric.tags.insert(name.into(), value.into()); + self + } + + pub fn with_time(mut self, time: SystemTime) -> Self { + self.metric.time = Some(time); + self + } + + pub fn finish(self) -> Metric { + self.metric + } +} + +pub struct MetricAggregator { + inner: Arc>, + handle: Option>, +} + +impl MetricAggregator { + pub fn new(transport: TransportArc) -> Self { + let inner = Arc::new(Mutex::new(AggregatorInner::new())); + let inner_clone = Arc::clone(&inner); + + let handle = thread::Builder::new() + .name("sentry-metrics".into()) + .spawn(move || Self::worker_thread(inner_clone, transport)) + .expect("failed to spawn thread"); + + Self { + inner, + handle: Some(handle), } } - pub fn flush(&self) { - let _ = self.sender.send(Task::Flush); + pub fn add(&self, metric: Metric) { + let Metric { + name, + unit, + value, + tags, + time, + } = metric; + + let timestamp = time + .unwrap_or_else(SystemTime::now) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let key = BucketKey { + timestamp, + ty: value.ty(), + name, + unit, + tags, + }; + + let mut guard = self.inner.lock().unwrap(); + guard.add(key, value); + + if guard.weight() > MAX_WEIGHT { + if let Some(ref handle) = self.handle { + handle.thread().unpark(); + } + } } - fn worker_thread(receiver: mpsc::Receiver, transport: TransportArc) { - let mut buckets = AggregateMetrics::new(); - let mut last_flush = Instant::now(); + pub fn flush(&self) { + self.inner.lock().unwrap().force_flush = true; + if let Some(ref handle) = self.handle { + handle.thread().unpark(); + } + } + fn worker_thread(inner: Arc>, transport: TransportArc) { loop { - let timeout = FLUSH_INTERVAL.saturating_sub(last_flush.elapsed()); + let mut guard = inner.lock().unwrap(); + let should_stop = !guard.running; - match receiver.recv_timeout(timeout) { - Err(_) | Ok(Task::Flush) => { - // flush - Self::flush_buckets(std::mem::take(&mut buckets), &transport); - last_flush = Instant::now(); - } - Ok(Task::RecordMetric((mut key, value))) => { - // aggregate - let rounding_interval = BUCKET_INTERVAL.as_secs(); - let rounded_timestamp = (key.timestamp / rounding_interval) * rounding_interval; - - key.timestamp = rounded_timestamp; - - match buckets.entry(key) { - Entry::Occupied(mut entry) => { - let _ = entry.get_mut().merge(value); - } - Entry::Vacant(entry) => { - entry.insert(value); - } - } - } - _ => { - // shutdown - Self::flush_buckets(buckets, &transport); - return; - } + let last_flush = Instant::now(); + let buckets = guard.take_buckets(); + drop(guard); + + if !buckets.is_empty() { + Self::flush_buckets(buckets, &transport); } + + if should_stop { + break; + } + + // Park instead of sleep so we can wake the thread up + let park_time = FLUSH_INTERVAL.saturating_sub(last_flush.elapsed()); + thread::park_timeout(park_time); } } - fn flush_buckets(buckets: AggregateMetrics, transport: &TransportArc) { - if buckets.is_empty() { - return; + fn flush_buckets(buckets: BucketMap, transport: &TransportArc) { + // The transport is usually available when flush is called. Prefer a short lock and worst + // case throw away the result rather than blocking the transport for too long. + if let Ok(output) = Self::format_payload(buckets) { + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Metrics(output)); + + if let Some(ref transport) = *transport.read().unwrap() { + transport.send_envelope(envelope); + } } + } - fn format_payload(buckets: AggregateMetrics) -> std::io::Result> { - use std::io::Write; - let mut out = vec![]; - for (key, value) in buckets { - write!(&mut out, "{}", key.name)?; + fn format_payload(buckets: BucketMap) -> std::io::Result> { + use std::io::Write; + let mut out = vec![]; - match value { - BucketValue::Counter(c) => { - write!(&mut out, ":{}", c)?; - } - BucketValue::Distribution(d) => { - for v in d { - write!(&mut out, ":{}", v)?; - } - } - BucketValue::Set(s) => { - for v in s { - write!(&mut out, ":{}", v)?; - } + for (key, value) in buckets.into_iter().flat_map(|(_, v)| v) { + write!(&mut out, "{}@{}", SafeKey(key.name.as_ref()), key.unit)?; + + match value { + BucketValue::Counter(c) => { + write!(&mut out, ":{}", c)?; + } + BucketValue::Distribution(d) => { + for v in d { + write!(&mut out, ":{}", v)?; } - BucketValue::Gauge(g) => { - write!( - &mut out, - ":{}:{}:{}:{}:{}", - g.last, g.min, g.max, g.sum, g.count - )?; + } + BucketValue::Set(s) => { + for v in s { + write!(&mut out, ":{}", v)?; } } - - write!(&mut out, "|{}", key.ty.as_str())?; - if !key.tags.is_empty() { - write!(&mut out, "|#{}", key.tags)?; + BucketValue::Gauge(g) => { + write!( + &mut out, + ":{}:{}:{}:{}:{}", + g.last, g.min, g.max, g.sum, g.count + )?; } - writeln!(&mut out, "|T{}", key.timestamp)?; } - Ok(out) - } + write!(&mut out, "|{}", key.ty.as_str())?; - let Ok(output) = format_payload(buckets) else { - return; - }; - - let mut envelope = Envelope::new(); - envelope.add_item(EnvelopeItem::Metrics(output)); + if !key.tags.is_empty() { + write!(&mut out, "|#")?; + for (i, (k, v)) in key.tags.into_iter().enumerate() { + if i > 0 { + write!(&mut out, ",")?; + } + write!(&mut out, "{}:{}", SafeKey(k.as_ref()), SaveVal(v.as_ref()))?; + } + } - if let Some(ref transport) = *transport.read().unwrap() { - transport.send_envelope(envelope); + writeln!(&mut out, "|T{}", key.timestamp)?; } + + Ok(out) } } impl Drop for MetricAggregator { fn drop(&mut self) { - let _ = self.sender.send(Task::Shutdown); + self.inner.lock().unwrap().running = false; if let Some(handle) = self.handle.take() { handle.join().unwrap(); } } } + +struct SafeKey<'s>(&'s str); + +impl<'s> fmt::Display for SafeKey<'s> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for c in self.0.chars() { + if c.is_ascii_alphanumeric() || ['_', '-', '.', '/'].contains(&c) { + write!(f, "{}", c)?; + } + } + Ok(()) + } +} + +struct SaveVal<'s>(&'s str); + +impl<'s> fmt::Display for SaveVal<'s> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for c in self.0.chars() { + if c.is_alphanumeric() + || ['_', ':', '/', '@', '.', '{', '}', '[', ']', '$', '-'].contains(&c) + { + write!(f, "{}", c)?; + } + } + Ok(()) + } +} diff --git a/sentry-core/src/units.rs b/sentry-core/src/units.rs new file mode 100644 index 00000000..93236e1e --- /dev/null +++ b/sentry-core/src/units.rs @@ -0,0 +1,293 @@ +//! Type definitions for Sentry metrics. + +use std::fmt; + +/// The unit of measurement of a metric value. +/// +/// Units augment metric values by giving them a magnitude and semantics. There are certain types of +/// units that are subdivided in their precision, such as the [`DurationUnit`] for time +/// measurements. +/// +/// Units and their precisions are uniquely represented by a string identifier. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)] +pub enum MetricUnit { + /// A time duration, defaulting to `"millisecond"`. + Duration(DurationUnit), + /// Size of information derived from bytes, defaulting to `"byte"`. + Information(InformationUnit), + /// Fractions such as percentages, defaulting to `"ratio"`. + Fraction(FractionUnit), + /// user-defined units without builtin conversion or default. + Custom(CustomUnit), + /// Untyped value without a unit (`""`). + #[default] + None, +} + +impl MetricUnit { + /// Returns `true` if the metric_unit is [`None`]. + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +impl fmt::Display for MetricUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MetricUnit::Duration(u) => u.fmt(f), + MetricUnit::Information(u) => u.fmt(f), + MetricUnit::Fraction(u) => u.fmt(f), + MetricUnit::Custom(u) => u.fmt(f), + MetricUnit::None => f.write_str("none"), + } + } +} + +impl std::str::FromStr for MetricUnit { + type Err = ParseMetricUnitError; + + fn from_str(s: &str) -> Result { + Ok(match s { + "nanosecond" | "ns" => Self::Duration(DurationUnit::NanoSecond), + "microsecond" => Self::Duration(DurationUnit::MicroSecond), + "millisecond" | "ms" => Self::Duration(DurationUnit::MilliSecond), + "second" | "s" => Self::Duration(DurationUnit::Second), + "minute" => Self::Duration(DurationUnit::Minute), + "hour" => Self::Duration(DurationUnit::Hour), + "day" => Self::Duration(DurationUnit::Day), + "week" => Self::Duration(DurationUnit::Week), + + "bit" => Self::Information(InformationUnit::Bit), + "byte" => Self::Information(InformationUnit::Byte), + "kilobyte" => Self::Information(InformationUnit::KiloByte), + "kibibyte" => Self::Information(InformationUnit::KibiByte), + "megabyte" => Self::Information(InformationUnit::MegaByte), + "mebibyte" => Self::Information(InformationUnit::MebiByte), + "gigabyte" => Self::Information(InformationUnit::GigaByte), + "gibibyte" => Self::Information(InformationUnit::GibiByte), + "terabyte" => Self::Information(InformationUnit::TeraByte), + "tebibyte" => Self::Information(InformationUnit::TebiByte), + "petabyte" => Self::Information(InformationUnit::PetaByte), + "pebibyte" => Self::Information(InformationUnit::PebiByte), + "exabyte" => Self::Information(InformationUnit::ExaByte), + "exbibyte" => Self::Information(InformationUnit::ExbiByte), + + "ratio" => Self::Fraction(FractionUnit::Ratio), + "percent" => Self::Fraction(FractionUnit::Percent), + + "" | "none" => Self::None, + _ => Self::Custom(CustomUnit::parse(s)?), + }) + } +} + +/// Time duration units used in [`MetricUnit::Duration`]. +/// +/// Defaults to `millisecond`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum DurationUnit { + /// Nanosecond (`"nanosecond"`), 10^-9 seconds. + NanoSecond, + /// Microsecond (`"microsecond"`), 10^-6 seconds. + MicroSecond, + /// Millisecond (`"millisecond"`), 10^-3 seconds. + MilliSecond, + /// Full second (`"second"`). + Second, + /// Minute (`"minute"`), 60 seconds. + Minute, + /// Hour (`"hour"`), 3600 seconds. + Hour, + /// Day (`"day"`), 86,400 seconds. + Day, + /// Week (`"week"`), 604,800 seconds. + Week, +} + +impl Default for DurationUnit { + fn default() -> Self { + Self::MilliSecond + } +} + +impl fmt::Display for DurationUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NanoSecond => f.write_str("nanosecond"), + Self::MicroSecond => f.write_str("microsecond"), + Self::MilliSecond => f.write_str("millisecond"), + Self::Second => f.write_str("second"), + Self::Minute => f.write_str("minute"), + Self::Hour => f.write_str("hour"), + Self::Day => f.write_str("day"), + Self::Week => f.write_str("week"), + } + } +} + +/// An error parsing a [`MetricUnit`] or one of its variants. +#[derive(Clone, Copy, Debug)] +pub struct ParseMetricUnitError(()); + +/// Size of information derived from bytes, used in [`MetricUnit::Information`]. +/// +/// Defaults to `byte`. See also [Units of +/// information](https://en.wikipedia.org/wiki/Units_of_information). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum InformationUnit { + /// Bit (`"bit"`), corresponding to 1/8 of a byte. + /// + /// Note that there are computer systems with a different number of bits per byte. + Bit, + /// Byte (`"byte"`). + Byte, + /// Kilobyte (`"kilobyte"`), 10^3 bytes. + KiloByte, + /// Kibibyte (`"kibibyte"`), 2^10 bytes. + KibiByte, + /// Megabyte (`"megabyte"`), 10^6 bytes. + MegaByte, + /// Mebibyte (`"mebibyte"`), 2^20 bytes. + MebiByte, + /// Gigabyte (`"gigabyte"`), 10^9 bytes. + GigaByte, + /// Gibibyte (`"gibibyte"`), 2^30 bytes. + GibiByte, + /// Terabyte (`"terabyte"`), 10^12 bytes. + TeraByte, + /// Tebibyte (`"tebibyte"`), 2^40 bytes. + TebiByte, + /// Petabyte (`"petabyte"`), 10^15 bytes. + PetaByte, + /// Pebibyte (`"pebibyte"`), 2^50 bytes. + PebiByte, + /// Exabyte (`"exabyte"`), 10^18 bytes. + ExaByte, + /// Exbibyte (`"exbibyte"`), 2^60 bytes. + ExbiByte, +} + +impl Default for InformationUnit { + fn default() -> Self { + Self::Byte + } +} + +impl fmt::Display for InformationUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bit => f.write_str("bit"), + Self::Byte => f.write_str("byte"), + Self::KiloByte => f.write_str("kilobyte"), + Self::KibiByte => f.write_str("kibibyte"), + Self::MegaByte => f.write_str("megabyte"), + Self::MebiByte => f.write_str("mebibyte"), + Self::GigaByte => f.write_str("gigabyte"), + Self::GibiByte => f.write_str("gibibyte"), + Self::TeraByte => f.write_str("terabyte"), + Self::TebiByte => f.write_str("tebibyte"), + Self::PetaByte => f.write_str("petabyte"), + Self::PebiByte => f.write_str("pebibyte"), + Self::ExaByte => f.write_str("exabyte"), + Self::ExbiByte => f.write_str("exbibyte"), + } + } +} + +/// Units of fraction used in [`MetricUnit::Fraction`]. +/// +/// Defaults to `ratio`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum FractionUnit { + /// Floating point fraction of `1`. + Ratio, + /// Ratio expressed as a fraction of `100`. `100%` equals a ratio of `1.0`. + Percent, +} + +impl Default for FractionUnit { + fn default() -> Self { + Self::Ratio + } +} + +impl fmt::Display for FractionUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ratio => f.write_str("ratio"), + Self::Percent => f.write_str("percent"), + } + } +} + +const CUSTOM_UNIT_MAX_SIZE: usize = 15; + +/// Custom user-defined units without builtin conversion. +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub struct CustomUnit([u8; CUSTOM_UNIT_MAX_SIZE]); + +impl CustomUnit { + /// Parses a `CustomUnit` from a string. + pub fn parse(s: &str) -> Result { + if !s.is_ascii() { + return Err(ParseMetricUnitError(())); + } + + let mut unit = Self([0; CUSTOM_UNIT_MAX_SIZE]); + let slice = unit.0.get_mut(..s.len()).ok_or(ParseMetricUnitError(()))?; + slice.copy_from_slice(s.as_bytes()); + unit.0.make_ascii_lowercase(); + Ok(unit) + } + + /// Returns the string representation of this unit. + #[inline] + pub fn as_str(&self) -> &str { + // Safety: The string is already validated to be valid ASCII when + // parsing `CustomUnit`. + unsafe { std::str::from_utf8_unchecked(&self.0).trim_end_matches('\0') } + } +} + +impl fmt::Debug for CustomUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } +} + +impl fmt::Display for CustomUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } +} + +impl std::str::FromStr for CustomUnit { + type Err = ParseMetricUnitError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl std::ops::Deref for CustomUnit { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_custom_unit_parse() { + assert_eq!("foo", CustomUnit::parse("Foo").unwrap().as_str()); + assert_eq!( + "0123456789abcde", + CustomUnit::parse("0123456789abcde").unwrap().as_str() + ); + assert!(CustomUnit::parse("this_is_a_unit_that_is_too_long").is_err()); + } +}