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 HopError::Db(_) => "db",
76 }
77}
78
79struct Inner {
80 pool_entries: Gauge<U64>,
81 pool_bytes: Gauge<U64>,
82 pool_max_bytes: Gauge<U64>,
83 pool_inserted_bytes_total: Counter<U64>,
84 pool_removed_total: CounterVec<U64>,
85 rpc_errors_total: CounterVec<U64>,
86 promotions_confirmed_total: Counter<U64>,
87 promotion_backlog: Gauge<U64>,
88 maintenance_ticks_total: Counter<U64>,
89}
90
91impl Inner {
92 fn register(registry: &Registry) -> Result<Self, PrometheusError> {
93 let pool_removed_total = register(
94 CounterVec::new(
95 Opts::new(
96 "substrate_hop_pool_removed_total",
97 "Total number of entries removed from the HOP pool, by reason",
98 ),
99 &["reason"],
100 )?,
101 registry,
102 )?;
103 for reason in removal_reasons::ALL {
106 pool_removed_total.with_label_values(&[reason]);
107 }
108 Ok(Self {
109 pool_entries: register(
110 Gauge::new("substrate_hop_pool_entries", "Number of entries in the HOP pool")?,
111 registry,
112 )?,
113 pool_bytes: register(
114 Gauge::new(
115 "substrate_hop_pool_bytes",
116 "Accounted size of the HOP pool in bytes (data plus per-recipient overhead)",
117 )?,
118 registry,
119 )?,
120 pool_max_bytes: register(
121 Gauge::new(
122 "substrate_hop_pool_max_bytes",
123 "Configured maximum HOP pool size in bytes",
124 )?,
125 registry,
126 )?,
127 pool_inserted_bytes_total: register(
128 Counter::new(
129 "substrate_hop_pool_inserted_bytes_total",
130 "Total accounted bytes successfully inserted into the HOP pool",
131 )?,
132 registry,
133 )?,
134 pool_removed_total,
135 rpc_errors_total: register(
139 CounterVec::new(
140 Opts::new(
141 "substrate_hop_rpc_errors_total",
142 "Total number of failed HOP RPC requests, by method and reason",
143 ),
144 &["method", "reason"],
145 )?,
146 registry,
147 )?,
148 promotions_confirmed_total: register(
149 Counter::new(
150 "substrate_hop_promotions_confirmed_total",
151 "Total number of HOP entries confirmed as promoted on-chain",
152 )?,
153 registry,
154 )?,
155 promotion_backlog: register(
156 Gauge::new(
157 "substrate_hop_promotion_backlog",
158 "Number of unpromoted HOP entries inside the promotion window",
159 )?,
160 registry,
161 )?,
162 maintenance_ticks_total: register(
163 Counter::new(
164 "substrate_hop_maintenance_ticks_total",
165 "Total number of completed HOP maintenance cycles (promotion + cleanup)",
166 )?,
167 registry,
168 )?,
169 })
170 }
171}
172
173pub struct HopMetrics {
175 inner: Option<Inner>,
176}
177
178impl HopMetrics {
179 pub fn new(registry: Option<&Registry>) -> Result<Self, PrometheusError> {
181 Ok(Self { inner: registry.map(Inner::register).transpose()? })
182 }
183
184 pub fn disabled() -> Self {
186 Self { inner: None }
187 }
188
189 pub(crate) fn is_enabled(&self) -> bool {
191 self.inner.is_some()
192 }
193
194 pub(crate) fn set_pool_status(&self, entries: u64, bytes: u64, max_bytes: u64) {
196 if let Some(inner) = &self.inner {
197 inner.pool_entries.set(entries);
198 inner.pool_bytes.set(bytes);
199 inner.pool_max_bytes.set(max_bytes);
200 }
201 }
202
203 pub(crate) fn set_pool_size(&self, entries: u64, bytes: u64) {
207 if let Some(inner) = &self.inner {
208 inner.pool_entries.set(entries);
209 inner.pool_bytes.set(bytes);
210 }
211 }
212
213 pub(crate) fn record_inserted_bytes(&self, accounted: u64) {
215 if let Some(inner) = &self.inner {
216 inner.pool_inserted_bytes_total.inc_by(accounted);
217 }
218 }
219
220 pub(crate) fn record_removed(&self, reason: &str, entries: u64) {
222 if let Some(inner) = &self.inner {
223 inner.pool_removed_total.with_label_values(&[reason]).inc_by(entries);
224 }
225 }
226
227 pub(crate) fn record_rpc_error(&self, method: &str, err: &HopError) {
229 if let Some(inner) = &self.inner {
230 inner.rpc_errors_total.with_label_values(&[method, error_label(err)]).inc();
231 }
232 }
233
234 pub(crate) fn record_promotion_confirmed(&self) {
236 if let Some(inner) = &self.inner {
237 inner.promotions_confirmed_total.inc();
238 }
239 }
240
241 pub(crate) fn set_promotion_backlog(&self, backlog: u64) {
243 if let Some(inner) = &self.inner {
244 inner.promotion_backlog.set(backlog);
245 }
246 }
247
248 pub(crate) fn record_maintenance_tick(&self) {
250 if let Some(inner) = &self.inner {
251 inner.maintenance_ticks_total.inc();
252 }
253 }
254
255 #[cfg(test)]
257 pub(crate) fn removed_count(&self, reason: &str) -> u64 {
258 self.inner
259 .as_ref()
260 .map(|i| i.pool_removed_total.with_label_values(&[reason]).get())
261 .unwrap_or(0)
262 }
263
264 #[cfg(test)]
266 pub(crate) fn pool_gauges(&self) -> (u64, u64) {
267 self.inner
268 .as_ref()
269 .map(|i| (i.pool_entries.get(), i.pool_bytes.get()))
270 .unwrap_or((0, 0))
271 }
272
273 #[cfg(test)]
275 pub(crate) fn promotions_confirmed(&self) -> u64 {
276 self.inner.as_ref().map(|i| i.promotions_confirmed_total.get()).unwrap_or(0)
277 }
278
279 #[cfg(test)]
281 pub(crate) fn promotion_backlog(&self) -> u64 {
282 self.inner.as_ref().map(|i| i.promotion_backlog.get()).unwrap_or(0)
283 }
284
285 #[cfg(test)]
287 pub(crate) fn rpc_error_count(&self, method: &str, reason: &str) -> u64 {
288 self.inner
289 .as_ref()
290 .map(|i| i.rpc_errors_total.with_label_values(&[method, reason]).get())
291 .unwrap_or(0)
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn no_registry_or_disabled_means_no_ops() {
301 assert!(!HopMetrics::new(None).unwrap().is_enabled());
302
303 let metrics = HopMetrics::disabled();
304 metrics.set_pool_status(1, 2, 3);
305 metrics.set_pool_size(1, 2);
306 metrics.record_inserted_bytes(42);
307 metrics.record_removed(removal_reasons::ACKED, 1);
308 metrics.record_rpc_error(rpc_methods::SUBMIT, &HopError::NotFound);
309 metrics.record_promotion_confirmed();
310 metrics.set_promotion_backlog(7);
311 metrics.record_maintenance_tick();
312 assert_eq!(metrics.removed_count(removal_reasons::ACKED), 0);
313 }
314
315 #[test]
316 fn duplicate_registration_fails_and_falls_back_to_disabled() {
317 let registry = Registry::new();
318 assert!(HopMetrics::new(Some(®istry)).unwrap().is_enabled());
319
320 assert!(matches!(HopMetrics::new(Some(®istry)), Err(PrometheusError::AlreadyReg)));
322
323 let fallback = HopMetrics::new(Some(®istry)).unwrap_or_else(|_| HopMetrics::disabled());
324 assert!(!fallback.is_enabled());
325 }
326
327 #[test]
328 fn removal_series_are_pre_created_at_registration() {
329 let registry = Registry::new();
330 let _metrics = HopMetrics::new(Some(®istry)).unwrap();
331
332 let family = registry
333 .gather()
334 .into_iter()
335 .find(|f| f.get_name() == "substrate_hop_pool_removed_total")
336 .expect("family is registered");
337 assert_eq!(family.get_metric().len(), removal_reasons::ALL.len());
338 }
339
340 #[test]
341 fn enabled_metrics_track_values() {
342 let registry = Registry::new();
343 let metrics = HopMetrics::new(Some(®istry)).unwrap();
344
345 metrics.set_pool_status(2, 200, 1000);
346 metrics.set_pool_size(3, 300);
347 metrics.record_inserted_bytes(100);
348 metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, 2);
349 metrics.record_rpc_error(rpc_methods::CLAIM, &HopError::NotFound);
350 metrics.record_promotion_confirmed();
351 metrics.set_promotion_backlog(4);
352 metrics.record_maintenance_tick();
353
354 let inner = metrics.inner.as_ref().unwrap();
355 assert_eq!(metrics.pool_gauges(), (3, 300));
356 assert_eq!(inner.pool_max_bytes.get(), 1000);
357 assert_eq!(inner.pool_inserted_bytes_total.get(), 100);
358 assert_eq!(metrics.removed_count(removal_reasons::EXPIRED_UNPROMOTED), 2);
359 assert_eq!(metrics.removed_count(removal_reasons::ACKED), 0);
360 assert_eq!(metrics.rpc_error_count(rpc_methods::CLAIM, "not_found"), 1);
361 assert_eq!(metrics.promotions_confirmed(), 1);
362 assert_eq!(metrics.promotion_backlog(), 4);
363 assert_eq!(inner.maintenance_ticks_total.get(), 1);
364 }
365}