Web Vitals de usuarios reales sin un proveedor de paga: de LCP en el navegador a un p75 sobre el que puedes alarmar
Lighthouse te dice cómo se comportó una corrida en tu laptop. Tus
usuarios están en un Android de gama media sobre 4G en otro país. Esos son números distintos, y solo uno de ellos importa. Así es como recolecto Core Web Vitals de usuarios reales (RUM), los guardo barato como logs (sin SDK de terceros, sin base de datos extra), y los convierto en un p75 que puedes graficar y sobre el que puedes alarmar.
elchesco
/
blog-web-vitals-code
Code companion — real-user Web Vitals post
Code companion — real-user Web Vitals post
Each folder maps to one stage of the pipeline:
00-collect/ browser: web-vitals -> sample -> sendBeacon
vitals.ts collection module
main.tsx boot wiring + sample-rate guidance
01-ingest/ server: one endpoint, one positional log line
rum.py FastAPI route + validated payload
02-metric-filter/ infra: log line -> CloudWatch metric (CDK)
metric-filters.ts one filter per vital
03-dashboard/ infra + API: read p50/p75/p95, alarm on LCP
dashboard.py GetMetricData query
alarm-and-dashboard.ts CDK alarm + CW dashboard
Suggested reading order
-
00-collect/vitals.ts— what gets sent and whysendBeacon. -
01-ingest/rum.py— the positional log line (the contract). -
02-metric-filter/metric-filters.ts— the matching filter pattern. -
03-dashboard/— reading the percentile and alarming.
The data contract (don't break it silently)
The log line in rum.py and the filter pattern in metric-filters.ts are
two ends of one positional contract:
log: <date> <time> <level> <src> web_vital <NAME> <VALUE> <rating> <url> <nav>
pattern: [date, time, level,…TL;DR
| Métrica | Qué mide | Bueno (p75) | Malo (p75) | Se dispara |
|---|---|---|---|---|
| LCP | Largest Contentful Paint, cuándo se pinta el contenido principal | ≤ 2.5 s | > 4.0 s | al cargar |
| INP | Interaction to Next Paint, capacidad de respuesta a la entrada | ≤ 200 ms | > 500 ms | al interactuar |
| CLS | Cumulative Layout Shift, estabilidad visual | ≤ 0.10 | > 0.25 | durante toda la vida |
| FCP | First Contentful Paint, primer pixel de contenido | ≤ 1.8 s | > 3.0 s | al cargar |
| TTFB | Time to First Byte, latencia de servidor + red | ≤ 0.8 s | > 1.8 s | al cargar |
Dos cosas hunden a la mayoría del RUM casero y ninguna es el código:
- La tasa de muestreo contra el tráfico. Un muestreo del 10% es lo correcto para "miles de vistas de página al día". A unos cuantos cientos produce un dashboard vacío que se ve roto. Ajusta la tasa a tu volumen.
-
Una URL de beacon relativa. Si el endpoint resuelve al origen de tu propia SPA en lugar de a la API,
sendBeacon"tiene éxito" (regresatrue) y tu SPA le devuelveindex.htmla un POST de dispara-y-olvida que nadie lee. Cada medición se descarta en silencio.
Qué significan de verdad las métricas
No puedes arreglar lo que no puedes nombrar:
LCP, Largest Contentful Paint. El tiempo de renderizado de la imagen o bloque de texto más grande visible en el viewport. Es el proxy de datos de campo para "la página se ve cargada". Dominado por: el TTFB, el CSS/JS que bloquea el renderizado, y el recurso propio del elemento LCP (a menudo una imagen principal). fetchpriority="high" + preload sobre ese único elemento es el arreglo de mayor palanca.
INP, Interaction to Next Paint. Reemplazó a FID en marzo de 2024. FID solo medía el retraso de entrada de la primera interacción; INP mide la latencia completa (retraso de entrada + procesamiento + presentación) de la peor interacción de toda la visita. Atrapa ese "hice clic y no pasó nada durante 400 ms" que los usuarios de verdad sienten. Normalmente un problema del hilo principal: tareas largas, manejadores de eventos pesados, layout sincrónico.
CLS, Cumulative Layout Shift. Un puntaje sin unidades de cuánto brinca el contenido visible sin entrada del usuario. Causas clásicas:
imágenes/iframes sin dimensiones, fuentes web que se intercambian (FOUT), y contenido inyectado arriba del pliegue (banners, anuncios). El arreglo es aburrido y efectivo: reserva el espacio (width/height o aspect-ratio), font-display: optional/swap, nunca insertes arriba del contenido existente.
FCP, First Contentful Paint. La primera vez que cualquier contenido se pinta. Casi todo es TTFB + recursos que bloquean el renderizado. Un buen compañero de diagnóstico para LCP: si el FCP está bien pero el LCP está mal, tu cascaron pinta rápido pero la imagen principal va lenta.
TTFB, Time to First Byte. El tiempo de servidor + red hasta el primer byte del documento. No es un Core Web Vital en sí, pero es el piso debajo del FCP y del LCP: no puedes pintar antes de que lleguen los bytes. Un TTFB alto apunta al backend, a fallos de caché del CDN, o a redirecciones, no al frontend.
Dos propiedades que cambian cómo los guardas:
Son distribuciones, no puntos. "LCP promedio" es una mentira: un solo usuario en 3G lo arrastra, o un caché de vistas repetidas rápidas esconde una ruta fría lenta. Google califica sobre p75: el 75% de las visitas estuvieron al menos así de bien.
Algunas se finalizan tarde. CLS e INP se acumulan a lo largo de la vida de la página y solo se conocen de manera confiable en visibilitychange / descarga, lo cual dicta cómo las mandas.
Por qué datos de campo, no de laboratorio
Las herramientas de laboratorio (Lighthouse, WebPageTest) son sintéticas: dispositivo fijo, red fija, caché frío, sin interacción real. Buenísimas para atrapar regresiones en CI, inútiles para saber qué reciben tus usuarios. El RUM es de campo: dispositivos reales, redes reales, interacciones reales, la cola larga que nunca podrías guionizar. Quieres las dos: laboratorio para gatear los PRs, campo para saber la verdad. Este post es la mitad de campo.
Recolectar en el navegador
La librería web-vitals de Google hace la parte difícil (timings correctos, finalización tardía). Tú cableas los callbacks y mandas cada medición.
// vitals.ts — ve 00-collect/
import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from "web-vitals";
// IMPORTANTE: URL absoluta. Una "/api/v1/rum/vitals" relativa resuelve al
// origen de la SPA, no al host de la API, y el beacon se pierde en silencio.
const ENDPOINT = (import.meta.env.VITE_API_URL ?? "") + "/api/v1/rum/vitals";
function send(metric: Metric, sampleRate: number) {
if (Math.random() >= sampleRate) return; // muestreo independiente por métrica
const body = JSON.stringify({
v: 1,
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
id: metric.id,
navigation_type: metric.navigationType,
url: location.pathname, // solo el path, sin query string / PII
});
// sendBeacon es dispara-y-olvida Y sobrevive la descarga de la página, que es cuando
// se finalizan CLS/INP. Cae a fetch(keepalive) donde no exista.
if (navigator.sendBeacon) navigator.sendBeacon(ENDPOINT, body);
else fetch(ENDPOINT, { method: "POST", body, keepalive: true });
}
export function reportWebVitals({ sampleRate }: { sampleRate: number }) {
const opts = { reportAllChanges: false }; // un valor final por métrica
onLCP((m) => send(m, sampleRate), opts);
onINP((m) => send(m, sampleRate), opts);
onCLS((m) => send(m, sampleRate), opts);
onFCP((m) => send(m, sampleRate), opts);
onTTFB((m) => send(m, sampleRate), opts);
}
// main.tsx — muestrea al 100% en dev para que veas cada evento mientras construyes.
// En prod, fija la tasa desde tu volumen real, no desde un 0.1 copiado.
reportWebVitals({ sampleRate: import.meta.env.DEV ? 1.0 : SAMPLE_RATE });
Por qué sendBeacon y no fetch: el navegador mantiene vivo un beacon a través de la descarga, así que los valores de CLS/INP que solo existen al final de la visita igual salen. Un fetch normal se cancela cuando la página se va.
Mandarlo sin un proveedor
No necesitas un SaaS de RUM ni una tabla rum_events. Una medición es de escritura única, estadística, e inútil de manera individual: solo lees agregados. Así que escríbela en tu log de aplicación y deja que tu pipeline de logs a métricas agregue. Un endpoint diminuto:
# rum.py — ve 01-ingest/
@router.post("/rum/vitals", status_code=204)
@limiter.limit("60/minute")
async def ingest_vitals(payload: VitalsPayload, request: Request) -> Response:
# POSICIONAL, delimitado por espacios: el orden es un contrato con el filtro
# de métrica, que extrae el token del valor pelón por posición. No cambies
# esto a clave=valor o JSON sin actualizar el patrón del filtro.
logger.info(
"web_vital %s %.4f %s %s %s",
payload.name, payload.value, payload.rating,
payload.url, payload.navigation_type,
)
return Response(status_code=204) # la SPA nunca lee la respuesta
Valida la carga (un modelo de Pydantic con un conjunto Literal cerrado de nombres de métrica y longitudes de cadena acotadas) para que un beacon hostil no pueda escribir basura o PII en tus logs. Ponle límite de tasa: es un POST sin autenticar.
Convertir logs en métricas
Un filtro de métrica de CloudWatch escanea el grupo de logs y emite un punto de dato de métrica por cada línea que coincide. Un filtro por vital, acotado por nombre:
// metric-filters.ts — ve 02-metric-filter/
for (const vital of ["LCP", "INP", "CLS", "FCP", "TTFB"] as const) {
new logs.MetricFilter(stack, `WebVital${vital}Filter`, {
logGroup,
// tokens del encabezado (date,time,level,src) + tag + name + el token del valor.
// `name="LCP"` acota el filtro; `value` es el numérico que extraemos.
filterPattern: logs.FilterPattern.literal(
`[date, time, level, src, tag="web_vital", name="${vital}", value, ...]`
),
metricNamespace: "myapp/RUM",
metricName: vital,
metricValue: "$value",
// SIN defaultValue. Quiero que los periodos vacíos se queden vacíos: un 0 en cada
// línea que no coincide envenenaría el percentil hacia cero.
});
}
Dos sutilezas:
El patrón del filtro tiene que coincidir con tu formato de log real. El patrón entre corchetes es posicional:
[date, time, level, src, tag="web_vital", ...] asume que tu logger
antepone 2026-06-25 18:13:31,358 INFO mod: .... Si tu formato difiere (logs en JSON, sin nivel), el patrón no coincide con nada en silencio. Verifícalo contra una línea real, no contra el código.
Sin defaultValue. Con uno, cada línea de log no relacionada emite un 0 en la métrica y tu p75 se colapsa. Omítelo para que solo cuenten las muestras reales.
Leer el p75
Los filtros de métrica guardan los valores crudos; CloudWatch calcula los percentiles en el momento de la consulta. Pide p50/p75/p95 con
GetMetricData:
# dashboard.py — ve 03-dashboard/
queries = [
{
"Id": f"{vital.lower()}_{stat}",
"MetricStat": {
"Metric": {"Namespace": "myapp/RUM",
"MetricName": vital},
"Period": 3600,
"Stat": stat, # "p50" | "p75" | "p95"
},
}
for vital in ["LCP", "INP", "CLS", "FCP", "TTFB"]
for stat in ("p50", "p75", "p95")
]
resp = cloudwatch.get_metric_data(MetricDataQueries=queries, ...)
Reporta el p75 como el titular (la línea con la que Google califica), el p50 para el usuario típico, el p95 para la cola que estás ignorando. Y alarma sobre la que correlaciona con los ingresos, normalmente LCP:
// LCP p75 > 4s ("poor") por 2 de 3 horas. Tolera periodos vacíos para que una
// noche tranquila no te despierte con un aviso.
new cw.Alarm(stack, "WebVitalLcpPoor", {
metric: new cw.Metric({ namespace: "myapp/RUM", metricName: "LCP",
period: cdk.Duration.hours(1), statistic: "p75" }),
threshold: 4000,
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 3,
datapointsToAlarm: 2,
treatMissingData: cw.TreatMissingData.NOT_BREACHING,
});
Las trampas (qué suele significar "el dashboard está vacío")
En orden aproximado de qué tan seguido es la causa real cada una:
-
El endpoint del beacon es relativo.
("" ) + "/api/v1/rum/vitals"→ postea a tu SPA, que respondeindex.htmlcon un200. Nada lanza excepción, nada se registra, el dashboard se queda vacío por siempre. Arregla la URL base y verifica que el bundle desplegado contenga el host absoluto de la API, no solo el código fuente. - La tasa de muestreo muy baja para tu volumen. El 10% de 300 vistas al día son ~30 muestras repartidas entre 5 métricas y 24 horas. Eso es ruido, no un p75. Súbela; las métricas derivadas de logs son baratas.
- El patrón del filtro no coincide con la línea de log. Los filtros posicionales son exactos. Un cambio de formato aguas arriba y la métrica se queda muda. Prueba el patrón contra un evento real.
-
defaultValuepuesto en el filtro. Cada línea emite un 0; p75 → ~0. - Tratarlo como un promedio. Un plano "2.1s de LCP promedio" puede esconder un p95 de 9s. Siempre percentiles.
Lecciones
- El campo le gana al laboratorio para "qué reciben los usuarios". Deja Lighthouse en CI para las regresiones; confía en el RUM para la realidad.
- El RUM es logs, no una base de datos. Escritura única, lectura como agregado. Una línea de log + un filtro de métrica es toda la capa de almacenamiento.
- Las líneas de log posicionales son un contrato. Baratas de parsear, frágiles si reformateas. Comenta los dos extremos y fija el orden.
- Muestrea para tu tráfico, no para el tráfico de un blog. La tasa por default del post de alguien más asume el volumen de alguien más.
-
sendBeacon, nofetch. Las métricas que se finalizan tarde (CLS, INP) solo escapan en la descarga. - Verifica el artefacto construido, no el código fuente. La variable de entorno que fija la URL de tu beacon tiene que sobrevivir la compilación. Hazle grep al bundle.
Si te llevas una sola cosa: la primera vez que tu dashboard de RUM esté vacío, POSTea una muestra falsa al endpoint de ingesta a mano y míralo fluir. Si la métrica aparece, el pipeline está bien y tu navegador no está mandando, casi siempre por una URL relativa o una tasa de muestreo afinada para un sitio más grande.
Top comments (0)