1pub 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#[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#[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#[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 #[must_use]
104 pub const fn noop() -> Self {
105 Self
106 }
107
108 #[must_use]
110 pub fn from_handle(_telemetry: &crate::TelemetryHandle) -> Self {
111 Self::noop()
112 }
113
114 #[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 #[must_use]
198 pub fn noop() -> Self {
199 Self::default()
200 }
201
202 #[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 #[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 #[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}