Monitoring: metric history, one edition of metrics, and x:Metric over the stored samples (MON-4 to MON-7, MON-9, MON-17, MON-39)

Every node writes a sample per metric on metricsCollectionInterval:
counters as the increase since its last sample, gauges always, histograms
as totals when changed. Samples are x:Metric in the registry's encoding
under the telemetry key class, ids time-ordered. x:Metric/get and /query
read them with metric and timestamp filters and full paging, hide what's
past holdMetricsFor, and the data purge deletes it. The is_enterprise
split is gone, so every gauge and histogram is collected and exported, and
queue.count is set from the queue itself. The shared metrics suite runs.
This commit is contained in:
2026-09-19 08:21:45 -07:00
parent 715f219528
commit 9f7035588f
18 changed files with 586 additions and 64 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ impl Server {
// Update tracers
#[cfg(not(feature = "enterprise"))]
tracers.update(false);
tracers.update();
// Reload queue settings
self.inner
+3 -3
View File
@@ -158,7 +158,7 @@ impl BootManager {
#[cfg(not(feature = "enterprise"))]
telemetry.enable(false);
telemetry.enable();
if bootstrap.registry.is_bootstrap_mode() {
trc::event!(
@@ -241,7 +241,7 @@ impl BootManager {
}
StoreOp::Export(path) => {
// Enable telemetry
telemetry.enable(false);
telemetry.enable();
// Parse settings and backup
Box::pin(Core::parse(&mut bootstrap, storage))
@@ -252,7 +252,7 @@ impl BootManager {
}
StoreOp::Import(path) => {
// Enable telemetry
telemetry.enable(false);
telemetry.enable();
// Parse settings and restore
Box::pin(Core::parse(&mut bootstrap, storage))
@@ -6,5 +6,6 @@
pub mod otel;
pub mod prometheus;
pub mod store; // inbuxa: monitoring history (MON-4 to MON-9)
+4 -4
View File
@@ -17,12 +17,12 @@ use std::time::SystemTime;
use trc::{Collector, TelemetryEvent};
impl OtelMetrics {
pub async fn push_metrics(&self, is_enterprise: bool, start_time: SystemTime) {
pub async fn push_metrics(&self, start_time: SystemTime) {
let mut metrics = Vec::with_capacity(256);
let time = SystemTime::now();
// Add counters
for counter in Collector::collect_counters(is_enterprise) {
for counter in Collector::collect_counters() {
metrics.push(Metric::new(
counter.id().as_str(),
counter.id().description(),
@@ -38,7 +38,7 @@ impl OtelMetrics {
}
// Add gauges
for gauge in Collector::collect_gauges(is_enterprise) {
for gauge in Collector::collect_gauges() {
metrics.push(Metric::new(
gauge.id().as_str(),
gauge.id().description(),
@@ -52,7 +52,7 @@ impl OtelMetrics {
}
// Add histograms
for histogram in Collector::collect_histograms(is_enterprise) {
for histogram in Collector::collect_histograms() {
metrics.push(Metric::new(
histogram.id().as_str(),
histogram.id().description(),
@@ -18,10 +18,9 @@ impl Server {
#[cfg(not(feature = "enterprise"))]
let is_enterprise = false;
// Add counters
for counter in Collector::collect_counters(is_enterprise) {
for counter in Collector::collect_counters() {
let mut metric = MetricFamily::default();
metric.set_name(metric_name(counter.id().as_str()));
metric.set_help(counter.id().description().into());
@@ -31,7 +30,7 @@ impl Server {
}
// Add gauges
for gauge in Collector::collect_gauges(is_enterprise) {
for gauge in Collector::collect_gauges() {
let mut metric = MetricFamily::default();
metric.set_name(metric_name(gauge.id().as_str()));
metric.set_help(gauge.id().description().into());
@@ -41,7 +40,7 @@ impl Server {
}
// Add histograms
for histogram in Collector::collect_histograms(is_enterprise) {
for histogram in Collector::collect_histograms() {
let mut metric = MetricFamily::default();
metric.set_name(metric_name(histogram.id().as_str()));
metric.set_help(histogram.id().description().into());
@@ -0,0 +1,267 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Metric history (monitoring spec MON-4 to MON-9, MON-17). Each sample is
//! stored under `TelemetryClass::Metric(id)`, as an `x:Metric` in the
//! registry's own encoding. The id is a snowflake of the tick's time, so key
//! order is time order and the timestamp is read from the id.
use crate::Server;
use ahash::AHashMap;
use registry::{
pickle::PickledStream,
schema::{
prelude::{ObjectInner, ObjectType},
structs::{DataRetention, Metric, MetricCount, MetricSum},
},
};
use std::{future::Future, sync::Mutex, time::Duration};
use store::{
IterateParams, Store, ValueKey,
write::{BatchBuilder, TelemetryClass, ValueClass, key::DeserializeBigEndian, now},
};
use trc::{AddContext, Collector, MetricType, TelemetryEvent};
use types::id::Id;
use utils::snowflake::SnowflakeIdGenerator;
pub trait MetricsStore: Sync + Send {
/// Writes one tick's samples, all at `timestamp`.
fn write_metrics(
&self,
samples: Vec<Metric>,
timestamp: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
/// Deletes samples older than `keep` (MON-17).
fn purge_metrics(&self, keep: Duration) -> impl Future<Output = trc::Result<()>> + Send;
}
impl MetricsStore for Store {
async fn write_metrics(&self, samples: Vec<Metric>, timestamp: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
for sample in samples {
let Some(id) = SnowflakeIdGenerator::global_id_from_timestamp(timestamp) else {
continue;
};
batch.set(
ValueClass::Telemetry(TelemetryClass::Metric(id)),
ObjectInner::Metric(sample).to_pickled_vec(),
);
if batch.is_large_batch() {
self.write(batch.build_all()).await?;
batch = BatchBuilder::new();
}
}
if !batch.is_empty() {
self.write(batch.build_all()).await?;
}
Ok(())
}
async fn purge_metrics(&self, keep: Duration) -> trc::Result<()> {
let Some(until) = SnowflakeIdGenerator::from_duration(keep) else {
return Ok(());
};
self.delete_range(
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(0))),
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(until))),
)
.await
.caused_by(trc::location!())
}
}
/// Decodes a stored sample. Records in any other encoding (INBUXA's history
/// from before the fork) read as `None` and are skipped.
pub fn decode_metric(bytes: &[u8]) -> Option<Metric> {
PickledStream::new(bytes)
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Metric, &mut stream))
.and_then(|inner| match inner {
ObjectInner::Metric(metric) => Some(metric),
_ => None,
})
}
/// A stored sample as read by key: `None` when it can't be decoded.
pub struct MaybeMetric(pub Option<Metric>);
impl store::Deserialize for MaybeMetric {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(MaybeMetric(decode_metric(bytes)))
}
}
/// A stored sample with its id.
pub struct StoredMetric {
pub id: u64,
pub metric: Metric,
}
impl StoredMetric {
pub fn timestamp(&self) -> u64 {
SnowflakeIdGenerator::to_timestamp(self.id)
}
}
/// What the node wrote last, so counters and histograms are written as
/// changes (MON-4). Per process: a restart counts from the start.
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
/// One tick's samples (MON-4 to MON-6).
pub fn sample() -> Vec<Metric> {
let mut last_guard = LAST.lock().unwrap();
let last = last_guard.get_or_insert_with(AHashMap::new);
let mut samples = Vec::new();
// Counters: the increase since the previous sample; none if unchanged
for counter in Collector::collect_counters() {
let Some(metric) = MetricType::parse(counter.id().as_str()) else {
continue;
};
let total = counter.value();
let previous = last.insert(metric, (total, 0)).map_or(0, |(count, _)| count);
let increase = total.saturating_sub(previous);
if increase > 0 {
samples.push(Metric::Counter(MetricCount {
count: increase,
metric,
}));
}
}
// Gauges: the reading, always (MON-5)
for gauge in Collector::collect_gauges() {
samples.push(Metric::Gauge(MetricCount {
count: gauge.get(),
metric: gauge.id(),
}));
}
// Histograms: totals, when changed (MON-4 Decision)
for histogram in Collector::collect_histograms() {
let metric = histogram.id();
let current = (histogram.count(), histogram.sum());
if last.insert(metric, current) != Some(current) {
samples.push(Metric::Histogram(MetricSum {
count: current.0,
sum: current.1,
metric,
}));
}
}
samples
}
/// The retention settings in force now (MON-3 Decision: no reload needed).
pub async fn retention(server: &Server) -> DataRetention {
server
.registry()
.object::<DataRetention>(Id::singleton())
.await
.ok()
.flatten()
.unwrap_or_default()
}
impl Server {
/// Writes one tick of metric history, if it's on (MON-4, MON-9). Never
/// fails loudly: history is lost, mail isn't (MON-35).
pub async fn store_metrics(&self) {
let store = self.metrics_store();
if store.is_none() {
return;
}
let samples = sample();
let count = samples.len();
let started = std::time::Instant::now();
match store.write_metrics(samples, now()).await {
Ok(()) => trc::event!(
Telemetry(TelemetryEvent::MetricsStored),
Total = count,
Elapsed = started.elapsed(),
),
Err(err) => {
trc::error!(err.details("Failed to store metric history"));
}
}
}
/// The stored samples between two ids, in key order, skipping any that
/// can't be decoded or are past `holdMetricsFor` (MON-17).
pub async fn read_metrics(
&self,
from_id: u64,
to_id: u64,
ascending: bool,
mut accept: impl FnMut(&StoredMetric) -> bool + Send + Sync,
) -> trc::Result<Vec<StoredMetric>> {
let store = self.metrics_store();
let mut out = Vec::new();
if store.is_none() {
return Ok(out);
}
let floor = match retention(self).await.hold_metrics_for {
Some(keep) => SnowflakeIdGenerator::from_duration(keep.into_inner()).unwrap_or(0),
None => 0,
};
let from_id = from_id.max(floor);
if from_id > to_id {
return Ok(out);
}
let params = IterateParams::new(
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(from_id))),
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(to_id))),
);
let params = if ascending {
params.ascending()
} else {
params.descending()
};
store
.iterate(params, |key, value| {
let id = key.deserialize_be_u64(0)?;
if let Some(metric) = decode_metric(value) {
let sample = StoredMetric { id, metric };
if accept(&sample) {
out.push(sample);
}
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
Ok(out)
}
/// Test data for the shared metrics suite: 90 days of hourly ticks, a
/// counter, a gauge and a histogram each.
#[cfg(feature = "test_mode")]
pub async fn insert_test_metrics(&self) {
let now = now();
for hour in (0..90 * 24u64).rev() {
let samples = vec![
Metric::Counter(MetricCount {
count: 1 + hour % 7,
metric: MetricType::AuthSuccess,
}),
Metric::Gauge(MetricCount {
count: 20 + hour % 11,
metric: MetricType::QueueCount,
}),
Metric::Histogram(MetricSum {
count: 100 + hour,
sum: 1000 + hour * 10,
metric: MetricType::DeliveryTotalTime,
}),
];
self.metrics_store()
.write_metrics(samples, now - hour * 3600)
.await
.unwrap();
}
}
}
+3 -6
View File
@@ -17,14 +17,13 @@ use webhooks::spawn_webhook_tracer;
use crate::config::telemetry::{Telemetry, TelemetrySubscriberType};
impl Telemetry {
pub fn enable(self, is_enterprise: bool) {
pub fn enable(self) {
// Spawn tracers
for tracer in self.tracers.subscribers {
tracer.typ.spawn(
SubscriberBuilder::new(tracer.id)
.with_interests(tracer.interests)
.with_lossy(tracer.lossy),
is_enterprise,
);
}
@@ -35,7 +34,7 @@ impl Telemetry {
Collector::reload();
}
pub fn update(self, is_enterprise: bool) {
pub fn update(self) {
// Remove tracers that are no longer active
let active_subscribers = Collector::get_subscribers();
for subscribed_id in &active_subscribers {
@@ -58,7 +57,6 @@ impl Telemetry {
SubscriberBuilder::new(tracer.id)
.with_interests(tracer.interests)
.with_lossy(tracer.lossy),
is_enterprise,
);
}
}
@@ -96,8 +94,7 @@ impl Telemetry {
}
impl TelemetrySubscriberType {
// inbuxa: `_is_enterprise` is unused until monitoring history is rebuilt, and goes when it is: there is one edition
pub fn spawn(self, builder: SubscriberBuilder, _is_enterprise: bool) {
pub fn spawn(self, builder: SubscriberBuilder) {
match self {
TelemetrySubscriberType::ConsoleTracer(settings) => {
spawn_console_tracer(builder, settings)