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::Debug;
13
14/// Provides fluent test assertions for metrics.
15///
16/// # Examples
17///
18/// ```rust
19/// use ic_metrics_assert::{MetricsAssert, PocketIcHttpQuery};
20/// use pocket_ic::PocketIc;
21/// use ic_management_canister_types::CanisterId;
22///
23/// struct Setup {
24///     env: PocketIc,
25///     canister_id : CanisterId,
26/// }
27///
28/// impl Setup {
29///     pub fn check_metrics(self) -> MetricsAssert<Self> {
30///         MetricsAssert::from_http_query(self)
31///     }
32/// }
33///
34/// impl PocketIcHttpQuery for Setup {
35///     fn get_pocket_ic(&self) -> &PocketIc {
36///         &self.env
37///     }
38///
39///     fn get_canister_id(&self) -> CanisterId {
40///         self.canister_id
41///     }
42/// }
43///
44/// fn assert_metrics () {
45///     use pocket_ic::PocketIcBuilder;
46///     use candid::Principal;
47///
48///     let env = PocketIcBuilder::new().build();
49///     let canister_id = Principal::from_text("7hfb6-caaaa-aaaar-qadga-cai").unwrap();
50///     let setup = Setup {env, canister_id};
51///
52///     setup
53///         .check_metrics()
54///         .assert_contains_metric_matching("started action \\d+")
55///         .assert_contains_metric_matching("completed action 1")
56///         .assert_does_not_contain_metric_matching(".*trap.*");
57/// }
58/// ```
59pub struct MetricsAssert<T> {
60    actual: T,
61    metrics: Vec<String>,
62}
63
64impl<T> MetricsAssert<T> {
65    /// Initializes an instance of [`MetricsAssert`] by querying the metrics from the `/metrics`
66    /// endpoint of a canister via the [`CanisterHttpQuery::http_query`] method.
67    pub fn from_http_query<E>(actual: T) -> Self
68    where
69        T: CanisterHttpQuery<E>,
70        E: Debug,
71    {
72        let metrics =
73            decode_metrics_response_or_unwrap(actual.http_query(encoded_metrics_request()));
74        Self { actual, metrics }
75    }
76
77    /// Initializes an instance of [`MetricsAssert`] by querying the metrics from the `/metrics`
78    /// endpoint of a canister via the [`AsyncCanisterHttpQuery::http_query`] method.
79    pub async fn from_async_http_query<E>(actual: T) -> Self
80    where
81        T: AsyncCanisterHttpQuery<E>,
82        E: Debug,
83    {
84        let metrics =
85            decode_metrics_response_or_unwrap(actual.http_query(encoded_metrics_request()).await);
86        Self { actual, metrics }
87    }
88
89    /// Returns the internal instance being tested.
90    pub fn into(self) -> T {
91        self.actual
92    }
93
94    /// Asserts that the metrics contain at least one entry matching the given Regex pattern.
95    pub fn assert_contains_metric_matching(self, pattern: &str) -> Self {
96        assert!(
97            !self.find_metrics_matching(pattern).is_empty(),
98            "Expected to find metric matching '{}', but none matched in:\n{:?}",
99            pattern,
100            self.metrics
101        );
102        self
103    }
104
105    /// Asserts that the metrics do not contain any entries matching the given Regex pattern.
106    pub fn assert_does_not_contain_metric_matching(self, pattern: &str) -> Self {
107        let matches = self.find_metrics_matching(pattern);
108        assert!(
109            matches.is_empty(),
110            "Expected not to find any metric matching '{}', but found the following matches:\n{:?}",
111            pattern,
112            matches
113        );
114        self
115    }
116
117    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::{nonblocking, PocketIc, RejectResponse};
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}