diff --git a/apps/server/src/routes/heartbeat/index.test.ts b/apps/server/src/routes/heartbeat/index.test.ts index f77a27e92..d6db7878c 100644 --- a/apps/server/src/routes/heartbeat/index.test.ts +++ b/apps/server/src/routes/heartbeat/index.test.ts @@ -129,6 +129,36 @@ describe("heartbeat route", () => { expect(event?.headers).not.toContain("secret"); }); + test("/fail records a failure and publishes it as one", async () => { + // after the ping above: older pings never overwrite newer state + const failedAt = NOW.getTime() + 30_000; + vi.setSystemTime(failedAt); + + const res = await route.request(`/${TOKEN}/fail`, { + method: "POST", + body: "exit 1", + }); + + expect(res.status).toBe(200); + const state = await readState(); + expect(state?.lastPingKind).toBe("fail"); + expect(state?.nextDueAt?.getTime()).toBe(failedAt + 3_900_000); + expect(published[0]).toMatchObject({ kind: "fail", body: "exit 1" }); + + vi.setSystemTime(failedAt + 15_000); + await route.request(`/${TOKEN}`); + expect((await readState())?.lastPingKind).toBe("success"); + expect(published[1]?.kind).toBe("success"); + }); + + test("/fail with an unknown token returns 404", async () => { + const res = await route.request( + `/hb-route-test-unknown-${crypto.randomUUID()}/fail`, + ); + expect(res.status).toBe(404); + expect(published).toHaveLength(0); + }); + test("a large body is cut and flagged", async () => { await route.request(`/${TOKEN}`, { method: "POST", diff --git a/apps/server/src/routes/heartbeat/index.ts b/apps/server/src/routes/heartbeat/index.ts index 731b40c8f..163071f78 100644 --- a/apps/server/src/routes/heartbeat/index.ts +++ b/apps/server/src/routes/heartbeat/index.ts @@ -1,7 +1,8 @@ import { getLogger } from "@logtape/logtape"; +import type { HeartbeatPingKind } from "@openstatus/db/src/schema"; import { recordHeartbeatPing } from "@openstatus/services/monitor"; import type { HeartbeatPingEvent } from "@openstatus/tinybird"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { tb } from "@/libs/clients"; import { Batcher } from "@/libs/heartbeat/batcher"; @@ -22,14 +23,19 @@ export const heartbeatBatcher = new Batcher( const TOKEN_PATTERN = /^[\w-]{16,64}$/; +const METHODS = ["GET", "POST", "HEAD"]; + export const heartbeatRoute = new Hono(); -heartbeatRoute.on(["GET", "POST", "HEAD"], "/:token", async (c) => { - const { token } = c.req.param(); +heartbeatRoute.on(METHODS, "/:token", (c) => handlePing(c, "success")); +heartbeatRoute.on(METHODS, "/:token/fail", (c) => handlePing(c, "fail")); + +async function handlePing(c: Context, kind: HeartbeatPingKind) { + const token = c.req.param("token") ?? ""; if (!TOKEN_PATTERN.test(token)) return c.text("Not Found", 404); const at = new Date(); - const result = await recordHeartbeatPing({ token, at }); + const result = await recordHeartbeatPing({ token, at, kind }); if (result.kind === "not-found") return c.text("Not Found", 404); if (result.kind === "paused") return c.text("OK (paused)", 200); @@ -47,6 +53,7 @@ heartbeatRoute.on(["GET", "POST", "HEAD"], "/:token", async (c) => { headers: captureHeaders(c.req.raw.headers), body, bodyTruncated: truncated ? 1 : 0, + kind, }); } catch (error) { logger.warn("heartbeat ping capture failed", { @@ -56,4 +63,4 @@ heartbeatRoute.on(["GET", "POST", "HEAD"], "/:token", async (c) => { } return c.text("OK", 200); -}); +} diff --git a/packages/services/src/monitor/__tests__/heartbeat.test.ts b/packages/services/src/monitor/__tests__/heartbeat.test.ts index 132652401..6d8b0b362 100644 --- a/packages/services/src/monitor/__tests__/heartbeat.test.ts +++ b/packages/services/src/monitor/__tests__/heartbeat.test.ts @@ -456,6 +456,26 @@ describe("getHeartbeatState", () => { }); describe("recordHeartbeatPing", () => { + test("a fail moves the deadline and a later ping clears it", async () => { + await withTestTransaction(async (tx) => { + const row = await createHeartbeat(tx); + const token = row.heartbeatToken ?? ""; + const failedAt = new Date("2026-01-01T00:00:00.000Z"); + const pingedAt = new Date("2026-01-01T01:00:00.000Z"); + + await recordHeartbeatPing({ token, at: failedAt, kind: "fail", db: tx }); + let state = await readState(tx, row.id); + expect(state?.lastPingKind).toBe("fail"); + expect(state?.nextDueAt?.getTime()).toBe( + failedAt.getTime() + (HOUR + FIVE_MIN) * 1000, + ); + + await recordHeartbeatPing({ token, at: pingedAt, db: tx }); + state = await readState(tx, row.id); + expect(state?.lastPingKind).toBe("success"); + }); + }); + test("an older ping landing late does not move the deadline back", async () => { await withTestTransaction(async (tx) => { const row = await createHeartbeat(tx); diff --git a/packages/services/src/monitor/heartbeat.ts b/packages/services/src/monitor/heartbeat.ts index a173d6675..0dda8620a 100644 --- a/packages/services/src/monitor/heartbeat.ts +++ b/packages/services/src/monitor/heartbeat.ts @@ -213,6 +213,8 @@ export type HeartbeatPingResult = export async function recordHeartbeatPing(args: { token: string; at: Date; + /** `fail` (the `/fail` URL) still resets the deadline, but keeps the heartbeat down. */ + kind?: HeartbeatPingKind; db?: DB; }): Promise { const db = args.db ?? defaultDb; @@ -239,12 +241,13 @@ export async function recordHeartbeatPing(args: { if (!row.active) return { kind: "paused", ...ids }; const nextDueAt = heartbeatDeadline(args.at, row); + const lastPingKind = args.kind ?? "success"; await db .insert(monitorHeartbeatState) - .values({ monitorId: row.id, lastPingAt: args.at, nextDueAt }) + .values({ monitorId: row.id, lastPingAt: args.at, lastPingKind, nextDueAt }) .onConflictDoUpdate({ target: monitorHeartbeatState.monitorId, - set: { lastPingAt: args.at, nextDueAt }, + set: { lastPingAt: args.at, lastPingKind, nextDueAt }, // a slower, older ping must not pull the deadline back setWhere: or( isNull(monitorHeartbeatState.lastPingAt),