1use crate::types::HopError;
20use prometheus_endpoint::{
21 register, Counter, CounterVec, Gauge, Opts, PrometheusError, Registry, U64,
22};
23
24pub mod removal_reasons {
26 pub const ACKED: &str = "acked";
28 pub const EXPIRED_PROMOTED: &str = "expired_promoted";
30 pub const EXPIRED_UNPROMOTED: &str = "expired_unpromoted";
33 pub const CORRUPT: &str = "corrupt";
35 pub const STARTUP_DROPPED: &str = "startup_dropped";
38
39 pub const ALL: [&str; 5] =
41 [ACKED, EXPIRED_PROMOTED, EXPIRED_UNPROMOTED, CORRUPT, STARTUP_DROPPED];
42}
43
44pub 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
52fn 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 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 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
172pub struct HopMetrics {
174 inner: Option<Inner>,
175}
176
177impl HopMetrics {
178 pub fn new(registry: Option<&Registry>) -> Result<Self, PrometheusError> {
180 Ok(Self { inner: registry.map(Inner::register).transpose()? })
181 }
182
183 pub fn disabled() -> Self {
185 Self { inner: None }
186 }
187
188 pub(crate) fn is_enabled(&self) -> bool {
190 self.inner.is_some()
191 }
192
193 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 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 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 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 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 pub(crate) fn record_promotion_confirmed(&self) {
235 if let Some(inner) = &self.inner {
236 inner.promotions_confirmed_total.inc();
237 }
238 }
239
240 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 pub(crate) fn record_maintenance_tick(&self) {
249 if let Some(inner) = &self.inner {
250 inner.maintenance_ticks_total.inc();
251 }
252 }
253
254 #[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 #[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 #[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 #[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 #[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(®istry)).unwrap().is_enabled());
318
319 assert!(matches!(HopMetrics::new(Some(®istry)), Err(PrometheusError::AlreadyReg)));
321
322 let fallback = HopMetrics::new(Some(®istry)).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(®istry)).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(®istry)).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}