openzeppelin_relayer/queues/pubsub/
monitoring.rs1use std::collections::HashMap;
11use std::sync::Arc;
12
13use gcloud_pubsub::client::google_cloud_auth::project::Config;
14use gcloud_pubsub::client::google_cloud_auth::token::DefaultTokenSourceProvider;
15use serde::Deserialize;
16use token_source::{TokenSource, TokenSourceProvider};
17
18use super::QueueType;
19
20const MONITORING_SCOPE: &str = "https://www.googleapis.com/auth/monitoring.read";
21const NUM_UNDELIVERED_METRIC: &str = "pubsub.googleapis.com/subscription/num_undelivered_messages";
22
23pub(crate) async fn monitoring_token_source() -> Result<Arc<dyn TokenSource>, String> {
29 let config = Config::default().with_scopes(&[MONITORING_SCOPE]);
30 let provider = DefaultTokenSourceProvider::new(config)
31 .await
32 .map_err(|e| format!("failed to init Cloud Monitoring ADC token source: {e}"))?;
33 Ok(provider.token_source())
34}
35
36pub(crate) async fn read_backlog_depths(
40 http: &reqwest::Client,
41 token_source: &Arc<dyn TokenSource>,
42 project_id: &str,
43 subscription_to_queue: &HashMap<String, QueueType>,
44) -> Result<HashMap<QueueType, u64>, String> {
45 let token = token_source
47 .token()
48 .await
49 .map_err(|e| format!("Cloud Monitoring token fetch failed: {e}"))?;
50
51 let end = chrono::Utc::now();
52 let start = end - chrono::Duration::seconds(300);
53 let url = format!("https://monitoring.googleapis.com/v3/projects/{project_id}/timeSeries");
54
55 let resp = http
56 .get(&url)
57 .header("Authorization", token)
58 .query(&[
59 ("filter", build_filter(subscription_to_queue)),
60 ("interval.startTime", start.to_rfc3339()),
61 ("interval.endTime", end.to_rfc3339()),
62 ])
63 .send()
64 .await
65 .map_err(|e| format!("Cloud Monitoring request failed: {e}"))?;
66
67 if !resp.status().is_success() {
68 return Err(format!(
69 "Cloud Monitoring returned HTTP {}",
70 resp.status().as_u16()
71 ));
72 }
73
74 let body: TimeSeriesResponse = resp
75 .json()
76 .await
77 .map_err(|e| format!("Cloud Monitoring response parse failed: {e}"))?;
78
79 Ok(parse_depths(&body, subscription_to_queue))
80}
81
82fn build_filter(subscription_to_queue: &HashMap<String, QueueType>) -> String {
93 let metric = format!("metric.type=\"{NUM_UNDELIVERED_METRIC}\"");
94 let mut ids: Vec<&str> = subscription_to_queue.keys().map(String::as_str).collect();
95 if ids.is_empty() {
96 return metric;
97 }
98 ids.sort_unstable(); let subs = ids
100 .iter()
101 .map(|id| format!("\"{id}\""))
102 .collect::<Vec<_>>()
103 .join(",");
104 format!("{metric} AND resource.label.subscription_id=one_of({subs})")
105}
106
107fn parse_depths(
110 body: &TimeSeriesResponse,
111 subscription_to_queue: &HashMap<String, QueueType>,
112) -> HashMap<QueueType, u64> {
113 let mut depths = HashMap::new();
114 for series in &body.time_series {
115 let Some(sub_id) = series.resource.labels.get("subscription_id") else {
116 continue;
117 };
118 let Some(&queue_type) = subscription_to_queue.get(sub_id) else {
119 continue; };
121 if let Some(value) = series
123 .points
124 .first()
125 .and_then(|p| p.value.int64_value.as_deref())
126 .and_then(|v| v.parse::<u64>().ok())
127 {
128 depths.insert(queue_type, value);
129 }
130 }
131 depths
132}
133
134#[derive(Debug, Deserialize)]
135struct TimeSeriesResponse {
136 #[serde(rename = "timeSeries", default)]
137 time_series: Vec<TimeSeries>,
138}
139
140#[derive(Debug, Deserialize)]
141struct TimeSeries {
142 #[serde(default)]
143 resource: MonitoredResource,
144 #[serde(default)]
145 points: Vec<Point>,
146}
147
148#[derive(Debug, Default, Deserialize)]
149struct MonitoredResource {
150 #[serde(default)]
151 labels: HashMap<String, String>,
152}
153
154#[derive(Debug, Deserialize)]
155struct Point {
156 value: PointValue,
157}
158
159#[derive(Debug, Deserialize)]
160struct PointValue {
161 #[serde(rename = "int64Value", default)]
162 int64_value: Option<String>,
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn sub_map() -> HashMap<String, QueueType> {
170 HashMap::from([
171 (
172 "relayer-status-check-evm-sub".to_string(),
173 QueueType::StatusCheckEvm,
174 ),
175 (
176 "relayer-transaction-request-sub".to_string(),
177 QueueType::TransactionRequest,
178 ),
179 ])
180 }
181
182 #[test]
183 fn test_parse_depths_maps_subscriptions_to_queues() {
184 let json = r#"{
186 "timeSeries": [
187 {
188 "resource": { "labels": { "subscription_id": "relayer-status-check-evm-sub" } },
189 "points": [ { "value": { "int64Value": "42" } }, { "value": { "int64Value": "40" } } ]
190 },
191 {
192 "resource": { "labels": { "subscription_id": "relayer-transaction-request-sub" } },
193 "points": [ { "value": { "int64Value": "7" } } ]
194 },
195 {
196 "resource": { "labels": { "subscription_id": "some-other-sub" } },
197 "points": [ { "value": { "int64Value": "999" } } ]
198 }
199 ]
200 }"#;
201 let body: TimeSeriesResponse = serde_json::from_str(json).unwrap();
202 let depths = parse_depths(&body, &sub_map());
203
204 assert_eq!(depths.get(&QueueType::StatusCheckEvm), Some(&42)); assert_eq!(depths.get(&QueueType::TransactionRequest), Some(&7));
206 assert_eq!(depths.len(), 2, "unknown subscriptions are ignored");
207 }
208
209 #[test]
210 fn test_build_filter_scopes_to_our_subscriptions() {
211 let filter = build_filter(&sub_map());
212 assert!(filter.starts_with(&format!("metric.type=\"{NUM_UNDELIVERED_METRIC}\"")));
213 assert!(filter.contains("resource.label.subscription_id=one_of("));
214 assert!(filter.contains(
216 "one_of(\"relayer-status-check-evm-sub\",\"relayer-transaction-request-sub\")"
217 ));
218 }
219
220 #[test]
221 fn test_build_filter_falls_back_when_empty() {
222 let filter = build_filter(&HashMap::new());
223 assert_eq!(filter, format!("metric.type=\"{NUM_UNDELIVERED_METRIC}\""));
224 }
225
226 #[test]
227 fn test_parse_depths_empty_response() {
228 let body: TimeSeriesResponse = serde_json::from_str(r#"{}"#).unwrap();
229 assert!(parse_depths(&body, &sub_map()).is_empty());
230 }
231
232 #[test]
233 fn test_parse_depths_skips_series_without_points() {
234 let json = r#"{"timeSeries":[{"resource":{"labels":{"subscription_id":"relayer-status-check-evm-sub"}},"points":[]}]}"#;
235 let body: TimeSeriesResponse = serde_json::from_str(json).unwrap();
236 assert!(parse_depths(&body, &sub_map()).is_empty());
237 }
238}