Skip to main content

rocketmq_observability/metrics/
client.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub use crate::semantic::metrics::CLIENT_CONSUME_LATENCY;
16pub use crate::semantic::metrics::CLIENT_CONSUME_TOTAL;
17pub use crate::semantic::metrics::CLIENT_NAMESRV_DISCOVERY_ENDPOINT_COUNT;
18pub use crate::semantic::metrics::CLIENT_NAMESRV_DISCOVERY_FRESHNESS;
19pub use crate::semantic::metrics::CLIENT_NAMESRV_DISCOVERY_REFRESH_TOTAL;
20pub use crate::semantic::metrics::CLIENT_NAMESRV_DISCOVERY_SNAPSHOT_AGE;
21pub use crate::semantic::metrics::CLIENT_NAMESRV_FAILOVER_TOTAL;
22pub use crate::semantic::metrics::CLIENT_ONEWAY_EGRESS_BYTES;
23pub use crate::semantic::metrics::CLIENT_ONEWAY_EGRESS_EVENTS_TOTAL;
24pub use crate::semantic::metrics::CLIENT_ONEWAY_EGRESS_ITEMS;
25pub use crate::semantic::metrics::CLIENT_ONEWAY_EGRESS_OLDEST_AGE;
26pub use crate::semantic::metrics::CLIENT_ONEWAY_EGRESS_WAITERS;
27pub use crate::semantic::metrics::CLIENT_REBALANCE_TOTAL;
28pub use crate::semantic::metrics::CLIENT_SEND_LATENCY;
29pub use crate::semantic::metrics::CLIENT_SEND_TOTAL;
30
31use std::time::Duration;
32
33/// Fixed discovery refresh outcomes permitted as metric labels.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum NameServerDiscoveryRefreshResult {
36    Success,
37    Error,
38}
39
40#[cfg(feature = "otel-metrics")]
41impl NameServerDiscoveryRefreshResult {
42    const fn as_str(self) -> &'static str {
43        match self {
44            Self::Success => "success",
45            Self::Error => "error",
46        }
47    }
48}
49
50/// Fixed freshness states permitted as metric labels.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum NameServerDiscoveryFreshness {
53    Fresh,
54    Stale,
55    Unavailable,
56}
57
58#[cfg(feature = "otel-metrics")]
59impl NameServerDiscoveryFreshness {
60    const fn as_str(self) -> &'static str {
61        match self {
62            Self::Fresh => "fresh",
63            Self::Stale => "stale",
64            Self::Unavailable => "unavailable",
65        }
66    }
67}
68
69/// Fixed failover reasons permitted as metric labels.
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub enum NameServerFailoverReason {
72    ConnectFailure,
73    Unhealthy,
74    CircuitOpen,
75    Draining,
76}
77
78#[cfg(feature = "otel-metrics")]
79impl NameServerFailoverReason {
80    const fn as_str(self) -> &'static str {
81        match self {
82            Self::ConnectFailure => "connect_failure",
83            Self::Unhealthy => "unhealthy",
84            Self::CircuitOpen => "circuit_open",
85            Self::Draining => "draining",
86        }
87    }
88}
89
90#[cfg(feature = "otel-metrics")]
91#[inline]
92fn duration_millis_u64(duration: Duration) -> u64 {
93    duration.as_millis().clamp(0, u128::from(u64::MAX)) as u64
94}
95
96#[cfg(not(feature = "otel-metrics"))]
97#[derive(Debug, Clone, Default)]
98pub struct ClientMetrics;
99
100#[cfg(not(feature = "otel-metrics"))]
101impl ClientMetrics {
102    /// Creates an instance-scoped recorder that never emits metrics.
103    #[must_use]
104    pub const fn noop() -> Self {
105        Self
106    }
107
108    /// Creates a recorder from an explicit telemetry capability.
109    #[must_use]
110    pub fn from_handle(_telemetry: &crate::TelemetryHandle) -> Self {
111        Self::noop()
112    }
113
114    /// Returns whether this recorder can currently emit metrics.
115    #[must_use]
116    pub const fn is_enabled(&self) -> bool {
117        false
118    }
119
120    #[inline]
121    pub fn record_send_total(&self, _count: u64) {}
122
123    #[inline]
124    pub fn record_send_latency(&self, _latency_ms: u64) {}
125
126    #[inline]
127    pub fn record_consume_total(&self, _count: u64) {}
128
129    #[inline]
130    pub fn record_consume_latency(&self, _latency_ms: u64) {}
131
132    #[inline]
133    pub fn record_rebalance_total(&self, _count: u64) {}
134
135    #[inline]
136    pub fn record_send(&self, _elapsed: Duration) {}
137
138    #[inline]
139    pub fn record_consume(&self, _message_count: usize, _latency_ms: u64) {}
140
141    #[inline]
142    pub fn record_rebalance(&self) {}
143
144    #[inline]
145    pub fn record_oneway_egress_state(&self, _items: u64, _bytes: u64, _oldest_age_ms: u64, _waiters: u64) {}
146
147    #[inline]
148    pub fn record_oneway_egress_event(&self, _result: &'static str) {}
149
150    #[inline]
151    pub fn record_nameserver_discovery_refresh(&self, _result: NameServerDiscoveryRefreshResult) {}
152
153    #[inline]
154    pub fn record_nameserver_discovery_snapshot(
155        &self,
156        _freshness: NameServerDiscoveryFreshness,
157        _ipv4_count: u64,
158        _ipv6_count: u64,
159        _snapshot_age: Duration,
160    ) {
161    }
162
163    #[inline]
164    pub fn record_nameserver_failover(&self, _reason: NameServerFailoverReason) {}
165}
166
167#[cfg(feature = "otel-metrics")]
168#[derive(Clone, Default)]
169pub struct ClientMetrics {
170    telemetry: Option<crate::TelemetryHandle>,
171    instruments: Option<ClientMetricInstruments>,
172}
173
174#[cfg(feature = "otel-metrics")]
175#[derive(Clone)]
176struct ClientMetricInstruments {
177    send_total: opentelemetry::metrics::Counter<u64>,
178    send_latency: opentelemetry::metrics::Histogram<u64>,
179    consume_total: opentelemetry::metrics::Counter<u64>,
180    consume_latency: opentelemetry::metrics::Histogram<u64>,
181    rebalance_total: opentelemetry::metrics::Counter<u64>,
182    oneway_egress_items: opentelemetry::metrics::Gauge<u64>,
183    oneway_egress_bytes: opentelemetry::metrics::Gauge<u64>,
184    oneway_egress_oldest_age: opentelemetry::metrics::Gauge<u64>,
185    oneway_egress_waiters: opentelemetry::metrics::Gauge<u64>,
186    oneway_egress_events_total: opentelemetry::metrics::Counter<u64>,
187    nameserver_discovery_refresh_total: opentelemetry::metrics::Counter<u64>,
188    nameserver_discovery_endpoint_count: opentelemetry::metrics::Gauge<u64>,
189    nameserver_discovery_freshness: opentelemetry::metrics::Gauge<u64>,
190    nameserver_discovery_snapshot_age: opentelemetry::metrics::Gauge<u64>,
191    nameserver_failover_total: opentelemetry::metrics::Counter<u64>,
192}
193
194#[cfg(feature = "otel-metrics")]
195impl ClientMetrics {
196    /// Creates an instance-scoped recorder that never emits metrics.
197    #[must_use]
198    pub fn noop() -> Self {
199        Self::default()
200    }
201
202    /// Creates a lifecycle-gated recorder from an explicit telemetry capability.
203    #[must_use]
204    pub fn from_handle(telemetry: &crate::TelemetryHandle) -> Self {
205        let Some(meter) = telemetry.meter(crate::CLIENT_METER_SCOPE) else {
206            return Self::noop();
207        };
208        Self {
209            telemetry: Some(telemetry.clone()),
210            instruments: Some(ClientMetricInstruments::new(&meter)),
211        }
212    }
213
214    /// Creates an unmanaged recorder from a caller-owned meter.
215    ///
216    /// Runtime composition should prefer [`Self::from_handle`] so records stop when the owning
217    /// telemetry runtime closes.
218    #[must_use]
219    #[cfg(test)]
220    pub(crate) fn new(meter: &opentelemetry::metrics::Meter) -> Self {
221        Self {
222            telemetry: None,
223            instruments: Some(ClientMetricInstruments::new(meter)),
224        }
225    }
226
227    /// Returns whether this recorder can currently emit metrics.
228    #[must_use]
229    pub fn is_enabled(&self) -> bool {
230        self.instruments.is_some() && self.telemetry.as_ref().is_none_or(crate::TelemetryHandle::is_active)
231    }
232
233    #[inline]
234    pub fn record_send_total(&self, count: u64, attributes: &crate::MetricAttributes) {
235        if self.is_enabled() {
236            if let Some(instruments) = &self.instruments {
237                instruments.send_total.add(count, attributes.as_key_values());
238            }
239        }
240    }
241
242    #[inline]
243    pub fn record_send_latency(&self, latency_ms: u64, attributes: &crate::MetricAttributes) {
244        if self.is_enabled() {
245            if let Some(instruments) = &self.instruments {
246                instruments.send_latency.record(latency_ms, attributes.as_key_values());
247            }
248        }
249    }
250
251    #[inline]
252    pub fn record_consume_total(&self, count: u64, attributes: &crate::MetricAttributes) {
253        if self.is_enabled() {
254            if let Some(instruments) = &self.instruments {
255                instruments.consume_total.add(count, attributes.as_key_values());
256            }
257        }
258    }
259
260    #[inline]
261    pub fn record_consume_latency(&self, latency_ms: u64, attributes: &crate::MetricAttributes) {
262        if self.is_enabled() {
263            if let Some(instruments) = &self.instruments {
264                instruments
265                    .consume_latency
266                    .record(latency_ms, attributes.as_key_values());
267            }
268        }
269    }
270
271    #[inline]
272    pub fn record_rebalance_total(&self, count: u64, attributes: &crate::MetricAttributes) {
273        if self.is_enabled() {
274            if let Some(instruments) = &self.instruments {
275                instruments.rebalance_total.add(count, attributes.as_key_values());
276            }
277        }
278    }
279
280    #[inline]
281    pub fn record_send(&self, elapsed: Duration) {
282        self.record_send_total(1, &crate::MetricAttributes::new());
283        self.record_send_latency(duration_millis_u64(elapsed), &crate::MetricAttributes::new());
284    }
285
286    #[inline]
287    pub fn record_consume(&self, message_count: usize, latency_ms: u64) {
288        self.record_consume_total(message_count as u64, &crate::MetricAttributes::new());
289        self.record_consume_latency(latency_ms, &crate::MetricAttributes::new());
290    }
291
292    #[inline]
293    pub fn record_rebalance(&self) {
294        self.record_rebalance_total(1, &crate::MetricAttributes::new());
295    }
296
297    #[inline]
298    pub fn record_oneway_egress_state(&self, items: u64, bytes: u64, oldest_age_ms: u64, waiters: u64) {
299        if self.is_enabled() {
300            if let Some(instruments) = &self.instruments {
301                instruments.oneway_egress_items.record(items, &[]);
302                instruments.oneway_egress_bytes.record(bytes, &[]);
303                instruments.oneway_egress_oldest_age.record(oldest_age_ms, &[]);
304                instruments.oneway_egress_waiters.record(waiters, &[]);
305            }
306        }
307    }
308
309    #[inline]
310    pub fn record_oneway_egress_event(&self, result: &'static str) {
311        if self.is_enabled() {
312            if let Some(instruments) = &self.instruments {
313                instruments.oneway_egress_events_total.add(
314                    1,
315                    &[opentelemetry::KeyValue::new(crate::semantic::labels::RESULT, result)],
316                );
317            }
318        }
319    }
320
321    #[inline]
322    pub fn record_nameserver_discovery_refresh(&self, result: NameServerDiscoveryRefreshResult) {
323        if self.is_enabled() {
324            if let Some(instruments) = &self.instruments {
325                instruments.nameserver_discovery_refresh_total.add(
326                    1,
327                    &[
328                        opentelemetry::KeyValue::new(crate::semantic::labels::SOURCE_KIND, "dns"),
329                        opentelemetry::KeyValue::new(crate::semantic::labels::RESULT, result.as_str()),
330                    ],
331                );
332            }
333        }
334    }
335
336    #[inline]
337    pub fn record_nameserver_discovery_snapshot(
338        &self,
339        freshness: NameServerDiscoveryFreshness,
340        ipv4_count: u64,
341        ipv6_count: u64,
342        snapshot_age: Duration,
343    ) {
344        if !self.is_enabled() {
345            return;
346        }
347        if let Some(instruments) = &self.instruments {
348            let source = opentelemetry::KeyValue::new(crate::semantic::labels::SOURCE_KIND, "dns");
349            instruments.nameserver_discovery_endpoint_count.record(
350                ipv4_count,
351                &[
352                    source.clone(),
353                    opentelemetry::KeyValue::new(crate::semantic::labels::ADDRESS_FAMILY, "ipv4"),
354                ],
355            );
356            instruments.nameserver_discovery_endpoint_count.record(
357                ipv6_count,
358                &[
359                    source.clone(),
360                    opentelemetry::KeyValue::new(crate::semantic::labels::ADDRESS_FAMILY, "ipv6"),
361                ],
362            );
363            let state = [
364                source,
365                opentelemetry::KeyValue::new(crate::semantic::labels::FRESHNESS, freshness.as_str()),
366            ];
367            instruments.nameserver_discovery_freshness.record(1, &state);
368            instruments
369                .nameserver_discovery_snapshot_age
370                .record(snapshot_age.as_secs(), &state);
371        }
372    }
373
374    #[inline]
375    pub fn record_nameserver_failover(&self, reason: NameServerFailoverReason) {
376        if self.is_enabled() {
377            if let Some(instruments) = &self.instruments {
378                instruments.nameserver_failover_total.add(
379                    1,
380                    &[opentelemetry::KeyValue::new(
381                        crate::semantic::labels::REASON,
382                        reason.as_str(),
383                    )],
384                );
385            }
386        }
387    }
388}
389
390#[cfg(feature = "otel-metrics")]
391impl ClientMetricInstruments {
392    fn new(meter: &opentelemetry::metrics::Meter) -> Self {
393        let send_total = meter
394            .u64_counter(CLIENT_SEND_TOTAL)
395            .with_description("Total number of messages sent by client")
396            .with_unit("{message}")
397            .build();
398
399        let send_latency = meter
400            .u64_histogram(CLIENT_SEND_LATENCY)
401            .with_description("Client send latency")
402            .with_unit("ms")
403            .build();
404
405        let consume_total = meter
406            .u64_counter(CLIENT_CONSUME_TOTAL)
407            .with_description("Total number of messages consumed by client")
408            .with_unit("{message}")
409            .build();
410
411        let consume_latency = meter
412            .u64_histogram(CLIENT_CONSUME_LATENCY)
413            .with_description("Client consume latency")
414            .with_unit("ms")
415            .build();
416
417        let rebalance_total = meter
418            .u64_counter(CLIENT_REBALANCE_TOTAL)
419            .with_description("Total number of client rebalance events")
420            .with_unit("{event}")
421            .build();
422        let oneway_egress_items = meter
423            .u64_gauge(CLIENT_ONEWAY_EGRESS_ITEMS)
424            .with_description("Current one-way egress messages charged to the process budget")
425            .with_unit("{message}")
426            .build();
427        let oneway_egress_bytes = meter
428            .u64_gauge(CLIENT_ONEWAY_EGRESS_BYTES)
429            .with_description("Current one-way egress retained bytes charged to the process budget")
430            .with_unit("By")
431            .build();
432        let oneway_egress_oldest_age = meter
433            .u64_gauge(CLIENT_ONEWAY_EGRESS_OLDEST_AGE)
434            .with_description("Age of the oldest one-way egress message")
435            .with_unit("ms")
436            .build();
437        let oneway_egress_waiters = meter
438            .u64_gauge(CLIENT_ONEWAY_EGRESS_WAITERS)
439            .with_description("Current one-way egress admission waiters")
440            .with_unit("{waiter}")
441            .build();
442        let oneway_egress_events_total = meter
443            .u64_counter(CLIENT_ONEWAY_EGRESS_EVENTS_TOTAL)
444            .with_description("One-way egress accepted, delivered, failed, cancelled, and rejected events")
445            .with_unit("{event}")
446            .build();
447        let nameserver_discovery_refresh_total = meter
448            .u64_counter(CLIENT_NAMESRV_DISCOVERY_REFRESH_TOTAL)
449            .with_description("NameServer discovery refresh outcomes")
450            .with_unit("{refresh}")
451            .build();
452        let nameserver_discovery_endpoint_count = meter
453            .u64_gauge(CLIENT_NAMESRV_DISCOVERY_ENDPOINT_COUNT)
454            .with_description("Current NameServer discovery endpoint count by address family")
455            .with_unit("{endpoint}")
456            .build();
457        let nameserver_discovery_freshness = meter
458            .u64_gauge(CLIENT_NAMESRV_DISCOVERY_FRESHNESS)
459            .with_description("Current NameServer discovery freshness state")
460            .with_unit("1")
461            .build();
462        let nameserver_discovery_snapshot_age = meter
463            .u64_gauge(CLIENT_NAMESRV_DISCOVERY_SNAPSHOT_AGE)
464            .with_description("Age of the current NameServer discovery snapshot")
465            .with_unit("s")
466            .build();
467        let nameserver_failover_total = meter
468            .u64_counter(CLIENT_NAMESRV_FAILOVER_TOTAL)
469            .with_description("NameServer failover events grouped by bounded reason")
470            .with_unit("{failover}")
471            .build();
472
473        Self {
474            send_total,
475            send_latency,
476            consume_total,
477            consume_latency,
478            rebalance_total,
479            oneway_egress_items,
480            oneway_egress_bytes,
481            oneway_egress_oldest_age,
482            oneway_egress_waiters,
483            oneway_egress_events_total,
484            nameserver_discovery_refresh_total,
485            nameserver_discovery_endpoint_count,
486            nameserver_discovery_freshness,
487            nameserver_discovery_snapshot_age,
488            nameserver_failover_total,
489        }
490    }
491}
492
493#[cfg(all(test, feature = "otel-metrics"))]
494mod tests {
495    use opentelemetry::metrics::MeterProvider;
496    use opentelemetry_sdk::metrics::SdkMeterProvider;
497
498    use super::*;
499
500    #[test]
501    fn client_metrics_constructs_and_records() {
502        let provider = SdkMeterProvider::builder().build();
503        let meter = provider.meter("client-metrics-test");
504        let metrics = ClientMetrics::new(&meter);
505        let attrs = crate::MetricAttributes::new().with("client_id", "client-a");
506
507        metrics.record_send_total(1, &attrs);
508        metrics.record_send_latency(10, &attrs);
509        metrics.record_consume_total(1, &attrs);
510        metrics.record_consume_latency(8, &attrs);
511        metrics.record_rebalance_total(1, &attrs);
512        metrics.record_send(Duration::from_millis(10));
513        metrics.record_consume(1, 8);
514        metrics.record_rebalance();
515        metrics.record_nameserver_discovery_refresh(NameServerDiscoveryRefreshResult::Success);
516        metrics.record_nameserver_discovery_snapshot(NameServerDiscoveryFreshness::Fresh, 2, 1, Duration::from_secs(3));
517        metrics.record_nameserver_failover(NameServerFailoverReason::ConnectFailure);
518        assert!(metrics.is_enabled());
519    }
520
521    #[test]
522    fn nameserver_metric_labels_are_closed_fixed_enums() {
523        assert_eq!(NameServerDiscoveryRefreshResult::Success.as_str(), "success");
524        assert_eq!(NameServerDiscoveryRefreshResult::Error.as_str(), "error");
525        assert_eq!(NameServerDiscoveryFreshness::Fresh.as_str(), "fresh");
526        assert_eq!(NameServerDiscoveryFreshness::Stale.as_str(), "stale");
527        assert_eq!(NameServerDiscoveryFreshness::Unavailable.as_str(), "unavailable");
528        assert_eq!(NameServerFailoverReason::ConnectFailure.as_str(), "connect_failure");
529        assert_eq!(NameServerFailoverReason::Unhealthy.as_str(), "unhealthy");
530        assert_eq!(NameServerFailoverReason::CircuitOpen.as_str(), "circuit_open");
531        assert_eq!(NameServerFailoverReason::Draining.as_str(), "draining");
532    }
533
534    #[test]
535    fn client_noop_recorder_is_disabled() {
536        let metrics = ClientMetrics::from_handle(&crate::TelemetryHandle::noop());
537
538        metrics.record_send(Duration::from_millis(1));
539        metrics.record_consume(1, 2);
540        metrics.record_rebalance();
541
542        assert!(!metrics.is_enabled());
543    }
544
545    #[test]
546    fn client_metrics_source_has_no_process_global_facade() {
547        let source = include_str!("client.rs");
548
549        for forbidden in [
550            concat!("Once", "Lock"),
551            concat!("fn ", "init_global"),
552            concat!("fn ", "global_metrics"),
553            concat!("pub fn ", "record_send(elapsed"),
554            concat!("pub fn ", "record_consume(message_count"),
555            concat!("pub fn ", "record_rebalance()"),
556        ] {
557            assert!(
558                !source.contains(forbidden),
559                "client metrics must remain instance scoped: found {forbidden}"
560            );
561        }
562    }
563}