Skip to main content

sui_rpc_api/
metrics.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use axum::http;
5use std::{
6    borrow::Cow,
7    collections::HashSet,
8    sync::Arc,
9    time::{Duration, Instant},
10};
11
12use prometheus::{
13    Histogram, HistogramTimer, HistogramVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec,
14    Registry, register_histogram_vec_with_registry, register_histogram_with_registry,
15    register_int_counter_vec_with_registry, register_int_counter_with_registry,
16    register_int_gauge_vec_with_registry, register_int_gauge_with_registry,
17};
18use prost::Message;
19use sui_http::middleware::callback::{MakeCallbackHandler, ResponseHandler};
20
21#[derive(Clone)]
22pub struct RpcMetrics {
23    inflight_requests: IntGaugeVec,
24    num_requests: IntCounterVec,
25    request_latency: HistogramVec,
26    request_handler_latency: HistogramVec,
27    first_chunk_latency: HistogramVec,
28}
29
30const LATENCY_SEC_BUCKETS: &[f64] = &[
31    0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1., 2.5, 5., 10., 20., 30., 60., 90.,
32];
33
34impl RpcMetrics {
35    pub fn new(registry: &Registry) -> Self {
36        Self {
37            inflight_requests: register_int_gauge_vec_with_registry!(
38                "rpc_inflight_requests",
39                "Total in-flight RPC requests per route",
40                &["path"],
41                registry,
42            )
43            .unwrap(),
44            num_requests: register_int_counter_vec_with_registry!(
45                "rpc_requests",
46                "Total RPC requests per route and their http status",
47                &["path", "status"],
48                registry,
49            )
50            .unwrap(),
51            request_latency: register_histogram_vec_with_registry!(
52                "rpc_request_latency",
53                "Latency of RPC requests per route, measured from receipt of the request \
54                 until the response body finished streaming back to the client",
55                &["path"],
56                LATENCY_SEC_BUCKETS.to_vec(),
57                registry,
58            )
59            .unwrap(),
60            request_handler_latency: register_histogram_vec_with_registry!(
61                "rpc_request_handler_latency",
62                "Latency of RPC requests per route, measured from receipt of the request \
63                 until the request handler produced a response, excluding the time spent \
64                 streaming the response body back to the client",
65                &["path"],
66                LATENCY_SEC_BUCKETS.to_vec(),
67                registry,
68            )
69            .unwrap(),
70            first_chunk_latency: register_histogram_vec_with_registry!(
71                "rpc_first_chunk_latency",
72                "Latency of RPC requests per route, measured from receipt of the request \
73                 until the first response body data chunk is produced. For streaming responses \
74                 this is when the first chunk is handed to the transport, which for gRPC \
75                 typically carries the first encoded message; response headers are excluded. \
76                 Responses whose body never yields a data chunk are not observed.",
77                &["path"],
78                LATENCY_SEC_BUCKETS.to_vec(),
79                registry,
80            )
81            .unwrap(),
82        }
83    }
84}
85
86#[derive(Clone)]
87pub(crate) struct ListApiMetrics {
88    list_first_frame_seconds: HistogramVec,
89    list_response_page_bytes: HistogramVec,
90    list_watermark_frames_total: IntCounterVec,
91    list_stream_yield_wait_seconds: HistogramVec,
92    list_render_seconds: HistogramVec,
93    list_chunk_seconds: HistogramVec,
94    list_query_ends_total: IntCounterVec,
95    list_bitmap_buckets_evaluated: HistogramVec,
96}
97
98impl ListApiMetrics {
99    pub(crate) fn new(registry: &Registry) -> Self {
100        Self {
101            list_first_frame_seconds: register_histogram_vec_with_registry!(
102                "list_first_frame_seconds",
103                "Time in seconds from List handler entry to the first response frame of any kind — data, watermark-only, or terminal — the client's first actionable signal; resolution label derived only from the validated read mask.",
104                &["method", "resolution"],
105                prometheus::exponential_buckets(0.001, 2.0, 17).unwrap(),
106                registry,
107            )
108            .unwrap(),
109            list_response_page_bytes: register_histogram_vec_with_registry!(
110                "list_response_page_bytes",
111                "Protobuf encoded size in bytes of data-bearing List response frames, measured with encoded_len without serializing or copying; watermark-only and terminal-only frames are excluded and counted by list_watermark_frames_total.",
112                &["method", "resolution"],
113                prometheus::exponential_buckets(1024.0, 2.0, 17).unwrap(),
114                registry,
115            )
116            .unwrap(),
117            list_watermark_frames_total: register_int_counter_vec_with_registry!(
118                "list_watermark_frames_total",
119                "Total watermark-only and terminal-only response frames emitted by List handlers; data-bearing frames are excluded.",
120                &["method"],
121                registry,
122            )
123            .unwrap(),
124            list_stream_yield_wait_seconds: register_histogram_vec_with_registry!(
125                "list_stream_yield_wait_seconds",
126                "Time from yielding one List response frame (any kind) until the handler stream is polled again; downstream transport consumption and backpressure signal.",
127                &["method", "resolution"],
128                prometheus::exponential_buckets(0.00001, 2.0, 24).unwrap(),
129                registry,
130            )
131            .unwrap(),
132            list_render_seconds: register_histogram_vec_with_registry!(
133                "list_render_seconds",
134                "Time in seconds spent rendering one data item into a List response frame. Request setup, scan-only watermark rendering, standalone terminal rendering, and chunk-level batch reads are excluded. The resolution label is derived only from the validated read mask.",
135                &["method", "resolution"],
136                prometheus::exponential_buckets(0.00001, 2.0, 24).unwrap(),
137                registry,
138            )
139            .unwrap(),
140            list_chunk_seconds: register_histogram_vec_with_registry!(
141                "list_chunk_seconds",
142                "Time in seconds for one blocking List chunk phase. queue spans immediately before spawn_blocking through entry into its closure; work spans execution of the blocking chunk and includes chunks that return an error.",
143                &["method", "phase"],
144                prometheus::exponential_buckets(0.0001, 2.0, 20).unwrap(),
145                registry,
146            )
147            .unwrap(),
148            list_query_ends_total: register_int_counter_vec_with_registry!(
149                "list_query_ends_total",
150                "Successful List streams by effective protocol QueryEndReason. Errors, cancellation, and dropped streams are excluded.",
151                &["method", "reason"],
152                registry,
153            )
154            .unwrap(),
155            list_bitmap_buckets_evaluated: register_histogram_vec_with_registry!(
156                "list_bitmap_buckets_evaluated",
157                "Total bitmap buckets evaluated across all blocking chunks of one successfully completed filtered List request. Unfiltered requests are not observed.",
158                &["method"],
159                prometheus::exponential_buckets(1.0, 2.0, 12).unwrap(),
160                registry,
161            )
162            .unwrap(),
163        }
164    }
165
166    pub(crate) fn stream_metrics(
167        &self,
168        method: &'static str,
169        resolution: &'static str,
170    ) -> ListStreamMetrics {
171        ListStreamMetrics {
172            method,
173            first_frame: self
174                .list_first_frame_seconds
175                .with_label_values(&[method, resolution]),
176            page_bytes: self
177                .list_response_page_bytes
178                .with_label_values(&[method, resolution]),
179            watermark_frames: self
180                .list_watermark_frames_total
181                .with_label_values(&[method]),
182            yield_wait: self
183                .list_stream_yield_wait_seconds
184                .with_label_values(&[method, resolution]),
185            render: self
186                .list_render_seconds
187                .with_label_values(&[method, resolution]),
188            chunk_queue: self
189                .list_chunk_seconds
190                .with_label_values(&[method, "queue"]),
191            chunk_work: self.list_chunk_seconds.with_label_values(&[method, "work"]),
192            query_ends: self.list_query_ends_total.clone(),
193            bitmap_buckets_evaluated: self
194                .list_bitmap_buckets_evaluated
195                .with_label_values(&[method]),
196        }
197    }
198}
199
200/// Set of `/package.Service/Method` paths that are safe to use as metric
201/// labels.
202///
203/// Services are mounted with the wildcard route `/{ServiceName}/{*rest}`, so
204/// any path under a registered prefix matches a route and would otherwise be
205/// taken verbatim as a `path` label. Bounding the labels to known methods
206/// prevents an unauthenticated attacker from inflating Prometheus label maps
207/// (which the prometheus crate retains for the lifetime of the process) by
208/// streaming requests with random method suffixes.
209pub type GrpcMethodAllowlist = Arc<HashSet<String>>;
210
211/// Decode one or more encoded `FileDescriptorSet` byte slices and return the
212/// set of `/package.Service/Method` paths they declare.
213///
214/// Intended to be called once at server startup with the same bytes that are
215/// registered with `tonic_reflection`, so the metrics allowlist stays in sync
216/// with the services actually exposed over gRPC.
217pub fn grpc_method_paths_from_file_descriptor_sets(
218    encoded_sets: &[&[u8]],
219) -> Result<HashSet<String>, prost::DecodeError> {
220    let mut paths = HashSet::new();
221    for bytes in encoded_sets {
222        let fds = prost_types::FileDescriptorSet::decode(*bytes)?;
223        for file in fds.file {
224            let package = file.package.unwrap_or_default();
225            for service in file.service {
226                let Some(service_name) = service.name else {
227                    continue;
228                };
229                let qualified_service = if package.is_empty() {
230                    service_name
231                } else {
232                    format!("{}.{}", package, service_name)
233                };
234                for method in service.method {
235                    let Some(method_name) = method.name else {
236                        continue;
237                    };
238                    paths.insert(format!("/{}/{}", qualified_service, method_name));
239                }
240            }
241        }
242    }
243    Ok(paths)
244}
245
246#[derive(Clone)]
247pub struct RpcMetricsMakeCallbackHandler {
248    metrics: Arc<RpcMetrics>,
249    grpc_method_allowlist: GrpcMethodAllowlist,
250}
251
252impl RpcMetricsMakeCallbackHandler {
253    /// Construct a handler with no gRPC method allowlist. All gRPC requests
254    /// will be labelled with their matched route pattern (e.g.
255    /// `/sui.rpc.v2.LedgerService/{*rest}`) rather than the per-method path,
256    /// which is safe but loses per-method granularity.
257    pub fn new(metrics: Arc<RpcMetrics>) -> Self {
258        Self::with_grpc_method_allowlist(metrics, Arc::new(HashSet::new()))
259    }
260
261    /// Construct a handler that uses `allowlist` to decide which gRPC request
262    /// paths are safe to emit as Prometheus labels.
263    pub fn with_grpc_method_allowlist(
264        metrics: Arc<RpcMetrics>,
265        allowlist: GrpcMethodAllowlist,
266    ) -> Self {
267        Self {
268            metrics,
269            grpc_method_allowlist: allowlist,
270        }
271    }
272}
273
274impl MakeCallbackHandler for RpcMetricsMakeCallbackHandler {
275    type RequestHandler = ();
276    type ResponseHandler = RpcMetricsCallbackHandler;
277
278    fn make_handler(
279        &self,
280        request: &http::request::Parts,
281    ) -> (Self::RequestHandler, Self::ResponseHandler) {
282        let start = Instant::now();
283        let metrics = self.metrics.clone();
284
285        let matched_path = request
286            .extensions
287            .get::<axum::extract::MatchedPath>()
288            .map(|m| m.as_str());
289        let is_grpc = request
290            .headers
291            .get(&http::header::CONTENT_TYPE)
292            .is_some_and(is_grpc_content_type);
293
294        let path = compute_metric_label(
295            is_grpc,
296            request.uri.path(),
297            matched_path,
298            &self.grpc_method_allowlist,
299        );
300
301        metrics
302            .inflight_requests
303            .with_label_values(&[path.as_ref()])
304            .inc();
305
306        (
307            (),
308            RpcMetricsCallbackHandler {
309                metrics,
310                path,
311                start,
312                counted_response: false,
313                counted_first_chunk: false,
314            },
315        )
316    }
317}
318
319/// Decide which string to use as the `path` Prometheus label for a request.
320///
321/// For gRPC traffic, prefer the per-method URI path when it is in the
322/// allowlist; otherwise fall back to the matched route pattern so unknown
323/// methods collapse into a single bounded series per service. For non-gRPC
324/// traffic the matched path is already bounded by the routes registered on
325/// the router, so it is used directly.
326fn compute_metric_label(
327    is_grpc: bool,
328    uri_path: &str,
329    matched_path: Option<&str>,
330    grpc_method_allowlist: &HashSet<String>,
331) -> Cow<'static, str> {
332    match (is_grpc, matched_path) {
333        (true, _) if grpc_method_allowlist.contains(uri_path) => Cow::Owned(uri_path.to_owned()),
334        (true, Some(matched)) => Cow::Owned(matched.to_owned()),
335        (false, Some(matched)) => Cow::Owned(matched.to_owned()),
336        (_, None) => Cow::Borrowed("unknown"),
337    }
338}
339
340fn is_grpc_content_type(content_type: &http::HeaderValue) -> bool {
341    content_type
342        .as_bytes()
343        .starts_with(tonic::metadata::GRPC_CONTENT_TYPE.as_bytes())
344}
345
346pub struct RpcMetricsCallbackHandler {
347    metrics: Arc<RpcMetrics>,
348    path: Cow<'static, str>,
349    start: Instant,
350    // Indicates if we successfully counted the response. In some cases when a request is
351    // prematurely canceled this will remain false
352    counted_response: bool,
353    counted_first_chunk: bool,
354}
355
356impl ResponseHandler for RpcMetricsCallbackHandler {
357    fn on_response(&mut self, response: &http::response::Parts) {
358        const GRPC_STATUS: http::HeaderName = http::HeaderName::from_static("grpc-status");
359
360        // Unlike `request_latency` (observed in `Drop`, after the response
361        // body finished streaming), this fires as soon as the handler
362        // produced a response, so it excludes client-side network latency.
363        self.metrics
364            .request_handler_latency
365            .with_label_values(&[self.path.as_ref()])
366            .observe(self.start.elapsed().as_secs_f64());
367
368        let status = if response
369            .headers
370            .get(&http::header::CONTENT_TYPE)
371            .is_some_and(is_grpc_content_type)
372        {
373            let code = response
374                .headers
375                .get(&GRPC_STATUS)
376                .map(http::HeaderValue::as_bytes)
377                .map(tonic::Code::from_bytes)
378                .unwrap_or(tonic::Code::Ok);
379
380            code_as_str(code)
381        } else {
382            response.status.as_str()
383        };
384
385        self.metrics
386            .num_requests
387            .with_label_values(&[self.path.as_ref(), status])
388            .inc();
389
390        self.counted_response = true;
391    }
392
393    fn on_body_chunk<B>(&mut self, _chunk: &B)
394    where
395        B: bytes::Buf,
396    {
397        if !self.counted_first_chunk {
398            self.metrics
399                .first_chunk_latency
400                .with_label_values(&[self.path.as_ref()])
401                .observe(self.start.elapsed().as_secs_f64());
402            self.counted_first_chunk = true;
403        }
404    }
405
406    fn on_service_error<E>(&mut self, _error: &E)
407    where
408        E: std::fmt::Display + 'static,
409    {
410        // Do nothing if the whole service errored
411        //
412        // in Axum this isn't possible since all services are required to have an error type of
413        // Infallible
414    }
415}
416
417impl Drop for RpcMetricsCallbackHandler {
418    fn drop(&mut self) {
419        self.metrics
420            .inflight_requests
421            .with_label_values(&[self.path.as_ref()])
422            .dec();
423
424        let latency = self.start.elapsed().as_secs_f64();
425        self.metrics
426            .request_latency
427            .with_label_values(&[self.path.as_ref()])
428            .observe(latency);
429
430        if !self.counted_response {
431            self.metrics
432                .num_requests
433                .with_label_values(&[self.path.as_ref(), "canceled"])
434                .inc();
435        }
436    }
437}
438
439fn code_as_str(code: tonic::Code) -> &'static str {
440    match code {
441        tonic::Code::Ok => "ok",
442        tonic::Code::Cancelled => "canceled",
443        tonic::Code::Unknown => "unknown",
444        tonic::Code::InvalidArgument => "invalid-argument",
445        tonic::Code::DeadlineExceeded => "deadline-exceeded",
446        tonic::Code::NotFound => "not-found",
447        tonic::Code::AlreadyExists => "already-exists",
448        tonic::Code::PermissionDenied => "permission-denied",
449        tonic::Code::ResourceExhausted => "resource-exhausted",
450        tonic::Code::FailedPrecondition => "failed-precondition",
451        tonic::Code::Aborted => "aborted",
452        tonic::Code::OutOfRange => "out-of-range",
453        tonic::Code::Unimplemented => "unimplemented",
454        tonic::Code::Internal => "internal",
455        tonic::Code::Unavailable => "unavailable",
456        tonic::Code::DataLoss => "data-loss",
457        tonic::Code::Unauthenticated => "unauthenticated",
458    }
459}
460
461#[derive(Clone)]
462pub(crate) struct ListStreamMetrics {
463    method: &'static str,
464    first_frame: Histogram,
465    page_bytes: Histogram,
466    watermark_frames: IntCounter,
467    yield_wait: Histogram,
468    render: Histogram,
469    chunk_queue: Histogram,
470    chunk_work: Histogram,
471    query_ends: IntCounterVec,
472    bitmap_buckets_evaluated: Histogram,
473}
474
475impl ListStreamMetrics {
476    pub(crate) fn observe_render(&self, elapsed: Duration) {
477        self.render.observe(elapsed.as_secs_f64());
478    }
479
480    pub(crate) fn start_queue_timer(&self) -> HistogramTimer {
481        self.chunk_queue.start_timer()
482    }
483
484    pub(crate) fn start_work_timer(&self) -> HistogramTimer {
485        self.chunk_work.start_timer()
486    }
487}
488
489pub(crate) struct ListRequestMetrics {
490    inner: Option<ListRequestMetricsInner>,
491}
492
493struct ListRequestMetricsInner {
494    handles: ListStreamMetrics,
495    started: Instant,
496    first_frame_observed: bool,
497    success_finished: bool,
498}
499
500impl ListRequestMetrics {
501    pub(crate) fn new(handles: Option<ListStreamMetrics>, started: Instant) -> Self {
502        Self {
503            inner: handles.map(|handles| ListRequestMetricsInner {
504                handles,
505                started,
506                first_frame_observed: false,
507                success_finished: false,
508            }),
509        }
510    }
511
512    pub(crate) fn chunk_metrics(&self) -> Option<ListStreamMetrics> {
513        self.inner.as_ref().map(|inner| inner.handles.clone())
514    }
515
516    pub(crate) fn observe_frame<M: prost::Message>(&mut self, response: &M, is_data: bool) {
517        let Some(inner) = &mut self.inner else {
518            return;
519        };
520        if is_data {
521            inner
522                .handles
523                .page_bytes
524                .observe(response.encoded_len() as f64);
525        } else {
526            inner.handles.watermark_frames.inc();
527        }
528        if !inner.first_frame_observed {
529            inner
530                .handles
531                .first_frame
532                .observe(inner.started.elapsed().as_secs_f64());
533            inner.first_frame_observed = true;
534        }
535    }
536
537    pub(crate) fn yield_clock(&self) -> Option<Instant> {
538        self.inner.as_ref().map(|_| Instant::now())
539    }
540
541    /// Pair with `yield_clock`: capture immediately before `yield`, then observe as the first
542    /// statement after resumption. A stream dropped while suspended records no sample.
543    pub(crate) fn observe_yield_wait(&self, yield_started: Option<Instant>) {
544        if let (Some(inner), Some(yield_started)) = (&self.inner, yield_started) {
545            inner
546                .handles
547                .yield_wait
548                .observe(yield_started.elapsed().as_secs_f64());
549        }
550    }
551
552    pub(crate) fn finish_success(
553        &mut self,
554        reason: sui_rpc::proto::sui::rpc::v2::QueryEndReason,
555        bitmap_buckets_evaluated: Option<usize>,
556    ) {
557        let Some(inner) = &mut self.inner else {
558            return;
559        };
560        if inner.success_finished {
561            return;
562        }
563        inner.success_finished = true;
564        let reason = match reason {
565            sui_rpc::proto::sui::rpc::v2::QueryEndReason::ItemLimit => "item_limit",
566            sui_rpc::proto::sui::rpc::v2::QueryEndReason::ScanLimit => "scan_limit",
567            sui_rpc::proto::sui::rpc::v2::QueryEndReason::LedgerTip => "ledger_tip",
568            sui_rpc::proto::sui::rpc::v2::QueryEndReason::CheckpointBound => "checkpoint_bound",
569            sui_rpc::proto::sui::rpc::v2::QueryEndReason::CursorBound => "cursor_bound",
570            // Validation guarantees successful List streams always have a concrete end reason.
571            sui_rpc::proto::sui::rpc::v2::QueryEndReason::Unknown => {
572                unreachable!("validated successful List stream has an unspecified end reason")
573            }
574            _ => unreachable!("validated successful List stream has an unsupported end reason"),
575        };
576        inner
577            .handles
578            .query_ends
579            .with_label_values(&[inner.handles.method, reason])
580            .inc();
581        if let Some(bitmap_buckets_evaluated) = bitmap_buckets_evaluated {
582            inner
583                .handles
584                .bitmap_buckets_evaluated
585                .observe(bitmap_buckets_evaluated as f64);
586        }
587    }
588}
589
590#[derive(Clone, Copy)]
591pub(crate) enum SubscriptionFrameKind {
592    Payload,
593    Watermark,
594}
595
596#[derive(Clone)]
597pub(crate) struct SubscriptionStreamMetrics {
598    pub(crate) payload_messages: IntCounter,
599    watermark_messages: IntCounter,
600    payload_bytes: Histogram,
601    yield_wait: Histogram,
602}
603
604impl SubscriptionStreamMetrics {
605    pub(crate) fn observe_frame<M: prost::Message>(
606        &self,
607        response: &M,
608        kind: SubscriptionFrameKind,
609    ) {
610        match kind {
611            SubscriptionFrameKind::Payload => {
612                self.payload_messages.inc();
613                self.payload_bytes.observe(response.encoded_len() as f64);
614            }
615            SubscriptionFrameKind::Watermark => {
616                self.watermark_messages.inc();
617            }
618        }
619    }
620
621    pub(crate) fn observe_yield_wait(&self, elapsed: Duration) {
622        self.yield_wait.observe(elapsed.as_secs_f64());
623    }
624}
625
626#[derive(Clone)]
627pub(crate) struct SubscriptionMetrics {
628    pub(crate) inflight_subscribers: IntGaugeVec,
629    pub(crate) last_recieved_checkpoint: IntGauge,
630    pub payload_messages: IntCounterVec,
631    pub(crate) watermark_messages: IntCounterVec,
632    pub(crate) payload_bytes: HistogramVec,
633    pub(crate) stream_yield_wait_seconds: HistogramVec,
634    pub(crate) terminations_total: IntCounterVec,
635    pub(crate) index_wait_seconds: Histogram,
636    pub(crate) index_wait_timeouts_total: IntCounter,
637}
638
639impl SubscriptionMetrics {
640    pub fn new(registry: &Registry) -> Self {
641        Self {
642            inflight_subscribers: register_int_gauge_vec_with_registry!(
643                "subscription_inflight_subscribers",
644                "Current admitted gRPC subscriptions by type and whether a filter is present.",
645                &["type", "filtered"],
646                registry,
647            )
648            .unwrap(),
649            last_recieved_checkpoint: register_int_gauge_with_registry!(
650                "subscription_last_recieved_checkpoint",
651                "Last recieved checkpoint by the subscription service",
652                registry,
653            )
654            .unwrap(),
655            payload_messages: register_int_counter_vec_with_registry!(
656                "subscription_payload_messages",
657                "Total number of payload messages emitted by gRPC subscriptions, by type",
658                &["type"],
659                registry,
660            )
661            .unwrap(),
662            watermark_messages: register_int_counter_vec_with_registry!(
663                "subscription_watermark_messages_total",
664                "Total progress-only response frames emitted by gRPC subscriptions, including initial recovery-boundary frames, by type.",
665                &["type"],
666                registry,
667            )
668            .unwrap(),
669            payload_bytes: register_histogram_vec_with_registry!(
670                "subscription_payload_bytes",
671                "Protobuf encoded size in bytes of payload response frames yielded by a gRPC subscription, measured with encoded_len without serializing or copying the response. Progress-only frames are excluded and counted by subscription_watermark_messages_total.",
672                &["type"],
673                prometheus::exponential_buckets(1024.0, 2.0, 17).unwrap(),
674                registry,
675            )
676            .unwrap(),
677            stream_yield_wait_seconds: register_histogram_vec_with_registry!(
678                "subscription_stream_yield_wait_seconds",
679                "Time in seconds from yielding any gRPC subscription response until the stream is polled again; this is a downstream transport consumption and backpressure signal.",
680                &["type"],
681                prometheus::exponential_buckets(0.00001, 2.0, 24).unwrap(),
682                registry,
683            )
684            .unwrap(),
685            terminations_total: register_int_counter_vec_with_registry!(
686                "subscription_terminations_total",
687                "Admitted gRPC subscriptions terminated by bounded lifecycle reason. Admission rejections are excluded.",
688                &["type", "reason"],
689                registry,
690            )
691            .unwrap(),
692            index_wait_seconds: register_histogram_with_registry!(
693                "subscription_index_wait_seconds",
694                "Time in seconds spent waiting for the subscription index to catch up before dispatching a checkpoint. Checkpoints that do not wait are excluded.",
695                LATENCY_SEC_BUCKETS.to_vec(),
696                registry,
697            )
698            .unwrap(),
699            index_wait_timeouts_total: register_int_counter_with_registry!(
700                "subscription_index_wait_timeouts_total",
701                "Total subscription index waits that reached the 10-second timeout and dispatched the checkpoint before the index caught up.",
702                registry,
703            )
704            .unwrap(),
705        }
706    }
707}
708impl SubscriptionMetrics {
709    pub(crate) fn stream_metrics(&self, type_label: &'static str) -> SubscriptionStreamMetrics {
710        SubscriptionStreamMetrics {
711            payload_messages: self.payload_messages.with_label_values(&[type_label]),
712            watermark_messages: self.watermark_messages.with_label_values(&[type_label]),
713            payload_bytes: self.payload_bytes.with_label_values(&[type_label]),
714            yield_wait: self
715                .stream_yield_wait_seconds
716                .with_label_values(&[type_label]),
717        }
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::collections::BTreeSet;
725
726    use prost_types::{
727        FileDescriptorProto, FileDescriptorSet, MethodDescriptorProto, ServiceDescriptorProto,
728    };
729    use sui_rpc::proto::sui::rpc::v2::{
730        ListTransactionsResponse, QueryEnd, SubscribeCheckpointsResponse, SubscribeEventsResponse,
731        SubscribeTransactionsResponse, Watermark,
732    };
733
734    fn encode(set: FileDescriptorSet) -> Vec<u8> {
735        let mut buf = Vec::with_capacity(set.encoded_len());
736        set.encode(&mut buf).unwrap();
737        buf
738    }
739
740    fn fds(package: &str, services: &[(&str, &[&str])]) -> Vec<u8> {
741        encode(FileDescriptorSet {
742            file: vec![FileDescriptorProto {
743                package: Some(package.to_owned()),
744                service: services
745                    .iter()
746                    .map(|(name, methods)| ServiceDescriptorProto {
747                        name: Some((*name).to_owned()),
748                        method: methods
749                            .iter()
750                            .map(|m| MethodDescriptorProto {
751                                name: Some((*m).to_owned()),
752                                ..Default::default()
753                            })
754                            .collect(),
755                        ..Default::default()
756                    })
757                    .collect(),
758                ..Default::default()
759            }],
760        })
761    }
762
763    #[test]
764    fn parses_method_paths_from_file_descriptor_sets() {
765        let v2 = fds(
766            "sui.rpc.v2",
767            &[("LedgerService", &["GetCheckpoint", "GetTransaction"])],
768        );
769        let v2alpha = fds(
770            "sui.rpc.v2alpha",
771            &[("ProofService", &["GetCheckpointObjectProof"])],
772        );
773
774        let paths = grpc_method_paths_from_file_descriptor_sets(&[&v2, &v2alpha]).unwrap();
775
776        assert_eq!(paths.len(), 3);
777        assert!(paths.contains("/sui.rpc.v2.LedgerService/GetCheckpoint"));
778        assert!(paths.contains("/sui.rpc.v2.LedgerService/GetTransaction"));
779        assert!(paths.contains("/sui.rpc.v2alpha.ProofService/GetCheckpointObjectProof"));
780    }
781
782    #[test]
783    fn parser_handles_files_without_a_package() {
784        let bare = fds("", &[("BareService", &["Ping"])]);
785        let paths = grpc_method_paths_from_file_descriptor_sets(&[&bare]).unwrap();
786        assert!(paths.contains("/BareService/Ping"));
787    }
788
789    #[test]
790    fn known_grpc_method_uses_uri_path_label() {
791        let mut allowlist = HashSet::new();
792        allowlist.insert("/sui.rpc.v2.LedgerService/GetCheckpoint".to_owned());
793
794        let label = compute_metric_label(
795            true,
796            "/sui.rpc.v2.LedgerService/GetCheckpoint",
797            Some("/sui.rpc.v2.LedgerService/{*rest}"),
798            &allowlist,
799        );
800        assert_eq!(label, "/sui.rpc.v2.LedgerService/GetCheckpoint");
801    }
802
803    #[test]
804    fn known_grpc_method_without_matched_path_uses_uri_path_label() {
805        let mut allowlist = HashSet::new();
806        allowlist.insert("/sui.rpc.v2.LedgerService/ListTransactions".to_owned());
807
808        let label = compute_metric_label(
809            true,
810            "/sui.rpc.v2.LedgerService/ListTransactions",
811            None,
812            &allowlist,
813        );
814        assert_eq!(label, "/sui.rpc.v2.LedgerService/ListTransactions");
815    }
816
817    #[test]
818    fn unknown_grpc_method_falls_back_to_route_pattern() {
819        // Empty allowlist simulates an attacker hitting an unknown method
820        // under a registered service. The label must collapse onto the
821        // route pattern instead of the attacker-controlled URI path,
822        // otherwise the prometheus label map can be inflated without bound.
823        let allowlist = HashSet::new();
824        let label = compute_metric_label(
825            true,
826            "/sui.rpc.v2.LedgerService/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
827            Some("/sui.rpc.v2.LedgerService/{*rest}"),
828            &allowlist,
829        );
830        assert_eq!(label, "/sui.rpc.v2.LedgerService/{*rest}");
831    }
832
833    #[test]
834    fn non_grpc_request_uses_matched_path() {
835        let allowlist = HashSet::new();
836        let label = compute_metric_label(false, "/health", Some("/health"), &allowlist);
837        assert_eq!(label, "/health");
838    }
839
840    #[test]
841    fn request_without_matched_path_is_labelled_unknown() {
842        let allowlist = HashSet::new();
843        let label = compute_metric_label(true, "/no/match", None, &allowlist);
844        assert_eq!(label, "unknown");
845    }
846
847    #[test]
848    fn grpc_content_type_accepts_codec_suffixes() {
849        assert!(is_grpc_content_type(&http::HeaderValue::from_static(
850            "application/grpc"
851        )));
852        assert!(is_grpc_content_type(&http::HeaderValue::from_static(
853            "application/grpc+proto"
854        )));
855        assert!(!is_grpc_content_type(&http::HeaderValue::from_static(
856            "application/json"
857        )));
858    }
859
860    /// Builds a handler for a request with no matched path, so all metric
861    /// observations land on the "unknown" label.
862    fn make_test_handler(metrics: &Arc<RpcMetrics>) -> RpcMetricsCallbackHandler {
863        let make = RpcMetricsMakeCallbackHandler::new(metrics.clone());
864        let (parts, _) = http::Request::new(()).into_parts();
865        let ((), handler) = make.make_handler(&parts);
866        handler
867    }
868
869    // The handler latency is observed as soon as the handler produces a
870    // response, while the total request latency is only observed once the
871    // handler is dropped (i.e. the response body finished streaming).
872    #[test]
873    fn handler_latency_observed_on_response_and_total_latency_on_drop() {
874        let metrics = Arc::new(RpcMetrics::new(&Registry::new()));
875        let mut handler = make_test_handler(&metrics);
876
877        let handler_latency = metrics
878            .request_handler_latency
879            .with_label_values(&["unknown"]);
880        let total_latency = metrics.request_latency.with_label_values(&["unknown"]);
881
882        assert_eq!(handler_latency.get_sample_count(), 0);
883
884        let (parts, _) = http::Response::new(()).into_parts();
885        handler.on_response(&parts);
886
887        assert_eq!(handler_latency.get_sample_count(), 1);
888        assert_eq!(total_latency.get_sample_count(), 0);
889
890        drop(handler);
891
892        assert_eq!(handler_latency.get_sample_count(), 1);
893        assert_eq!(total_latency.get_sample_count(), 1);
894    }
895
896    #[test]
897    fn first_chunk_latency_observed_once_on_first_body_chunk() {
898        let metrics = Arc::new(RpcMetrics::new(&Registry::new()));
899        let mut handler = make_test_handler(&metrics);
900        let first_chunk_latency = metrics.first_chunk_latency.with_label_values(&["unknown"]);
901
902        let (parts, _) = http::Response::new(()).into_parts();
903        handler.on_response(&parts);
904        handler.on_body_chunk(&bytes::Bytes::from_static(b"first"));
905        handler.on_body_chunk(&bytes::Bytes::from_static(b"second"));
906
907        assert_eq!(first_chunk_latency.get_sample_count(), 1);
908
909        drop(handler);
910
911        assert_eq!(first_chunk_latency.get_sample_count(), 1);
912    }
913
914    // A request canceled before the handler produces a response records the
915    // total latency and the canceled count, but no handler latency.
916    #[test]
917    fn handler_latency_not_observed_for_canceled_requests() {
918        let metrics = Arc::new(RpcMetrics::new(&Registry::new()));
919        let handler = make_test_handler(&metrics);
920
921        drop(handler);
922
923        assert_eq!(
924            metrics
925                .request_handler_latency
926                .with_label_values(&["unknown"])
927                .get_sample_count(),
928            0
929        );
930        assert_eq!(
931            metrics
932                .first_chunk_latency
933                .with_label_values(&["unknown"])
934                .get_sample_count(),
935            0
936        );
937        assert_eq!(
938            metrics
939                .request_latency
940                .with_label_values(&["unknown"])
941                .get_sample_count(),
942            1
943        );
944        assert_eq!(
945            metrics
946                .num_requests
947                .with_label_values(&["unknown", "canceled"])
948                .get(),
949            1
950        );
951    }
952    fn metric_label_sets(
953        family: &prometheus::proto::MetricFamily,
954    ) -> BTreeSet<Vec<(String, String)>> {
955        family
956            .get_metric()
957            .iter()
958            .map(|metric| {
959                let mut labels = metric
960                    .get_label()
961                    .iter()
962                    .map(|label| (label.name().to_owned(), label.value().to_owned()))
963                    .collect::<Vec<_>>();
964                labels.sort();
965                labels
966            })
967            .collect()
968    }
969
970    fn expected_label_sets(rows: Vec<Vec<(&str, &str)>>) -> BTreeSet<Vec<(String, String)>> {
971        rows.into_iter()
972            .map(|row| {
973                let mut labels = row
974                    .into_iter()
975                    .map(|(name, value)| (name.to_owned(), value.to_owned()))
976                    .collect::<Vec<_>>();
977                labels.sort();
978                labels
979            })
980            .collect()
981    }
982
983    fn assert_metric_family(
984        families: &[prometheus::proto::MetricFamily],
985        name: &str,
986        expected_labels: BTreeSet<Vec<(String, String)>>,
987    ) {
988        let family = families
989            .iter()
990            .find(|family| family.name() == name)
991            .unwrap_or_else(|| panic!("missing metric family {name}"));
992        assert_eq!(metric_label_sets(family), expected_labels, "{name}");
993    }
994
995    #[test]
996    fn focused_metric_families_use_exact_bounded_labels() {
997        let registry = Registry::new();
998        let list_metrics = ListApiMetrics::new(&registry);
999        let method_resolutions = [
1000            ("list_checkpoints", "summary"),
1001            ("list_checkpoints", "transactions"),
1002            ("list_checkpoints", "objects"),
1003            ("list_transactions", "digest"),
1004            ("list_transactions", "full"),
1005            ("list_transactions", "full_objects"),
1006            ("list_events", "no_json"),
1007            ("list_events", "json"),
1008        ];
1009        for (method, resolution) in method_resolutions {
1010            list_metrics.stream_metrics(method, resolution);
1011        }
1012        let methods = ["list_checkpoints", "list_transactions", "list_events"];
1013        let reasons = [
1014            "item_limit",
1015            "scan_limit",
1016            "ledger_tip",
1017            "checkpoint_bound",
1018            "cursor_bound",
1019        ];
1020        for method in methods {
1021            for reason in reasons {
1022                list_metrics
1023                    .list_query_ends_total
1024                    .with_label_values(&[method, reason]);
1025            }
1026        }
1027
1028        let subscription_metrics = SubscriptionMetrics::new(&registry);
1029        let types = ["checkpoint", "transaction", "event"];
1030        for type_label in types {
1031            subscription_metrics.stream_metrics(type_label);
1032            for filtered in ["true", "false"] {
1033                subscription_metrics
1034                    .inflight_subscribers
1035                    .with_label_values(&[type_label, filtered]);
1036            }
1037            for reason in [
1038                "client_closed",
1039                "slow_consumer",
1040                "source_lag",
1041                "service_shutdown",
1042            ] {
1043                subscription_metrics
1044                    .terminations_total
1045                    .with_label_values(&[type_label, reason]);
1046            }
1047        }
1048
1049        let families = registry.gather();
1050        let method_resolution_labels = expected_label_sets(
1051            method_resolutions
1052                .into_iter()
1053                .map(|(method, resolution)| vec![("method", method), ("resolution", resolution)])
1054                .collect(),
1055        );
1056        for name in [
1057            "list_first_frame_seconds",
1058            "list_response_page_bytes",
1059            "list_stream_yield_wait_seconds",
1060            "list_render_seconds",
1061        ] {
1062            assert_metric_family(&families, name, method_resolution_labels.clone());
1063        }
1064        assert_metric_family(
1065            &families,
1066            "list_watermark_frames_total",
1067            expected_label_sets(
1068                methods
1069                    .into_iter()
1070                    .map(|method| vec![("method", method)])
1071                    .collect(),
1072            ),
1073        );
1074        assert_metric_family(
1075            &families,
1076            "list_chunk_seconds",
1077            expected_label_sets(
1078                methods
1079                    .into_iter()
1080                    .flat_map(|method| {
1081                        ["queue", "work"]
1082                            .into_iter()
1083                            .map(move |phase| vec![("method", method), ("phase", phase)])
1084                    })
1085                    .collect(),
1086            ),
1087        );
1088        assert_metric_family(
1089            &families,
1090            "list_query_ends_total",
1091            expected_label_sets(
1092                methods
1093                    .into_iter()
1094                    .flat_map(|method| {
1095                        reasons
1096                            .into_iter()
1097                            .map(move |reason| vec![("method", method), ("reason", reason)])
1098                    })
1099                    .collect(),
1100            ),
1101        );
1102        assert_metric_family(
1103            &families,
1104            "list_bitmap_buckets_evaluated",
1105            expected_label_sets(
1106                methods
1107                    .into_iter()
1108                    .map(|method| vec![("method", method)])
1109                    .collect(),
1110            ),
1111        );
1112
1113        let type_labels = expected_label_sets(
1114            types
1115                .into_iter()
1116                .map(|type_label| vec![("type", type_label)])
1117                .collect(),
1118        );
1119        assert_metric_family(
1120            &families,
1121            "subscription_payload_messages",
1122            type_labels.clone(),
1123        );
1124        assert_metric_family(
1125            &families,
1126            "subscription_watermark_messages_total",
1127            type_labels.clone(),
1128        );
1129        assert_metric_family(
1130            &families,
1131            "subscription_stream_yield_wait_seconds",
1132            type_labels.clone(),
1133        );
1134        assert_metric_family(&families, "subscription_payload_bytes", type_labels);
1135        assert_metric_family(
1136            &families,
1137            "subscription_inflight_subscribers",
1138            expected_label_sets(
1139                types
1140                    .into_iter()
1141                    .flat_map(|type_label| {
1142                        ["true", "false"]
1143                            .into_iter()
1144                            .map(move |filtered| vec![("type", type_label), ("filtered", filtered)])
1145                    })
1146                    .collect(),
1147            ),
1148        );
1149        assert_metric_family(
1150            &families,
1151            "subscription_terminations_total",
1152            expected_label_sets(
1153                types
1154                    .into_iter()
1155                    .flat_map(|type_label| {
1156                        [
1157                            "client_closed",
1158                            "slow_consumer",
1159                            "source_lag",
1160                            "service_shutdown",
1161                        ]
1162                        .into_iter()
1163                        .map(move |reason| vec![("type", type_label), ("reason", reason)])
1164                    })
1165                    .collect(),
1166            ),
1167        );
1168        assert_metric_family(
1169            &families,
1170            "subscription_index_wait_seconds",
1171            expected_label_sets(vec![vec![]]),
1172        );
1173        assert_metric_family(
1174            &families,
1175            "subscription_index_wait_timeouts_total",
1176            expected_label_sets(vec![vec![]]),
1177        );
1178    }
1179
1180    #[test]
1181    fn list_page_and_watermark_metrics_cover_all_frame_kinds() {
1182        let registry = Registry::new();
1183        let metrics = ListApiMetrics::new(&registry);
1184        let handles = metrics.stream_metrics("list_transactions", "full");
1185        let mut request_metrics = ListRequestMetrics::new(Some(handles.clone()), Instant::now());
1186
1187        let mut data = ListTransactionsResponse::default();
1188        data.transaction = Some(Default::default());
1189        let mut watermark_only = ListTransactionsResponse::default();
1190        watermark_only.watermark = Some(Watermark::default());
1191        let mut terminal = ListTransactionsResponse::default();
1192        terminal.end = Some(QueryEnd::default());
1193
1194        request_metrics.observe_frame(&watermark_only, false);
1195        assert_eq!(handles.first_frame.get_sample_count(), 1);
1196        let yield_started = request_metrics.yield_clock();
1197        request_metrics.observe_yield_wait(yield_started);
1198        request_metrics.observe_frame(&data, true);
1199        let yield_started = request_metrics.yield_clock();
1200        request_metrics.observe_yield_wait(yield_started);
1201        request_metrics.observe_frame(&terminal, false);
1202        let yield_started = request_metrics.yield_clock();
1203        request_metrics.observe_yield_wait(yield_started);
1204        handles.observe_render(Duration::from_millis(1));
1205
1206        assert_eq!(handles.page_bytes.get_sample_count(), 1);
1207        assert_eq!(
1208            handles.page_bytes.get_sample_sum(),
1209            data.encoded_len() as f64
1210        );
1211        assert_eq!(handles.watermark_frames.get(), 2);
1212        assert_eq!(handles.first_frame.get_sample_count(), 1);
1213        assert_eq!(handles.yield_wait.get_sample_count(), 3);
1214        assert_eq!(handles.render.get_sample_count(), 1);
1215
1216        let terminal_registry = Registry::new();
1217        let terminal_metrics = ListApiMetrics::new(&terminal_registry);
1218        let terminal_handles = terminal_metrics.stream_metrics("list_transactions", "digest");
1219        let mut terminal_request =
1220            ListRequestMetrics::new(Some(terminal_handles.clone()), Instant::now());
1221        terminal_request.observe_frame(&terminal, false);
1222
1223        assert_eq!(terminal_handles.page_bytes.get_sample_count(), 0);
1224        assert_eq!(terminal_handles.page_bytes.get_sample_sum(), 0.0);
1225        assert_eq!(terminal_handles.watermark_frames.get(), 1);
1226        assert_eq!(terminal_handles.first_frame.get_sample_count(), 1);
1227    }
1228
1229    fn assert_subscription_response_metrics<M: Message>(
1230        metrics: &SubscriptionMetrics,
1231        type_label: &'static str,
1232        payload: &M,
1233        watermark: &M,
1234    ) {
1235        let stream_metrics = metrics.stream_metrics(type_label);
1236        stream_metrics.observe_frame(payload, SubscriptionFrameKind::Payload);
1237        stream_metrics.observe_yield_wait(Duration::from_millis(1));
1238        stream_metrics.observe_frame(watermark, SubscriptionFrameKind::Watermark);
1239        stream_metrics.observe_yield_wait(Duration::from_millis(2));
1240
1241        assert_eq!(stream_metrics.payload_messages.get(), 1);
1242        assert_eq!(stream_metrics.watermark_messages.get(), 1);
1243        assert_eq!(stream_metrics.payload_bytes.get_sample_count(), 1);
1244        assert_eq!(
1245            stream_metrics.payload_bytes.get_sample_sum(),
1246            payload.encoded_len() as f64
1247        );
1248        assert_eq!(stream_metrics.yield_wait.get_sample_count(), 2);
1249    }
1250
1251    #[test]
1252    fn subscription_response_metrics_split_payload_and_watermark_frames() {
1253        let registry = Registry::new();
1254        let metrics = SubscriptionMetrics::new(&registry);
1255
1256        let mut checkpoint_payload = SubscribeCheckpointsResponse::default();
1257        checkpoint_payload.cursor = Some(7);
1258        checkpoint_payload.checkpoint = Some(Default::default());
1259        let mut checkpoint_watermark = SubscribeCheckpointsResponse::default();
1260        checkpoint_watermark.cursor = Some(8);
1261        assert_subscription_response_metrics(
1262            &metrics,
1263            "checkpoint",
1264            &checkpoint_payload,
1265            &checkpoint_watermark,
1266        );
1267
1268        let mut transaction_payload = SubscribeTransactionsResponse::default();
1269        transaction_payload.transaction = Some(Default::default());
1270        transaction_payload.watermark = Some(Watermark::default());
1271        let mut transaction_watermark = SubscribeTransactionsResponse::default();
1272        transaction_watermark.watermark = Some(Watermark::default());
1273        assert_subscription_response_metrics(
1274            &metrics,
1275            "transaction",
1276            &transaction_payload,
1277            &transaction_watermark,
1278        );
1279
1280        let mut event_payload = SubscribeEventsResponse::default();
1281        event_payload.event = Some(Default::default());
1282        event_payload.watermark = Some(Watermark::default());
1283        let mut event_watermark = SubscribeEventsResponse::default();
1284        event_watermark.watermark = Some(Watermark::default());
1285        assert_subscription_response_metrics(&metrics, "event", &event_payload, &event_watermark);
1286    }
1287}