Feat(monitoring): integrate Sentry across frontend and backend, env-gated end to end

Frontend (@sentry/vue, loaded via build-time-gated dynamic import — no
VITE_SENTRY_DSN_FRONTEND, no Sentry in the bundle; init joins the mount
gate so instrumentation precedes the first render):
- uncaught errors, route-change tracing / Web Vitals, error-only masked
  Session Replay, console.error capture fingerprinted per message prefix
  so each fallback source stays a distinct issue
- explicit ip-source:exhausted signal via the app-events bus
- hidden source maps uploaded (then deleted) by @sentry/vite-plugin,
  gated on SENTRY_AUTH_TOKEN

Backend (@sentry/node, bootstrapped by sentry-instrument.js via
node --import so ESM loader hooks land before express; never loaded
without SENTRY_DSN_BACKEND):
- Express error capture + per-route tracing
- logger hook mirrors warn+ lines to Sentry Logs and elevates error+
  to grouped, alertable Issues
- Sentry Crons check-ins for the MaxMind / CAIDA / service-status jobs
  via the no-op-without-DSN wrapper common/sentry-cron.js

Delivery & ops:
- first-party tunnel /api/monitoring relays envelopes past ad blockers;
  DSN-allowlisted (never an open relay), exempt from the shared rate
  limiter with its own generous ceiling
- rate-limit ledger file becomes opt-in now that the warn line reaches
  Sentry Logs; ecosystem.config.cjs carries the --import flag for pm2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5 committed 2026-07-12 11:16:17 +08:00
1 parent 49c27295dc
commit 9bfbf21a60
19 files changed
+1569 -141

No files matched your search

+18
View File
@@ -13,6 +13,9 @@ MAC_LOOKUP_API_KEY=""
IPCHECKING_API_ENDPOINT=""
RIPESTAT_SOURCE_APP=""
# Security related
# SECURITY_BLACKLIST_LOG_FILE_PATH: opt-in on-disk ledger of rate-limited IPs
# (e.g. "logs/blacklist-ip.log"). Empty = no file; the event is always logged
# via the shared logger either way (and mirrored to Sentry when configured).
SECURITY_BLACKLIST_LOG_FILE_PATH=""
SECURITY_RATE_LIMIT=""
SECURITY_DELAY_AFTER=""
@@ -24,6 +27,21 @@ VITE_CURL_IPV6_DOMAIN=""
VITE_CURL_IPV64_DOMAIN=""
# Google Analytics
VITE_GOOGLE_ANALYTICS_ID=""
# Sentry — all optional; leave empty and no Sentry code ships or runs at all.
# VITE_SENTRY_DSN_FRONTEND: frontend DSN (build-time; baked into the bundle;
# also read by the backend at runtime as the allowlist for /api/monitoring,
# the first-party tunnel that relays envelopes past ad blockers)
# SENTRY_DSN_BACKEND: backend DSN (runtime; read by sentry-instrument.js)
# SENTRY_ENVIRONMENT: backend environment tag — unset means "production",
# set "development" on dev machines (frontend tags itself automatically)
# SENTRY_ORG / SENTRY_PROJECT_FRONTEND / SENTRY_AUTH_TOKEN: source-map upload
# at build time only — the token is a secret, never exposed to the browser
VITE_SENTRY_DSN_FRONTEND=""
SENTRY_DSN_BACKEND=""
SENTRY_ENVIRONMENT=""
SENTRY_ORG=""
SENTRY_PROJECT_FRONTEND=""
SENTRY_AUTH_TOKEN=""
# MaxMind
MAXMIND_ACCOUNT_ID=""
MAXMIND_LICENSE_KEY=""
+60
View File
@@ -0,0 +1,60 @@
// /api/monitoring — first-party relay ("tunnel") for frontend Sentry
// envelopes, so ad/privacy blockers that block *.ingest.sentry.io don't
// silence error reporting. The browser SDK posts envelopes here (see
// `tunnel` in frontend/sentry-init.js); we validate that the envelope is
// addressed to OUR project and forward it. backend-server.js mounts the
// route only when VITE_SENTRY_DSN_FRONTEND is set.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
// The envelope header is the first newline-delimited JSON line and carries
// the DSN the SDK was configured with. Returns a URL or null. Exported for
// tests.
export const parseEnvelopeDsn = (rawBody) => {
try {
const text = rawBody.toString('utf8');
const newline = text.indexOf('\n');
const header = JSON.parse(newline === -1 ? text : text.slice(0, newline));
return header?.dsn ? new URL(header.dsn) : null;
} catch {
return null;
}
};
export default async (req, res) => {
// Defensive method gate (route already POST-only) — covered by tests.
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
// Only the DSN this deployment was built with may be relayed — anything
// else would make us an open proxy to arbitrary Sentry accounts.
const allowedDsn = process.env.VITE_SENTRY_DSN_FRONTEND;
if (!allowedDsn) {
return res.status(404).json({ error: 'Tunnel not configured' });
}
const dsn = parseEnvelopeDsn(req.body);
if (!dsn) {
return res.status(400).json({ error: 'Invalid envelope' });
}
if (dsn.href !== new URL(allowedDsn).href) {
return res.status(403).json({ error: 'DSN not allowed' });
}
try {
const projectId = dsn.pathname.replace(/^\//, '');
const upstream = `https://${dsn.host}/api/${projectId}/envelope/`;
const apiRes = await fetchUpstream(upstream, {
method: 'POST',
headers: { 'Content-Type': 'application/x-sentry-envelope' },
body: req.body,
});
res.status(apiRes.status).send(await apiRes.text());
} catch (error) {
// warn, not error: a transient relay failure is infra noise, not an
// application defect worth a grouped Issue of its own.
logger.warn({ err: error }, 'sentry tunnel forward failed');
res.status(502).json({ error: 'Relay failed' });
}
};
+46 -6
View File
@@ -29,6 +29,7 @@ import serviceStatusHandler, {
} from './api/service-status.js';
import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js';
import getWhois from './api/get-whois.js';
import sentryTunnelHandler from './api/sentry-tunnel.js';
import invisibilitytestHandler from './api/invisibility-test.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
@@ -45,7 +46,12 @@ dotenv.config({ quiet: true });
const app = express();
const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10);
const blackListIPLogFilePath = process.env.SECURITY_BLACKLIST_LOG_FILE_PATH || 'logs/blacklist-ip.log';
// Local rate-limit ledger file — opt-in: empty means no file is written.
// The logger.warn in the limiter handler always fires regardless (and flows
// to Sentry Logs when a backend DSN is configured), so the file only adds a
// permanent on-disk record for deployments that want one (e.g. Sentry-less
// self-hosts, or feeding a firewall script).
const blackListIPLogFilePath = process.env.SECURITY_BLACKLIST_LOG_FILE_PATH || '';
const rateLimitSet = parseInt(process.env.SECURITY_RATE_LIMIT || 0, 10);
const speedLimitSet = parseInt(process.env.SECURITY_DELAY_AFTER || 0, 10);
@@ -113,7 +119,6 @@ function logLimitedIP(ip) {
if (currentIp === ip) {
newCount = parseInt(count, 10) + 1;
logExists = true;
logger.warn({ ip, count: newCount }, 'Rate-limited IP hit again');
return `${ip},${newCount},${timestamp}`;
}
return line;
@@ -123,7 +128,6 @@ function logLimitedIP(ip) {
if (!logExists) {
const newLine = `${ip},${newCount},${formatDate(now)}`;
updatedData += (updatedData ? '\n' : '') + newLine;
logger.warn({ ip }, 'IP rate-limited for the first time');
}
fs.writeFile(logPath, updatedData, 'utf8', err => {
@@ -138,13 +142,22 @@ const rateLimiter = rateLimit({
windowMs: 20 * 60 * 1000,
max: rateLimitSet,
message: 'Too Many Requests',
// The Sentry tunnel is exempted — it has its own limiter at the route.
// Telemetry sharing the app quota is how reporting silently dies: one
// 429 and the browser SDK drops every event for the next minute.
skip: (req) => req.path === '/monitoring',
handler: (req, res, next) => {
const ip = getClientIp(req);
// Log on the exact transition into rate-limited state — not every
// blocked request — to avoid log flooding when an abusive client
// keeps hammering after being limited.
if (req.rateLimit.current === req.rateLimit.limit + 1 && blackListIPLogFilePath) {
logLimitedIP(ip);
// keeps hammering after being limited. The warn line is the primary
// record (mirrored to Sentry Logs when configured); the on-disk
// ledger is the opt-in extra.
if (req.rateLimit.current === req.rateLimit.limit + 1) {
logger.warn({ ip }, 'IP rate-limited');
if (blackListIPLogFilePath) {
logLimitedIP(ip);
}
}
res.status(429).json({ message: 'Too Many Requests' });
}
@@ -154,6 +167,7 @@ const speedLimiter = slowDown({
windowMs: 60 * 60 * 1000,
delayAfter: speedLimitSet,
delayMs: (hits) => hits * 400,
skip: (req) => req.path === '/monitoring',
})
if (rateLimitSet !== 0) {
@@ -231,11 +245,37 @@ app.get('/api/getuserinfo', getUserinfo);
app.put('/api/updateuserachievement', updateUserAchievement);
app.get('/api/configs', validateConfigs);
// Sentry tunnel — first-party relay for the frontend SDK's envelopes
// Mounted only when this deployment actually built the frontend with a DSN.
//
// `type` must be a catch-all FUNCTION: Replay envelopes are binary
// (deflate-compressed recording) and fetch sends those with NO Content-Type
// header at all — a '*/*' string matcher skips such requests and req.body
// would never be populated.
if (process.env.VITE_SENTRY_DSN_FRONTEND) {
// Dedicated, more generous per-IP limiter
const monitoringLimiter = rateLimit({
windowMs: 20 * 60 * 1000,
max: 600,
message: 'Too Many Requests',
});
app.post('/api/monitoring', monitoringLimiter, express.raw({ type: () => true, limit: '10mb' }), sentryTunnelHandler);
}
// Set static file server
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, './dist')));
// Sentry error capture — the SDK itself is initialized (or not) by
// sentry-instrument.js via `node --import`; this attaches the Express error
// handler only when a DSN made that init happen. Must come after every route.
if (process.env.SENTRY_DSN_BACKEND) {
const Sentry = await import('@sentry/node');
Sentry.setupExpressErrorHandler(app);
logger.info('🛰️ Sentry backend monitoring enabled');
}
// Bootstrap every offline dataset (MaxMind, CAIDA) before accepting traffic
// so we never serve mid-download. Each step is non-fatal: a failure leaves
+9 -1
View File
@@ -17,6 +17,7 @@ import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import { createDecompressor } from './decompress.js';
import { AS_ORG_DB_DIR, AS_ORG_FILE, reloadAsOrgDatabase } from './as-org-db.js';
import { AS_REL_DB_DIR, AS_REL_FILE, reloadAsRelDatabase } from './as-rel-db.js';
@@ -96,8 +97,15 @@ export function startCaidaAutoUpdate() {
schedulerStarted = true;
if (!isAutoUpdateEnabled()) return;
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN). Note runAllUpdates degrades per-dataset
// internally, so the monitor mainly catches a dead/hung scheduler.
const run = () => {
runAllUpdates().catch(error => {
withCronMonitor('caida-auto-update', runAllUpdates, {
schedule: { type: 'interval', value: UPDATE_INTERVAL_MS / (60 * 60 * 1000), unit: 'hour' },
checkinMargin: 60,
maxRuntime: 30,
}).catch(error => {
logger.error({ err: error }, 'CAIDA auto update tick failed');
});
};
+80
View File
@@ -3,6 +3,17 @@
// LOG_LEVEL defaults to 'warn'; LOG_FORMAT=json switches off pino-pretty
// for log shippers. No NODE_ENV dependency.
//
// When SENTRY_DSN_BACKEND is set, the logMethod hook below mirrors log lines
// to Sentry (no official pino transport exists; the hook runs in-process and
// needs no extra dependency), split by severity to match the frontend's
// signal philosophy:
// - warn+ → Sentry Logs (queryable stream, Explore → Logs)
// - error+ → ALSO a Sentry Issue (grouped by the `err` stack when present,
// alertable) — a caught-and-logged upstream failure is a signal,
// not just a log line
// Without the DSN, @sentry/node is never loaded and the hook is not
// installed at all — the usual gating philosophy.
//
// ES module imports are hoisted, so backend-server.js's dotenv.config()
// runs after this file — we load .env ourselves here so LOG_LEVEL is
// honored. dotenv is idempotent; `quiet: true` avoids double banners.
@@ -14,8 +25,77 @@ dotenv.config({ quiet: true });
const useJson = process.env.LOG_FORMAT === 'json';
let sentry = null;
if (process.env.SENTRY_DSN_BACKEND) {
sentry = await import('@sentry/node');
}
// Flatten a pino context object into Sentry log attributes, which must be
// primitives: Errors become message + stack strings, nested objects become
// JSON. Exported for tests.
export const flattenLogAttributes = (ctx) => {
const attributes = {};
for (const [key, value] of Object.entries(ctx)) {
if (value instanceof Error) {
attributes[`${key}.message`] = value.message;
attributes[`${key}.stack`] = value.stack ?? '';
} else if (typeof value === 'object' && value !== null) {
try {
attributes[key] = JSON.stringify(value);
} catch {
attributes[key] = String(value);
}
} else {
attributes[key] = value;
}
}
return attributes;
};
// pino numeric level → Sentry log level; anything below warn stays local.
const SENTRY_LOG_LEVELS = { 40: 'warn', 50: 'error', 60: 'fatal' };
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
...(sentry ? {
hooks: {
// Mirror warn+ lines to Sentry Logs and elevate error+ to Issues,
// then log normally. pino call shapes are (msg) or (ctx, msg).
// Only enabled levels reach this hook, so LOG_LEVEL filtering
// applies to Sentry too.
logMethod(args, method, level) {
const sentryLevel = SENTRY_LOG_LEVELS[level];
if (sentryLevel) {
try {
const [first, second] = args;
const msg = String(typeof first === 'string' ? first : (second ?? ''));
const ctx = (typeof first === 'object' && first !== null) ? first : {};
const attributes = flattenLogAttributes(ctx);
sentry.logger[sentryLevel](msg, attributes);
if (level >= 50) {
// Group by the original throw site when an err
// object is attached (the logger.error({ err }...)
// convention); the log message rides along as a
// searchable tag.
const captureContext = {
level: sentryLevel,
tags: { log_message: msg },
extra: attributes,
};
if (ctx.err instanceof Error) {
sentry.captureException(ctx.err, captureContext);
} else {
sentry.captureMessage(msg, captureContext);
}
}
} catch {
// Telemetry must never break local logging.
}
}
return method.apply(this, args);
},
},
} : {}),
...(useJson ? {} : {
transport: {
target: 'pino-pretty',
+8 -1
View File
@@ -7,6 +7,7 @@ import { pipeline } from 'stream/promises';
import * as tar from 'tar';
import maxmind from 'maxmind';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import {
getMaxMindDbPaths,
MAXMIND_ASN_DB,
@@ -69,8 +70,14 @@ export function startMaxMindAutoUpdate({ reload } = {}) {
}
// Run one update cycle and keep the scheduler alive if that cycle fails.
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN) so a silently dead scheduler gets noticed.
const run = () => {
updateMaxMindDatabases({ reload }).catch(error => {
withCronMonitor('maxmind-auto-update', () => updateMaxMindDatabases({ reload }), {
schedule: { type: 'interval', value: UPDATE_INTERVAL_MS / (60 * 60 * 1000), unit: 'hour' },
checkinMargin: 60, // minutes of lateness tolerated before "missed"
maxRuntime: 30, // minutes before an in-progress run counts as failed
}).catch(error => {
logger.error({ err: error }, 'MaxMind auto update failed');
});
};
+18
View File
@@ -0,0 +1,18 @@
// Sentry Cron check-in wrapper for the backend's periodic jobs (MaxMind /
// CAIDA / service-status). Same gating as sentry-instrument.js: without
// SENTRY_DSN_BACKEND this is a pure passthrough and @sentry/node is never
// loaded, so job modules can call it unconditionally.
//
// The monitor is upserted from the monitorConfig on first check-in — nothing
// to pre-create in the Sentry UI. A rejected job promise reports an "error"
// check-in and rethrows, so callers keep their own .catch logging.
let sentryWithMonitor = null;
if (process.env.SENTRY_DSN_BACKEND) {
({ withMonitor: sentryWithMonitor } = await import('@sentry/node'));
}
export const withCronMonitor = (slug, fn, monitorConfig) => {
if (!sentryWithMonitor) return fn();
return sentryWithMonitor(slug, fn, monitorConfig);
};
+8 -1
View File
@@ -11,6 +11,7 @@
import { fetchUpstream } from './fetch-with-timeout.js';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import { STATUS_PROVIDERS } from './service-status-providers.js';
import { assembleProvider } from './service-status-transform.js';
@@ -106,8 +107,14 @@ export async function bootstrapServiceStatus() {
export function startServiceStatusPolling() {
if (schedulerStarted) return;
schedulerStarted = true;
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN) so a dead poller surfaces as a missed check-in.
const tick = () => {
refreshServiceStatus().catch((error) => {
withCronMonitor('service-status-refresh', refreshServiceStatus, {
schedule: { type: 'interval', value: REFRESH_INTERVAL_MS / (60 * 1000), unit: 'minute' },
checkinMargin: 5,
maxRuntime: 5,
}).catch((error) => {
logger.error({ err: error }, 'service-status refresh tick failed');
});
};
+22
View File
@@ -0,0 +1,22 @@
// ecosystem.config.cjs — pm2 process definitions for production.
// `pm2 start ecosystem.config.cjs` so nobody has to remember the node flags:
// the backend must load sentry-instrument.js via --import BEFORE express for
// Sentry's ESM auto-instrumentation (a no-op without SENTRY_DSN_BACKEND, so
// Sentry-less deployments can use this file unchanged).
// CommonJS on purpose: pm2 reads its config synchronously and the package is
// "type": "module", hence the .cjs extension.
module.exports = {
apps: [
{
name: 'myip-backend',
script: 'backend-server.js',
cwd: __dirname,
node_args: '--import ./sentry-instrument.js',
},
{
name: 'myip-frontend',
script: 'frontend-server.js',
cwd: __dirname,
},
],
};
+22 -1
View File
@@ -34,6 +34,7 @@ import { trackEvent } from '@/utils/analytics';
import { isValidIP } from '@/utils/valid-ip.js';
import { transformDataFromIPapi } from '@/utils/transform-ip-data.js';
import { getIPFromIPIP, getIPFromCloudflare_V4, getIPFromCloudflare_V6, getIPFromIPChecking64, getIPFromIPChecking4, getIPFromIPChecking6 } from '@/utils/getips';
import { emitAppEvent } from '@/utils/app-events';
import { authenticatedFetch } from '@/utils/authenticated-fetch';
import IPCard from './ip-infos/IPCard.vue';
@@ -136,6 +137,18 @@ const markFetched = (cardID) => {
trackFetchStatus(fetchStatus);
};
// Stable source slugs by card ID, used in the ip-source:exhausted domain
// event (function names would be mangled by minification). Order matches the
// ipSources table in checkAllIPs.
const CARD_SOURCE_SLUGS = [
'ipchecking-v4',
'ipchecking-v6',
'cloudflare-v4',
'cloudflare-v6',
'ipip',
'ipchecking-64',
];
// Phase 1 — resolve one card's IP and render it immediately.
const resolveIP = async (cardID, getFromSource) => {
let ip = null;
@@ -153,9 +166,17 @@ const resolveIP = async (cardID, getFromSource) => {
IPArray.value = [...IPArray.value, { ip, country: '' }];
} else {
// v6 cards: ipchecking_v6 (1), cloudflare_v6 (3).
ipDataCards[cardID].ip = (cardID === 1 || cardID === 3)
const isV6Card = cardID === 1 || cardID === 3;
ipDataCards[cardID].ip = isV6Card
? t('ipInfos.IPv6Error')
: t('ipInfos.IPv4Error');
// Domain event: this source AND all its internal fallbacks failed to
// produce an IP. Emitted unconditionally (bus semantics); subscribers
// decide what matters — e.g. sentry-init.js reports v4 exhaustion.
emitAppEvent('ip-source:exhausted', {
source: CARD_SOURCE_SLUGS[cardID] ?? `card-${cardID}`,
ipVersion: isV6Card ? 'v6' : 'v4',
});
markFetched(cardID); // no IP → no detail phase
}
return { cardID, ip };
+15 -1
View File
@@ -51,6 +51,19 @@ const store = useMainStore(pinia);
app.use(i18n);
app.use(router);
// Sentry error monitoring — VITE_SENTRY_DSN_FRONTEND is a build-time
// constant, so when it's unset Vite folds this branch away and the Sentry
// chunk neither ships nor loads. The returned promise joins the mount gate
// below: the chunk loads in parallel with the auth/config/locale fetches the
// mount already waits for, so console/error instrumentation is guaranteed to
// be in place before any component code runs (checkAllIPs fires immediately
// at mount). A failed load must never block the app — hence the catch.
const sentryReady = import.meta.env.VITE_SENTRY_DSN_FRONTEND
? import('./sentry-init')
.then(({ initSentry }) => initSentry(app, router))
.catch(() => {})
: Promise.resolve();
//
// Initialize a series of operations
//
@@ -91,7 +104,8 @@ Promise.all([
store.isFireBaseSet ? store.initializeAuthListener() : Promise.resolve(),
store.loadPreferences(), // Load user preferences
store.fetchConfigs(), // Fetch backend configs
loadActiveLocaleMessages() // Load the active language pack before first render
loadActiveLocaleMessages(), // Load the active language pack before first render
sentryReady // Sentry instrumentation in place before first render
]).then(() => {
app.mount('#app');
}).catch(error => {
+73
View File
@@ -0,0 +1,73 @@
// 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';
const env = import.meta.env ?? {};
const initSentry = (app, router) => {
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
Sentry.browserTracingIntegration({ router }),
// Session Replay, error-only: nothing is uploaded unless an error event fires
Sentry.replayIntegration(),
// console.error() → grouped Issues (tagged logger:console).
Sentry.captureConsoleIntegration({ levels: ['error'] }),
],
// 100% trace sampling — Should change based on plan quota
tracesSampleRate: 1.0,
// Replay: never record plain sessions, always keep the buffer that
// gets flushed when an error occurs
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
// MyIP is a privacy tool: never attach user IP / headers to events
sendDefaultPii: false,
// Relay envelopes through our own backend (api/sentry-tunnel.js)
tunnel: '/api/monitoring',
// 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) {
if (event.logger === 'console') {
const firstArg = event.extra?.arguments?.[0];
if (typeof firstArg === 'string' && firstArg.trim()) {
event.fingerprint = [firstArg.trim().slice(0, 200)];
}
}
return event;
},
});
// Explicit product signals arrive via the app-events bus — business code
// stays Sentry-free and just emits domain events (see frontend/AGENTS.md).
// A whole IP source chain (primary + every fallback) failed. v6
// exhaustion is routine for IPv4-only visitors, so only v4 is reported;
// per-hop degradation inside a chain is already covered by the
// console.error capture above.
onAppEvent('ip-source:exhausted', ({ source, ipVersion }) => {
if (ipVersion !== 'v4') return;
Sentry.captureMessage(`IP source chain exhausted: ${source}`, {
level: 'error',
tags: { source, ipVersion },
});
});
};
export { initSentry };
+8 -5
View File
@@ -5,14 +5,14 @@
"type": "module",
"packageManager": "pnpm@11.10.0",
"scripts": {
"dev": "concurrently \"vite\" \"nodemon backend-server.js\"",
"dev": "concurrently \"vite\" \"nodemon --import ./sentry-instrument.js backend-server.js\"",
"build": "vite build",
"test": "node --test tests/*.test.js",
"check": "pnpm run test && pnpm run build",
"preview": "vite preview",
"start-backend": "node backend-server.js",
"start-backend": "node --import ./sentry-instrument.js backend-server.js",
"start-frontend": "node frontend-server.js",
"start": "concurrently \"node frontend-server.js\" \"node backend-server.js\""
"start": "concurrently \"node frontend-server.js\" \"node --import ./sentry-instrument.js backend-server.js\""
},
"dependencies": {
"@cloudflare/speedtest": "^1.11.0",
@@ -20,6 +20,8 @@
"@iconify/vue": "^5.0.1",
"@khmyznikov/pwa-install": "^0.6.4",
"@lucide/vue": "^1.23.0",
"@sentry/node": "^10.64.0",
"@sentry/vue": "^10.64.0",
"@tanstack/vue-table": "^8.21.3",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"@vueuse/core": "^14.3.0",
@@ -34,7 +36,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"express-slow-down": "^3.1.0",
"firebase": "^12.15.0",
"firebase": "^12.16.0",
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^4.2.0",
"maxmind": "^5.0.6",
@@ -58,12 +60,13 @@
"whoiser": "^1.18.0"
},
"devDependencies": {
"@sentry/vite-plugin": "^5.3.0",
"@tailwindcss/vite": "^4.3.2",
"@vitejs/plugin-vue": "^6.0.7",
"code-inspector-plugin": "^1.6.6",
"nodemon": "^3.1.14",
"tailwindcss": "^4.3.2",
"vconsole": "^3.15.1",
"vite": "^8.1.3"
"vite": "^8.1.4"
}
}
+1007 -124
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -1,10 +1,11 @@
allowBuilds:
'@firebase/util': true
'@sentry/cli': true
core-js: true
protobufjs: true
vue-demi: true
minimumReleaseAgeExclude:
- vite@8.1.0
- vite@8.1.0 || 8.1.4
- tar@7.5.19
- '@code-inspector/core@1.6.3 || 1.6.6'
- '@code-inspector/esbuild@1.6.3 || 1.6.6'
@@ -30,3 +31,8 @@ minimumReleaseAgeExclude:
- code-inspector-plugin@1.6.3 || 1.6.6
- tailwindcss@4.3.2
- launch-ide@1.4.5
- '@firebase/app-compat@0.5.15'
- '@firebase/app@0.15.1'
- '@firebase/remote-config-compat@0.2.27'
- '@firebase/remote-config@0.9.0'
- firebase@12.16.0
+26
View File
@@ -0,0 +1,26 @@
// Sentry bootstrap for the Express backend. Loaded via `node --import` (see
// the dev / start scripts in package.json) so the SDK registers its ESM
// loader hooks BEFORE express & co. load — that's what makes automatic
// per-route tracing work.
//
// Gated on SENTRY_DSN_BACKEND, same philosophy as the frontend: when the env
// var is unset this module does nothing and @sentry/node is never even
// loaded. backend-server.js attaches the matching Express error handler.
import dotenv from 'dotenv';
dotenv.config({ quiet: true });
if (process.env.SENTRY_DSN_BACKEND) {
const Sentry = await import('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN_BACKEND,
environment: process.env.SENTRY_ENVIRONMENT || 'production',
// 100% trace sampling (per-route latency / throughput / error rate under Insights)
tracesSampleRate: 1.0,
// Sentry Logs — receives warn/error/fatal lines forwarded by the
// logMethod hook in common/logger.js
enableLogs: true,
// Never attach caller IPs / headers to events (privacy tool)
sendDefaultPii: false,
});
}
+55
View File
@@ -0,0 +1,55 @@
// Passthrough behavior of common/sentry-cron.js when no backend DSN is set —
// the wrapper must be a transparent no-op (result and rejection both flow
// through) so periodic jobs behave identically on Sentry-less deployments.
// Also covers common/logger.js's flattenLogAttributes (the pino-context →
// Sentry-log-attributes normalizer used by the forwarding hook).
import { test } from 'node:test';
import assert from 'node:assert';
// Ensure the gate is closed regardless of the shell environment, then import
// (both modules evaluate the gate once at load).
delete process.env.SENTRY_DSN_BACKEND;
const { withCronMonitor } = await import('../common/sentry-cron.js');
const { flattenLogAttributes } = await import('../common/logger.js');
test('returns the job result unchanged when Sentry is not configured', async () => {
const result = await withCronMonitor('test-job', async () => 42, {
schedule: { type: 'interval', value: 5, unit: 'minute' },
});
assert.strictEqual(result, 42);
});
test('propagates a rejected job to the caller', async () => {
await assert.rejects(
() => withCronMonitor('test-job', async () => { throw new Error('boom'); }, {}),
/boom/,
);
});
test('runs the job exactly once', async () => {
let calls = 0;
await withCronMonitor('test-job', async () => { calls += 1; }, {});
assert.strictEqual(calls, 1);
});
test('flattens Error values into message + stack attributes', () => {
const err = new Error('kaput');
const attrs = flattenLogAttributes({ err, ip: '1.2.3.4' });
assert.strictEqual(attrs['err.message'], 'kaput');
assert.ok(attrs['err.stack'].includes('kaput'));
assert.strictEqual(attrs.ip, '1.2.3.4');
assert.strictEqual('err' in attrs, false);
});
test('serializes nested objects and passes primitives through', () => {
const attrs = flattenLogAttributes({
req: { method: 'GET', url: '/api/x' },
count: 3,
flag: true,
nothing: null,
});
assert.strictEqual(attrs.req, '{"method":"GET","url":"/api/x"}');
assert.strictEqual(attrs.count, 3);
assert.strictEqual(attrs.flag, true);
assert.strictEqual(attrs.nothing, null);
});
+64
View File
@@ -0,0 +1,64 @@
// api/sentry-tunnel.js — envelope-header parsing and the guard branches that
// return before any upstream forward (method gate / unconfigured / invalid
// envelope / foreign DSN). No real Sentry traffic is ever sent.
import { test, beforeEach } from 'node:test';
import assert from 'node:assert';
import tunnelHandler, { parseEnvelopeDsn } from '../api/sentry-tunnel.js';
const OUR_DSN = 'https://publickey@o111.ingest.sentry.io/222';
const makeRes = () => {
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
send(payload) { this.body = payload; return this; },
};
return res;
};
const envelopeFor = (dsn) => Buffer.from(`${JSON.stringify({ dsn })}\n{"type":"event"}\n{}`);
beforeEach(() => {
delete process.env.VITE_SENTRY_DSN_FRONTEND;
});
test('parseEnvelopeDsn extracts the DSN from the envelope header', () => {
const dsn = parseEnvelopeDsn(envelopeFor(OUR_DSN));
assert.strictEqual(dsn.host, 'o111.ingest.sentry.io');
assert.strictEqual(dsn.pathname, '/222');
});
test('parseEnvelopeDsn returns null for garbage input', () => {
assert.strictEqual(parseEnvelopeDsn(Buffer.from('not json')), null);
assert.strictEqual(parseEnvelopeDsn(Buffer.from('{"no_dsn":1}\n{}')), null);
});
test('rejects non-POST methods with 405', async () => {
const res = makeRes();
await tunnelHandler({ method: 'GET' }, res);
assert.strictEqual(res.statusCode, 405);
});
test('returns 404 when no frontend DSN is configured', async () => {
const res = makeRes();
await tunnelHandler({ method: 'POST', body: envelopeFor(OUR_DSN) }, res);
assert.strictEqual(res.statusCode, 404);
});
test('returns 400 on an unparseable envelope', async () => {
process.env.VITE_SENTRY_DSN_FRONTEND = OUR_DSN;
const res = makeRes();
await tunnelHandler({ method: 'POST', body: Buffer.from('garbage') }, res);
assert.strictEqual(res.statusCode, 400);
});
test('returns 403 when the envelope targets a foreign DSN', async () => {
process.env.VITE_SENTRY_DSN_FRONTEND = OUR_DSN;
const res = makeRes();
const foreign = envelopeFor('https://otherkey@o999.ingest.sentry.io/888');
await tunnelHandler({ method: 'POST', body: foreign }, res);
assert.strictEqual(res.statusCode, 403);
});
+23
View File
@@ -3,9 +3,14 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { CodeInspectorPlugin } from 'code-inspector-plugin';
import { sentryVitePlugin } from '@sentry/vite-plugin';
dotenv.config();
// Sentry source-map upload — build-time only, gated on SENTRY_AUTH_TOKEN
// uploaded to Sentry, then deleted from dist/ — users never download them.
const sentryUploadEnabled = !!process.env.SENTRY_AUTH_TOKEN;
const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10);
const frontEndPort = parseInt(process.env.FRONTEND_PORT || 18966, 10);
const nodeModuleChunkGroups = {
@@ -98,6 +103,21 @@ export default defineConfig({
copy: '{file}',
},
}),
// Must stay last so it sees the final emitted chunks. Release name
// defaults to the current git commit SHA; the plugin injects the same
// release into the SDK at runtime, tying events to their sourcemaps.
sentryUploadEnabled && sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT_FRONTEND,
authToken: process.env.SENTRY_AUTH_TOKEN,
telemetry: false,
sourcemaps: {
// Rolldown's module-loading runtime chunk has no source to map —
// skip it so every build doesn't warn about its missing .map
ignore: ['**/rolldown-runtime*.js'],
filesToDeleteAfterUpload: ['dist/**/*.map'],
},
}),
],
resolve: {
alias: {
@@ -105,6 +125,9 @@ export default defineConfig({
},
},
build: {
// Hidden sourcemaps exist only for the Sentry upload; without a token,
// don't generate them at all (they'd end up publicly served from dist/)
sourcemap: sentryUploadEnabled ? 'hidden' : false,
rollupOptions: {
// @vueuse/core ships a couple of /* #__PURE__ */ comments in positions
// Rolldown (Vite 8's new bundler) can't interpret. They're harmless —