1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct RumDataPoint {
6 pub path: String,
8 pub lcp: Option<f64>,
10 pub inp: Option<f64>,
12 pub cls: Option<f64>,
14 pub fcp: Option<f64>,
16 pub ttfb: Option<f64>,
18 pub page_views: u64,
20 pub period: String,
22}
23
24pub struct GoogleAnalyticsAdapter {
26 property_id: Option<String>,
28 api_key: Option<String>,
30}
31
32impl GoogleAnalyticsAdapter {
33 #[must_use]
35 pub fn new(property_id: Option<String>, api_key: Option<String>) -> Self {
36 Self {
37 property_id,
38 api_key,
39 }
40 }
41
42 #[must_use]
44 pub fn from_env() -> Self {
45 Self {
46 property_id: std::env::var("GA4_PROPERTY_ID").ok(),
47 api_key: std::env::var("GA4_API_KEY").ok(),
48 }
49 }
50
51 #[must_use]
53 pub fn is_available(&self) -> bool {
54 self.property_id.is_some() && self.api_key.is_some()
55 }
56
57 pub async fn fetch_rum_data(&self, paths: &[String]) -> Result<Vec<RumDataPoint>, RumError> {
62 if !self.is_available() {
63 return Err(RumError::NotConfigured);
64 }
65
66 let property_id = self.property_id.as_deref().ok_or(RumError::NotConfigured)?;
67 let api_key = self.api_key.as_deref().ok_or(RumError::NotConfigured)?;
68 let client = reqwest::Client::new();
69
70 let mut results = Vec::new();
71
72 for path in paths {
73 let url = format!(
74 "https://analyticsdata.googleapis.com/v1beta/properties/{}:runReport?key={}",
75 property_id, api_key
76 );
77
78 let body = serde_json::json!({
79 "dateRanges": [{ "startDate": "30daysAgo", "endDate": "today" }],
80 "dimensions": [{ "name": "pagePath" }],
81 "metrics": [
82 { "name": "averageLargestContentfulPaint" },
83 { "name": "averageCumulativeLayoutShift" },
84 { "name": "averageFirstContentfulPaint" },
85 { "name": "averageTimeToFirstByte" },
86 { "name": "screenPageViews" }
87 ],
88 "dimensionFilter": {
89 "filter": {
90 "fieldName": "pagePath",
91 "stringFilter": { "matchType": "EXACT", "value": path }
92 }
93 }
94 });
95
96 let response = client
97 .post(&url)
98 .json(&body)
99 .send()
100 .await
101 .map_err(|e| RumError::RequestFailed(e.to_string()))?;
102
103 if !response.status().is_success() {
104 return Err(RumError::RequestFailed(format!(
105 "HTTP {}",
106 response.status()
107 )));
108 }
109
110 let data: serde_json::Value = response
111 .json()
112 .await
113 .map_err(|e| RumError::RequestFailed(e.to_string()))?;
114
115 if let Some(rows) = data["rows"].as_array() {
116 for row in rows {
117 let empty_vec = vec![];
118 let metric_values = row["metricValues"].as_array().unwrap_or(&empty_vec);
119 results.push(RumDataPoint {
120 path: path.clone(),
121 lcp: metric_values
122 .first()
123 .and_then(|v| v["value"].as_str())
124 .and_then(|s| s.parse().ok()),
125 inp: None,
126 cls: metric_values
127 .get(1)
128 .and_then(|v| v["value"].as_str())
129 .and_then(|s| s.parse().ok()),
130 fcp: metric_values
131 .get(2)
132 .and_then(|v| v["value"].as_str())
133 .and_then(|s| s.parse().ok()),
134 ttfb: metric_values
135 .get(3)
136 .and_then(|v| v["value"].as_str())
137 .and_then(|s| s.parse().ok()),
138 page_views: metric_values
139 .get(4)
140 .and_then(|v| v["value"].as_str())
141 .and_then(|s| s.parse().ok())
142 .unwrap_or(0),
143 period: "30d".to_string(),
144 });
145 }
146 }
147 }
148
149 Ok(results)
150 }
151}
152
153impl Default for GoogleAnalyticsAdapter {
154 fn default() -> Self {
155 Self::from_env()
156 }
157}
158
159pub struct CruxAdapter {
161 api_key: Option<String>,
163}
164
165impl CruxAdapter {
166 #[must_use]
168 pub fn new(api_key: Option<String>) -> Self {
169 Self { api_key }
170 }
171
172 #[must_use]
174 pub fn from_env() -> Self {
175 Self {
176 api_key: std::env::var("PAGESPEED_API_KEY").ok(),
177 }
178 }
179
180 #[must_use]
182 pub fn is_available(&self) -> bool {
183 self.api_key.is_some()
184 }
185
186 pub async fn fetch_crux_data(&self, url: &str) -> Result<Option<CruxData>, RumError> {
191 if !self.is_available() {
192 return Err(RumError::NotConfigured);
193 }
194
195 let api_key = self.api_key.as_deref().ok_or(RumError::NotConfigured)?;
196 let client = reqwest::Client::new();
197
198 let request_url = format!(
199 "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url={}&key={}&strategy=mobile",
200 urlencoding::encode(url),
201 api_key
202 );
203
204 let response = client
205 .get(&request_url)
206 .send()
207 .await
208 .map_err(|e| RumError::RequestFailed(e.to_string()))?;
209
210 if !response.status().is_success() {
211 return Err(RumError::RequestFailed(format!(
212 "HTTP {}",
213 response.status()
214 )));
215 }
216
217 let data: serde_json::Value = response
218 .json()
219 .await
220 .map_err(|e| RumError::RequestFailed(e.to_string()))?;
221
222 let lighthouse = &data["lighthouseResult"]["audits"];
224
225 let crux_data = CruxData {
226 url: url.to_string(),
227 lcp_p75: lighthouse["largest-contentful-paint"]["numericValue"].as_f64(),
228 inp_p75: None, cls_p75: lighthouse["cumulative-layout-shift"]["numericValue"].as_f64(),
230 fcp_p75: lighthouse["first-contentful-paint"]["numericValue"].as_f64(),
231 ttfb_p75: lighthouse["server-response-time"]["numericValue"].as_f64(),
232 };
233
234 Ok(Some(crux_data))
235 }
236}
237
238impl Default for CruxAdapter {
239 fn default() -> Self {
240 Self::from_env()
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct CruxData {
247 pub url: String,
248 pub lcp_p75: Option<f64>,
249 pub inp_p75: Option<f64>,
250 pub cls_p75: Option<f64>,
251 pub fcp_p75: Option<f64>,
252 pub ttfb_p75: Option<f64>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct MergedMetrics {
258 pub url: String,
259 pub lab: LabMetrics,
260 pub field: Option<FieldMetrics>,
261 pub deltas: MetricDeltas,
262}
263
264#[derive(Debug, Clone, Default, Serialize, Deserialize)]
266pub struct LabMetrics {
267 pub lcp: Option<f64>,
268 pub fid: Option<f64>,
269 pub cls: Option<f64>,
270 pub ttfb: Option<f64>,
271 pub fcp: Option<f64>,
272 pub tti: Option<f64>,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277pub struct FieldMetrics {
278 pub lcp_p75: Option<f64>,
279 pub inp_p75: Option<f64>,
280 pub cls_p75: Option<f64>,
281 pub fcp_p75: Option<f64>,
282 pub ttfb_p75: Option<f64>,
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287pub struct MetricDeltas {
288 pub lcp_delta: Option<f64>,
289 pub cls_delta: Option<f64>,
290 pub fcp_delta: Option<f64>,
291}
292
293#[derive(Debug, thiserror::Error)]
295pub enum RumError {
296 #[error("RUM not configured")]
297 NotConfigured,
298
299 #[error("API request failed: {0}")]
300 RequestFailed(String),
301
302 #[error("No data available for URL: {0}")]
303 NoData(String),
304}
305
306#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_ga_adapter_not_available() {
316 let adapter = GoogleAnalyticsAdapter::new(None, None);
317 assert!(!adapter.is_available());
318 }
319
320 #[test]
321 fn test_crux_adapter_not_available() {
322 let adapter = CruxAdapter::new(None);
323 assert!(!adapter.is_available());
324 }
325
326 #[test]
327 fn test_merged_metrics() {
328 let metrics = MergedMetrics {
329 url: "https://example.com".to_string(),
330 lab: LabMetrics {
331 lcp: Some(2500.0),
332 ..Default::default()
333 },
334 field: Some(FieldMetrics {
335 lcp_p75: Some(3000.0),
336 ..Default::default()
337 }),
338 deltas: MetricDeltas {
339 lcp_delta: Some(500.0),
340 ..Default::default()
341 },
342 };
343 assert_eq!(metrics.lab.lcp, Some(2500.0));
344 assert_eq!(metrics.deltas.lcp_delta, Some(500.0));
345 }
346}