项目目标
本项目把监控看作包裹周期性检查的状态机,重点在转移而非请求本身:抖动到何种程度算宕机,又何时算恢复。服务跟踪连续失败,只有达到阈值才开启事故,并为每次转移记录时间戳,使事故历史有意义而非嘈杂。
核心特性
- 按固定间隔带超时轮询每个目标
- 跟踪连续失败并在阈值处开启事故
- 目标恢复时关闭事故并记录时长
- 提供 JSON 状态端点并持久化事故历史
架构与分层
调度器以定时器触发周期检查;检查器分类每次响应;状态机跟踪目标状态与转移;事故日志持久化转移;HTTP 层提供当前状态。时钟被注入,使间隔与阈值行为可确定性测试。
实施阶段
六个阶段为:检查器、目标状态机、事故日志、调度器、状态服务、CLI 与测试。阈值边界与首次成功恢复是核心断言。
测试与验收
用 vitest 运行。状态测试驱动一串结果断言事故恰在阈值处开启、首次成功即关闭;转移测试用假时钟断言事故时长由开启与关闭时间戳之差得出。
延伸方向
多种检查类型、告警通道与响应时间分位都是检查器与事故日志外围的增量。调度器只调用检查器,因此新检查类型不改动状态机。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export function applyCheck(state, outcome, { threshold = 3 } = {}) {
const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
if (state.status === 'up' && next.consecutiveFailures >= threshold) {
return { next: { ...next, status: 'down' }, transition: { type: 'opened', at: outcome.at } };
}
if (state.status === 'down' && outcome.ok) {
return { next: { ...next, status: 'up' }, transition: { type: 'closed', at: outcome.at } };
}
return { next, transition: null };
}
mkdir uptime-monitor && cd uptime-monitor
npm init -y
npm install -D vitest@^2.1.0
{
"name": "uptime-monitor",
"type": "module",
"scripts": {
"start": "node src/cli.js",
"test": "vitest run"
}
}
export async function checkTarget(target, { fetchImpl = fetch, timeoutMs = 5000, at = Date.now() } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetchImpl(target.url, { signal: controller.signal });
return { ok: res.status >= 200 && res.status < 400, status: res.status, latencyMs: Date.now() - started, at: at() };
} catch (err) {
return { ok: false, status: 0, reason: err.name, latencyMs: Date.now() - started, at: at() };
} finally {
clearTimeout(timer);
}
}
export function initialState() {
return { status: 'up', consecutiveFailures: 0, since: null };
}
export function applyCheck(state, outcome, { threshold = 3 } = {}) {
const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
if (state.status === 'up' && next.consecutiveFailures >= threshold) {
return { next: { ...next, status: 'down', since: outcome.at }, transition: { type: 'opened', at: outcome.at } };
}
if (state.status === 'down' && outcome.ok) {
return { next: { ...next, status: 'up', since: outcome.at }, transition: { type: 'closed', at: outcome.at } };
}
return { next, transition: null };
}
import { readFile, writeFile } from 'node:fs/promises';
export async function appendIncident(path, incident) {
let log = [];
try { log = JSON.parse(await readFile(path, 'utf8')); } catch { log = []; }
log.push(incident);
await writeFile(path, JSON.stringify(log, null, 2));
return log;
}
export function durations(log) {
const open = new Map();
const out = [];
for (const entry of log) {
if (entry.type === 'opened') open.set(entry.target, entry.at);
else if (entry.type === 'closed' && open.has(entry.target)) {
out.push({ target: entry.target, downMs: entry.at - open.get(entry.target) });
open.delete(entry.target);
}
}
return out;
}
export function createScheduler({ intervalMs, check, onOutcome, setIntervalImpl = setInterval }) {
let timer = null;
async function tick() {
const outcomes = await Promise.all(check());
for (const outcome of outcomes) onOutcome(outcome);
}
return {
start() { timer = setIntervalImpl(tick, intervalMs); return timer; },
stop() { if (timer) clearInterval(timer); },
tick,
};
}
import { createServer } from 'node:http';
export function createStatusServer({ getState }) {
return createServer((req, res) => {
if (req.url === '/status') {
const targets = Object.entries(getState()).map(([url, s]) => ({ url, status: s.status, since: s.since }));
const down = targets.filter((t) => t.status === 'down').length;
res.writeHead(down ? 503 : 200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ down, targets }));
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
});
}
import { initialState, applyCheck } from '../src/state.js';
const fail = (at) => ({ ok: false, at });
const ok = (at) => ({ ok: true, at });
test('opens at the threshold and closes on first success', () => {
let state = initialState();
state = applyCheck(state, fail(1)).next;
state = applyCheck(state, fail(2)).next;
expect(state.status).toBe('up');
const third = applyCheck(state, fail(3));
expect(third.next.status).toBe('down');
expect(third.transition.type).toBe('opened');
const recovered = applyCheck(third.next, ok(4));
expect(recovered.transition.type).toBe('closed');
});