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