惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
T
Threatpost
P
Palo Alto Networks Blog
NISL@THU
NISL@THU
O
OpenAI News
Project Zero
Project Zero
G
GRAHAM CLULEY
P
Privacy International News Feed
A
Arctic Wolf
Microsoft Azure Blog
Microsoft Azure Blog
H
Help Net Security
M
MIT News - Artificial intelligence
T
Threat Research - Cisco Blogs
S
Security @ Cisco Blogs
Google DeepMind News
Google DeepMind News
B
Blog RSS Feed
D
Docker
aimingoo的专栏
aimingoo的专栏
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
云风的 BLOG
云风的 BLOG
雷峰网
雷峰网
W
WeLiveSecurity
P
Proofpoint News Feed
腾讯CDC
Cloudbric
Cloudbric
S
Secure Thoughts
C
Check Point Blog
博客园 - Franky
T
The Exploit Database - CXSecurity.com
T
Troy Hunt's Blog
GbyAI
GbyAI
Security Archives - TechRepublic
Security Archives - TechRepublic
Application and Cybersecurity Blog
Application and Cybersecurity Blog
月光博客
月光博客
C
Cyber Attacks, Cyber Crime and Cyber Security
I
Intezer
TaoSecurity Blog
TaoSecurity Blog
L
Lohrmann on Cybersecurity
V
Visual Studio Blog
F
Fortinet All Blogs
博客园 - 叶小钗
C
CXSECURITY Database RSS Feed - CXSecurity.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Recorded Future
Recorded Future
C
Cisco Blogs
博客园 - 司徒正美
Stack Overflow Blog
Stack Overflow Blog
Y
Y Combinator Blog
Apple Machine Learning Research
Apple Machine Learning Research

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Web Vitals de usuarios reales sin un proveedor de paga
Franchesco Romero · 2026-06-27 · via DEV Community

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.

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

  1. 00-collect/vitals.ts — what gets sent and why sendBeacon.
  2. 01-ingest/rum.py — the positional log line (the contract).
  3. 02-metric-filter/metric-filters.ts — the matching filter pattern.
  4. 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&gt
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:

  1. 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.
  2. Una URL de beacon relativa. Si el endpoint resuelve al origen de tu propia SPA en lugar de a la API, sendBeacon "tiene éxito" (regresa true) y tu SPA le devuelve index.html a 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:

  1. El endpoint del beacon es relativo. ("" ) + "/api/v1/rum/vitals" → postea a tu SPA, que responde index.html con un 200. 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.
  2. 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.
  3. 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.
  4. defaultValue puesto en el filtro. Cada línea emite un 0; p75 → ~0.
  5. Tratarlo como un promedio. Un plano "2.1s de LCP promedio" puede esconder un p95 de 9s. Siempre percentiles.

Lecciones

  1. 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.
  2. 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.
  3. Las líneas de log posicionales son un contrato. Baratas de parsear, frágiles si reformateas. Comenta los dos extremos y fija el orden.
  4. 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.
  5. sendBeacon, no fetch. Las métricas que se finalizan tarde (CLS, INP) solo escapan en la descarga.
  6. 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.