Retour au blog
13 août 2026Sergei Solod17 min de lecture

J’alertais sur tout : comment j’ai transformé le bruit des erreurs navigateur en supervision de production utile

Mon système de remontée d’erreurs frontend traitait de la même manière le blocage des publicités, les échecs GTM, AbortError, les Script error opaques et les vrais échecs de chunks Next.js. J’ai reconstruit les alertes autour de la propriété de la ressource, de l’impact utilisateur, de la qualité des preuves, de la corrélation et du rétablissement.

Supervision des erreurs navigateurObservabilité frontendErreurs JavaScriptSupervision de productionNext.jsPerformance web

J’ai mis en place un remontée d’erreurs côté client pour une raison simple : je voulais voir les problèmes rencontrés par de vrais utilisateurs, notamment ceux que je n’arrivais pas à reproduire localement. Le système faisait exactement ce que je lui demandais : il capturait les erreurs et me les envoyait.

Le problème, c’est qu’il présentait presque tout comme si tout avait la même importance.

Un script d’analytics tiers ne se charge pas ? Alerte rouge. Un script publicitaire est bloqué ? Alerte rouge. Un robot d’exploration n’arrive pas à charger Google Analytics ? Alerte rouge. Un aperçu vidéo appelle play(), puis est mise en pause avant que la Promise ne soit résolue ? Alerte rouge. Un Script error. opaque arrive sans source ni pile exploitable ? Encore une alerte rouge.

Et au milieu de ce flux se trouvaient de vrais signaux : une URL de l’application devenue accidentellement https://example.comhttps://example.com/..., et un fichier Next.js de mon application sous /_next/static/chunks/... qu’un navigateur n’avait pas réussi à charger.

La collecte fonctionnait. Le supervision, non.

Cette distinction a changé ma manière de penser l’observabilité frontend. Un événement d’erreur prouve qu’il s’est passé quelque chose. Ce n’est pas encore un diagnostic, un niveau de gravité ni un incident.

Le premier bug était de traiter le mot « erreur » comme une sévérité

Mon premier modèle ressemblait à ceci :

le navigateur signale une erreur
        ↓
envoyer CLIENT ERROR
        ↓
le développeur doit intervenir

Ce modèle fusionnait trop de questions. Est-ce mon code ? La route active est-elle cassée ? S’agit-il d’une annulation attendue ? Le navigateur dispose-t-il d’assez d’informations pour identifier la source ? L’application s’est-elle rétablie ? Dix messages représentent-ils dix incidents ou dix symptômes du même incident ?

Tant que ces questions restent ouvertes, un événement ne devrait pas devenir automatiquement une alerte.

Lors d’une première analyse, j’avais environ dix-huit messages sous les yeux. La majorité relevait du bruit tiers ou du cycle de vie normal du navigateur. Deux cas ressortaient nettement : l’URL de l’application mal formée était objectivement incorrecte, et l’échec du chunk JavaScript de l’application pouvait casser tout ou partie de la page. Pourtant, le système les présentait presque avec la même urgence qu’un script publicitaire bloqué.

C’est là que j’ai cessé de considérer « collecter toutes les erreurs navigateur » et « construire un supervision de production » comme le même problème. La collecte conserve les preuves. Le supervision doit transformer ces preuves en décisions.

Le navigateur n’a pas un canal d’erreur universel

Ma deuxième erreur était de supposer que tous les échecs côté client arrivaient avec une sémantique comparable.

L’événement error sur window sert notamment aux erreurs synchrones de script et intervient aussi pour les erreurs de ressources. Une Promise rejetée sans handler suit un autre chemin : le navigateur émet unhandledrejection. Les éléments qui chargent des scripts, images ou médias peuvent émettre leur propre error. React et Next.js ajoutent ensuite leurs propres error boundaries.

window.error
→ une erreur synchrone de script a pu s’échapper

unhandledrejection
→ une Promise rejetée n’avait pas de handler à cet instant

error sur un élément
→ une ressource n’a pas pu être chargée ou utilisée

framework boundary
→ l’exécution ou le rendu a atteint une frontière d’erreur

Un hook global voit un symptôme à une frontière du système. Il ne connaît pas forcément toute la chaîne causale.

À partir de là, j’ai arrêté de normaliser immédiatement chaque événement en un Error générique avec la même priorité.

La propriété du code est le premier filtre utile

La séparation la plus productive consiste d’abord à demander : à qui appartient ce qui a échoué ?

Un échec de /_next/static/chunks/app/... n’a pas la même signification qu’un SDK publicitaire hébergé sur une autre origine. Une URL mal construite par mon code n’est pas équivalente à une requête analytics bloquée. Une extension navigateur constitue encore une autre catégorie.

  • application elle-même : mon JavaScript, mon CSS, mes appels API, mes médias et les URL que je génère ;
  • framework et environnement d’exécution : Next.js ou React lorsqu’ils font partie de la chaîne d’exécution de l’application ;
  • intégrations tierces : analytics, publicité, widgets et SDK externes ;
  • environnement : extensions, crawlers, réseau, outils de confidentialité et particularités du navigateur.

« Tiers » ne veut pas dire « sans importance ». Une intégration de paiement ou d’authentification peut être critique. Un problème publicitaire peut avoir un impact économique. Mais la santé d’une intégration n’équivaut pas automatiquement à un plantage de l’application.

Si tout arrive dans le même canal urgent, ce canal perd son sens.

Deux défaillances de mon application m’ont appris ce qu’est un signal vraiment exploitable

La mauvaise URL était le cas simple :

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

Je n’avais pas besoin d’hypothèses sur AdBlock, un VPN ou une politique de navigateur. L’URL elle-même était invalide. Quelque part, mon code ajoutait l’origine à une valeur qui était déjà une URL absolue.

C’était exploitable : preuve précise, ressource sous mon contrôle, chemin de code identifiable.

L’échec du chunk Next.js était plus subtil :

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

La ressource appartenait elle aussi à l’application et pouvait casser la page, mais l’événement ne prouvait pas la cause. Un ancien client pouvait demander une ressource d’une version précédente. La requête pouvait expirer. Un proxy inverse ou un CDN pouvait échouer. La connexion pouvait disparaître. Le fichier pouvait réellement manquer.

La bonne réaction n’était donc pas « j’ai trouvé la cause », mais « cette classe est importante et exige davantage d’éléments ».

Une erreur peut être très grave alors que la certitude sur sa cause reste faible.

Script error. est un indice, pas une trace de pile

Un classique revenait régulièrement :

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

Le message paraît inquiétant et apporte presque zéro information.

Les navigateurs limitent volontairement les détails des erreurs de scripts cross-origine. MDN explique que, sans configuration CORS adaptée, window.onerror expose des informations limitées ; le comportement crossorigin de <script> influence directement ce qui peut être révélé.

Je ne peux donc pas traduire silencieusement un Script error. opaque par « mon application vient de planter ». Cela peut venir de mon code, d’un tiers, de code injecté ou simplement d’une origine dont le navigateur masque les détails.

Je conserve l’événement, je le corrèle avec la page, le build, le navigateur et les événements voisins, mais un unique 0:0 ne déclenche pas une alerte urgente. Si la même signature se concentre autour d’une route ou d’un version, sa priorité change.

« Inconnu » ne signifie ni « inoffensif », ni « critique ».

Un AbortError peut être réel tout en étant attendu

Les aperçus vidéo ont fourni le meilleur exemple :

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

HTMLMediaElement.play() renvoie une Promise, qui peut être rejetée. Le cycle de vie d’un média peut aussi interrompre volontairement une lecture en attente ; MDN documente notamment que load() annule les Promises de lecture en attente avec AbortError.

Dans une grille de aperçus, cela peut se produire sans bug utilisateur. Un élément entre dans le viewport, le code appelle play(), l’utilisateur scrolle, l’élément sort du viewport et le code le met en pause ou remplace sa source avant la fin du démarrage.

Le rejet de Promise est réel. L’incident utilisateur peut ne pas exister.

La bonne gestion se situe près de l’appel : traiter la Promise et distinguer l’annulation attendue d’un vrai problème de lecture. unhandledrejection reste un filet de sécurité, pas le premier endroit où comprendre le cycle de vie normal du composant.

Les services tiers ont besoin de leur propre modèle de santé

Mes premiers logs contenaient beaucoup d’échecs analytics et publicitaires. Certains venaient de navigateurs orientés confidentialité, d’autres de robots d’exploration. Un exemple particulièrement inutile était un robot d’exploration incapable de charger Google Analytics.

Cela prouve l’échec d’une requête. Cela ne dit presque rien sur la capacité d’un utilisateur humain à utiliser l’application.

Le problème n’était pas de conserver l’événement. Le problème était de le mettre dans le même flux qu’un chunk JavaScript de l’application manquant.

  • L’application est-elle cassée pour l’utilisateur ?
  • L’intégration externe fonctionne-t-elle correctement ?

Un script publicitaire bloqué peut alimenter une métrique de diffusion publicitaire. Un échec d’analytics peut mesurer la couverture de l’analytics. Aucun des deux ne devrait être signalé comme « plantage du frontend » si les fonctions principales continuent de fonctionner.

La séparation améliore même l’analyse des tiers : on peut les agréger par fournisseur, navigateur ou région.

navigator.onLine donne du contexte, pas une preuve de connectivité

J’ai aussi commencé à enregistrer si le navigateur se considérait en ligne. C’est utile à condition de ne pas l’interpréter comme une vérité réseau.

Certains événements classés comme problèmes réseau avaient :

Online: true

Ce n’est pas contradictoire. MDN avertit explicitement que navigator.onLine repose sur des heuristiques du navigateur et du système d’exploitation. Une machine peut voir son réseau local sans pouvoir atteindre mon origine. VPN, firewall, DNS et pannes partielles compliquent encore le diagnostic.

online === false
→ indice fort d’un problème environnemental

online === true
→ ne prouve PAS que l’origin ou la ressource étaient joignables

Cette nuance évite de transformer un indice en diagnostic certain.

Une seule cause peut produire plusieurs événements navigateur

Après avoir amélioré la collecte, un autre problème est apparu : un incident pouvait générer plusieurs messages.

Un chunk JavaScript peut produire d’abord un resource.error, puis un ChunkLoadError, ensuite une error boundary React/Next.js, puis une tentative de récupération. Si chaque couche alerte séparément, une seule action utilisateur ressemble à plusieurs pannes.

Cinq messages donnent psychologiquement l’impression de cinq utilisateurs touchés, alors qu’ils peuvent provenir d’une seule session et d’une seule ressource.

Le remède n’est pas une simple déduplication textuelle, mais une corrélation d’incident :

session
+ courte fenêtre temporelle
+ classe d’erreur normalisée
+ ressource first-party
+ client build
+ route

Je conserve les événements bruts, mais j’envoie à l’humain la représentation la plus informative. Si une limite d’erreur du framework fournit déjà une pile issue de l’application et l’URL exacte du chunk, l’erreur de ressource générique précédente n’a pas besoin d’une seconde alerte urgente.

Alerter sur les incidents. Stocker les événements.

Le contexte autour de l’erreur est devenu plus précieux que son message

Ma télémétrie plus récente est devenue beaucoup plus structurée :

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

Pour les médias, j’ai aussi collecté le code d’erreur, le statut HTTP lorsqu’une vérification indépendante pouvait l’obtenir, le Content-Type réellement reçu et une classification HTTP ou réseau.

Ces champs permettent de poser de meilleures questions : le problème commence-t-il sur un build précis ? Une réponse HTTP a-t-elle été reçue ? Un Service Worker contrôlait-il la page ? Le mécanisme de récupération s’est-il déclenché ? Plusieurs événements concernent-ils la même ressource ? La route active était-elle réellement cassée ?

L’API Resource Timing peut fournir des durées, des tailles de transfert et, lorsque disponible et autorisé, un statut de réponse. Elle a des limites : les informations cross-origine sont restreintes, un cache hit peut produire transferSize: 0, et responseStatus n’est pas disponible partout. Pour cette raison, null et 0 doivent rester des états honnêtes plutôt que d’être transformés en fausse certitude.

ChunkLoadError est un symptôme, pas un détecteur de 404

Un événement plus récent a beaucoup changé ma manière de lire les erreurs de chunks.

Le navigateur a signalé un ChunkLoadError Next.js sur un chunk de layout, tandis que la télémétrie enrichie contenait :

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

La mesure enregistrée représentait environ 170 secondes. Quelle que soit la cause exacte de cet incident, ces données suffisent à rejeter une équation simpliste :

ChunkLoadError === le serveur a répondu 404

D’autres échecs de chunks n’avaient aucun statut observable. Certains étaient des timeouts. D’autres avaient des informations de transfert. Même classe d’erreur, preuves différentes.

C’est particulièrement important avec Next.js : les fichiers sous /_next/static/ sont généralement hashés par contenu et conçus comme des assets immuables. La documentation actuelle de Next.js sur le self-hosting décrit des cache headers longue durée pour ces assets. Un ChunkLoadError peut donc impliquer un décalage de déploiement, un ancien client, le réseau, un proxy inverse, un CDN, un cache, un Service Worker ou un fichier réellement absent.

Le système d’alerte ne doit pas inventer laquelle de ces causes est vraie. Il doit préserver suffisamment de preuves pour l’enquête.

Les erreurs média m’ont enseigné la même chose depuis une autre couche

Sur une vidéo, le navigateur pouvait produire :

MEDIA_ELEMENT_ERROR: Format error

Pris isolément, cela ressemble à une incompatibilité de codec.

Mais une vérification de distribution a montré pour certains événements :

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

Le navigateur demandait une vidéo et recevait une réponse HTTP d’erreur contenant du HTML. Le media element signalait un « format error » parce qu’il ne pouvait évidemment pas décoder cette page HTML comme une vidéo. Le diagnostic utile concernait la livraison.

La couche qui détecte le symptôme n’est pas forcément celle qui a créé le problème.

« Erreur codec », « panne réseau », « bug de cache » et « chunk absent » sont des conclusions. Je préfère que la télémétrie commence par des observations.

J’évalue chaque erreur navigateur sur cinq dimensions

1. Propriété

Le code appartient-il à mon application, au framework/environnement d’exécution, à un tiers ou à l’environnement ?

2. Impact utilisateur

La route active, le rendu, l’authentification, le chat, le checkout ou une autre fonction essentielle sont-ils cassés ? Ou seule une publicité, une requête analytics, un preload ou une aperçu optionnel a-t-elle échoué ?

3. Qualité des preuves

Ai-je une pile issue de l’application, une URL de ressource, un statut HTTP, un identifiant de build et une pile de composants ? Ou seulement Script error. à 0:0 ?

4. Répétition et portée

Est-ce une session isolée ou la même signature apparaît-elle chez plusieurs utilisateurs, navigateurs et routes après le même version ?

5. Récupération

L’application s’est-elle rétablie ? Un reload de chunk a-t-il été programmé ? Le fallback média a-t-il fonctionné ? L’utilisateur reste-t-il bloqué ?

propriété first-party certaine
+ fort impact utilisateur
+ preuves solides
+ répétition sur plusieurs sessions
+ absence de recovery
= incident urgent

tiers
+ fonction optionnelle
+ preuves faibles
+ cas isolé
+ utilisateur non affecté
= métrique ou investigation basse priorité

La suppression doit rester prudente

Quand le bruit devient pénible, la tentation est d’ajouter des dizaines de regex et de supprimer tout ce qui dérange. C’est risqué.

Ignorer tous les AbortError peut cacher de vraies requêtes applicatives interrompues. Supprimer tous les Script error. peut masquer un cluster propre à un navigateur. Ignorer tout le tiers peut cacher un fournisseur de paiement, d’authentification ou de consentement réellement cassé.

ALERT
→ incident fort nécessitant une action

RETAIN / AGGREGATE
→ conserver et compter ; alerter si un cluster se forme

METRIC / SAMPLE
→ bruit attendu ou faible impact ; garder tendances et exemples

Le but est de rendre le système plus calme sans le rendre aveugle.

Un bon classifieur est surtout une politique encodée

Le code suivant n’est pas copié de mon projet de production. C’est une illustration compacte de la politique que j’aurais aimé avoir au départ :

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 vraie difficulté se cache dans des fonctions comme breaksActiveRoute(). Le nom de l’exception ne suffit pas : il faut le contexte de la route, la propriété de la ressource, les données de la limite d’erreur et parfois une connaissance propre au produit.

L’empreinte doit suivre l’incident, pas la chaîne de caractères

Comparer les messages littéralement est une mauvaise déduplication. Les offsets minifiés, hashes de chunks, identifiants dynamiques et formulations des navigateurs changent.

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

Pour un crash global, le premier frame issu de l’application peut être utile ; pour un chunk, la ressource normalisée ; pour un média, la classe de distribution.

resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled

peut ainsi devenir un seul incident enrichi de quatre observations, au lieu de quatre urgences.

Le canal urgent doit avoir une mission beaucoup plus étroite

Je réserverais l’alerte immédiate aux erreurs d’exécution de l’application qui cassent la route active, aux limites d’erreur React/Next.js avec impact réel, aux échecs du JavaScript ou du CSS nécessaires à l’application, aux ChunkLoadError répétés sur plusieurs sessions, aux pannes d’API essentielles sans récupération et aux violations évidentes d’invariants comme une URL générée invalide.

Je conserverais sans alerte immédiate un Script error. isolé, un échec de l’application correctement récupéré, une erreur média dont la couche racine reste inconnue et une anomalie spécifique à un navigateur qui nécessite de l’agrégation.

Les échecs connus d’analytics ou de publicité, les annulations média AbortError attendues, le bruit tiers réservé aux crawlers, les événements fortement associés à un état hors ligne et les ressources spéculatives optionnelles iraient généralement en métriques ou échantillons.

Une alerte urgente doit représenter un impact utilisateur sur lequel on peut agir, pas le volume brut des plaintes du navigateur.

Je préfère mesurer les incidents plutôt qu’un « nombre d’erreurs »

  • incidents de l’application pour 1 000 sessions ;
  • sessions affectées par identifiant de build ;
  • error boundaries par route ;
  • échecs de chunks par ressource et déploiement ;
  • taux d’échec des intégrations par fournisseur ;
  • volume des annulations attendues pour détecter une hausse anormale ;
  • taux de récupération réussi ;
  • incidents uniques séparés du nombre brut d’événements.

« Un événement est arrivé » est rarement un bon seuil. « Le même incident de l’application touche plusieurs sessions indépendantes sur le nouveau build et la récupération échoue » est beaucoup plus exploitable.

La supervision côté client ne prouve toujours pas la cause racine

La télémétrie navigateur a des limites strictes.

Un statut HTTP absent peut venir d’un manque de support, d’une restriction cross-origine, d’une annulation ou d’un trou d’observabilité. Un Service Worker actif ne prouve pas qu’il a servi une ressource obsolète. Un ChunkLoadError après un déploiement ne prouve pas un version skew. online: true ne prouve pas que l’origine était joignable.

Le supervision client réduit le champ des hypothèses. Des logs serveur et proxy inverse, des manifestes de déploiement, l’état du cache et une reproduction peuvent encore être nécessaires.

Je ne veux pas non plus que l’observabilité devienne une collecte illimitée de données utilisateur. Chaque champ doit exister parce qu’il aide à distinguer des modes de panne.

Une meilleure télémétrie n’est pas forcément plus de télémétrie. C’est une télémétrie plus discriminante.

Ma règle actuelle : collecter les événements, enquêter sur les incidents, alerter sur l’impact

Au début, je voulais simplement répondre à la question : « Est-ce que quelque chose s’est cassé ? » En production, cette question est trop large. Quelque part, un robot d’exploration, un bloqueur, une Promise média, un réseau intermittent ou un SDK tiers finit toujours par échouer.

Est-ce à nous ?
L’utilisateur a-t-il perdu une fonction ?
Quelle est la qualité des preuves ?
Le problème se répète-t-il ?
L’application s’est-elle rétablie ?
S’agit-il de plusieurs événements ou d’un seul incident ?

Quand j’ai organisé la supervision autour de ces questions, le flot de messages rouges a cessé d’être un tuyau d’incendie pour devenir un véritable outil d’ingénierie.

Une erreur navigateur est une observation. Un incident est une explication corrélée de l’impact utilisateur. Une alerte est la décision qu’un humain doit agir.

Je ne veux plus que ces trois notions soient synonymes.