mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
49c27295dc
commit
9bfbf21a60
19 files changed
+1569
-141
No files matched your search
@@ -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=""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1007
-124
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 —
|
||||
|
||||
Reference in new issue
Block a user