Zbudowałem raportowanie błędów po stronie klienta, ponieważ chciałem widzieć problemy prawdziwych użytkowników, których nie potrafiłem wiarygodnie odtworzyć lokalnie. Moduł raportujący robił dokładnie to, o co prosiłem: przechwytywał błędy i wysyłał je do mnie.
Problem polegał na tym, że niemal wszystko wyglądało tak samo ważnie.
Nie załadował się zewnętrzny skrypt analityczny? Czerwony alarm. Reklama została zablokowana? Czerwony alarm. Crawler nie pobrał Google Analytics? Czerwony alarm. Podgląd wideo wywołał play(), a chwilę później został zatrzymany, zanim Promise się zakończyła? Czerwony alarm. Pojawił się nieprzejrzysty Script error. bez użytecznego źródła i stack trace? Też czerwony alarm.
W tym samym strumieniu znajdowały się jednak zdarzenia, które naprawdę mogły uszkodzić aplikację: własny URL przypadkowo złożony jako https://example.comhttps://example.com/... oraz własny plik Next.js w /_next/static/chunks/..., którego przeglądarka nie zdołała załadować.
Zbieranie danych działało. Monitoring nie.
To rozróżnienie zmieniło moje podejście do obserwowalności frontendu. Zdarzenie błędu jest dowodem, że coś się wydarzyło. Nie jest jeszcze diagnozą, poziomem ważności ani incydentem.
Pierwszym błędem było uznanie słowa „error” za poziom pilności
Początkowy model wyglądał mniej więcej tak:
przeglądarka zgłasza błąd
↓
wyślij CLIENT ERROR
↓
programista powinien reagować
W jednym kroku mieszałem zbyt wiele pytań. Czy to mój kod? Czy aktywna strona faktycznie przestała działać? Czy było to oczekiwane anulowanie? Czy przeglądarka zna źródło? Czy aplikacja się odtworzyła? Czy dziesięć wiadomości oznacza dziesięć incydentów, czy dziesięć objawów jednego problemu?
Dopóki nie znam odpowiedzi, samo zdarzenie nie powinno automatycznie stawać się alarmem.
Podczas jednej z pierwszych analiz miałem około osiemnastu komunikatów. Większość była szumem zewnętrznym albo efektem normalnego cyklu życia. Dwa przypadki wyraźnie odstawały: błędny URL własnej aplikacji był bezdyskusyjnym bugiem, a niezaładowany własny chunk JavaScript mógł pozbawić stronę potrzebnego kodu. Moduł raportujący nadawał im jednak prawie tę samą pilność co zablokowanej reklamie.
Wtedy przestałem traktować „zbieranie wszystkich błędów przeglądarki” i „monitoring produkcyjny” jako jedno zadanie. Zbieranie danych ma zachować dowody. Monitoring ma z dowodów tworzyć decyzje.
Przeglądarka nie ma jednego uniwersalnego kanału błędów
Różne awarie klienta nie przychodzą z taką samą semantyką.
Zdarzenie error na window obejmuje m.in. synchroniczne błędy skryptów i uczestniczy także w obsłudze błędów zasobów. Odrzucona Promise bez handlera trafia inną ścieżką przez unhandledrejection. Elementy ładujące skrypty, obrazy lub media mogą emitować własne error. React i Next.js dokładają własne error boundaries.
window.error
→ mógł wydostać się synchroniczny błąd skryptu
unhandledrejection
→ odrzucona Promise nie miała w tym momencie obsługi
error elementu
→ zasobu nie udało się załadować lub użyć
framework boundary
→ renderowanie lub wykonanie dotarło do granicy błędu
Globalny hook widzi objaw na granicy systemu. Nie musi znać całego łańcucha przyczynowego.
Dlatego przestałem natychmiast sprowadzać wszystkie zdarzenia do jednego ogólnego Error z identyczną poziom ważności.
Własność zasobu to pierwszy naprawdę użyteczny filtr
Najpierw pytam: do kogo należy kod lub zasób, który zawiódł?
Błąd /_next/static/chunks/app/... różni się od awarii reklamowego SDK na innym źródło. URL zepsuty przez mój generator to nie to samo co zablokowany żądanie analytics. Kod rozszerzenia przeglądarki jest jeszcze inną klasą.
- własna aplikacja: mój JavaScript, CSS, wywołania API, multimedia i generowane adresy URL;
- framework i środowisko wykonawcze: Next.js lub React, gdy są częścią ścieżki wykonania aplikacji;
- integracje zewnętrzne: analytics, reklamy, widgety i SDK;
- środowisko: rozszerzenia, roboty indeksujące, stan sieci, narzędzia prywatności i cechy przeglądarki.
Zewnętrzne nie znaczy „nieważne”. Awaria płatności albo uwierzytelniania może być krytyczna. Problem reklamowy może mieć wpływ finansowy. Ale zdrowie integracji nie jest automatycznie awarią aplikacji.
Wspólny kanał alarmowy dla wszystkiego przestaje cokolwiek komunikować.
Dwie awarie mojej aplikacji pokazały mi, co naprawdę wymaga działania
Ze złym URL-em sytuacja była prosta:
https://example.comhttps://example.com/resource
Nie potrzebowałem teorii o AdBlock, VPN czy zachowaniu przeglądarki. URL sam w sobie był nieprawidłowy. Mój kod dokleił źródło do wartości, która była już pełnym adresem.
Zdarzenie było użyteczny operacyjnie, bo dowód był konkretny, zasób należał do mnie, a problem prowadził do kodu pod moją kontrolą.
Z chunkiem Next.js było inaczej:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
To również był zasób własny i mógł zepsuć stronę, ale samo zdarzenie nie udowadniało przyczyny. Stary klient mógł prosić o asset poprzedniego deploymentu. Request mógł przekroczyć timeout. Reverse proxy lub CDN mogły zawieść. Połączenie mogło się urwać. Pliku rzeczywiście mogło nie być.
Właściwa reakcja nie brzmiała „znam przyczynę”, tylko „wysoki priorytet, potrzebuję więcej kontekstu”.
Wysoka waga błędu i niska pewność co do przyczyna źródłowa mogą występować jednocześnie.
Script error. to wskazówka, a nie ślad stosu
Error: Script error.
filename: unknown
line: 0
column: 0
Brzmi groźnie i prawie nic nie mówi.
Przeglądarki celowo ograniczają szczegóły błędów skryptów cross-źródło. MDN opisuje, że bez poprawnego CORS window.onerror otrzymuje ograniczone informacje; zachowanie crossorigin w <script> bezpośrednio wpływa na dostępność szczegółów.
Dlatego nie tłumaczę pojedynczego Script error. jako „moja aplikacja się wywaliła”. Źródłem może być mój kod, zewnętrzny, kod wstrzyknięty albo błąd, którego szczegółów przeglądarka nie może ujawnić.
Zachowuję zdarzenie, koreluję je ze stroną, buildem, przeglądarką i zdarzeniami obok, ale samotne 0:0 nie jest alarmem. Klaster wokół konkretnego wydanie’u lub route’u zmienia priorytet.
Nieznane nie znaczy nieszkodliwe. Nie znaczy też krytyczne.
AbortError może być prawdziwy i jednocześnie oczekiwany
Podglądy wideo pokazały to najlepiej:
AbortError:
The play() request was interrupted by a call to pause()
HTMLMediaElement.play() zwraca Promise, która może zostać odrzucona. Operacje cyklu życia mediów mogą świadomie przerwać oczekujące odtwarzanie; MDN dokumentuje również, że load() przerywa oczekujące Promise z play() przez AbortError.
W siatce podgląd może to wystąpić bez problemu odczuwanego przez użytkownika: element pojawia się w viewport, kod uruchamia play(), użytkownik scrolluje, element znika i zostaje zatrzymany lub podmieniony przed zakończeniem startu.
Odrzucenie Promise jest prawdziwe. Incydentu użytkownika może nie być.
Poprawna obsługa powinna znajdować się przy wywołaniu: rozróżniać spodziewane anulowanie od realnego błędu playback. unhandledrejection jest siatką bezpieczeństwa, a nie miejscem, w którym po raz pierwszy rozpoznajemy normalny lifecycle komponentu.
Błędy zewnętrznych usług potrzebują osobnego modelu zdrowia
Pierwsze logi były pełne błędów domen analytics i reklam. Część pochodziła z przeglądarek nastawionych na prywatność, część od robotów indeksujących. Wyjątkowo mało użyteczny był robot indeksujący, który nie mógł pobrać Google Analytics.
To dowodzi niepowodzenia żądanieu. Prawie nic nie mówi o tym, czy człowiek mógł używać aplikacji.
Zbieranie tego zdarzenia nie było błędem. Błędem było wrzucanie go do tego samego incident stream co niezaładowany własny chunk JavaScript.
- Czy aplikacja jest zepsuta dla użytkownika?
- Czy zewnętrzna integracja jest zdrowa?
Zablokowana reklama może zasilać metrykę dostarczania reklam. Awaria analityki może mierzyć jej pokrycie. Żadna z nich nie musi budzić mnie jako „awaria frontendu”, jeśli podstawowa funkcja od niej nie zależy.
Po rozdzieleniu problemy zewnętrznych integracji łatwiej agregować według dostawcy, przeglądarki i regionu.
navigator.onLine to kontekst, nie dowód łączności
Zacząłem też zapisywać, czy przeglądarka uważa się za online. To przydatna wskazówka, lecz nie fakt o dostępności źródło.
Miałem błędy klasyfikowane jako sieciowe, mimo że log mówił:
Online: true
To nie sprzeczność. MDN ostrzega, że navigator.onLine opiera się na heurystykach przeglądarki i systemu operacyjnego. Komputer może widzieć LAN i nie mieć dostępu do mojego źródło. VPN, firewall, DNS i częściowe awarie jeszcze bardziej komplikują sytuację.
online === false
→ silna wskazówka problemu środowiskowego
online === true
→ NIE dowodzi dostępności origin ani zasobu
To mała różnica, która chroni monitoring przed zbyt pewną diagnozą.
Jedna przyczyna może wygenerować wiele zdarzeń
Po rozbudowie zbieranie danych zobaczyłem kolejne źródło szumu: jeden incident może wygenerować kilka komunikatów.
Chunk może najpierw spowodować resource.error, potem loader wyrzuca ChunkLoadError, następnie React lub Next.js trafia do error boundary, a odzyskiwanie planuje reload. Jeśli każda warstwa alarmuje osobno, jedno działanie użytkownika wygląda jak kilka awarii.
Pięć wiadomości psychologicznie wygląda jak pięciu użytkowników, choć może pochodzić z jednej sesji i jednego pliku.
Nie wystarczy dedupe tekstu. Potrzebna jest korelacja incidentu:
session
+ krótkie okno czasu
+ znormalizowana klasa błędu
+ własny zasób
+ client build
+ route
Surowe zdarzenia pozostają, ale człowiek dostaje najbardziej informacyjny obraz incydentu. Jeśli granica błędu frameworka zawiera już stos z mojego kodu i dokładny URL chunka, wcześniejszy ogólny błąd zasobu nie potrzebuje drugiego alarmu.
Alarmować incydenty. Przechowywać zdarzenia.
Kontekst wokół błędu stał się cenniejszy niż sam komunikat
Późniejsza telemetria stała się znacznie bogatsza:
clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack
Dla mediów zbierałem też kod błędu, status HTTP tam, gdzie dało się go niezależnie sprawdzić, faktyczny Content-Type oraz klasyfikację HTTP/network.
Dzięki temu mogłem pytać: czy problem zaczął się na jednym buildzie? Czy przyszła odpowiedź HTTP? Czy Service Worker kontrolował stronę? Czy odzyskiwanie wystartowało? Czy kilka zdarzeń dotyczy jednego zasobu? Czy aktywna route była naprawdę zepsuta?
Resource Timing API może dostarczać timing, informacje o transferze i, gdzie jest to wspierane i dozwolone, response status. Są ograniczenia: dane cross-źródło są ograniczone, zasób z cache może mieć transferSize: 0, a responseStatus nie jest wszędzie dostępny. Dlatego null i 0 pozostają uczciwymi stanami, a nie powodem do zgadywania.
ChunkLoadError jest objawem, a nie detektorem 404
Jeden późniejszy przypadek mocno zmienił moją interpretację.
Przeglądarka zgłosiła Next.js ChunkLoadError dla layout chunk, a wzbogacona telemetria pokazywała:
resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true
Zarejestrowany pomiar wynosił około 170 sekund. Niezależnie od dokładnej przyczyna źródłowa wystarczyło to, by odrzucić równanie:
ChunkLoadError === serwer zwrócił 404
Inne błędy chunków nie miały obserwowalnego statusu. Niektóre były timeoutami, inne miały dane o transferze. Ta sama klasa błędu, różne dowody.
W Next.js ma to szczególne znaczenie, ponieważ pliki pod /_next/static/ są zwykle content-hashed i traktowane jako immutable. Aktualna dokumentacja self-hostingu Next.js opisuje długie cache headers dla takich assetów. ChunkLoadError może więc wiązać się z deployment skew, starym klientem, siecią, reverse proxy, CDN, cache, Service Workerem albo naprawdę brakującym artefaktem.
Warstwa alertów nie powinna wymyślać przyczyny. Powinna zachowywać dowody do śledztwa.
Błędy mediów nauczyły mnie tego samego z innej warstwy
Element media potrafił zgłosić:
MEDIA_ELEMENT_ERROR: Format error
To brzmi jak problem z kodekiem.
Jednak dodatkowa weryfikacja dostarczania pokazała dla części zdarzeń:
HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http
Przeglądarka poprosiła o wideo, a dostała stronę błędu HTML z HTTP 410. Element multimedialny nie mógł zdekodować HTML jako filmu, więc zewnętrznym objawem był „Format error”. Użyteczna diagnoza dotyczyła warstwy dostarczania.
Warstwa, która zauważa objaw, nie musi być warstwą, która go spowodowała.
“Błąd kodeka”, “awaria sieci”, “bug cache” i “brakujący chunk” to wnioski. Telemetria powinna najpierw zapisywać obserwacje.
Oceniam każdy błąd w pięciu wymiarach
1. Własność
Własna aplikacja, framework/runtime, zewnętrzna integracja czy środowisko?
2. Wpływ na użytkownika
Czy padła aktywna route, rendering, logowanie, chat, checkout lub inny kluczowy flow? Czy tylko opcjonalna reklama, analytics, preload albo podgląd?
3. Jakość dowodów
Czy mam własny stack, URL zasobu, status HTTP, build ID i component stack? Czy tylko Script error. na 0:0?
4. Powtarzalność i zasięg
Jedna sesja, czy ta sama sygnatura pojawia się u różnych użytkowników, na różnych route’ach i przeglądarkach po tej samej wydanie?
5. Odzyskiwanie
Czy aplikacja się podniosła? Czy zaplanowano reload chunka? Czy fallback zadziałał? Czy użytkownik nadal jest zablokowany?
wyraźnie first-party
+ duży wpływ
+ mocne dowody
+ wiele sesji
+ brak recovery
= pilny incydent
third-party
+ funkcja opcjonalna
+ słabe dowody
+ pojedyncze zdarzenie
+ użytkownik nie odczuł problemu
= metryka lub niski priorytet
Wyciszanie zdarzeń musi być ostrożne
Kiedy szum irytuje, łatwo zbudować ogromną blacklistę regexów. To ryzyko.
Wyciszenie każdego AbortError może ukryć prawdziwe przerwane żądaniey API. Usunięcie wszystkich Script error. może ukryć klaster specyficzny dla przeglądarki. Ignorowanie wszystkiego zewnętrzny może ukryć zepsuty dostawca płatności, auth lub consent.
ALERT
→ silny incydent wymagający reakcji
RETAIN / AGGREGATE
→ zachowaj i licz; alarmuj po utworzeniu klastra
METRIC / SAMPLE
→ oczekiwany lub mało istotny szum; zachowaj trend i przykłady
System może być cichszy bez utraty widoczności.
Klasyfikator to przede wszystkim polityka zapisana w kodzie
Poniższy kod nie jest kopią mojego kodu produkcyjnego. To skrót polityki, z którą wolałbym zaczynać:
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" };
}
Prawdziwa trudność ukrywa się w breaksActiveRoute(): potrzebne są kontekst trasy, własność zasobu, dane z granicy błędu i czasem wiedza o produkcie.
Odcisk incydentu powinien śledzić incydent, a nie tekst komunikatu
Dosłowne porównanie wiadomości słabo deduplikuje. Zmieniają się minified offsets, hashe chunków, dynamiczne ID i wording przeglądarek.
{
errorClass,
normalizedFirstPartyResource,
routeFamily,
clientBuild,
sessionId,
shortTimeBucket
}
Dla globalnej awarii ważna może być pierwsza własna ramka stosu, dla chunka znormalizowany zasób, a dla mediów klasa błędu dostarczania.
resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled
staje się jednym incydentem z czterema obserwacjami zamiast czterema alarmami.
Kanał alarmowy powinien mieć znacznie węższe zadanie
Natychmiastowe alerty zostawiam dla własnych runtime errors psujących aktywny route, istotnych boundary React/Next.js, niezbędnego własnego JavaScript/CSS, powtarzalnych ChunkLoadError na wielu sesjach, blokujących awarii core API i jasnych naruszeń invariantów jak błędny generowany URL.
Pojedynczy Script error., własny błąd zasobu z udanym odzyskaniem, niejasny błąd multimediów i anomalia konkretnej przeglądarki powinny najpierw zostać zachowane i zagregowane.
Znane błędy reklam/analytics, oczekiwane AbortError, robot indeksujący-only zewnętrzny noise, silny sygnał bez połączenia i opcjonalne speculative resources zwykle trafiają do metryk lub próbkowanieu.
Pilny alert powinien reprezentować wpływ na użytkownika, na który można zareagować, a nie głośność narzekań przeglądarki.
Wolę mierzyć incydenty niż globalną „liczbę błędów”
- incydenty własny na 1 000 sesji;
- dotknięte sesje według build ID;
- error boundaries według route;
- chunk failures według zasobu i deploymentu;
- odsetek awarii integracji według dostawcy;
- liczbę oczekiwanych anulowań, aby widzieć nagłe zmiany;
- skuteczność odzyskiwanie;
- unikalne incydenty oddzielone od liczby surowych zdarzeń.
“Wystąpiło jedno zdarzenie” rzadko jest dobrym progiem. “Ten sam własny incident dotyka kilku niezależnych sesji na nowym buildzie i odzyskiwanie nie działa” jest znacznie bardziej użyteczne.
Monitoring po stronie klienta nadal nie dowodzi przyczyny źródłowej
Telemetria przeglądarki ma twarde ograniczenia.
Brak statusu HTTP może wynikać z braku wsparcia, restrykcji cross-źródło, anulowania lub innej luki obserwowalności. Aktywny Service Worker nie dowodzi, że podał stale resource. ChunkLoadError po deploymencie nie dowodzi version skew. online: true nie dowodzi dostępności źródło.
Client monitoring zawęża hipotezy. Wciąż mogą być potrzebne logi serwera i reverse proxy, deployment manifests, stan cache oraz reprodukcja.
Observability nie powinna też oznaczać nieograniczonego zbierania danych użytkowników. Każde pole musi mieć sens diagnostyczny.
Lepsza telemetria to niekoniecznie więcej telemetrii, lecz telemetria, która lepiej rozróżnia rodzaje awarii.
Moja zasada: zbierać zdarzenia, badać incydenty, alarmować o wpływie
Na początku chciałem tylko odpowiedzieć: “czy coś się zepsuło?”. W produkcji pytanie jest zbyt szerokie. Zawsze gdzieś nie działa robot indeksujący, filtr prywatności, Promise media, sieć albo zewnętrzny SDK.
Czy to nasze?
Czy użytkownik stracił funkcję?
Jak mocne są dowody?
Czy problem się powtarza?
Czy aplikacja się odtworzyła?
Czy to kilka zdarzeń, czy jeden incydent?
Kiedy monitoring zaczął odpowiadać na te pytania, strumień czerwonych wiadomości stał się narzędziem inżynierskim.
Błąd przeglądarki jest obserwacją. Incydent jest skorelowanym wyjaśnieniem wpływu na użytkownika. Alert jest decyzją, że człowiek powinien działać.
Nie chcę już traktować tych trzech rzeczy jako synonimów.