Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/bpp/newsfragments/+rollbar-browser-szum.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Monitoring błędów po stronie przeglądarki przestał zbierać zgłoszenia, na
które nie da się zareagować: awarie pochodzące wyłącznie z obcych skryptów
(np. widget dostępności ładowany z zewnętrznego serwera) oraz zdarzenia, które
nie niosą ani lokalizacji, ani treści błędu. Błędy z kodu BPP są raportowane
jak dotąd.
136 changes: 136 additions & 0 deletions src/bpp/static/bpp/js/rollbar-filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Filtr zgłoszeń dla frontendowego Rollbara (`checkIgnore`).
//
// Odsiewamy dwie klasy zgłoszeń, z którymi NIC nie da się zrobić:
//
// 1. Błędy pochodzące wyłącznie z obcych skryptów — u nas w praktyce widget
// dostępności UserWay, ładowany z ich CDN-u. Przykład: SyntaxError
// "invalid group specifier name" (lookbehind w regexie, nieobsługiwany
// przez Safari < 16.4). To ich bundle, ich wydanie — nie mamy jak tego
// naprawić ani nawet zdiagnozować.
//
// 2. Zgłoszenia, które nie mają ANI lokalizacji, ANI treści — czyli klasa
// "(unknown)" z komunikatem "{}". Powstają, gdy window.onerror dostaje
// zdarzenie zamiast Errora (nieudane ładowanie <link>).
//
// UWAGA: sam brak lokalizacji NIE wystarcza do wyciszenia. Wcześniejsza,
// szersza wersja tej reguły zjadała nasze własne błędy: ręczne
// `Rollbar.error("...")` (buduje `body.message`, zero ramek), odrzucone
// obietnice z reason innym niż Error (ramka "(unknown)") oraz SyntaxError
// z Rollbar #502 — a ten ostatni jest najpewniej sygnałem, że któryś nasz
// statyk nie parsuje się na starszej przeglądarce.
//
// ZASADA: w razie wątpliwości RAPORTUJ. Filtr, który przez własny błąd
// wycisza prawdziwe awarie, jest gorszy niż brak filtra — dlatego każda
// nierozpoznana sytuacja (brak payloadu, nieznany kształt) daje `false`.
//
// Ten plik jest ZWYKŁYM skryptem, nie modułem: rollbar.html ładuje go wcześnie
// w <head>, a `type="module"` odroczyłby wykonanie i część błędów z czasu
// ładowania strony uciekłaby przed inicjalizacją Rollbara.
(function (root) {
"use strict";

// Czy `filename` w ogóle wskazuje na jakiś plik? Rollbar wstawia tu
// czasem "(unknown)" albo — dla przeglądarek nie podających lokalizacji —
// sam komunikat błędu ("SyntaxError: Unexpected token =").
function czyUzytecznaSciezka(filename) {
if (!filename || typeof filename !== "string") {
return false;
}
return /^https?:\/\//.test(filename) || filename.charAt(0) === "/";
}

function czyNaszaSciezka(filename, origin) {
if (filename.charAt(0) === "/") {
// Ścieżka względna → zawsze z bieżącego origin.
return true;
}
return filename.indexOf(origin + "/") === 0 || filename === origin;
}

// Rollbar zapisuje ramki w `body.trace.frames` albo — dla wyjątków
// łańcuchowych — w `body.trace_chain[].frames`.
function zbierzRamki(payload) {
var body = (payload && payload.body) || {};
var ramki = [];

if (body.trace && Array.isArray(body.trace.frames)) {
ramki = ramki.concat(body.trace.frames);
}
if (Array.isArray(body.trace_chain)) {
body.trace_chain.forEach(function (trace) {
if (trace && Array.isArray(trace.frames)) {
ramki = ramki.concat(trace.frames);
}
});
}
return ramki;
}

// Opis wyjątku z `body.trace` albo z pierwszego ogniwa `body.trace_chain`.
function opisWyjatku(body) {
if (body.trace && body.trace.exception) {
return body.trace.exception;
}
if (Array.isArray(body.trace_chain) && body.trace_chain.length) {
return body.trace_chain[0].exception || {};
}
return {};
}

// Czy zgłoszenie bez lokalizacji niesie JAKĄKOLWIEK treść, na której da
// się pracować. Rollbar #444 to `class: "(unknown)"`, `message: "{}"` —
// powstaje, gdy window.onerror dostaje zdarzenie zamiast Errora (nieudane
// ładowanie <link>). Tam faktycznie nie ma czego szukać.
function czyPustyOpis(wyjatek) {
var klasa = wyjatek.class;
var komunikat = wyjatek.message;
var bezKlasy = !klasa || klasa === "(unknown)";
var bezKomunikatu = !komunikat || komunikat === "{}";
return bezKlasy && bezKomunikatu;
}

function czyPominac(payload, origin) {
if (!payload || !payload.body || !origin) {
return false;
}

var body = payload.body;

// Brak `trace`/`trace_chain` → to nie jest raport o wyjątku, tylko
// ręczny log (`Rollbar.error("...")` buduje `body.message`) albo
// komunikat samego Rollbara o przekroczeniu rate-limitu. Nigdy nie
// wyciszamy — to są zgłoszenia, które ktoś wysłał świadomie.
if (!body.trace && !Array.isArray(body.trace_chain)) {
return false;
}

var sciezki = zbierzRamki(payload)
.map(function (ramka) {
return ramka && ramka.filename;
})
.filter(czyUzytecznaSciezka);

if (sciezki.length) {
var mamyNaszaRamke = sciezki.some(function (sciezka) {
return czyNaszaSciezka(sciezka, origin);
});
// Choć jedna ramka z naszego kodu → to może być nasz błąd.
return !mamyNaszaRamke;
}

// Brak jakiejkolwiek lokalizacji. Wyciszamy TYLKO wtedy, gdy nie ma
// też treści — inaczej wyrzucilibyśmy m.in. odrzucone obietnice
// z reason innym niż Error (ramka "(unknown)") oraz SyntaxError
// z Rollbar #502. Ten ostatni jest szczególnie ważny: przeglądarka
// ujawnia treść błędu parsowania wyłącznie dla skryptów same-origin
// (obce bez CORS dostają gołe "Script error."), więc konkretny
// komunikat sugeruje, że któryś z NASZYCH statyków się nie parsuje —
// czyli realną regresję kompatybilności, a nie szum.
return czyPustyOpis(opisWyjatku(body));
}

root.bppRollbarFilters = {
czyPominac: czyPominac,
czyUzytecznaSciezka: czyUzytecznaSciezka,
};
})(typeof window !== "undefined" ? window : this);
19 changes: 19 additions & 0 deletions src/django_bpp/templates/rollbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@
{# Biblioteka hostowana lokalnie ze static (nie CDN). #}
{# Bez danych użytkownika (person) i bez gejtowania zgodą cookie. #}
<script src="{% static 'rollbar/rollbar.umd.min.js' %}"></script>
{# Filtr szumu (obce skrypty, zgłoszenia bez stack trace'u) — testowany #}
{# jednostkowo w tests/js/rollbar-filters.test.js. #}
<script src="{% static 'bpp/js/rollbar-filters.js' %}"></script>
<script>
(function () {
var _rollbarConfig = {
accessToken: "{{ ROLLBAR_CLIENT.accessToken }}",
captureUncaught: true,
captureUnhandledRejections: true,
checkIgnore: function (isUncaught, args, payload) {
var filtry = window.bppRollbarFilters;
if (!filtry) {
// Skrypt filtra się nie załadował — raportujemy wszystko,
// tak jak przed jego wprowadzeniem.
return false;
}
try {
return filtry.czyPominac(payload, window.location.origin);
} catch (e) {
// Błąd w samym filtrze NIE może wyciszyć raportu —
// filtr, który przez własną awarię chowa prawdziwe
// błędy, jest gorszy niż brak filtra.
return false;
}
},
payload: {
environment: "{{ ROLLBAR_CLIENT.environment }}",
client: {
Expand Down
186 changes: 186 additions & 0 deletions tests/js/rollbar-filters.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// @vitest-environment jsdom
//
// Filtr `checkIgnore` dla frontendowego Rollbara: odsiewa zgłoszenia, na
// których nie da się nic zrobić — błędy z obcych skryptów (widget dostępności
// UserWay na CDN) oraz zdarzenia bez użytecznego stack trace'u.
//
// rollbar-filters.js jest ZWYKŁYM skryptem (nie modułem), bo rollbar.html
// ładuje go wcześnie w <head> — `type="module"` odroczyłoby wykonanie i część
// błędów z czasu ładowania strony uciekłaby przed inicjalizacją. Dlatego
// ładujemy go tu dla efektu ubocznego i czytamy z `window`, tak samo jak
// robi to djangoql-locate.test.js.
import { describe, test, expect, beforeAll } from "vitest";

let czyPominac;

beforeAll(async () => {
await import("../../src/bpp/static/bpp/js/rollbar-filters.js");
({ czyPominac } = window.bppRollbarFilters);
});

const ORIGIN = "https://bpp.piwet.pulawy.pl";

function payloadZRamkami(filenames, exception) {
return {
body: {
trace: {
exception: exception || { class: "TypeError", message: "x" },
frames: filenames.map((f) => ({ filename: f })),
},
},
};
}

describe("błędy z naszego kodu — raportujemy", () => {
test("ramka z naszego origin", () => {
const p = payloadZRamkami([`${ORIGIN}/static/bpp/js/core.js`]);
expect(czyPominac(p, ORIGIN)).toBe(false);
});

test("ścieżka względna też jest nasza", () => {
expect(czyPominac(payloadZRamkami(["/static/bpp/js/core.js"]), ORIGIN)).toBe(
false,
);
});

test("mieszanka: nasza ramka w stosie obok obcej", () => {
const p = payloadZRamkami([
"https://cdn.userway.org/widgetapp/widget.js",
`${ORIGIN}/static/bpp/js/core.js`,
]);
expect(czyPominac(p, ORIGIN)).toBe(false);
});
});

describe("obce skrypty — pomijamy", () => {
test("widget UserWay (Rollbar #1477: lookbehind w starym Safari)", () => {
const p = payloadZRamkami(
["https://cdn.userway.org/widgetapp/2026-07-07/widget_app_base.js"],
{
class: "SyntaxError",
message: "invalid group specifier name",
},
);
expect(czyPominac(p, ORIGIN)).toBe(true);
});

test("dowolny inny obcy host", () => {
const p = payloadZRamkami(["https://example.com/tracker.js"]);
expect(czyPominac(p, ORIGIN)).toBe(true);
});
});

describe("zgłoszenia bez lokalizacji I bez treści — pomijamy", () => {
test("Rollbar #444: błąd ładowania <link>, class '(unknown)', message '{}'", () => {
const p = {
body: { trace: { exception: { class: "(unknown)", message: "{}" }, frames: [] } },
};
expect(czyPominac(p, ORIGIN)).toBe(true);
});

test("filename '(unknown)' przy pustym opisie też pomijamy", () => {
const p = payloadZRamkami(["(unknown)"], { class: "(unknown)", message: "" });
expect(czyPominac(p, ORIGIN)).toBe(true);
});

test("ALE filename '(unknown)' z konkretnym wyjątkiem → raportuj", () => {
// Brak lokalizacji nie znaczy brak informacji: klasa i komunikat
// wystarczą, żeby zacząć szukać. Za szeroka reguła zjadałaby nasze
// błędy — patrz sekcja niżej.
const p = payloadZRamkami(["(unknown)"], {
class: "TypeError",
message: "x is not a function",
});
expect(czyPominac(p, ORIGIN)).toBe(false);
});
});

describe("bezpieczeństwo filtra — w razie wątpliwości raportuj", () => {
test("payload bez body nie wywala filtra i NIE wycisza", () => {
expect(czyPominac({}, ORIGIN)).toBe(false);
expect(czyPominac(null, ORIGIN)).toBe(false);
expect(czyPominac(undefined, ORIGIN)).toBe(false);
});

test("trace_chain (wyjątki łańcuchowe) jest obsługiwany", () => {
const p = {
body: {
trace_chain: [
{ frames: [{ filename: `${ORIGIN}/static/a.js` }] },
{ frames: [{ filename: "https://cdn.userway.org/w.js" }] },
],
},
};
expect(czyPominac(p, ORIGIN)).toBe(false);
});

test("trace_chain wyłącznie z obcych skryptów jest pomijany", () => {
const p = {
body: {
trace_chain: [{ frames: [{ filename: "https://cdn.userway.org/w.js" }] }],
},
};
expect(czyPominac(p, ORIGIN)).toBe(true);
});
});

// Kształty payloadów zmierzone na rollbar.js 3.1.0 (nie wymyślone):
// ręczny log daje `body.message` BEZ `trace`/`frames`, a odrzucona obietnica
// z reason innym niż Error daje ramkę z filename "(unknown)".
describe("nasze błędy, które wcześniej filtr zjadał", () => {
test("ręczny Rollbar.error('...') — body.message, zero ramek", () => {
const p = { body: { message: { body: "coś poszło nie tak" }, telemetry: [] } };
expect(czyPominac(p, ORIGIN)).toBe(false);
});

test("ostrzeżenie samego Rollbara o rate-limicie (też body.message)", () => {
const p = {
body: { message: { body: "maxItems has been hit. Ignoring errors..." } },
};
expect(czyPominac(p, ORIGIN)).toBe(false);
});

test("odrzucona obietnica z reason innym niż Error", () => {
const p = {
body: {
trace: {
exception: { class: "UnhandledRejection", message: "{...}" },
frames: [{ filename: "(unknown)" }],
},
},
};
expect(czyPominac(p, ORIGIN)).toBe(false);
});

test("SyntaxError bez ścieżki NIE jest wyciszany — to może być nasz bundle", () => {
// Rollbar #502. Przeglądarka ujawnia treść błędu parsowania tylko dla
// skryptów same-origin (obce bez CORS dają "Script error."), więc
// konkretny komunikat sugeruje, że któryś NASZ statyk się nie parsuje.
const p = payloadZRamkami(["SyntaxError: Unexpected token ="], {
class: "SyntaxError",
message: "Unexpected token =",
});
expect(czyPominac(p, ORIGIN)).toBe(false);
});

test("ale SyntaxError z obcego skryptu nadal wyciszamy (#1477)", () => {
const p = payloadZRamkami(["https://cdn.userway.org/widgetapp/w.js"], {
class: "SyntaxError",
message: "invalid group specifier name",
});
expect(czyPominac(p, ORIGIN)).toBe(true);
});
});

describe("odporność filtra", () => {
test("brak origin (stara przeglądarka bez location.origin) → raportuj", () => {
expect(czyPominac(payloadZRamkami(["https://cdn.userway.org/w.js"]), undefined)).toBe(
false,
);
});

test("host podszywający się pod nasz nie uchodzi za nasz", () => {
const p = payloadZRamkami([`${ORIGIN}.evil.example/x.js`]);
expect(czyPominac(p, ORIGIN)).toBe(true);
});
});