Skip to main content

crawlkit_engine/
rum.rs

1use serde::{Deserialize, Serialize};
2
3/// RUM (Real User Monitoring) data point.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct RumDataPoint {
6    /// URL path.
7    pub path: String,
8    /// Largest Contentful Paint (ms).
9    pub lcp: Option<f64>,
10    /// Interaction to Next Paint (ms).
11    pub inp: Option<f64>,
12    /// Cumulative Layout Shift.
13    pub cls: Option<f64>,
14    /// First Contentful Paint (ms).
15    pub fcp: Option<f64>,
16    /// Time to First Byte (ms).
17    pub ttfb: Option<f64>,
18    /// Number of page views.
19    pub page_views: u64,
20    /// Collection period.
21    pub period: String,
22}
23
24/// Google Analytics RUM adapter.
25pub struct GoogleAnalyticsAdapter {
26    /// GA4 property ID.
27    property_id: Option<String>,
28    /// API key for GA4 Data API.
29    api_key: Option<String>,
30}
31
32impl GoogleAnalyticsAdapter {
33    /// Create new GA adapter.
34    #[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    /// Create from environment variables.
43    #[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    /// Check if adapter is available.
52    #[must_use]
53    pub fn is_available(&self) -> bool {
54        self.property_id.is_some() && self.api_key.is_some()
55    }
56
57    /// Fetch RUM data for paths.
58    ///
59    /// # Errors
60    /// Returns error if API call fails.
61    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
159/// CrUX (Chrome User Experience Report) adapter.
160pub struct CruxAdapter {
161    /// API key for PageSpeed Insights API.
162    api_key: Option<String>,
163}
164
165impl CruxAdapter {
166    /// Create new CrUX adapter.
167    #[must_use]
168    pub fn new(api_key: Option<String>) -> Self {
169        Self { api_key }
170    }
171
172    /// Create from environment variable.
173    #[must_use]
174    pub fn from_env() -> Self {
175        Self {
176            api_key: std::env::var("PAGESPEED_API_KEY").ok(),
177        }
178    }
179
180    /// Check if adapter is available.
181    #[must_use]
182    pub fn is_available(&self) -> bool {
183        self.api_key.is_some()
184    }
185
186    /// Fetch CrUX data for a URL.
187    ///
188    /// # Errors
189    /// Returns error if API call fails.
190    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        // Extract CrUX data from lighthouse result
223        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, // Not directly available in PSI
229            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/// CrUX data for a single URL.
245#[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/// Merged lab + field data.
256#[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/// Lab (synthetic) metrics.
265#[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/// Field (real user) metrics.
276#[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/// Deltas between lab and field metrics.
286#[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/// RUM errors.
294#[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// ---------------------------------------------------------------------------
307// Tests
308// ---------------------------------------------------------------------------
309
310#[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}