Files
MyIP/frontend/sentry-init.js
T
jason5ng32andClaude Fable 5.1 9e4a0f5cb6 Improvements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 11:39:44 +08:00

234 lines
12 KiB
JavaScript

// Sentry error monitoring for the SPA. This module is loaded on demand by
// main.js ONLY when VITE_SENTRY_DSN_FRONTEND is set at build time — a build
// without the env var never ships nor requests this chunk, so self-hosted
// deployments stay completely Sentry-free.
//
// App code must never import Sentry directly (it would drag the SDK back into
// the main bundle and bypass the gating). For explicit signals, emit a domain
// event on utils/app-events.js and subscribe to it here instead.
import * as Sentry from '@sentry/vue';
import { onAppEvent } from '@/utils/app-events';
import { isValidIP } from '@/utils/ip/valid-ip.js';
import { readAccountId } from '@/utils/auth-hint.js';
const env = import.meta.env ?? {};
// html-to-image's webfont embedding console.errors on every stylesheet it
// can't read, then continues without it — noise, not a failed capture.
const SCREENSHOT_CSS_NOISE = [
'Error while reading CSS rules from',
'Error loading remote stylesheet',
'Error inlining remote css file',
];
// Direct captures carry the FirebaseError as originalException; console
// captures (`console.error('… sign-in failed:', error)`) surface it as the
// event's exception type.
const isFirebaseError = (event, hint) =>
hint?.originalException?.name === 'FirebaseError'
|| (event.exception?.values ?? []).some((value) => value?.type === 'FirebaseError');
// `earlyErrors` is main.js's pre-init buffer: ErrorEvent /
// PromiseRejectionEvent entries from its temporary window listeners, plus
// the raw Error from the mount chain's catch. Flushed right after init;
// main.js removes those listeners as soon as this function returns.
const initSentry = (app, router, earlyErrors = []) => {
Sentry.init({
app,
dsn: env.VITE_SENTRY_DSN_FRONTEND,
// Distinguish local `pnpm dev` events from production ones in the
// Sentry UI ("development" / "production")
environment: env.MODE,
integrations: [
// Route-change performance tracing via vue-router → Web Vitals
// (LCP / CLS / INP) and per-route load metrics under Insights.
// Speed-test endpoints are excluded from span creation:
// deliberately-huge downloads are the FEATURE there, and they
// both trip the "Large HTTP Payload" performance detector and
// flood traces with dozens of meaningless spans per run.
Sentry.browserTracingIntegration({
router,
shouldCreateSpanForRequest: (url) => {
try {
return new URL(url, window.location.origin).hostname !== 'speed.cloudflare.com';
} catch {
return true;
}
},
}),
// Session Replay, error-only: nothing is uploaded unless an error
// event fires. Page text is deliberately UNMASKED — this site's
// whole UI is the visitor's own network info (IPs, ASN, DNS), and
// seeing it in an error replay is exactly the debugging context
// we need; disclosed in the privacy policy. Typed input stays
// masked (maskAllInputs default).
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
// Plain-JSON recordings travel more reliably through edge
// proxies than opaque binary; error-only mode keeps the
// bandwidth cost negligible.
useCompression: false,
}),
// console.error() → grouped Issues (tagged logger:console).
Sentry.captureConsoleIntegration({ levels: ['error'] }),
],
// 10% trace sampling: pageload / navigation transactions and Web
// Vitals are statistical, so a sample keeps Insights meaningful
tracesSampleRate: 0.1,
// Discard-outcome client reports are quota bookkeeping we never
// reconcile — don't spend tunnel POSTs on them. (Release Health
// sessions stay on: browserSessionIntegration is a default.)
sendClientReports: false,
// Replay: never record plain sessions, always keep the buffer that
// gets flushed when an error occurs
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
// No IP, cookies or request bodies attached to event data; the
// visitor's network info is on the page itself, so an unmasked
// error-replay already carries it. `userInfo: false` also tells
// Sentry never to infer an IP from the connection — through the
// tunnel that would be our server's. Headers stay at the default:
// the browser SDK only collects User-Agent and Referer, which Sentry
// needs for its browser / OS tags.
dataCollection: {
userInfo: false,
cookies: false,
httpBodies: [],
},
// Relay envelopes through our own backend (api/sentry-tunnel.js)
tunnel: '/api/monitoring',
// Force a Content-Type on every envelope POST.
transportOptions: {
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
},
// Ignore visitor-side noise: DNS-leak providers answering empty,
// auth endpoints unreachable from the visitor's network, the visitor
// dismissing / re-triggering the sign-in popup, and a known
// firebase-js-sdk popup race (its internal assertion, not our state).
ignoreErrors: [
'auth/network-request-failed',
'auth/popup-closed-by-user',
'auth/popup-blocked',
'auth/internal-error',
'auth/cancelled-popup-request',
'INTERNAL ASSERTION FAILED',
// firebase auth's indexedDB persistence layer refuses writes once
// the page is hiding (its guard against sign-out on pagehide), and
// the browser itself rejects transactions on a closing connection.
// Both escape from the SDK's own init promise — benign, they mean
// "skipped a write", not user-visible. Three wordings, one per
// source; Sentry matches these as substrings.
'Database is closing',
'Database is hidden',
'The database connection is closing',
// WebKit tearing down IndexedDB under a live page: the host wiped
// site data (in-app browsers do this) or its storage process
// died. Escapes from Firebase's own IndexedDB opens, same as above.
'Database deleted by request of the user',
'Connection to Indexed Database server lost',
// Reader-mode script that Firefox-iOS-based browsers (Brave iOS
// among them) inject into the page — not our code.
'__firefox__',
// Stale-deploy chunk loads: a client from before the latest
// deploy lazy-loads a hashed asset that no longer exists.
// Self-heals on reload, not a defect. One entry per browser
// wording (Chrome / Firefox / Safari), plus Vite's CSS preload.
'Failed to fetch dynamically imported module',
'error loading dynamically imported module',
'Importing a module script failed',
'Unable to preload CSS',
],
// Third-party scripts we don't own: Cloudflare's RUM beacon throws
// in some browsers — not actionable from this codebase.
denyUrls: [/static\.cloudflareinsights\.com/],
// Console-captured events group by the console message instead of
// the exception stack. The fallback chains all surface the same
// "TypeError: Failed to fetch" from fetchWithTimeout, so stack
// grouping would collapse every source's degradation into ONE issue
// — indistinguishable per source, and a single archive/discard on it
// silently hides them all.
beforeSend(event, hint) {
// Timeout aborts (our own fetchWithTimeout firing on slow links)
// are visitor connectivity, not defects — drop them wherever
// they were logged from.
if (hint?.originalException?.name === 'AbortError') return null;
// "All sources failed to fetch IP details for IP: <ip>" only sums
// up the per-source failures logged right before it, embeds the
// queried IP (unbounded fingerprints), and in practice means
// visitor network trouble or an edge-side block — drop it. The
// per-source console.error lines remain the health signal.
if (String(hint?.originalException?.message ?? '')
.startsWith('All sources failed to fetch IP details')) return null;
// Firebase errors carry account id
if (isFirebaseError(event, hint)) {
const accountId = readAccountId();
if (accountId) event.user = { ...event.user, id: accountId };
}
if (event.logger === 'console') {
const firstArg = event.extra?.arguments?.[0];
if (typeof firstArg === 'string' && firstArg.trim()) {
const msg = firstArg.trim();
// Filter out DNS-leak probe chain errors.
if (msg.startsWith('Error fetching leak test data:')) return null;
// html-to-image logs these while walking a cross-origin
// stylesheet it can't read, then carries on — the
// screenshot still renders (see composables/use-screenshot.js).
if (SCREENSHOT_CSS_NOISE.some((prefix) => msg.startsWith(prefix))) return null;
event.fingerprint = [msg.slice(0, 200)];
}
}
return event;
},
});
// Replay the errors that fired before the SDK was ready. Unwrap the
// event shapes first so real Error objects keep their stacks.
for (const entry of earlyErrors) {
const err = entry instanceof Error
? entry
: entry?.error ?? entry?.reason ?? entry?.message ?? entry;
Sentry.captureException(err);
}
// Explicit product signals arrive via the app-events bus — business code
// stays Sentry-free and just emits domain events (see frontend/AGENTS.md).
//
// ip-source:exhausted — an IP card's whole source chain (primary + all
// fallbacks) produced no IP. Individual source failures are console.warn
// (any single provider can be blocked on a given network); full-card
// exhaustion is the health signal. Exhaustions are held until the
// ipinfo:finished snapshot and only report when some card resolved a
// valid IP of the same version — proof the visitor's network stack works
// for that version, so the exhausted chain is OUR problem. Without that
// proof the failure is indistinguishable from visitor-side conditions
// (no IPv6 — roughly half the internet — or a dead/offline network) and
// is pure noise. The snapshot re-emits after single-card refreshes, so
// queued entries always get flushed.
const reportExhaustion = (source, ipVersion) => {
Sentry.captureMessage(`IP source exhausted: ${source}`, {
level: 'error',
fingerprint: ['ip-source-exhausted', source],
tags: { ip_version: ipVersion },
});
};
const pendingExhaustions = { v4: [], v6: [] };
onAppEvent('ip-source:exhausted', ({ source, ipVersion }) => {
(pendingExhaustions[ipVersion] ?? pendingExhaustions.v4).push(source);
});
onAppEvent('ipinfo:finished', ({ cards }) => {
for (const version of ['v4', 'v6']) {
const queued = pendingExhaustions[version];
if (queued.length === 0) continue;
pendingExhaustions[version] = [];
const visitorHasVersion = (cards ?? []).some(
(card) => typeof card.ip === 'string' && isValidIP(card.ip)
&& card.ip.includes(':') === (version === 'v6')
);
if (visitorHasVersion) queued.forEach((source) => reportExhaustion(source, version));
}
});
};
export { initSentry };