Files
SRCmail/lib/telemetry/sender.ts
T

109 lines
3.7 KiB
TypeScript

import { logger } from '@/lib/logger';
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
import { buildPayload } from './payload';
import { resolveEndpointAllowed } from './endpoint-guard';
import { DEFAULT_ENDPOINT } from './types';
const DAY_MS = 24 * 60 * 60 * 1000;
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
export async function sendOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: number;
error?: string;
}> {
const { consent, source, state } = await effectiveConsent();
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
// Re-check at fetch time: defeats DNS rebinding, and catches the case
// where state.json was edited out-of-band to bypass the admin API.
const guard = await resolveEndpointAllowed(endpoint);
if (!guard.ok) {
logger.warn('telemetry: endpoint blocked', { reason: guard.reason });
return { ok: false, error: `endpoint blocked: ${guard.reason}` };
}
const payload = await buildPayload();
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
const ok = res.ok;
if (ok) {
const next = await loadState();
next.lastSentAt = new Date().toISOString();
await saveState(next);
}
logger.info('telemetry: heartbeat', {
ok, status: res.status, reason: opts?.reason ?? 'scheduled',
});
return { ok, status: res.status };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('telemetry: heartbeat failed', { error: msg });
return { ok: false, error: msg };
}
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
await sendOnce({ reason: 'scheduled' });
await scheduleNext(jitteredDelay(DAY_MS));
}
// Called from instrumentation. Idempotent.
export async function startScheduler(): Promise<void> {
const { consent } = await effectiveConsent();
if (consent !== 'on') {
logger.info('telemetry: scheduler not started', { consent });
return;
}
const state = await loadState();
// If we have a next-scheduled time in the future use it; otherwise schedule
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('telemetry: scheduler started', {
nextInMs: delay,
endpoint: state.endpoint,
});
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
// Called when consent flips on/off via the UI.
export async function reschedule(): Promise<void> {
await stopScheduler();
await startScheduler();
}