ic_metrics_assert/
lib.rs

1//! Fluent assertions for metrics.
2
3#![forbid(unsafe_code)]
4#![forbid(missing_docs)]
5
6use async_trait::async_trait;
7use candid::{Decode, Encode};
8use ic_http_types::{HttpRequest, HttpResponse};
9#[cfg(feature = "pocket_ic")]
10pub use pocket_ic_query_call::{PocketIcAsyncHttpQuery, PocketIcHttpQuery};
11use regex::Regex;
12use std::fmt;
13use std::fmt::Debug;
14
15/// Provides fluent test assertions for metrics.
16///
17/// # Examples
18///
19/// ```rust
20/// use ic_metrics_assert::{MetricsAssert, PocketIcHttpQuery};
21/// use pocket_ic::PocketIc;
22/// use ic_management_canister_types::CanisterId;
23///
24/// struct Setup {
25///     env: PocketIc,
26///     canister_id : CanisterId,
27/// }
28///
29/// impl Setup {
30///     pub fn check_metrics(self) -> MetricsAssert<Self> {
31///         MetricsAssert::from_http_query(self)
32///     }
33/// }
34///
35/// impl PocketIcHttpQuery for Setup {
36///     fn get_pocket_ic(&self) -> &PocketIc {
37///         &self.env
38///     }
39///
40///     fn get_canister_id(&self) -> CanisterId {
41///         self.canister_id
42///     }
43/// }
44///
45/// fn assert_metrics () {
46///     use pocket_ic::PocketIcBuilder;
47///     use candid::Principal;
48///
49///     let env = PocketIcBuilder::new().build();
50///     let canister_id = Principal::from_text("7hfb6-caaaa-aaaar-qadga-cai").unwrap();
51///     let setup = Setup {env, canister_id};
52///
53///     setup
54///         .check_metrics()
55///         .assert_contains_metric_matching("started action \\d+")
56///         .assert_contains_metric_matching("completed action 1")
57///         .assert_does_not_contain_metric_matching(".*trap.*");
58/// }
59/// ```
60pub struct MetricsAssert<T> {
61    actual: T,
62    metrics: Vec<String>,
63}
64
65impl<T> MetricsAssert<T> {
66    /// Initializes an instance of [`MetricsAssert`] by querying the metrics from the `/metrics`
67    /// endpoint of a canister via the [`CanisterHttpQuery::http_query`] method.
68    pub fn from_http_query<E>(actual: T) -> Self
69    where
70        T: CanisterHttpQuery<E>,
71        E: Debug,
72    {
73        let metrics =
74            decode_metrics_response_or_unwrap(actual.http_query(encoded_metrics_request()));
75        Self { actual, metrics }
76    }
77
78    /// Initializes an instance of [`MetricsAssert`] by querying the metrics from the `/metrics`
79    /// endpoint of a canister via the [`AsyncCanisterHttpQuery::http_query`] method.
80    pub async fn from_async_http_query<E>(actual: T) -> Self
81    where
82        T: AsyncCanisterHttpQuery<E>,
83        E: Debug,
84    {
85        let metrics =
86            decode_metrics_response_or_unwrap(actual.http_query(encoded_metrics_request()).await);
87        Self { actual, metrics }
88    }
89
90    /// Returns the internal instance being tested.
91    pub fn into(self) -> T {
92        self.actual
93    }
94
95    /// Asserts that the metrics contain at least one entry matching the given Regex pattern.
96    pub fn assert_contains_metric_matching<P: AsRef<str> + fmt::Display>(self, pattern: P) -> Self {
97        assert!(
98            !self.find_metrics_matching(pattern.as_ref()).is_empty(),
99            "Expected to find metric matching '{}', but none matched in:\n{:?}",
100            pattern,
101            self.metrics
102        );
103        self
104    }
105
106    /// Asserts that the metrics do not contain any entries matching the given Regex pattern.
107    pub fn assert_does_not_contain_metric_matching(self, pattern: &str) -> Self {
108        let matches = self.find_metrics_matching(pattern);
109        assert!(
110            matches.is_empty(),
111            "Expected not to find any metric matching '{pattern}', but found the following matches:\n{matches:?}"
112        );
113        self
114    }
115
116    fn find_metrics_matching(&self, pattern: &str) -> Vec<String> {
117        let regex = Regex::new(pattern).unwrap_or_else(|_| panic!("Invalid regex: {pattern}"));
118        self.metrics
119            .iter()
120            .filter(|line| regex.is_match(line))
121            .cloned()
122            .collect()
123    }
124}
125
126fn encoded_metrics_request() -> Vec<u8> {
127    let request = HttpRequest {
128        method: "GET".to_string(),
129        url: "/metrics".to_string(),
130        headers: Default::default(),
131        body: Default::default(),
132    };
133    Encode!(&request).expect("failed to encode HTTP request")
134}
135
136fn decode_metrics_response_or_unwrap<E: Debug>(response: Result<Vec<u8>, E>) -> Vec<String> {
137    let response = Decode!(&response.expect("failed to retrieve metrics"), HttpResponse)
138        .expect("failed to decode HTTP response");
139    assert_eq!(response.status_code, 200_u16);
140    String::from_utf8_lossy(response.body.as_slice())
141        .trim()
142        .split('\n')
143        .map(|line| line.to_string())
144        .collect()
145}
146
147/// Trait providing the ability to perform an HTTP request to a canister.
148pub trait CanisterHttpQuery<E: Debug> {
149    /// Sends a serialized HTTP request to a canister and returns the serialized HTTP response.
150    fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, E>;
151}
152
153/// Trait providing the ability to perform an async HTTP request to a canister.
154#[async_trait]
155pub trait AsyncCanisterHttpQuery<E: Debug> {
156    /// Sends a serialized HTTP request to a canister and returns the serialized HTTP response.
157    async fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, E>;
158}
159
160#[cfg(feature = "pocket_ic")]
161mod pocket_ic_query_call {
162    use super::*;
163    use candid::Principal;
164    use ic_management_canister_types::CanisterId;
165    use pocket_ic::{PocketIc, RejectResponse, nonblocking};
166
167    /// Provides an implementation of the [`CanisterHttpQuery`] trait in the case where the canister
168    /// HTTP requests are made through an instance of [`PocketIc`].
169    pub trait PocketIcHttpQuery {
170        /// Returns a reference to the instance of [`PocketIc`] through which the HTTP requests are made.
171        fn get_pocket_ic(&self) -> &PocketIc;
172
173        /// Returns the ID of the canister to which HTTP requests will be made.
174        fn get_canister_id(&self) -> CanisterId;
175    }
176
177    impl<T: PocketIcHttpQuery> CanisterHttpQuery<RejectResponse> for T {
178        fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, RejectResponse> {
179            self.get_pocket_ic().query_call(
180                self.get_canister_id(),
181                Principal::anonymous(),
182                "http_request",
183                request,
184            )
185        }
186    }
187
188    /// Provides an implementation of the [`AsyncCanisterHttpQuery`] trait in the case where the
189    /// canister HTTP requests are made through an instance of [`nonblocking::PocketIc`].
190    pub trait PocketIcAsyncHttpQuery {
191        /// Returns a reference to the instance of [`nonblocking::PocketIc`] through which the HTTP
192        /// requests are made.
193        fn get_pocket_ic(&self) -> &nonblocking::PocketIc;
194
195        /// Returns the ID of the canister to which HTTP requests will be made.
196        fn get_canister_id(&self) -> CanisterId;
197    }
198
199    #[async_trait]
200    impl<T: PocketIcAsyncHttpQuery + Send + Sync> AsyncCanisterHttpQuery<RejectResponse> for T {
201        async fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, RejectResponse> {
202            self.get_pocket_ic()
203                .query_call(
204                    self.get_canister_id(),
205                    Principal::anonymous(),
206                    "http_request",
207                    request,
208                )
209                .await
210        }
211    }
212}