Files
MyIP/tests/sentry-cron.test.js
T
jason5ng32andClaude Fable 5 9bfbf21a60 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>
2026-07-12 11:16:17 +08:00

56 lines
2.2 KiB
JavaScript

// 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);
});