From 8107f9d10e4d5f935c56ac148f26c5b069651a37 Mon Sep 17 00:00:00 2001 From: Stefan Martinov Date: Wed, 22 Oct 2025 15:48:42 +0200 Subject: [PATCH] feat: add enhanced CrUX metrics with category ratios and thresholds Add comprehensive Chrome UX Report (CrUX) metrics export including: - Category distribution ratios (fast/average/slow proportions) - Core Web Vitals thresholds extracted from API bucket boundaries - Full backward compatibility maintained Changes: - collector/collector.go: Enhanced collectLoadingExperience() to export category_ratio and threshold metrics for all CrUX metrics (LCP, INP, CLS, FCP, TTFB) - README.md: Added CrUX Metrics section with comprehensive documentation - CLAUDE.md: Updated metrics documentation with new metric types All metrics handle proper unit conversions (ms to seconds, CLS hundredths to decimal) and gracefully handle missing data. --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++ collector/collector.go | 65 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/README.md b/README.md index daff8aa..853864d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,71 @@ Note: The example dashboard assumes you're fetching all pagespeed categories. Prometheus exporter for google pagespeed metrics +## CrUX Metrics (Real User Monitoring) + +The exporter provides Chrome User Experience Report (CrUX) metrics, which represent real-world user experience data collected from Chrome browsers. CrUX data is only available for URLs and origins with sufficient user traffic. + +### Metric Prefixes + +- `pagespeed_loading_experience_*` - URL-specific RUM data (when available for the specific page) +- `pagespeed_origin_loading_experience_*` - Origin-wide RUM data (aggregated across the entire domain) + +### Available CrUX Metrics + +The exporter provides three types of metrics for each Core Web Vital: + +1. **P75 Percentile** - The 75th percentile value (75% of users experience better performance) +2. **Category Ratios** - Proportion of users experiencing fast/average/slow performance +3. **Thresholds** - Google's Core Web Vitals performance boundaries + +#### Core Web Vitals Included + +- **Largest Contentful Paint (LCP)** - Measures loading performance +- **Interaction to Next Paint (INP)** - Measures interactivity +- **Cumulative Layout Shift (CLS)** - Measures visual stability +- **First Contentful Paint (FCP)** - Measures perceived load speed +- **Experimental Time to First Byte (TTFB)** - Measures server responsiveness + +### Example Metrics Output + +For Largest Contentful Paint (LCP): + +```prometheus +# P75 percentile (existing metric) +pagespeed_loading_experience_metrics_largest_contentful_paint_duration_seconds 1.278 + +# Category distribution ratios (proportion of users in each category) +pagespeed_loading_experience_metrics_largest_contentful_paint_category_ratio{category="fast"} 0.9389 +pagespeed_loading_experience_metrics_largest_contentful_paint_category_ratio{category="average"} 0.0371 +pagespeed_loading_experience_metrics_largest_contentful_paint_category_ratio{category="slow"} 0.0240 + +# Core Web Vitals thresholds +pagespeed_loading_experience_metrics_largest_contentful_paint_threshold_duration_seconds{threshold="good"} 2.5 +pagespeed_loading_experience_metrics_largest_contentful_paint_threshold_duration_seconds{threshold="poor"} 4.0 +``` + +The same pattern applies to INP, FCP, and TTFB. For CLS (which is unitless), the metrics omit the `_duration_seconds` suffix: + +```prometheus +pagespeed_loading_experience_metrics_cumulative_layout_shift_score 0.0 +pagespeed_loading_experience_metrics_cumulative_layout_shift_score_category_ratio{category="fast"} 0.9994 +pagespeed_loading_experience_metrics_cumulative_layout_shift_score_threshold{threshold="good"} 0.1 +``` + +### Performance Categories + +- **Fast** - Meets Google's "good" threshold (provides a good user experience) +- **Average** - Between "good" and "poor" thresholds (needs improvement) +- **Slow** - Exceeds "poor" threshold (provides a poor user experience) + +### Data Availability + +CrUX data is based on real user measurements from Chrome browsers over the last 28 days. Metrics will only be available for: +- URLs with sufficient Chrome user traffic +- Origins (domains) with sufficient Chrome user traffic + +If data is unavailable, the corresponding metrics will not be exported. + ## Building And Running diff --git a/collector/collector.go b/collector/collector.go index 4e6eaf5..90a10b2 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -159,10 +159,75 @@ func collectLoadingExperience(prefix string, lexp *pagespeedonline.PagespeedApiL for k, v := range lexp.Metrics { name := strings.TrimSuffix(strings.ToLower(k), "_ms") + + // Export P75 percentile (existing metric - unchanged) ch <- prometheus.MustNewConstMetric( prometheus.NewDesc(fqname(prefix, "metrics", name, "duration_seconds"), "Percentile metrics for "+strings.Replace(name, "_", " ", -1), nil, constLables), prometheus.GaugeValue, float64(v.Percentile)/1000) + + // Export category distribution ratios (NEW) + if len(v.Distributions) >= 3 { + categories := []string{"fast", "average", "slow"} + for i, dist := range v.Distributions { + if i >= 3 { + break + } + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc(fqname(prefix, "metrics", name, "category_ratio"), "Proportion of users experiencing "+categories[i]+" performance", []string{"category"}, constLables), + prometheus.GaugeValue, + dist.Proportion, + categories[i]) + } + } + + // Export Core Web Vitals thresholds (NEW) + if len(v.Distributions) >= 2 { + // Determine if this is CLS (uses hundredths) or time-based (uses ms) + isCLS := strings.Contains(strings.ToLower(k), "cumulative_layout_shift") + + // Extract good threshold (upper bound of FAST bucket) + if v.Distributions[0].Max > 0 { + goodThreshold := float64(v.Distributions[0].Max) + if isCLS { + goodThreshold = goodThreshold / 100.0 // Convert hundredths to decimal + } else { + goodThreshold = goodThreshold / 1000.0 // Convert ms to seconds + } + + metricSuffix := "duration_seconds" + if isCLS { + metricSuffix = "" // CLS is unitless + } + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc(fqname(prefix, "metrics", name, "threshold", metricSuffix), "Core Web Vitals threshold for "+strings.Replace(name, "_", " ", -1), []string{"threshold"}, constLables), + prometheus.GaugeValue, + goodThreshold, + "good") + } + + // Extract poor threshold (upper bound of AVERAGE bucket) + if v.Distributions[1].Max > 0 { + poorThreshold := float64(v.Distributions[1].Max) + if isCLS { + poorThreshold = poorThreshold / 100.0 + } else { + poorThreshold = poorThreshold / 1000.0 + } + + metricSuffix := "duration_seconds" + if isCLS { + metricSuffix = "" + } + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc(fqname(prefix, "metrics", name, "threshold", metricSuffix), "Core Web Vitals threshold for "+strings.Replace(name, "_", " ", -1), []string{"threshold"}, constLables), + prometheus.GaugeValue, + poorThreshold, + "poor") + } + } } }