mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(sentry): allowlist third-party tile hosts for maplibre Failed-to-fetch filter (#3220)
* fix(sentry): allowlist third-party tile hosts for maplibre Failed-to-fetch filter Follow-up to #3217. The blanket "any maplibre frame + (hostname)" rule would drop real failures on our self-hosted R2 PMTiles bucket or any first-party fetch that happens to run on a maplibre-framed stack. Enumerated the actual third-party hosts our maplibre paths fetch from (tilecache.rainviewer.com, basemaps.cartocdn.com, tiles.openfreemap.org, protomaps.github.io) into a module-level Set and gated the filter on membership. First-party hosts keep surfacing. Updated regression test to mirror real-world mixed stacks (maplibre + first-party fetch wrapper) so the allowlist is what decides, not the pre-existing "all frames are maplibre internals" filter which is orthogonal. * fix(sentry): route maplibre AJAX errors past the generic vendor-only filter Review feedback: the broader "all non-infra frames are maplibre internals" TypeError filter at main.ts:287 runs BEFORE the new host-allowlist block and short-circuits it for all-vendor stacks. Meaning a self-hosted R2 basemap fetch failure whose stack is purely maplibre frames would still be silently dropped, defeating the point of the allowlist. Carve out the `Failed to fetch (<host>)` AJAX pattern: precompute `isMaplibreAjaxFailure` and skip the generic vendor filter when it matches, so the host-allowlist check is always the one that decides. Added two regression tests covering the all-maplibre edge case both ways: - allowlisted host + all-maplibre → still suppressed - non-allowlisted host + all-maplibre → surfaces
This commit is contained in:
38
src/main.ts
38
src/main.ts
@@ -8,6 +8,17 @@ import { installUtmInterceptor } from './utils/utm';
|
||||
|
||||
const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim();
|
||||
|
||||
// Known third-party hosts fetched by MapLibre (tiles, styles, glyphs, sprites).
|
||||
// Used by the beforeSend `Failed to fetch (<host>)` filter to avoid suppressing
|
||||
// failures from our self-hosted R2 PMTiles bucket or any api.worldmonitor.app
|
||||
// fetches that happen to land on a maplibre-framed stack.
|
||||
const MAPLIBRE_THIRD_PARTY_TILE_HOSTS = new Set([
|
||||
'tilecache.rainviewer.com',
|
||||
'basemaps.cartocdn.com',
|
||||
'tiles.openfreemap.org',
|
||||
'protomaps.github.io',
|
||||
]);
|
||||
|
||||
// Initialize Sentry error tracking (early as possible)
|
||||
Sentry.init({
|
||||
dsn: sentryDsn || undefined,
|
||||
@@ -272,18 +283,27 @@ Sentry.init({
|
||||
// Suppress any TypeError / RangeError that happens entirely within maplibre or deck.gl internals.
|
||||
// RangeError: "Invalid array length" during deck.gl bindVertexArray / _updateCache on large
|
||||
// GL layer updates (vertex-buffer allocation failure in vendor code — WORLDMONITOR-N4).
|
||||
// EXCEPTION: `Failed to fetch (<host>)` is routed through the host-allowlist block below
|
||||
// so a self-hosted R2 PMTiles / first-party basemap regression isn't silently dropped just
|
||||
// because its stack happens to be all-vendor frames (WORLDMONITOR-NE/NF follow-up).
|
||||
const excType = event.exception?.values?.[0]?.type ?? '';
|
||||
if ((excType === 'TypeError' || excType === 'RangeError' || /^(?:TypeError|RangeError):/.test(msg)) && frames.length > 0) {
|
||||
const isMaplibreAjaxFailure = excType === 'TypeError' && /^Failed to fetch \([^)]+\)$/.test(msg);
|
||||
if (!isMaplibreAjaxFailure
|
||||
&& (excType === 'TypeError' || excType === 'RangeError' || /^(?:TypeError|RangeError):/.test(msg))
|
||||
&& frames.length > 0) {
|
||||
if (nonInfraFrames.length > 0 && nonInfraFrames.every(f => /\/(map|maplibre|deck-stack)-[A-Za-z0-9_-]+\.js/.test(f.filename ?? ''))) return null;
|
||||
}
|
||||
// Suppress MapLibre AJAXError for raster tile fetches: maplibre wraps transient network
|
||||
// errors as `Failed to fetch (<hostname>)` and rethrows in a Generator-backed Promise
|
||||
// that leaks to onunhandledrejection even though DeckGLMap's map-error handler already
|
||||
// logs it as a warning. Our own fetch code throws plain `Failed to fetch` (no paren
|
||||
// suffix); the `(hostname)` format is maplibre-specific, and requiring a maplibre
|
||||
// vendor frame guards against hiding first-party regressions (WORLDMONITOR-NE/NF).
|
||||
if (excType === 'TypeError' && /^Failed to fetch \([^)]+\)$/.test(msg)
|
||||
&& frames.some(f => /\/maplibre-[A-Za-z0-9_-]+\.js/.test(f.filename ?? ''))) return null;
|
||||
// Suppress MapLibre AJAXError for third-party tile fetches: maplibre wraps transient
|
||||
// network errors as `Failed to fetch (<hostname>)` and rethrows in a Generator-backed
|
||||
// Promise that leaks to onunhandledrejection even though DeckGLMap's map-error handler
|
||||
// already logs it as a warning. Allowlist KNOWN third-party tile/style/glyph hosts —
|
||||
// leaves first-party fetch failures (self-hosted R2 PMTiles bucket, api.worldmonitor.app)
|
||||
// to surface so a real basemap regression is never silently dropped (WORLDMONITOR-NE/NF).
|
||||
if (isMaplibreAjaxFailure && frames.some(f => /\/maplibre-[A-Za-z0-9_-]+\.js/.test(f.filename ?? ''))) {
|
||||
const hostMatch = msg.match(/^Failed to fetch \(([^)]+)\)$/);
|
||||
const host = hostMatch?.[1];
|
||||
if (host && MAPLIBRE_THIRD_PARTY_TILE_HOSTS.has(host)) return null;
|
||||
}
|
||||
// Suppress Three.js/globe.gl TypeError crashes in main bundle (reading 'type'/'pathType'/'count'/'__globeObjType' on undefined during WebGL traversal/raycast).
|
||||
// __globeObjType is exclusively set by three-globe on its own objects and we have no user onClick/onHover handler, so it is always globe.gl internal even when the stack shows the bundled main chunk (WORLDMONITOR-ME).
|
||||
if (/reading '__globeObjType'|__globeObjType/.test(msg)) return null;
|
||||
|
||||
@@ -29,9 +29,14 @@ const fnBody = mainSrc.slice(bsStart + 'beforeSend(event) '.length, bsEnd)
|
||||
.replace(/as\s+\w+(\[\])?/g, '') // type assertions
|
||||
.replace(/<[A-Z]\w*>/g, ''); // generic type params
|
||||
|
||||
// Extract the MAPLIBRE_THIRD_PARTY_TILE_HOSTS Set so the test harness can evaluate
|
||||
// beforeSend with the same allowlist the real module has.
|
||||
const tpMatch = mainSrc.match(/const MAPLIBRE_THIRD_PARTY_TILE_HOSTS = new Set\(\[[^\]]*\]\);/);
|
||||
assert.ok(tpMatch, 'MAPLIBRE_THIRD_PARTY_TILE_HOSTS must be defined in src/main.ts');
|
||||
|
||||
// Build a callable version. Input: a Sentry-shaped event object. Returns event or null.
|
||||
// eslint-disable-next-line no-new-func
|
||||
const beforeSend = new Function('event', fnBody);
|
||||
const beforeSend = new Function('event', `${tpMatch[0]}\n${fnBody}`);
|
||||
|
||||
/** Helper to build a minimal Sentry event. */
|
||||
function makeEvent(value, type = 'Error', frames = []) {
|
||||
@@ -311,6 +316,16 @@ describe('existing beforeSend filters', () => {
|
||||
assert.equal(beforeSend(event), null, 'MapLibre tile AJAX failure should be suppressed');
|
||||
});
|
||||
|
||||
it('suppresses MapLibre AJAXError for allowlisted host even with an all-maplibre stack', () => {
|
||||
// Proves the allowlist path fires on all-vendor stacks too: the AJAX carve-out
|
||||
// above bypasses the broad "all-maplibre TypeError" filter and routes into the
|
||||
// host-allowlist check, which still suppresses allowlisted third-party hosts.
|
||||
const event = makeEvent('Failed to fetch (tilecache.rainviewer.com)', 'TypeError', [
|
||||
{ filename: '/assets/maplibre-A8Ca0ysS.js', lineno: 4, function: 'ajaxFetch' },
|
||||
]);
|
||||
assert.equal(beforeSend(event), null, 'Allowlisted AJAX host should be suppressed regardless of stack shape');
|
||||
});
|
||||
|
||||
it('does NOT suppress plain "Failed to fetch" from first-party code without maplibre frames', () => {
|
||||
const event = makeEvent('Failed to fetch', 'TypeError', [
|
||||
{ filename: '/assets/panels-wF5GXf0N.js', lineno: 100, function: 'MyApiCall' },
|
||||
@@ -327,6 +342,27 @@ describe('existing beforeSend filters', () => {
|
||||
assert.ok(beforeSend(event) !== null, 'Non-maplibre Failed-to-fetch must reach Sentry');
|
||||
});
|
||||
|
||||
it('does NOT suppress MapLibre AJAXError for a non-allowlisted host (mixed stack)', () => {
|
||||
// Mirrors WORLDMONITOR-NE/NF real-world stack: maplibre + first-party fetch wrapper.
|
||||
const event = makeEvent('Failed to fetch (pmtiles.worldmonitor.app)', 'TypeError', [
|
||||
{ filename: '/assets/maplibre-A8Ca0ysS.js', lineno: 4, function: 'ajaxFetch' },
|
||||
{ filename: '/assets/panels-wF5GXf0N.js', lineno: 24, function: 'window.fetch' },
|
||||
]);
|
||||
assert.ok(beforeSend(event) !== null, 'Self-hosted tile fetch failure must reach Sentry');
|
||||
});
|
||||
|
||||
it('does NOT suppress MapLibre AJAXError for a non-allowlisted host when stack is entirely maplibre', () => {
|
||||
// Critical edge case: the pre-existing "all non-infra frames are maplibre internals"
|
||||
// filter would normally drop TypeErrors with an all-maplibre stack. `Failed to fetch`
|
||||
// AJAX errors must bypass that generic filter so the host allowlist is what decides,
|
||||
// otherwise a self-hosted R2 basemap regression whose stack happens to be vendor-only
|
||||
// would be silently dropped.
|
||||
const event = makeEvent('Failed to fetch (pmtiles.worldmonitor.app)', 'TypeError', [
|
||||
{ filename: '/assets/maplibre-A8Ca0ysS.js', lineno: 4, function: 'ajaxFetch' },
|
||||
]);
|
||||
assert.ok(beforeSend(event) !== null, 'All-maplibre first-party tile fetch failure must still reach Sentry');
|
||||
});
|
||||
|
||||
it('does NOT suppress setPointerCapture NotFoundError when no frame context is present', () => {
|
||||
// Defensive: if Sentry strips context, we err on the side of surfacing.
|
||||
const event = makeEvent(
|
||||
|
||||
Reference in New Issue
Block a user