Ho costruito un sistema di segnalazione degli errori lato client perché volevo vedere i problemi che colpivano utenti reali e che spesso non riuscivo a riprodurre in locale. Il sistema di segnalazione faceva esattamente ciò che gli avevo chiesto: catturava gli errori e me li inviava.
Il problema era che quasi tutto sembrava avere la stessa importanza.
Non si carica uno script esterno di analytics? Allarme rosso. La pubblicità viene bloccata? Allarme rosso. Un crawler non riesce a caricare Google Analytics? Allarme rosso. Una anteprima video chiama play() e viene messa in pausa prima che la Promise termini? Allarme rosso. Arriva un Script error. opaco senza source o stack utili? Ancora allarme rosso.
Nello stesso flusso c’erano però eventi realmente importanti: un URL dell’applicazione trasformato per errore in https://example.comhttps://example.com/... e un file Next.js dell’applicazione sotto /_next/static/chunks/... che un browser non era riuscito a caricare.
La raccolta funzionava. Il monitoraggio no.
Questa differenza ha cambiato il mio modo di pensare all’osservabilità frontend. Un evento di errore è la prova che è successo qualcosa. Non è ancora una diagnosi, una severità o un incidente.
Il primo problema era considerare “errore” una priorità
Il modello iniziale era più o meno questo:
il browser segnala un errore
↓
invia CLIENT ERROR
↓
lo sviluppatore deve intervenire
Dentro questo schema finivano troppe domande diverse. È codice mio? La pagina corrente è davvero rotta? È un’annullamento previsto? Il browser possiede abbastanza informazioni per identificare la sorgente? L’app si è ripresa? Dieci messaggi rappresentano dieci incidenti oppure dieci sintomi dello stesso problema?
Finché queste domande non hanno risposta, un evento non dovrebbe diventare automaticamente un allarme.
In una delle prime revisioni avevo circa diciotto messaggi. La maggior parte era rumore di terze parti o del normale ciclo di vita. Due casi spiccavano: l’URL dell’applicazione era chiaramente sbagliato e il chunk JavaScript dell’applicazione poteva aver reso indisponibile codice necessario alla pagina. Eppure il sistema di segnalazione mostrava quasi tutto con la stessa urgenza.
È lì che ho smesso di trattare “raccogliere tutti gli errori del browser” e “costruire un monitoraggio di produzione” come lo stesso problema. La raccolta conserva evidenza. Il monitoraggio deve trasformare quell’evidenza in decisioni.
Il browser non ha un solo canale universale per gli errori
Un’altra assunzione sbagliata era pensare che tutti i fallimenti lato client avessero semantica comparabile.
L’evento error di window viene usato per errori sincroni degli script e partecipa anche alla segnalazione di errori delle risorse. Una Promise rifiutata senza handler passa invece da unhandledrejection. Gli elementi che caricano script, immagini o media possono generare il proprio error. React e Next.js aggiungono inoltre i loro error boundary.
window.error
→ può essere sfuggito un errore sincrono di script
unhandledrejection
→ una Promise rifiutata era senza handler in quel momento
errore di un elemento
→ una risorsa non è stata caricata o usata
framework boundary
→ rendering o esecuzione hanno raggiunto un confine di errore
Un hook globale osserva un sintomo su un confine del sistema. Non conosce necessariamente l’intera catena causale.
Da quel momento ho smesso di convertire subito tutto in un Error generico con la stessa severità.
La proprietà della risorsa è il primo filtro utile
La prima domanda che davvero riduce il rumore è: di chi è la cosa che ha fallito?
Un errore di /_next/static/chunks/app/... non equivale al fallimento di un SDK pubblicitario su un’altra origine. Un URL mal costruito dal mio codice non equivale a una richiesta analytics bloccata. Un errore proveniente da un’estensione del browser appartiene a un’altra categoria ancora.
- applicazione: JavaScript, CSS, API, media e URL generati da me;
- framework e runtime: Next.js o React quando fanno parte del percorso di esecuzione;
- integrazioni esterne: analytics, advertising, widget e SDK;
- ambiente: estensioni, crawler, stato di rete, strumenti privacy e peculiarità del browser.
Questo non significa ignorare tutto ciò che è di terze parti. Un fornitore di pagamento o autenticazione può essere critico. Un problema pubblicitario può avere impatto economico. Ma la salute di un’integrazione non è automaticamente un crash dell’app.
Mettere tutto nello stesso canale urgente distrugge il significato del canale.
Due errori della mia applicazione mi hanno insegnato cosa significa “utile per intervenire”
Il caso dell’URL errato era semplice:
https://example.comhttps://example.com/resource
Non servivano teorie su AdBlock, VPN o browser. L’URL era invalido. Da qualche parte il mio codice stava concatenando l’origine con un valore che era già assoluto.
L’evento era utile per intervenire perché le prove erano precise, la risorsa era mia e il problema indicava codice sotto il mio controllo.
Il chunk Next.js era diverso:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
Anche quello era dell’applicazione e poteva rompere la pagina, ma il singolo evento non dimostrava la causa. Un client vecchio poteva richiedere un asset di un deployment precedente. La richiesta poteva andare in timeout. Reverse proxy o CDN potevano fallire. La connessione poteva interrompersi. Il file poteva essere davvero assente.
La reazione corretta non era “conosco già la causa”, ma “questa classe merita priorità alta e più contesto”.
La severità può essere alta anche quando la certezza causale è bassa.
Script error. è un indizio, non uno stack trace
Error: Script error.
filename: unknown
line: 0
column: 0
Fa paura, ma contiene quasi zero informazione.
I browser limitano intenzionalmente i dettagli degli errori di script cross-origine. MDN spiega che senza la configurazione CORS appropriata window.onerror riceve informazioni limitate; il comportamento crossorigin di <script> influisce direttamente sulla disponibilità dei dettagli.
Quindi non trasformo automaticamente un Script error. opaco in “la mia app è crashata”. Potrebbe essere codice mio, codice di terze parti, codice iniettato o semplicemente un errore di cui il browser non può esporre i dettagli.
Lo conservo come telemetria e lo correlo con pagina, build, browser ed eventi vicini, ma un singolo 0:0 non merita un allarme urgente. Un cluster attorno a una release o a una route cambia il quadro.
Sconosciuto non vuol dire innocuo. Non vuol dire nemmeno critico.
Un AbortError può essere reale e comunque previsto
Le anteprima video hanno mostrato perfettamente la differenza:
AbortError:
The play() request was interrupted by a call to pause()
HTMLMediaElement.play() restituisce una Promise, che può essere rifiutata. Le operazioni sul ciclo di vita del media possono inoltre interrompere volontariamente una riproduzione pendente; MDN documenta che load() interrompe le Promise di play() pendenti con AbortError.
In una griglia di anteprime questo può succedere senza alcun problema percepibile: l’elemento entra nel viewport, parte play(), l’utente scorre, l’elemento esce e viene messo in pausa o sostituito prima che l’avvio sia completato.
Il rifiuto della Promise è reale. L’incidente utente può non esistere.
La gestione corretta sta vicino alla chiamata: distinguere annullamento previsto da fallimento reale. unhandledrejection è una rete di sicurezza, non il primo posto dove capire il ciclo di vita normale del componente.
Gli errori di terze parti richiedono un modello di salute separato
I primi log contenevano molti errori da domini analytics e pubblicitari. Alcuni arrivavano da browser orientati alla privacy, altri da crawler. Un caso particolarmente inutile era un crawler che non riusciva a caricare Google Analytics.
Questo dimostra che una richiesta è fallita. Dice quasi nulla sulla possibilità per un utente umano di usare l’app.
Conservare il dato non era sbagliato. Era sbagliato metterlo nello stesso flusso di un chunk JavaScript dell’applicazione che non si caricava.
- L’applicazione è rotta per l’utente?
- L’integrazione esterna è sana?
Uno script pubblicitario bloccato può alimentare metriche di distribuzione degli annunci. Un errore analytics può misurare copertura analytics. Nessuno dei due dovrebbe svegliarmi come “crash del frontend” se la funzionalità principale non dipende da quella risorsa.
Separando i flussi, i problemi di terze parti diventano anche più facili da aggregare per fornitore, browser e regione.
navigator.onLine è contesto, non prova di connettività
Ho iniziato a salvare anche se il browser si considerava online. È utile, ma soltanto come indizio.
Ho visto eventi classificati come problemi di rete con:
Online: true
Non è una contraddizione. MDN avverte che navigator.onLine si basa su euristiche del browser e del sistema operativo. Una macchina può vedere una LAN ma non raggiungere il mio origine. VPN, firewall, DNS e guasti parziali complicano ulteriormente tutto.
online === false
→ forte indizio di problema ambientale
online === true
→ NON dimostra che origin o risorsa fossero raggiungibili
Questa distinzione evita diagnosi eccessivamente sicure.
Una sola causa può generare più eventi nel browser
Con una raccolta migliore è emerso un altro problema: un singolo incidente poteva produrre parecchi messaggi.
Un chunk può prima generare resource.error, poi il loader può lanciare ChunkLoadError, quindi React o Next.js può entrare in un error boundary e infine la logica di recupero può programmare un reload. Se ogni livello manda un allarme indipendente, una sola azione utente sembra molteplici incidenti.
Cinque messaggi sembrano cinque utenti, anche se provengono dalla stessa sessione e dallo stesso file.
Non basta deduplicare il testo: serve correlazione dell’incidente.
sessione
+ breve finestra temporale
+ classe di errore normalizzata
+ risorsa first-party
+ client build
+ route
Conservo gli eventi grezzi, ma al canale umano mando la rappresentazione più informativa. Se ho già un limite di errore del framework con stack dell’applicazione e URL preciso del chunk, il precedente errore generico della risorsa non deve diventare un secondo allarme.
Allertare sugli incidenti. Conservare gli eventi.
Il contesto attorno all’errore è diventato più prezioso della stringa
La telemetria successiva era molto più strutturata:
clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack
Per i media raccoglievo anche codice di errore, status HTTP quando osservabile con una verifica separata, Content-Type effettivamente restituito e classificazione HTTP o network.
Questo permette domande che una semplice exception string non può risolvere: il problema è iniziato su una build? Il browser ha ricevuto risposta HTTP? Un Service Worker controllava la pagina? Il recupero era già partito? Più eventi puntano alla stessa risorsa? La route corrente era davvero inutilizzabile?
La Resource Timing API può fornire durata, informazioni sul trasferimento e, dove disponibile e consentito, lo status della risposta. Ma ha limiti: dati cross-origine ristretti, transferSize zero per risorse in cache e responseStatus non disponibile ovunque. Per questo null e 0 devono restare stati onesti, non essere interpretati a piacere.
ChunkLoadError è un sintomo, non un rilevatore di 404
Un caso successivo ha cambiato parecchio il mio modo di leggere questi errori.
Il browser segnalava un ChunkLoadError di Next.js su un chunk di layout, ma la telemetria conteneva anche:
resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true
La misura registrata era di circa 170 secondi. Qualunque fosse la causa radice esatta, bastava per rifiutare la regola:
ChunkLoadError === il server ha restituito 404
Altri errori non avevano status osservabile, alcuni erano timeout, altri avevano dati di trasferimento. Stessa classe di errore, evidenza diversa.
Con Next.js questo è particolarmente importante: i file sotto /_next/static/ sono normalmente content-hashed e trattati come asset immutabili. La documentazione attuale di Next.js sul self-hosting descrive cache header molto lunghi per questi asset. Un ChunkLoadError può quindi coinvolgere deployment skew, client vecchi, rete, reverse proxy, CDN, cache, Service Worker o artefatti davvero mancanti.
Il livello di allerta non deve inventare la causa. Deve preservare le prove necessarie a indagare.
Gli errori media mi hanno insegnato la stessa lezione da un altro livello
In alcuni casi il media element segnalava:
MEDIA_ELEMENT_ERROR: Format error
A prima vista sembra un problema di codec.
Ma una verifica aggiuntiva della distribuzione mostrava per alcuni eventi:
HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http
Il browser aveva chiesto un video e ricevuto una risposta HTTP di errore contenente HTML. Il media element non poteva decodificare HTML come video e quindi mostrava un “format error”. La diagnosi utile era nella distribuzione.
Il livello che osserva il sintomo non è necessariamente quello che causa il problema.
“Errore codec”, “rete giù”, “cache bug” e “chunk mancante” sono conclusioni. Preferisco che la telemetria inizi da osservazioni.
Valuto ogni errore su cinque dimensioni
1. Proprietà
Il codice o la risorsa sono dell’applicazione, framework/runtime, di terze parti o ambiente?
2. Impatto sull’utente
Si è rotta la route attiva, il rendering, l’autenticazione, la chat, il checkout o un altro flusso essenziale? Oppure è fallita soltanto pubblicità, analytics, preload o anteprima opzionale?
3. Qualità dell’evidenza
Ho stack dell’applicazione, URL, status HTTP, build ID e stack dei componenti? Oppure solo Script error. a 0:0?
4. Ripetizione e diffusione
È una sola sessione o la stessa firma compare su utenti, browser e route diversi dopo la stessa release?
5. Recupero
L’app si è ripresa? È stato programmato un reload del chunk? Il fallback ha funzionato? L’utente è ancora bloccato?
forte evidenza first-party
+ impatto elevato
+ buone prove
+ più sessioni
+ nessun recovery
= incidente urgente
third-party
+ funzione opzionale
+ evidenza debole
+ caso isolato
+ utente non colpito
= metrica o bassa priorità
La soppressione deve essere conservativa
Quando il rumore diventa fastidioso, è facile creare una blacklist enorme. È rischioso.
Sopprimere ogni AbortError può nascondere vere richieste API interrotte. Eliminare ogni Script error. può nascondere un cluster specifico di un browser. Ignorare ogni terza parte può nascondere un fornitore di pagamento, auth o consenso rotto.
ALERT
→ incidente forte che richiede attenzione
RETAIN / AGGREGATE
→ conserva e conta; allerta quando forma un cluster
METRIC / SAMPLE
→ rumore previsto o di basso impatto; conserva trend ed esempi
Il sistema diventa più silenzioso senza diventare cieco.
Un classificatore utile è soprattutto una politica tradotta in codice
Questo esempio non è copiato dal mio codice di produzione. Riassume la policy che avrei voluto fin dall’inizio:
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 difficoltà è dentro funzioni come breaksActiveRoute(): servono il contesto della route, la proprietà della risorsa, i dati dei limiti di errore e, a volte, conoscenza specifica del prodotto.
La firma deve seguire l’incidente, non il testo del messaggio
La stringa identica è un pessimo criterio di deduplica. Cambiano minified offsets, hash dei chunk, ID dinamici e wording dei browser.
{
errorClass,
normalizedFirstPartyResource,
routeFamily,
clientBuild,
sessionId,
shortTimeBucket
}
Per un crash globale può contare il primo frame dell’applicazione, per un chunk la risorsa normalizzata, per un problema media la classe di distribuzione.
resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled
può diventare un solo incidente con quattro osservazioni invece di quattro emergenze.
Il canale urgente deve avere un compito molto più stretto
Riservo l’urgenza a errori runtime dell’applicazione che rompono la route, boundary React/Next.js con impatto reale, JavaScript o CSS dell’applicazione necessari, ChunkLoadError ripetuti su più sessioni, fallimenti core API senza recupero e violazioni certe come URL generate male.
Conserverei senza allarme immediato un singolo Script error., un errore dell’applicazione recuperato correttamente, errori media con livello d’origine ancora incerto e anomalie specifiche di browser che richiedono aggregazione.
Errori noti di advertising/analytics, AbortError attesi, rumore di terze parti solo crawler, forte contesto fuori linea e risorse speculative opzionali andrebbero normalmente a metriche o campionamento.
Un allarme urgente deve rappresentare impatto utente su cui si può agire, non il volume grezzo delle lamentele del browser.
Misuro incidenti, non un unico “numero di errori”
- incidenti dell’applicazione per 1.000 sessioni;
- sessioni coinvolte per build ID;
- error boundary per route;
- errori dei chunk per risorsa e deployment;
- tasso di errore delle integrazioni per fornitore;
- volume degli annullamenti attesi, per rilevare variazioni anomale;
- percentuale di recupero riusciti;
- incidenti unici separati dagli eventi grezzi.
“È successo un evento” raramente è una buona soglia. “Lo stesso incidente dell’applicazione colpisce più sessioni indipendenti sulla nuova build e il recupero fallisce” è molto più utile.
Il monitoraggio client non dimostra comunque la causa radice
La telemetria browser ha limiti rigidi.
Uno status HTTP assente può derivare da supporto mancante, restrizioni cross-origine, annullamento o altre lacune di osservabilità. Un Service Worker attivo non dimostra che abbia servito una risorsa obsoleta. Un ChunkLoadError dopo un deployment non dimostra deployment skew. online: true non dimostra raggiungibilità.
La telemetria client restringe le ipotesi. Possono ancora servire log server e reverse proxy, deployment manifest, stato della cache e riproduzione.
Non voglio neppure che observability significhi raccolta illimitata di dati utente. Ogni campo deve avere una ragione diagnostica.
Telemetria migliore non significa necessariamente più telemetria: significa telemetria più discriminante.
La mia regola oggi: raccogliere eventi, investigare incidenti, allertare sull’impatto
All’inizio volevo rispondere a “si è rotto qualcosa?”. In produzione è una domanda troppo larga. Qualcosa fallisce sempre: crawler, blocchi privacy, Promise media, rete o SDK esterni.
È nostro?
L’utente ha perso funzionalità?
Quanto è forte l’evidenza?
Si ripete?
L’app si è ripresa?
Sono più eventi o un solo incidente?
Quando ho costruito il monitoraggio attorno a queste domande, il flusso di messaggi rossi è diventato uno strumento di ingegneria.
Un errore del browser è un’osservazione. Un incidente è una spiegazione correlata dell’impatto. Un allarme è la decisione che una persona deve agire.
Non voglio più trattare queste tre cose come sinonimi.