referrerpolicy=no-referrer-when-downgrade

sc_hop/
metrics.rs

1// Copyright (C) Parity Technologies (UK) Ltd.
2// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
3
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17//! Prometheus metrics for the HOP pool, RPC, and promotion task.
18
19use crate::types::HopError;
20use prometheus_endpoint::{
21	register, Counter, CounterVec, Gauge, Opts, PrometheusError, Registry, U64,
22};
23
24/// `reason` label values for `substrate_hop_pool_removed_total`.
25pub mod removal_reasons {
26	/// All recipients acknowledged; the entry served its purpose.
27	pub const ACKED: &str = "acked";
28	/// Expired after the node observed the entry on-chain.
29	pub const EXPIRED_PROMOTED: &str = "expired_promoted";
30	/// Expired without the node ever observing the entry on-chain. An upper
31	/// bound on loss: re-checking stops at `MAX_PROMOTION_ATTEMPTS`.
32	pub const EXPIRED_UNPROMOTED: &str = "expired_unpromoted";
33	/// Blob failed its content-hash integrity check.
34	pub const CORRUPT: &str = "corrupt";
35	/// Lost to startup recovery: `.meta` unreadable, undecodable, stale-version,
36	/// or missing its blob.
37	pub const STARTUP_DROPPED: &str = "startup_dropped";
38
39	/// Every reason, for pre-creating the series at registration.
40	pub const ALL: [&str; 5] =
41		[ACKED, EXPIRED_PROMOTED, EXPIRED_UNPROMOTED, CORRUPT, STARTUP_DROPPED];
42}
43
44/// `method` label values: the wire names, so they join against the RPC
45/// middleware's `substrate_rpc_calls_*`.
46pub mod rpc_methods {
47	pub const SUBMIT: &str = "hop_submit";
48	pub const CLAIM: &str = "hop_claim";
49	pub const ACK: &str = "hop_ack";
50}
51
52/// Stable low-cardinality label for a [`HopError`] variant.
53fn error_label(err: &HopError) -> &'static str {
54	match err {
55		HopError::DataTooLarge(_, _) => "data_too_large",
56		HopError::PoolFull(_, _) => "pool_full",
57		HopError::DuplicateEntry => "duplicate_entry",
58		HopError::NotFound => "not_found",
59		HopError::EmptyData => "empty_data",
60		HopError::InvalidSignature => "invalid_signature",
61		HopError::NotRecipient => "not_recipient",
62		HopError::NoRecipients => "no_recipients",
63		HopError::InvalidRecipientKey => "invalid_recipient_key",
64		HopError::UserQuotaExceeded { .. } => "user_quota_exceeded",
65		HopError::NotAuthorized => "not_authorized",
66		HopError::IoError(_) => "io_error",
67		HopError::InvalidSigner => "invalid_signer",
68		HopError::AlreadyClaimed => "already_claimed",
69		HopError::InvalidHashLength(_) => "invalid_hash_length",
70		HopError::RuntimeApiError(_) => "runtime_api_error",
71		HopError::TooManyRecipients { .. } => "too_many_recipients",
72		HopError::DuplicateRecipient => "duplicate_recipient",
73		HopError::RateLimited { .. } => "rate_limited",
74		HopError::MissingDataDir => "missing_data_dir",
75	}
76}
77
78struct Inner {
79	pool_entries: Gauge<U64>,
80	pool_bytes: Gauge<U64>,
81	pool_max_bytes: Gauge<U64>,
82	pool_inserted_bytes_total: Counter<U64>,
83	pool_removed_total: CounterVec<U64>,
84	rpc_errors_total: CounterVec<U64>,
85	promotions_confirmed_total: Counter<U64>,
86	promotion_backlog: Gauge<U64>,
87	maintenance_ticks_total: Counter<U64>,
88}
89
90impl Inner {
91	fn register(registry: &Registry) -> Result<Self, PrometheusError> {
92		let pool_removed_total = register(
93			CounterVec::new(
94				Opts::new(
95					"substrate_hop_pool_removed_total",
96					"Total number of entries removed from the HOP pool, by reason",
97				),
98				&["reason"],
99			)?,
100			registry,
101		)?;
102		// A CounterVec exports nothing until a series is touched; pre-create
103		// them all so the first data-loss event is a visible 0->1 transition.
104		for reason in removal_reasons::ALL {
105			pool_removed_total.with_label_values(&[reason]);
106		}
107		Ok(Self {
108			pool_entries: register(
109				Gauge::new("substrate_hop_pool_entries", "Number of entries in the HOP pool")?,
110				registry,
111			)?,
112			pool_bytes: register(
113				Gauge::new(
114					"substrate_hop_pool_bytes",
115					"Accounted size of the HOP pool in bytes (data plus per-recipient overhead)",
116				)?,
117				registry,
118			)?,
119			pool_max_bytes: register(
120				Gauge::new(
121					"substrate_hop_pool_max_bytes",
122					"Configured maximum HOP pool size in bytes",
123				)?,
124				registry,
125			)?,
126			pool_inserted_bytes_total: register(
127				Counter::new(
128					"substrate_hop_pool_inserted_bytes_total",
129					"Total accounted bytes successfully inserted into the HOP pool",
130				)?,
131				registry,
132			)?,
133			pool_removed_total,
134			// Per-method call counts and durations are already covered by the
135			// RPC middleware (`substrate_rpc_calls_*`); this only adds the
136			// error-variant granularity the middleware cannot see.
137			rpc_errors_total: register(
138				CounterVec::new(
139					Opts::new(
140						"substrate_hop_rpc_errors_total",
141						"Total number of failed HOP RPC requests, by method and reason",
142					),
143					&["method", "reason"],
144				)?,
145				registry,
146			)?,
147			promotions_confirmed_total: register(
148				Counter::new(
149					"substrate_hop_promotions_confirmed_total",
150					"Total number of HOP entries confirmed as promoted on-chain",
151				)?,
152				registry,
153			)?,
154			promotion_backlog: register(
155				Gauge::new(
156					"substrate_hop_promotion_backlog",
157					"Number of unpromoted HOP entries inside the promotion window",
158				)?,
159				registry,
160			)?,
161			maintenance_ticks_total: register(
162				Counter::new(
163					"substrate_hop_maintenance_ticks_total",
164					"Total number of completed HOP maintenance cycles (promotion + cleanup)",
165				)?,
166				registry,
167			)?,
168		})
169	}
170}
171
172/// HOP metrics; every recorder is a no-op when built without a `Registry`.
173pub struct HopMetrics {
174	inner: Option<Inner>,
175}
176
177impl HopMetrics {
178	/// Register the metrics with the given Prometheus registry, if any.
179	pub fn new(registry: Option<&Registry>) -> Result<Self, PrometheusError> {
180		Ok(Self { inner: registry.map(Inner::register).transpose()? })
181	}
182
183	/// Create no-op metrics.
184	pub fn disabled() -> Self {
185		Self { inner: None }
186	}
187
188	/// Whether metrics were registered.
189	pub(crate) fn is_enabled(&self) -> bool {
190		self.inner.is_some()
191	}
192
193	/// Set the pool gauges to absolute values (used after disk recovery).
194	pub(crate) fn set_pool_status(&self, entries: u64, bytes: u64, max_bytes: u64) {
195		if let Some(inner) = &self.inner {
196			inner.pool_entries.set(entries);
197			inner.pool_bytes.set(bytes);
198			inner.pool_max_bytes.set(max_bytes);
199		}
200	}
201
202	/// Snapshot the pool size gauges from the authoritative counts. Snapshots
203	/// rather than inc/dec because a `Gauge<U64>` wraps on underflow; callers
204	/// publish under the pool's index lock so updates arrive in order.
205	pub(crate) fn set_pool_size(&self, entries: u64, bytes: u64) {
206		if let Some(inner) = &self.inner {
207			inner.pool_entries.set(entries);
208			inner.pool_bytes.set(bytes);
209		}
210	}
211
212	/// Count `accounted` bytes successfully inserted.
213	pub(crate) fn record_inserted_bytes(&self, accounted: u64) {
214		if let Some(inner) = &self.inner {
215			inner.pool_inserted_bytes_total.inc_by(accounted);
216		}
217	}
218
219	/// Record `entries` removals under `reason`.
220	pub(crate) fn record_removed(&self, reason: &str, entries: u64) {
221		if let Some(inner) = &self.inner {
222			inner.pool_removed_total.with_label_values(&[reason]).inc_by(entries);
223		}
224	}
225
226	/// Record one failed RPC request.
227	pub(crate) fn record_rpc_error(&self, method: &str, err: &HopError) {
228		if let Some(inner) = &self.inner {
229			inner.rpc_errors_total.with_label_values(&[method, error_label(err)]).inc();
230		}
231	}
232
233	/// Record one entry confirmed as promoted on-chain.
234	pub(crate) fn record_promotion_confirmed(&self) {
235		if let Some(inner) = &self.inner {
236			inner.promotions_confirmed_total.inc();
237		}
238	}
239
240	/// Set the promotion backlog gauge.
241	pub(crate) fn set_promotion_backlog(&self, backlog: u64) {
242		if let Some(inner) = &self.inner {
243			inner.promotion_backlog.set(backlog);
244		}
245	}
246
247	/// Count one completed maintenance cycle (liveness signal).
248	pub(crate) fn record_maintenance_tick(&self) {
249		if let Some(inner) = &self.inner {
250			inner.maintenance_ticks_total.inc();
251		}
252	}
253
254	/// Counter value of `substrate_hop_pool_removed_total{reason}`.
255	#[cfg(test)]
256	pub(crate) fn removed_count(&self, reason: &str) -> u64 {
257		self.inner
258			.as_ref()
259			.map(|i| i.pool_removed_total.with_label_values(&[reason]).get())
260			.unwrap_or(0)
261	}
262
263	/// Current value of the pool entries / bytes gauges.
264	#[cfg(test)]
265	pub(crate) fn pool_gauges(&self) -> (u64, u64) {
266		self.inner
267			.as_ref()
268			.map(|i| (i.pool_entries.get(), i.pool_bytes.get()))
269			.unwrap_or((0, 0))
270	}
271
272	/// Counter value of `substrate_hop_promotions_confirmed_total`.
273	#[cfg(test)]
274	pub(crate) fn promotions_confirmed(&self) -> u64 {
275		self.inner.as_ref().map(|i| i.promotions_confirmed_total.get()).unwrap_or(0)
276	}
277
278	/// Current value of the `substrate_hop_promotion_backlog` gauge.
279	#[cfg(test)]
280	pub(crate) fn promotion_backlog(&self) -> u64 {
281		self.inner.as_ref().map(|i| i.promotion_backlog.get()).unwrap_or(0)
282	}
283
284	/// Counter value of `substrate_hop_rpc_errors_total{method,reason}`.
285	#[cfg(test)]
286	pub(crate) fn rpc_error_count(&self, method: &str, reason: &str) -> u64 {
287		self.inner
288			.as_ref()
289			.map(|i| i.rpc_errors_total.with_label_values(&[method, reason]).get())
290			.unwrap_or(0)
291	}
292}
293
294#[cfg(test)]
295mod tests {
296	use super::*;
297
298	#[test]
299	fn no_registry_or_disabled_means_no_ops() {
300		assert!(!HopMetrics::new(None).unwrap().is_enabled());
301
302		let metrics = HopMetrics::disabled();
303		metrics.set_pool_status(1, 2, 3);
304		metrics.set_pool_size(1, 2);
305		metrics.record_inserted_bytes(42);
306		metrics.record_removed(removal_reasons::ACKED, 1);
307		metrics.record_rpc_error(rpc_methods::SUBMIT, &HopError::NotFound);
308		metrics.record_promotion_confirmed();
309		metrics.set_promotion_backlog(7);
310		metrics.record_maintenance_tick();
311		assert_eq!(metrics.removed_count(removal_reasons::ACKED), 0);
312	}
313
314	#[test]
315	fn duplicate_registration_fails_and_falls_back_to_disabled() {
316		let registry = Registry::new();
317		assert!(HopMetrics::new(Some(&registry)).unwrap().is_enabled());
318
319		// `HopParams::build_pool` degrades this error into disabled metrics.
320		assert!(matches!(HopMetrics::new(Some(&registry)), Err(PrometheusError::AlreadyReg)));
321
322		let fallback = HopMetrics::new(Some(&registry)).unwrap_or_else(|_| HopMetrics::disabled());
323		assert!(!fallback.is_enabled());
324	}
325
326	#[test]
327	fn removal_series_are_pre_created_at_registration() {
328		let registry = Registry::new();
329		let _metrics = HopMetrics::new(Some(&registry)).unwrap();
330
331		let family = registry
332			.gather()
333			.into_iter()
334			.find(|f| f.get_name() == "substrate_hop_pool_removed_total")
335			.expect("family is registered");
336		assert_eq!(family.get_metric().len(), removal_reasons::ALL.len());
337	}
338
339	#[test]
340	fn enabled_metrics_track_values() {
341		let registry = Registry::new();
342		let metrics = HopMetrics::new(Some(&registry)).unwrap();
343
344		metrics.set_pool_status(2, 200, 1000);
345		metrics.set_pool_size(3, 300);
346		metrics.record_inserted_bytes(100);
347		metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, 2);
348		metrics.record_rpc_error(rpc_methods::CLAIM, &HopError::NotFound);
349		metrics.record_promotion_confirmed();
350		metrics.set_promotion_backlog(4);
351		metrics.record_maintenance_tick();
352
353		let inner = metrics.inner.as_ref().unwrap();
354		assert_eq!(metrics.pool_gauges(), (3, 300));
355		assert_eq!(inner.pool_max_bytes.get(), 1000);
356		assert_eq!(inner.pool_inserted_bytes_total.get(), 100);
357		assert_eq!(metrics.removed_count(removal_reasons::EXPIRED_UNPROMOTED), 2);
358		assert_eq!(metrics.removed_count(removal_reasons::ACKED), 0);
359		assert_eq!(metrics.rpc_error_count(rpc_methods::CLAIM, "not_found"), 1);
360		assert_eq!(metrics.promotions_confirmed(), 1);
361		assert_eq!(metrics.promotion_backlog(), 4);
362		assert_eq!(inner.maintenance_ticks_total.get(), 1);
363	}
364}