Misurare e Monitorare i Core Web Vitals in Produzione
Misurare i Core Web Vitals in laboratorio con Lighthouse ti dà una baseline — ma i dati reali da campo spesso divergono del 30-50%. Un sito con LCP di 1.5s in laboratorio potrebbe avere un P75 di 4s in produzione. Questa guida ti insegna come raccogliere dati di campo reali con web-vitals.js, inviarli a BigQuery, e monitorare le regressioni di performance con alerting automatico.
La differenza tra lab e field data è radicale: il laboratorio simula un dispositivo medio su una connessione controllata, mentre field data riflette utenti veri su dispositivi eterogenei (smartphone 4G lento, desktop WiFi veloce, tablet con CPU limitata). Per capire come il tuo sito performatico davvero, devi misurare sul campo.
Cosa Imparerai
- Differenze tra lab data (Lighthouse) e field data (CrUX, RUM)
- Chrome User Experience Report (CrUX): accedere ai dati di campo aggregati
- web-vitals.js: raccogliere INP, LCP, CLS da utenti reali
- Backend di ricezione e storage in BigQuery
- Query SQL per calcolare P75 e trend di regressione
- Dashboard e visualizzazione delle metriche di campo
- Alerting automatico su regressioni performance
- PageSpeed Insights API per insights legati a CrUX
Lab vs Field Data: Perché Divergono del 30-50%
Lighthouse simula un profilo predefinito: Pixel 5 su rete 4G throttled (velocità 4Mbps download, 3Mbps upload). Gli utenti reali hanno dispositivi completamente diversi: processori variabili, connessioni altalenanti (WiFi inconsistente), app di background che consumano risorse. Questa variabilità causa divergenze significative.
Cosa Misura Lighthouse (Lab)
Lighthouse è sintetico: naviga il tuo sito in un ambiente controllato una sola volta, misurando metriche con:
- Dispositivo simulato: Pixel 5 (CPU throttle 4x, memoria fissa)
- Rete simulata: 4G lento (4 Mbps down, 3 Mbps up, 150ms latenza)
- Cache: pulita per la prova (cold load, non representative)
- Variabilità: bassa (stesso scenario ogni volta)
Perfetto per debug locale e development, ma non per decisioni di business.
Cosa Misura CrUX (Field)
CrUX (Chrome User Experience Report) raccoglie dati reali da milioni di utenti Chrome che visitano il tuo sito:
- Dispositivi variegati: smartphone, tablet, desktop con processori e RAM diversi
- Connessioni reali: WiFi, 4G, 5G — velocità e latenza variabili
- Comportamento reale: heavy background load, batteria scarica, thermal throttling
- Campione large: milioni di sessioni → distribuzioni realistiche
- Percentili: P75, P90, P95 — quello che percepisce la maggior parte dell'utenza
CrUX è la sorgente di verità ufficiale di Google per il posizionamento SEO e core web vitals.
Chrome User Experience Report (CrUX)
Google pubblica CrUX pubblicamente su BigQuery, con miliardi di record di metriche di campo. Se il tuo sito riceve almeno 100k sessioni/mese su Chrome, comparirà in CrUX (con un delay di 1-2 settimane).
Accedere ai Dati CrUX su BigQuery
-- Query BigQuery CrUX: P75 per LCP, INP, CLS
SELECT
origin,
yyyymm,
percentile_exc(largest_contentful_paint.good, 0.75) as lcp_p75_good,
percentile_exc(interaction_to_next_paint.good, 0.75) as inp_p75_good,
percentile_exc(cumulative_layout_shift.good, 0.75) as cls_p75_good,
COUNT(DISTINCT origin) as num_origins
FROM `chrome-ux-report.all.202406`
WHERE origin = 'https://example.com/'
GROUP BY origin, yyyymm
ORDER BY yyyymm DESC;
Dataset: chrome-ux-report.all.YYYYMM (es. 202406 = giugno 2024).
Metriche principali:
largest_contentful_paint.good/needs_improvement/poor— LCP in millisecondiinteraction_to_next_paint.good/needs_improvement/poor— INP in millisecondicumulative_layout_shift.good/needs_improvement/poor— CLS adimensionale (0-1)
Real User Monitoring con web-vitals.js
web-vitals è una libreria Google ufficiale di 1.5KB che misura LCP, INP, CLS,
FCP, TTFB in real-user. Invia i dati al tuo backend per raccolta e analisi personalizzata.
Installazione e Setup Base
npm install web-vitals
// src/monitoring/vitals.ts
import { getCLS, getFCP, getFID, getLCP, getTTFB, getINP } from 'web-vitals';
export interface WebVital {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
navigationType: string;
}
// Raccolta metriche
export function initWebVitals() {
getCLS(onMetric);
getFCP(onMetric);
getLCP(onMetric);
getINP(onMetric);
getTTFB(onMetric);
}
function onMetric(metric: any) {
const payload: WebVital = {
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType
};
sendVitalToBackend(payload);
}
Invio Metriche al Backend
// Endpoint backend: POST /api/v1/telemetry/web-vitals
async function sendVitalToBackend(vital: WebVital) {
const payload = {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
vital: vital,
sessionId: getSessionId(), // UUID locale
deviceMemory: navigator.deviceMemory || null,
hardwareConcurrency: navigator.hardwareConcurrency || null,
effectiveType: (navigator as any).connection?.effectiveType || 'unknown'
};
try {
// Batch invio: ogni 5 metriche o 30s
vitalQueue.push(payload);
if (vitalQueue.length >= 5 || timeSinceLastBatch > 30000) {
await fetch('/api/v1/telemetry/web-vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vitals: vitalQueue, timestamp: new Date().toISOString() }),
keepalive: true // Continua inviando anche dopo unload
});
vitalQueue = [];
}
} catch (e) {
// Silent fail: metriche non critiche
console.error('Vital telemetry failed:', e);
}
}
Raccolta e Analisi in BigQuery
Il backend riceve i vitals e li scrive in PostgreSQL o BigQuery. BigQuery è preferibile per query analytics ad altissima scala (miliardi di righe).
Schema Tabella BigQuery
-- BigQuery: telemetry.web_vitals
CREATE TABLE `project-id.telemetry.web_vitals` (
timestamp TIMESTAMP,
session_id STRING,
url STRING,
vital_name STRING, -- LCP, INP, CLS, FCP, TTFB
vital_value FLOAT64, -- millisecondi
vital_rating STRING, -- good / needs_improvement / poor
device_memory INT32,
hardware_concurrency INT32,
effective_type STRING, -- 4g, 3g, slow-2g
user_agent STRING,
country STRING,
os STRING,
browser STRING
)
PARTITION BY DATE(timestamp)
CLUSTER BY vital_name, country;
Query SQL per P75 e Percentili
-- P75 per LCP per giorno e paese
SELECT
DATE(timestamp) as date,
country,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75_ms,
APPROX_QUANTILES(vital_value, 100)[OFFSET(90)] as p90_ms,
APPROX_QUANTILES(vital_value, 100)[OFFSET(95)] as p95_ms,
COUNT(*) as samples
FROM `project-id.telemetry.web_vitals`
WHERE vital_name IN ('LCP', 'INP', 'CLS')
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY date, country, vital_name
ORDER BY date DESC, country, vital_name;
Rilevamento Trend e Regressioni
-- Rilevamento regressione: P75 LCP oggi vs 7 giorni fa
WITH daily_p75 AS (
SELECT
DATE(timestamp) as date,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75_ms
FROM `project-id.telemetry.web_vitals`
WHERE vital_name = 'LCP'
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
GROUP BY date, vital_name
)
SELECT
today.date,
today.p75_ms as today_p75,
week_ago.p75_ms as week_ago_p75,
ROUND(((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms * 100), 2) as pct_change,
CASE
WHEN ((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms) > 0.1 THEN 'REGRESSION'
ELSE 'OK'
END as status
FROM daily_p75 today
LEFT JOIN daily_p75 week_ago
ON today.vital_name = week_ago.vital_name
AND week_ago.date = DATE_SUB(today.date, INTERVAL 7 DAY)
WHERE today.date = CURRENT_DATE();
Dashboard e Visualizzazione
Crea un dashboard (Grafana, Looker Studio, o custom React/Angular) che visualizzi:
- Gauge charts: P75 LCP, INP, CLS vs target (green/yellow/red)
- Trend charts: andamento giornaliero dei percentili
- Breakdown per device: smartphone vs desktop vs tablet
- Breakdown per rete: 4G vs WiFi vs 3G
- Heatmap: P75 per URL + country
- Alert status: regressioni attive
Looker Studio si integra direttamente con BigQuery e non richiede backend:
-- Looker Studio: connetti direttamente il dataset
SELECT
TIMESTAMP_TRUNC(timestamp, DAY) as date,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75,
COUNT(*) as sample_size
FROM `project-id.telemetry.web_vitals`
GROUP BY date, vital_name
ORDER BY date DESC;
Alerting su Regressioni Performance
Configura alert automatici che notifichino il team quando P75 regredisce oltre una soglia (es. 15% in 24h).
Script di Monitoraggio Automatico (Node.js)
// monitoring/alert-checker.ts
import { BigQuery } from '@google-cloud/bigquery';
import nodemailer from 'nodemailer';
const bigquery = new BigQuery();
const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});
async function checkRegressions() {
const query = `
WITH daily_p75 AS (
SELECT
DATE(timestamp) as date,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75_ms
FROM \`project-id.telemetry.web_vitals\`
WHERE timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 8 DAY)
GROUP BY date, vital_name
)
SELECT
today.date,
today.vital_name,
today.p75_ms,
week_ago.p75_ms,
ROUND(((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms * 100), 2) as pct_change
FROM daily_p75 today
LEFT JOIN daily_p75 week_ago
ON today.vital_name = week_ago.vital_name
AND week_ago.date = DATE_SUB(today.date, INTERVAL 7 DAY)
WHERE today.date = CURRENT_DATE()
AND ((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms) > 0.15
`;
const [rows] = await bigquery.query({ query });
for (const row of rows) {
if (row.pct_change > 15) {
await mailer.sendMail({
from: 'alerts@example.com',
to: process.env.ALERT_EMAIL,
subject: `PERF ALERT: ${row.vital_name} regressed ${row.pct_change}%`,
html: `
Performance Regression Detected
${row.vital_name}: ${row.p75_ms}ms (was ${row.week_ago_p75_ms}ms)
Change: ${row.pct_change}% in 7 days
View Dashboard
`
});
}
}
}
// Scheduled ogni ora via cron
setInterval(checkRegressions, 3600000);
PageSpeed Insights API
PageSpeed Insights combina lab data (Lighthouse) con CrUX field data ufficiale. Puoi interrogare l'API per monitorare sia lab che field in automatico.
// Fetch PageSpeed Insights API
async function fetchPageSpeedMetrics(url: string) {
const apiKey = process.env.PAGESPEED_API_KEY;
const response = await fetch(
`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?` +
`url=${encodeURIComponent(url)}&key=${apiKey}`
);
const data = await response.json();
// Lab data (Lighthouse)
const labMetrics = data.lighthouseResult.audits;
const labLcp = labMetrics['largest-contentful-paint'].numericValue;
const labInp = labMetrics['interaction-to-next-paint'].numericValue;
const labCls = labMetrics['cumulative-layout-shift'].numericValue;
// Field data (CrUX) — se disponibile
const fieldData = data.loadingExperience?.metrics || {};
const fieldLcp = fieldData.LARGEST_CONTENTFUL_PAINT_MS?.percentile;
const fieldInp = fieldData.INTERACTION_TO_NEXT_PAINT_MS?.percentile;
const fieldCls = fieldData.CUMULATIVE_LAYOUT_SHIFT?.percentile;
console.log('Lab LCP:', labLcp, 'ms | Field LCP:', fieldLcp, 'ms');
console.log('Lab INP:', labInp, 'ms | Field INP:', fieldInp, 'ms');
console.log('Lab CLS:', labCls, '| Field CLS:', fieldCls);
return { lab: { lcp: labLcp, inp: labInp, cls: labCls }, field: { lcp: fieldLcp, inp: fieldInp, cls: fieldCls } };
}
Domande Frequenti (FAQ)
D: Qual è il minimo volume di dati per CrUX?
R: Google richiede almeno 100k sessioni uniche/mese da utenti Chrome su una URL. Siti più piccoli compaiono in BigQuery ma con meno dettaglio geografico e device.
D: Come differisce INP da FID?
R: FID misura solo il primo input; INP (Interaction to Next Paint) misura tutti gli input sulla pagina e prende il 75esimo percentile. INP è il nuovo metric ufficiale dal 2024.
D: Quanto overhead ha web-vitals.js?
R: ~1.5KB minified, performance impact negligibile. PerformanceObserver è nativo e non blocca rendering.
D: Posso usare Ollama per analizzare i dati di performance?
R: Sì, puoi esportare query BigQuery e usare Ollama locale (`llama3.1:8b`) per interpretare trend e suggerire ottimizzazioni, mantenendo i dati on-prem.
D: Come riconosco regressioni false positive?
R: Confronta sempre against trend settimanale (non solo giornaliero) e valuta change % (es. >15%) anziché valore assoluto. Spiega anomalie (deploy, traffico spike).

Commenti
Caricamento commenti...
Accedi per commentare