diff --git a/crates/common/src/telemetry/metrics/store.rs b/crates/common/src/telemetry/metrics/store.rs index d3249c2..849a253 100644 --- a/crates/common/src/telemetry/metrics/store.rs +++ b/crates/common/src/telemetry/metrics/store.rs @@ -104,6 +104,12 @@ impl StoredMetric { pub fn timestamp(&self) -> u64 { SnowflakeIdGenerator::to_timestamp(self.id) } + + /// The node that wrote the sample. Histogram totals are per node, so a + /// reader diffs them per node. + pub fn node_id(&self) -> u64 { + SnowflakeIdGenerator::to_node_id(self.id) + } } /// What the node wrote last, so counters and histograms are written as diff --git a/crates/jmap/src/inbuxa/telemetry.rs b/crates/jmap/src/inbuxa/telemetry.rs index 5d43f93..f4b5722 100644 --- a/crates/jmap/src/inbuxa/telemetry.rs +++ b/crates/jmap/src/inbuxa/telemetry.rs @@ -6,7 +6,7 @@ //! `x:Metric/get` and `/query` over the stored history (monitoring spec, //! "Interfaces"). Samples are server-level (MON-31) and read-only (MON-32). -//! A sample's `timestamp` comes from its id. +//! A sample's `timestamp` and `nodeId` come from its id. use crate::{ api::query::QueryResponseBuilder, @@ -54,12 +54,16 @@ fn metric_type(metric: &Metric) -> MetricType { fn to_value(sample: StoredMetric) -> JmapValue<'static> { let timestamp = sample.timestamp(); + let node_id = sample.node_id(); let mut value = sample.metric.into_value(); if let JmapValue::Object(obj) = &mut value { obj.insert_unchecked( Property::Timestamp, JmapValue::Str(UTCDateTime::from_timestamp(timestamp as i64).to_string().into()), ); + // Histograms are running totals per node; without this a reader + // diffs one node's total against another's + obj.insert_unchecked(Property::NodeId, JmapValue::Number(node_id.into())); } value } diff --git a/crates/utils/src/snowflake.rs b/crates/utils/src/snowflake.rs index 7b5a8f5..35b4c6d 100644 --- a/crates/utils/src/snowflake.rs +++ b/crates/utils/src/snowflake.rs @@ -108,6 +108,12 @@ impl SnowflakeIdGenerator { (id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH } + // inbuxa: the node that made the id, so per-node history (metric + // totals) can be told apart + pub fn to_node_id(id: u64) -> u64 { + id & NODE_ID_MASK + } + #[inline(always)] pub fn past_id(&self, period: Duration) -> Option { self.epoch.elapsed().ok().map(|elapsed| { diff --git a/tests/src/telemetry/metrics.rs b/tests/src/telemetry/metrics.rs index f1ef62c..34515e4 100644 --- a/tests/src/telemetry/metrics.rs +++ b/tests/src/telemetry/metrics.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServer; @@ -53,6 +55,15 @@ pub async fn test(test: &TestServer) { ); assert_eq!(metrics.len(), metric_ids.len()); + // Every sample says which node wrote it, so histogram totals can be + // diffed per node + for metric in metrics { + assert!( + metric.get("nodeId").is_some_and(|v| v.is_u64()), + "Missing nodeId in {metric}" + ); + } + // Fetch the last 48 hours of metrics let metric_ids = admin .registry_query(