Volver al blog
13 de agosto de 2026Sergei Solod15 min de lectura

Alertaba por todo: cómo convertí el ruido de errores del navegador en monitorización útil de producción

Mi sistema de reporte del frontend trataba igual los bloqueos de anuncios, los fallos de GTM, AbortError, los Script error opacos y los fallos reales de chunks de Next.js. Reorganicé las alertas según la propiedad del recurso, el impacto en el usuario, la calidad de la evidencia, la correlación y la recuperación.

Monitorización de errores del navegadorObservabilidad frontendErrores JavaScriptMonitorización de producciónNext.jsRendimiento web

Construí un sistema de errores del lado del cliente porque quería enterarme de los problemas que sufrían usuarios reales y que yo no podía reproducir con facilidad en local. El sistema hacía exactamente lo que le había pedido: capturaba fallos y me los enviaba.

El problema era que casi todos parecían igual de importantes.

¿Falló un script externo de analítica? Alerta roja. ¿El navegador bloqueó publicidad? Alerta roja. ¿Un rastreador no pudo cargar Google Analytics? Alerta roja. ¿Un vídeo de vista previa llamó a play() y se pausó antes de que terminara la Promise? Alerta roja. ¿Llegó un Script error. sin origen ni stack útiles? Otra alerta roja.

En el mismo flujo también había fallos que sí podían romper el producto: una URL propia mal formada que había terminado como https://example.comhttps://example.com/... y un archivo propio de Next.js bajo /_next/static/chunks/... que un navegador no consiguió cargar.

La recolección funcionaba. La monitorización no.

Ese contraste cambió mi forma de entender la observabilidad del frontend. Un evento de error demuestra que ocurrió algo. Todavía no es un diagnóstico, una severidad ni un incidente.

El primer problema era llamar “error” a todo

Mi modelo inicial era casi éste:

el navegador informa de un error
        ↓
enviar CLIENT ERROR
        ↓
el desarrollador debe preocuparse

Ahí estaba mezclando demasiadas preguntas. ¿Falló algo mío? ¿Afectó a la ruta activa? ¿Fue una cancelación esperada? ¿El navegador tenía información suficiente para identificar el origen? ¿La aplicación se recuperó? ¿Diez mensajes eran diez incidentes o diez síntomas de uno solo?

Hasta responder a esas preguntas, un evento no debería convertirse automáticamente en una alerta.

En una de las primeras revisiones tenía alrededor de dieciocho mensajes. La mayoría eran ruido de terceros o del ciclo de vida. Dos destacaban: la URL propia era inequívocamente incorrecta y el chunk propio de JavaScript podía ser grave porque el código necesario quizá no estuviera disponible para la página. Mi sistema mostraba casi todos con la misma urgencia.

Ahí dejé de considerar equivalentes “capturar todos los errores del navegador” y “tener monitorización de producción”. La captura conserva evidencia. La monitorización convierte evidencia en decisiones.

El navegador no tiene un único canal universal de errores

También estaba suponiendo que los distintos fallos del cliente llegaban con semántica comparable. No es así.

El evento error de window cubre errores síncronos de scripts y participa en fallos de recursos. Una Promise rechazada sin manejar sigue otra ruta: el navegador emite unhandledrejection. Elementos que cargan scripts, imágenes o medios pueden emitir su propio error. React y Next.js añaden además sus propios límites de error.

window.error
→ pudo escapar un error síncrono de script

unhandledrejection
→ una Promise quedó sin manejar en ese momento

error de elemento
→ un recurso no pudo cargarse o utilizarse

límite del framework
→ el renderizado o la ejecución alcanzaron un límite de fallo

Un hook global observa un síntoma en un límite de la plataforma. No necesariamente conoce toda la cadena causal.

Cuando asumí esa diferencia dejé de normalizar inmediatamente todo como un Error genérico con la misma severidad.

La propiedad del recurso es el primer filtro útil, no el último

La primera separación realmente productiva fue averiguar de quién era cada fallo.

Que falle /_next/static/chunks/app/... no es lo mismo que un SDK publicitario alojado en otro origen. Una URL creada por mi código no es lo mismo que una petición de analítica bloqueada. Y un error de una extensión del navegador pertenece a otra categoría.

  • aplicación propia: JavaScript, CSS, API, medios y URLs generadas por mí;
  • framework o runtime: rutas de Next.js o React que forman parte de la ejecución;
  • integraciones externas: analítica, publicidad, widgets y SDK;
  • entorno: extensiones, rastreadores, estado de red, herramientas de privacidad y peculiaridades del agente de usuario.

Esto no significa ignorar terceros. Si una integración de analítica deja de funcionar tras una versión, puede importar. Si la publicidad financia infraestructura, su salud puede tener impacto económico. Pero son incidentes de integración, no necesariamente caídas de la aplicación.

Enviar ambas cosas al mismo canal urgente destruye el significado de ese canal.

Dos fallos propios me enseñaron qué significa “accionable”

La URL mal formada era el caso sencillo:

https://example.comhttps://example.com/resource

No hacía falta teorizar sobre AdBlock, VPN o políticas del navegador. La URL era inválida. En algún punto mi código estaba concatenando el origen con un valor que ya era absoluto.

Era accionable porque la evidencia era específica, el recurso era mío y apuntaba a un camino de código bajo mi control.

El fallo del chunk de Next.js era distinto:

Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js

También era mío y podía romper la página, pero el evento no probaba la causa. Un cliente antiguo podía pedir un recurso de otro despliegue. La petición podía agotar el tiempo. Podía fallar un proxy inverso o una CDN. Podía cortarse la conexión. El archivo podía faltar de verdad.

La respuesta correcta no era “ya sé la causa”, sino “esta clase tiene prioridad alta y necesita más contexto”.

La severidad puede ser alta aunque la certeza causal sea baja.

Script error. es una pista, no un rastro de pila

Otro evento recurrente era:

Error: Script error.
filename: unknown
line: 0
column: 0

Parece grave y casi no contiene información.

Los navegadores limitan deliberadamente los detalles de errores de scripts entre orígenes. MDN explica que, sin la configuración CORS adecuada, window.onerror recibe información limitada; el comportamiento de crossorigin en <script> afecta directamente a lo que puede exponerse.

Por eso no convierto automáticamente un Script error. opaco en “mi aplicación se cayó”. Puede proceder de mi código, de un tercero, de código inyectado o de un origen cuyos detalles el navegador no puede revelar.

Lo conservo, lo correlaciono con página, compilación, navegador y eventos cercanos, pero un único 0:0 no merece por sí solo despertar a nadie. Si aparece un clúster alrededor de un versión o una ruta, cambia de prioridad.

Desconocido no significa inocuo. Tampoco significa crítico.

Un AbortError puede ser real y totalmente esperado

La mejor demostración llegó con las vistas previas de vídeo.

AbortError:
The play() request was interrupted by a call to pause()

HTMLMediaElement.play() devuelve una Promise que puede rechazarse. Además, las operaciones del ciclo de vida del medio pueden cancelar reproducciones pendientes; MDN documenta que load() aborta las Promises pendientes de play() con AbortError.

En una cuadrícula de vista previas esto puede ocurrir sin que exista un fallo visible. El elemento entra en el viewport, se llama a play(), el usuario desplaza la página y el código pausa o reemplaza el medio antes de que el inicio haya terminado.

El rechazo de la Promise es real. El incidente para el usuario puede no existir.

La solución adecuada suele estar junto a la llamada: manejar la Promise y separar cancelaciones esperadas de fallos genuinos. unhandledrejection debe ser una red de seguridad, no el lugar donde se descubre por primera vez el comportamiento normal del componente.

Los fallos de terceros necesitan su propio modelo de salud

Mis primeros logs tenían muchos fallos de analítica y publicidad. Algunos venían de navegadores centrados en privacidad y otros de rastreadores. Un caso especialmente poco útil era un rastreador incapaz de cargar Google Analytics.

Eso demuestra que una petición de red falló. No demuestra que un usuario humano no pudiera usar el producto.

Conservar el evento no era el problema. El problema era mezclarlo con un chunk propio de JavaScript que no cargó.

  • ¿La aplicación dejó de funcionar para el usuario?
  • ¿La integración externa está sana?

Un script publicitario bloqueado puede alimentar una métrica de entrega de anuncios. Un fallo de analítica puede medir cobertura de analítica. Ninguno debe llamarse “crash del frontend” salvo que la funcionalidad dependa realmente de él.

Separarlos incluso mejora la observación de terceros: puedo agregarlos por proveedor, navegador y región en lugar de recibirlos como ruido aleatorio.

navigator.onLine aporta contexto, no prueba conectividad

Empecé a guardar si el navegador se consideraba conectado. Resultó útil sólo cuando dejé de tratarlo como verdad absoluta.

Tuve fallos clasificados como problemas de red con:

Online: true

No es contradictorio. MDN advierte que navigator.onLine depende de heurísticas del navegador y del sistema operativo. Un equipo puede estar conectado a una LAN y aun así no alcanzar mi origen. VPN, firewall, DNS y averías parciales complican todavía más el panorama.

online === false
→ señal fuerte de que el entorno puede explicar el fallo

online === true
→ NO demuestra que el origen o el recurso fueran alcanzables

Es una distinción pequeña que evita diagnósticos demasiado seguros.

Un solo fallo raíz puede producir varios eventos

Al mejorar la captura apareció otro problema: un único incidente podía generar varios mensajes.

Un chunk puede producir primero un resource.error. Después el cargador lanza ChunkLoadError. React o Next.js puede alcanzar un límite de error. La recuperación programa un reload. Si cada capa envía una alerta independiente, una sola acción del usuario parece varias caídas.

Además de inflar el volumen, distorsiona la percepción. Cinco mensajes parecen cinco usuarios aunque procedan de una sesión y un recurso.

No basta con deduplicar texto. Hay que correlacionar incidentes.

sesión
+ ventana temporal corta
+ clase de error normalizada
+ recurso propio
+ build del cliente
+ ruta

Conservo los eventos sin convertirlos todos en avisos. Si ya tengo un límite del framework con stack propio y URL exacta del chunk, el resource.error anterior no necesita otro aviso urgente.

Alertar incidentes. Guardar eventos.

El contexto alrededor del error terminó valiendo más que el mensaje

La telemetría posterior se volvió mucho más estructurada. Además de mensaje, URL y agente de usuario aparecieron campos como:

clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack

Para medios también recogí el código de error, el estado HTTP cuando podía observarlo por otra vía, el Content-Type recibido y si el fallo parecía de HTTP o de red.

Esto permite responder preguntas que una excepción aislada no responde: ¿empezó en un compilación? ¿Hubo respuesta HTTP? ¿Controlaba la página un Service Worker? ¿Se ejecutó la recuperación? ¿Varios eventos apuntaban al mismo recurso? ¿La ruta activa quedó realmente rota?

La API Resource Timing puede aportar duración, información de transferencia y, donde está soportado y permitido, estado de respuesta. Hay límites: los datos entre orígenes se restringen, un recurso en caché puede tener transferSize cero y responseStatus no está disponible en todos los navegadores. Por eso null y 0 deben mantenerse como estados honestos.

ChunkLoadError es un síntoma, no un detector de 404

Un evento posterior cambió mi forma de interpretar los chunks.

El navegador informó de un ChunkLoadError de Next.js para un chunk de layout, mientras la telemetría mostraba:

resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true

La medición registrada rondaba los 170 segundos. Sea cual fuera la causa exacta, esos datos bastaban para descartar:

ChunkLoadError === el servidor devolvió 404

Otros fallos no tenían estado observable; algunos eran timeouts y otros sí incluían datos de transferencia. La clase era la misma, la evidencia no.

Esto es especialmente relevante en Next.js porque los archivos de /_next/static/ suelen tener hash de contenido y están pensados como inmutables. La documentación actual de self-hosting de Next.js describe cache headers largos para recursos inmutables con hash. Por tanto, un fallo puede involucrar desajuste de despliegues, clientes antiguos, red, proxy, CDN, caché, Service Worker o un artefacto realmente ausente.

El sistema de alertas no debe inventar cuál de ellos fue. Debe conservar suficiente evidencia para investigarlo.

Los errores de medios me enseñaron lo mismo desde otra capa

En vídeo vi una trampa parecida. El elemento multimedia informaba:

MEDIA_ELEMENT_ERROR: Format error

Eso suena inmediatamente a incompatibilidad de codec.

Pero para algunos eventos la verificación de entrega mostraba:

HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http

El navegador pidió vídeo y recibió HTML con un error HTTP. El síntoma exterior era “format error” porque el elemento no podía decodificar HTML como vídeo. El diagnóstico útil estaba en la entrega.

La capa que detecta el problema no tiene por qué ser la capa que lo causa.

Por eso intento no bautizar incidentes usando la excepción exterior. “Fallo de codec”, “caída de red”, “bug de caché” o “chunk desaparecido” son conclusiones. Primero registro observaciones.

Valoro cada fallo en cinco dimensiones

1. Propiedad

¿Es código propio, framework/runtime, integración externa o entorno?

2. Impacto

¿Rompió la ruta activa, renderizado, autenticación, chat, checkout u otro flujo esencial? ¿O sólo falló publicidad, analítica, un preload o vista previa opcional?

3. Calidad de la evidencia

¿Tengo stack propio, URL, estado HTTP, ID de compilación y pila de componentes? ¿O sólo Script error. en 0:0?

4. Repetición y alcance

¿Es una sesión o la misma firma aparece en distintos usuarios, rutas y navegadores tras el mismo versión?

5. Recuperación

¿La aplicación se recuperó? ¿Se programó reload del chunk? ¿Funcionó el fallback? ¿El usuario sigue bloqueado?

propiedad propia clara
+ alto impacto
+ buena evidencia
+ varias sesiones
+ sin recuperación
= incidente urgente

tercero
+ función opcional
+ evidencia débil
+ aislado
+ usuario no afectado
= métrica o investigación de baja prioridad

El filtrado debe ser conservador

Cuando el ruido molesta, es tentador crear decenas de expresiones regulares y eliminar todo lo incómodo. Es peligroso.

Silenciar cualquier AbortError puede ocultar peticiones reales canceladas. Eliminar todo Script error. puede esconder un clúster específico de navegador. Ignorar todos los terceros puede ocultar un proveedor de pagos, autenticación o consentimiento roto.

ALERT
→ incidente con señal fuerte que exige atención

RETAIN / AGGREGATE
→ conservar y contar; alertar sólo al formar un clúster

METRIC / SAMPLE
→ ruido esperado o de poco impacto; guardar tendencia y ejemplos

Así el sistema puede ser silencioso sin ser ciego.

Un clasificador útil es política codificada

Este ejemplo no procede de mi código de producción. Resume la política que habría preferido tener desde el principio:

function classifyClientEvent(event) {
  const owner = classifyOwner(event);

  if (isExpectedMediaCancellation(event)) {
    return { severity: "metric", reason: "expected-cancellation" };
  }

  if (owner === "first-party" && breaksActiveRoute(event)) {
    return { severity: "alert", reason: "first-party-user-impact" };
  }

  if (isActiveFirstPartyChunkFailure(event)) {
    return { severity: "alert", reason: "application-chunk" };
  }

  if (owner === "third-party") {
    return { severity: "aggregate", reason: "integration-health" };
  }

  if (isOpaqueScriptError(event)) {
    return { severity: "aggregate", reason: "insufficient-evidence" };
  }

  return { severity: "aggregate", reason: "needs-correlation" };
}

La dificultad real está dentro de funciones como breaksActiveRoute(): hacen falta contexto de ruta, propiedad del recurso, datos del límite del framework y a veces conocimiento del producto.

La huella debe seguir al incidente, no al texto

Comparar mensajes literalmente deduplica mal. Cambian offsets minificados, hashes de chunks, identificadores en URLs y redacción entre navegadores.

{
  errorClass,
  normalizedFirstPartyResource,
  routeFamily,
  clientBuild,
  sessionId,
  shortTimeBucket
}

Para un crash global puede importar el primer frame propio; para un chunk, el recurso normalizado; para un problema de medios, la clase de entrega.

resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled

puede representarse como un incidente con cuatro observaciones, no cuatro emergencias.

El canal urgente debe tener un trabajo mucho más estrecho

Reservaría avisos inmediatos para errores runtime propios que rompen la ruta, límites de React/Next.js con impacto real, JavaScript o CSS propios necesarios, ChunkLoadError repetidos entre sesiones, fallos de API esenciales sin recuperación y violaciones claras como URLs generadas inválidas.

Retendría sin avisar de inmediato un Script error. aislado, un fallo propio con recuperación correcta, errores de medios aún no clasificados y anomalías de un navegador que necesitan agregación.

Y enviaría normalmente a métricas o muestras los fallos conocidos de publicidad/analítica, cancelaciones esperadas AbortError, ruido de terceros exclusivo de rastreadors, eventos con fuerte indicio sin conexión y recursos especulativos opcionales.

El canal urgente debe representar impacto accionable, no volumen bruto de quejas del navegador.

Prefiero medir incidentes en lugar de “número de errores”

Un contador global de errores pierde valor en cuanto existe tráfico real e integraciones diversas.

  • incidentes propios por 1.000 sesiones;
  • sesiones afectadas por ID de compilación;
  • límites de error por ruta;
  • fallos de chunks por recurso y despliegue;
  • tasa de fallo de cada integración;
  • volumen de cancelaciones esperadas para detectar cambios bruscos;
  • tasa de recuperación automática;
  • incidentes únicos frente a eventos brutos.

“Ocurrió un evento” rara vez es un buen umbral. “El mismo incidente propio afecta a varias sesiones independientes en el compilación nueva y la recuperación falla” sí es una señal operativa.

La monitorización del cliente todavía no prueba la causa raíz

La telemetría del navegador tiene límites duros.

Un estado ausente puede deberse a falta de soporte, restricciones entre orígenes, cancelación o simplemente falta de observabilidad. Un Service Worker activo no demuestra que él sirvió una respuesta obsoleta. Un ChunkLoadError tras desplegar no demuestra desajuste de versión. online: true no demuestra que el origen fuera accesible.

La monitorización reduce hipótesis. Todavía pueden hacer falta logs del servidor y proxy, manifiestos de despliegue, estado de caché y reproducción.

Tampoco quiero que observabilidad signifique recolectar datos sin límite. Cada campo debe existir porque ayuda a separar modos de fallo.

Mejor telemetría no significa más telemetría; significa telemetría que discrimina mejor.

Mi regla actual: recoger eventos, investigar incidentes, alertar por impacto

Al principio quería responder: “¿Se rompió algo?”. En producción esa pregunta es demasiado amplia. Siempre hay algo fallando: un rastreador, un bloqueador, una Promise cancelada, una red intermitente o un SDK externo.

¿Es nuestro?
¿El usuario perdió funcionalidad?
¿Qué tan sólida es la evidencia?
¿Se repite?
¿Se recuperó la aplicación?
¿Son varios eventos o un solo incidente?

Cuando empecé a organizar la monitorización alrededor de esas preguntas, dejó de ser una manguera de mensajes rojos y se convirtió en una herramienta de ingeniería.

Un error del navegador es una observación. Un incidente es una explicación correlacionada del impacto. Una alerta es la decisión de que una persona debe actuar.

No quiero volver a tratar las tres cosas como sinónimos.