Skip to main content

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    /// Find metrics matching the given pattern.
117    pub fn find_metrics_matching(&self, pattern: &str) -> Vec<String> {
118        let regex = Regex::new(pattern).unwrap_or_else(|_| panic!("Invalid regex: {pattern}"));
119        self.metrics
120            .iter()
121            .filter(|line| regex.is_match(line))
122            .cloned()
123            .collect()
124    }
125}
126
127fn encoded_metrics_request() -> Vec<u8> {
128    let request = HttpRequest {
129        method: "GET".to_string(),
130        url: "/metrics".to_string(),
131        headers: Default::default(),
132        body: Default::default(),
133    };
134    Encode!(&request).expect("failed to encode HTTP request")
135}
136
137fn decode_metrics_response_or_unwrap<E: Debug>(response: Result<Vec<u8>, E>) -> Vec<String> {
138    let response = Decode!(&response.expect("failed to retrieve metrics"), HttpResponse)
139        .expect("failed to decode HTTP response");
140    assert_eq!(response.status_code, 200_u16);
141    String::from_utf8_lossy(response.body.as_slice())
142        .trim()
143        .split('\n')
144        .map(|line| line.to_string())
145        .collect()
146}
147
148/// Trait providing the ability to perform an HTTP request to a canister.
149pub trait CanisterHttpQuery<E: Debug> {
150    /// Sends a serialized HTTP request to a canister and returns the serialized HTTP response.
151    fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, E>;
152}
153
154/// Trait providing the ability to perform an async HTTP request to a canister.
155#[async_trait]
156pub trait AsyncCanisterHttpQuery<E: Debug> {
157    /// Sends a serialized HTTP request to a canister and returns the serialized HTTP response.
158    async fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, E>;
159}
160
161#[cfg(feature = "pocket_ic")]
162mod pocket_ic_query_call {
163    use super::*;
164    use candid::Principal;
165    use ic_management_canister_types::CanisterId;
166    use pocket_ic::{PocketIc, RejectResponse, nonblocking};
167
168    /// Provides an implementation of the [`CanisterHttpQuery`] trait in the case where the canister
169    /// HTTP requests are made through an instance of [`PocketIc`].
170    pub trait PocketIcHttpQuery {
171        /// Returns a reference to the instance of [`PocketIc`] through which the HTTP requests are made.
172        fn get_pocket_ic(&self) -> &PocketIc;
173
174        /// Returns the ID of the canister to which HTTP requests will be made.
175        fn get_canister_id(&self) -> CanisterId;
176    }
177
178    impl<T: PocketIcHttpQuery> CanisterHttpQuery<RejectResponse> for T {
179        fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, RejectResponse> {
180            self.get_pocket_ic().query_call(
181                self.get_canister_id(),
182                Principal::anonymous(),
183                "http_request",
184                request,
185            )
186        }
187    }
188
189    /// Provides an implementation of the [`AsyncCanisterHttpQuery`] trait in the case where the
190    /// canister HTTP requests are made through an instance of [`nonblocking::PocketIc`].
191    pub trait PocketIcAsyncHttpQuery {
192        /// Returns a reference to the instance of [`nonblocking::PocketIc`] through which the HTTP
193        /// requests are made.
194        fn get_pocket_ic(&self) -> &nonblocking::PocketIc;
195
196        /// Returns the ID of the canister to which HTTP requests will be made.
197        fn get_canister_id(&self) -> CanisterId;
198    }
199
200    #[async_trait]
201    impl<T: PocketIcAsyncHttpQuery + Send + Sync> AsyncCanisterHttpQuery<RejectResponse> for T {
202        async fn http_query(&self, request: Vec<u8>) -> Result<Vec<u8>, RejectResponse> {
203            self.get_pocket_ic()
204                .query_call(
205                    self.get_canister_id(),
206                    Principal::anonymous(),
207                    "http_request",
208                    request,
209                )
210                .await
211        }
212    }
213}