///|
/// Stateful monitoring primitives represented as immutable snapshots. These
/// types support service metrics, quality gates, alerting, and SLO review.
pub enum MetricKind {
Gauge
Counter
Rate
Histogram
} derive(Debug, Eq, ToJson, FromJson)
///|
pub struct MetricSample {
timestamp : Int
value : Double
weight : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn MetricSample::new(timestamp : Int, value : Double) -> MetricSample {
{ timestamp, value, weight: 1.0 }
}
///|
pub fn MetricSample::weighted(
timestamp : Int,
value : Double,
weight : Double,
) -> MetricSample {
if weight < 0.0 {
abort("MetricSample: weight must be non-negative")
}
{ timestamp, value, weight }
}
///|
pub fn MetricSample::timestamp(self : MetricSample) -> Int {
self.timestamp
}
///|
pub fn MetricSample::value(self : MetricSample) -> Double {
self.value
}
///|
pub fn MetricSample::weight(self : MetricSample) -> Double {
self.weight
}
///|
pub struct MetricStream {
name : String
kind : MetricKind
samples : Array[MetricSample]
total : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn MetricStream::new(name : String, kind : MetricKind) -> MetricStream {
{ name, kind, samples: [], total: 0.0 }
}
///|
pub fn MetricStream::name(self : MetricStream) -> String {
self.name
}
///|
pub fn MetricStream::kind(self : MetricStream) -> MetricKind {
self.kind
}
///|
pub fn MetricStream::len(self : MetricStream) -> Int {
self.samples.length()
}
///|
pub fn MetricStream::samples(self : MetricStream) -> Array[MetricSample] {
self.samples.copy()
}
///|
pub fn MetricStream::push(
self : MetricStream,
sample : MetricSample,
) -> MetricStream {
if self.len() > 0 &&
sample.timestamp() < self.samples[self.len() - 1].timestamp() {
abort("MetricStream: samples must be ordered")
}
let samples = self.samples.copy()
samples.push(sample)
{ ..self, samples, total: self.total + sample.value() * sample.weight() }
}
///|
pub fn MetricStream::push_value(
self : MetricStream,
timestamp : Int,
value : Double,
) -> MetricStream {
self.push(MetricSample::new(timestamp, value))
}
///|
pub fn MetricStream::push_all(
self : MetricStream,
samples : Array[MetricSample],
) -> MetricStream {
samples.fold(init=self, (stream, sample) => stream.push(sample))
}
///|
pub fn MetricStream::values(self : MetricStream) -> Array[Double] {
self.samples.map(fn(sample) { sample.value() })
}
///|
pub fn MetricStream::window(
self : MetricStream,
start : Int,
finish : Int,
) -> MetricStream {
let samples = self.samples.filter(fn(sample) {
sample.timestamp() >= start && sample.timestamp() <= finish
})
MetricStream::new(self.name, self.kind).push_all(samples)
}
///|
pub fn MetricStream::summary(self : MetricStream) -> OnlineStats {
OnlineStats::new().push_all(self.values())
}
///|
pub fn MetricStream::weighted_mean(self : MetricStream) -> Double {
let denominator = self.samples.fold(init=0.0, (total, sample) => {
total + sample.weight()
})
if denominator == 0.0 {
0.0
} else {
self.total / denominator
}
}
///|
pub fn MetricStream::latest(self : MetricStream) -> Double {
if self.len() == 0 {
0.0
} else {
self.samples[self.len() - 1].value()
}
}
///|
pub fn MetricStream::first(self : MetricStream) -> Double {
if self.len() == 0 {
0.0
} else {
self.samples[0].value()
}
}
///|
pub fn MetricStream::change(self : MetricStream) -> Double {
self.latest() - self.first()
}
///|
pub fn MetricStream::rate(self : MetricStream, duration : Double) -> Double {
if duration <= 0.0 {
abort("MetricStream::rate: duration must be positive")
}
self.total / duration
}
///|
pub fn MetricStream::quantile(
self : MetricStream,
probability : Double,
) -> Double {
quantile(self.values(), probability)
}
///|
pub fn MetricStream::histogram(
self : MetricStream,
lower : Double,
upper : Double,
bins : Int,
) -> Array[Int] {
histogram(self.values(), lower, upper, bins)
}
///|
pub struct HistogramBucket {
lower : Double
upper : Double
count : Int
weight : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn HistogramBucket::new(lower : Double, upper : Double) -> HistogramBucket {
if lower >= upper {
abort("HistogramBucket: invalid interval")
}
{ lower, upper, count: 0, weight: 0.0 }
}
///|
pub fn HistogramBucket::add(
self : HistogramBucket,
value : Double,
weight : Double,
) -> HistogramBucket {
if value < self.lower || value > self.upper || weight < 0.0 {
abort("HistogramBucket::add: invalid sample")
}
{ ..self, count: self.count + 1, weight: self.weight + weight }
}
///|
pub fn HistogramBucket::contains(
self : HistogramBucket,
value : Double,
) -> Bool {
value >= self.lower && value <= self.upper
}
///|
pub fn HistogramBucket::count(self : HistogramBucket) -> Int {
self.count
}
///|
pub fn HistogramBucket::weight(self : HistogramBucket) -> Double {
self.weight
}
///|
pub fn HistogramBucket::midpoint(self : HistogramBucket) -> Double {
(self.lower + self.upper) / 2.0
}
///|
pub struct HistogramAccumulator {
lower : Double
upper : Double
buckets : Array[HistogramBucket]
total : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn HistogramAccumulator::new(
lower : Double,
upper : Double,
bins : Int,
) -> HistogramAccumulator {
if lower >= upper || bins <= 0 {
abort("HistogramAccumulator: invalid range")
}
let width = (upper - lower) / bins.to_double()
let buckets = []
for index in 0.. HistogramAccumulator {
if value < self.lower || value > self.upper {
return self
}
let index = if value == self.upper {
self.buckets.length() - 1
} else {
((value - self.lower) /
(self.upper - self.lower) *
self.buckets.length().to_double())
.floor()
.to_int()
}
let buckets = self.buckets.copy()
buckets[index] = buckets[index].add(value, 1.0)
{ ..self, buckets, total: self.total + 1 }
}
///|
pub fn HistogramAccumulator::add_all(
self : HistogramAccumulator,
values : Array[Double],
) -> HistogramAccumulator {
values.fold(init=self, (histogram, value) => histogram.add(value))
}
///|
pub fn HistogramAccumulator::counts(self : HistogramAccumulator) -> Array[Int] {
self.buckets.map(fn(bucket) { bucket.count() })
}
///|
pub fn HistogramAccumulator::cdf(self : HistogramAccumulator) -> Array[Double] {
let result = []
let mut cumulative = 0.0
for bucket in self.buckets {
cumulative = cumulative + bucket.count().to_double()
result.push(
if self.total == 0 {
0.0
} else {
cumulative / self.total.to_double()
},
)
}
result
}
///|
pub fn HistogramAccumulator::total(self : HistogramAccumulator) -> Int {
self.total
}
///|
pub enum Comparator {
LessThan
LessOrEqual
GreaterThan
GreaterOrEqual
Equal
NotEqual
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn compare_value(
comparator : Comparator,
actual : Double,
expected : Double,
tolerance : Double,
) -> Bool {
match comparator {
LessThan => actual < expected
LessOrEqual => actual <= expected
GreaterThan => actual > expected
GreaterOrEqual => actual >= expected
Equal => (actual - expected).abs() <= tolerance
NotEqual => (actual - expected).abs() > tolerance
}
}
///|
pub struct AlertRule {
name : String
comparator : Comparator
threshold : Double
tolerance : Double
consecutive : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn AlertRule::new(
name : String,
comparator : Comparator,
threshold : Double,
) -> AlertRule {
{ name, comparator, threshold, tolerance: 0.0, consecutive: 1 }
}
///|
pub fn AlertRule::with_tolerance(
self : AlertRule,
tolerance : Double,
) -> AlertRule {
if tolerance < 0.0 {
abort("AlertRule: tolerance must be non-negative")
}
{ ..self, tolerance, }
}
///|
pub fn AlertRule::with_consecutive(self : AlertRule, count : Int) -> AlertRule {
if count <= 0 {
abort("AlertRule: consecutive count must be positive")
}
{ ..self, consecutive: count }
}
///|
pub fn AlertRule::name(self : AlertRule) -> String {
self.name
}
///|
pub fn AlertRule::matches(self : AlertRule, value : Double) -> Bool {
compare_value(self.comparator, value, self.threshold, self.tolerance)
}
///|
pub fn AlertRule::required_consecutive(self : AlertRule) -> Int {
self.consecutive
}
///|
pub struct AlertEvent {
rule : String
timestamp : Int
value : Double
active : Bool
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn AlertEvent::rule(self : AlertEvent) -> String {
self.rule
}
///|
pub fn AlertEvent::timestamp(self : AlertEvent) -> Int {
self.timestamp
}
///|
pub fn AlertEvent::value(self : AlertEvent) -> Double {
self.value
}
///|
pub fn AlertEvent::active(self : AlertEvent) -> Bool {
self.active
}
///|
pub struct AlertEvaluator {
rule : AlertRule
streak : Int
events : Array[AlertEvent]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn AlertEvaluator::new(rule : AlertRule) -> AlertEvaluator {
{ rule, streak: 0, events: [] }
}
///|
pub fn AlertEvaluator::evaluate(
self : AlertEvaluator,
timestamp : Int,
value : Double,
) -> AlertEvaluator {
let streak = if self.rule.matches(value) { self.streak + 1 } else { 0 }
let active = streak >= self.rule.required_consecutive()
let events = self.events.copy()
events.push({ rule: self.rule.name(), timestamp, value, active })
{ ..self, streak, events }
}
///|
pub fn AlertEvaluator::is_active(self : AlertEvaluator) -> Bool {
self.streak >= self.rule.required_consecutive()
}
///|
pub fn AlertEvaluator::streak(self : AlertEvaluator) -> Int {
self.streak
}
///|
pub fn AlertEvaluator::events(self : AlertEvaluator) -> Array[AlertEvent] {
self.events.copy()
}
///|
pub fn AlertEvaluator::reset(self : AlertEvaluator) -> AlertEvaluator {
{ ..self, streak: 0 }
}
///|
pub struct SloTarget {
name : String
target : Double
window : Int
good : Int
total : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn SloTarget::new(
name : String,
target : Double,
window : Int,
) -> SloTarget {
if target < 0.0 || target > 1.0 || window <= 0 {
abort("SloTarget: invalid target or window")
}
{ name, target, window, good: 0, total: 0 }
}
///|
pub fn SloTarget::record(self : SloTarget, good : Bool) -> SloTarget {
let increment = if good { 1 } else { 0 }
{ ..self, good: self.good + increment, total: self.total + 1 }
}
///|
pub fn SloTarget::name(self : SloTarget) -> String {
self.name
}
///|
pub fn SloTarget::target(self : SloTarget) -> Double {
self.target
}
///|
pub fn SloTarget::attainment(self : SloTarget) -> Double {
if self.total == 0 {
0.0
} else {
self.good.to_double() / self.total.to_double()
}
}
///|
pub fn SloTarget::is_met(self : SloTarget) -> Bool {
self.attainment() >= self.target
}
///|
pub fn SloTarget::error_budget(self : SloTarget) -> Double {
(self.target - self.attainment()).max(0.0)
}
///|
pub fn SloTarget::remaining_budget(self : SloTarget) -> Int {
let allowed = ((1.0 - self.target) * self.total.to_double()).floor().to_int()
(allowed - (self.total - self.good)).max(0)
}
///|
pub fn SloTarget::good(self : SloTarget) -> Int {
self.good
}
///|
pub fn SloTarget::total(self : SloTarget) -> Int {
self.total
}
///|
pub struct MonitorReport {
name : String
count : Int
mean : Double
deviation : Double
latest : Double
min : Double
max : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn MonitorReport::from_stream(stream : MetricStream) -> MonitorReport {
let summary = stream.summary()
{
name: stream.name(),
count: summary.count(),
mean: summary.mean(),
deviation: summary.standard_deviation(),
latest: stream.latest(),
min: summary.min(),
max: summary.max(),
}
}
///|
pub fn MonitorReport::name(self : MonitorReport) -> String {
self.name
}
///|
pub fn MonitorReport::count(self : MonitorReport) -> Int {
self.count
}
///|
pub fn MonitorReport::mean(self : MonitorReport) -> Double {
self.mean
}
///|
pub fn MonitorReport::deviation(self : MonitorReport) -> Double {
self.deviation
}
///|
pub fn MonitorReport::latest(self : MonitorReport) -> Double {
self.latest
}
///|
pub fn MonitorReport::min(self : MonitorReport) -> Double {
self.min
}
///|
pub fn MonitorReport::max(self : MonitorReport) -> Double {
self.max
}
///|
pub fn MonitorReport::z_score(self : MonitorReport) -> Double {
if self.deviation == 0.0 {
0.0
} else {
(self.latest - self.mean) / self.deviation
}
}
///|
pub fn monitor_drift(
stream : MetricStream,
baseline : OnlineStats,
threshold : Double,
) -> Bool {
if threshold < 0.0 {
abort("monitor_drift: threshold must be non-negative")
}
let deviation = baseline.standard_deviation()
if deviation == 0.0 {
stream.latest() != baseline.mean()
} else {
(stream.latest() - baseline.mean()).abs() > threshold * deviation
}
}
///|
pub fn monitor_outliers(
stream : MetricStream,
z_threshold : Double,
) -> Array[MetricSample] {
if z_threshold <= 0.0 {
abort("monitor_outliers: threshold must be positive")
}
let summary = stream.summary()
stream
.samples()
.filter(fn(sample) {
if summary.standard_deviation() == 0.0 {
false
} else {
((sample.value() - summary.mean()) / summary.standard_deviation()).abs() >
z_threshold
}
})
}
///|
pub fn aggregate_streams(streams : Array[MetricStream]) -> MetricStream {
if streams.length() == 0 {
abort("aggregate_streams: streams must not be empty")
}
let result = MetricStream::new(streams[0].name(), streams[0].kind())
streams.fold(init=result, (current, stream) => {
current.push_all(stream.samples())
})
}
///|
pub fn metric_snapshot(streams : Array[MetricStream]) -> Array[MonitorReport] {
streams.map(fn(stream) { MonitorReport::from_stream(stream) })
}