mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge the per-account off-host backup freshness and the honest error-report badge
This commit is contained in:
commit
e8a2cbe82e
36 files changed
+2227
-27
No files matched your search
@@ -210,6 +210,11 @@ export const WIPE_MODELS = [
|
||||
// just returned.
|
||||
"IdempotencyKey",
|
||||
"DataBackup",
|
||||
// What this host last put in the operator's bucket for this account. The
|
||||
// objects themselves stay — the worker holds no DeleteObject grant and the
|
||||
// bucket's lifecycle rule owns their retirement — but the row saying when
|
||||
// they were written is about this account and goes with it.
|
||||
"OffhostBackupState",
|
||||
"AuditLog",
|
||||
"ConsentReceipt",
|
||||
"UserKnownDevice",
|
||||
|
||||
@@ -642,6 +642,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
|
||||
"Replay protection for requests this host already answered. Restoring it would suppress a legitimate new request.",
|
||||
DataBackup:
|
||||
"The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.",
|
||||
OffhostBackupState:
|
||||
"When this host last put this account's copy in this operator's bucket, and how big it was. It describes one deployment's relationship with one bucket, so restoring it elsewhere would assert an off-host copy that host has never written.",
|
||||
ImportJob:
|
||||
"A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.",
|
||||
InviteToken:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* One spelling of "1.25 MB" for every surface that shows a byte count from
|
||||
* the admin backups response.
|
||||
*
|
||||
* It lives here rather than beside the card that happened to need it first:
|
||||
* the second reader was importing a formatter out of a thousand-line client
|
||||
* component, which is a dependency on a page's worth of code for four lines of
|
||||
* arithmetic. Both cards render on the same route today, so it costs nothing
|
||||
* today — and it becomes a cost the moment either one moves.
|
||||
*/
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
fmt: ReturnType<typeof useFormatters>,
|
||||
): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${fmt.integer(Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
return `${fmt.number(bytes / 1024 / 1024, 2)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_GRACE_HOURS,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
} from "@/lib/jobs/offhost-backup-freshness";
|
||||
|
||||
const NOW = new Date("2026-09-10T09:00:00.000Z");
|
||||
const HOUR = 3_600_000;
|
||||
const PERIOD = OFFHOST_BACKUP_PERIOD_HOURS * HOUR;
|
||||
const GRACE = OFFHOST_BACKUP_GRACE_HOURS * HOUR;
|
||||
|
||||
/** `age` hours before NOW. */
|
||||
function ago(ms: number): Date {
|
||||
return new Date(NOW.getTime() - ms);
|
||||
}
|
||||
|
||||
describe("classifyOffhostBackup", () => {
|
||||
it("says unknown, not never, when no run has recorded this account", () => {
|
||||
// The ledger starts empty. On the first morning after the upgrade every
|
||||
// account on a perfectly healthy host looks like this, and `never` would
|
||||
// assert something about the bucket the host has not looked at.
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: null,
|
||||
lastSuccessAt: null,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ freshness: "unknown", ageHours: null });
|
||||
});
|
||||
|
||||
it("says never once a run has walked the account and put nothing there", () => {
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(HOUR),
|
||||
lastSuccessAt: null,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ freshness: "never", ageHours: null });
|
||||
});
|
||||
|
||||
it("is fresh right up to one period plus the grace and due one millisecond past it", () => {
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: ago(PERIOD + GRACE),
|
||||
now: NOW,
|
||||
}).freshness,
|
||||
).toBe("fresh");
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: ago(PERIOD + GRACE + 1),
|
||||
now: NOW,
|
||||
}).freshness,
|
||||
).toBe("due");
|
||||
});
|
||||
|
||||
it("is due right up to two periods plus the grace and stale one millisecond past it", () => {
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: ago(PERIOD * 2 + GRACE),
|
||||
now: NOW,
|
||||
}).freshness,
|
||||
).toBe("due");
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: ago(PERIOD * 2 + GRACE + 1),
|
||||
now: NOW,
|
||||
}).freshness,
|
||||
).toBe("stale");
|
||||
});
|
||||
|
||||
it("keeps the whole cohort fresh across the Berlin DST fall-back night", () => {
|
||||
// 2026-10-25 is the fall-back Sunday in Europe/Berlin: the clocks go back
|
||||
// at 03:00 CEST, so 02:30 local happens 25 hours after the previous
|
||||
// 02:30 local. Both instants are pinned in UTC because that is what the
|
||||
// ledger stores, and the test must not depend on the runner's own zone.
|
||||
const beforeTheChange = new Date("2026-10-24T00:30:00.000Z"); // 02:30 CEST
|
||||
const afterTheChange = new Date("2026-10-25T01:30:00.000Z"); // 02:30 CET
|
||||
const gapHours =
|
||||
(afterTheChange.getTime() - beforeTheChange.getTime()) / HOUR;
|
||||
expect(gapHours).toBe(25);
|
||||
|
||||
// Without slack the whole cohort would read `due` on that one morning,
|
||||
// on every host, with nothing wrong anywhere.
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: beforeTheChange,
|
||||
now: afterTheChange,
|
||||
}).freshness,
|
||||
).toBe("fresh");
|
||||
});
|
||||
|
||||
it("still calls a genuinely missed night due", () => {
|
||||
// 31 hours: past one period even with six hours of slack, so a run that
|
||||
// produced nothing for this account still shows.
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: ago(31 * HOUR),
|
||||
now: NOW,
|
||||
}).freshness,
|
||||
).toBe("due");
|
||||
});
|
||||
|
||||
it("reports whole hours of age", () => {
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: ago(90 * 60_000),
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ freshness: "fresh", ageHours: 1 });
|
||||
});
|
||||
|
||||
it("honours a caller's own period rather than the nightly default", () => {
|
||||
// A six-hour schedule: the same 12-hour-old object that is fresh under the
|
||||
// nightly cron is two periods behind under this one. The grace is passed
|
||||
// as zero so the thresholds under test are the caller's period alone.
|
||||
const twelveHours = ago(12 * HOUR);
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: twelveHours,
|
||||
now: NOW,
|
||||
}).freshness,
|
||||
).toBe("fresh");
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: twelveHours,
|
||||
now: NOW,
|
||||
periodHours: 6,
|
||||
graceHours: 0,
|
||||
}).freshness,
|
||||
).toBe("due");
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: twelveHours,
|
||||
now: NOW,
|
||||
periodHours: 5,
|
||||
graceHours: 0,
|
||||
}).freshness,
|
||||
).toBe("stale");
|
||||
});
|
||||
|
||||
it("does not call a copy stale because the host clock ran backwards", () => {
|
||||
expect(
|
||||
classifyOffhostBackup({
|
||||
lastAttemptAt: ago(0),
|
||||
lastSuccessAt: new Date(NOW.getTime() + 2 * HOUR),
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ freshness: "fresh", ageHours: 0 });
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,7 @@ describe("runOffhostBackup", () => {
|
||||
s3.store.set("2020-01-01/user-old.json.enc", Buffer.from([0]));
|
||||
|
||||
const prisma = {
|
||||
offhostBackupState: { upsert: vi.fn() },
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]),
|
||||
},
|
||||
@@ -229,11 +230,28 @@ describe("runOffhostBackup", () => {
|
||||
// Nothing went through the whole-buffer arm.
|
||||
expect(s3.putObject).not.toHaveBeenCalled();
|
||||
expect(s3.putStream).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Every account that got an object gets a ledger row, because the run's
|
||||
// own counts cannot say WHICH account has a copy off-host and which one
|
||||
// has been failing quietly since March.
|
||||
expect(prisma.offhostBackupState.upsert).toHaveBeenCalledTimes(2);
|
||||
const ledgered = prisma.offhostBackupState.upsert.mock.calls.map(
|
||||
(call) => (call[0] as { where: { userId: string } }).where.userId,
|
||||
);
|
||||
expect(ledgered).toEqual(["u1", "u2"]);
|
||||
const first = prisma.offhostBackupState.upsert.mock.calls[0]?.[0] as {
|
||||
create: { sizeBytes: bigint; lastSuccessAt: Date };
|
||||
};
|
||||
// BigInt, because the uploader permits an 80 GB object and a 32-bit
|
||||
// column would refuse the biggest account on the host.
|
||||
expect(first.create.sizeBytes).toBe(BigInt(ct.byteLength));
|
||||
expect(first.create.lastSuccessAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("uploads the canonical builder output without reshaping it", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
offhostBackupState: { upsert: vi.fn() },
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
};
|
||||
const canonicalPayload = {
|
||||
@@ -290,7 +308,9 @@ describe("runOffhostBackup", () => {
|
||||
|
||||
it("counts per-user failures without aborting the whole run", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const upsert = vi.fn();
|
||||
const prisma = {
|
||||
offhostBackupState: { upsert },
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]),
|
||||
},
|
||||
@@ -314,11 +334,55 @@ describe("runOffhostBackup", () => {
|
||||
|
||||
expect(report.uploaded).toBe(1);
|
||||
expect(report.failed).toBe(1);
|
||||
// Both accounts are recorded as walked — that is what separates "a run
|
||||
// looked and found nothing" from "no run has looked at this account" —
|
||||
// but only the one whose object landed carries a success.
|
||||
type Upsert = {
|
||||
where: { userId: string };
|
||||
update: Record<string, unknown>;
|
||||
create: Record<string, unknown>;
|
||||
};
|
||||
const calls = upsert.mock.calls.map((call) => call[0] as Upsert);
|
||||
expect(calls.map((call) => call.where.userId)).toEqual(["u1", "u2"]);
|
||||
expect(calls[0]?.update).toHaveProperty("lastSuccessAt");
|
||||
// The guarantee an operator leans on: an account whose upload failed keeps
|
||||
// whatever `lastSuccessAt` it already had. The failed arm names only the
|
||||
// walk instant, so yesterday's good copy is still what the console
|
||||
// reports — the run does not overwrite it with today's failure.
|
||||
expect(calls[1]?.update).not.toHaveProperty("lastSuccessAt");
|
||||
expect(calls[1]?.update).not.toHaveProperty("sizeBytes");
|
||||
expect(calls[1]?.create).not.toHaveProperty("lastSuccessAt");
|
||||
});
|
||||
|
||||
it("still reports a landed object as uploaded when the ledger write fails", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
offhostBackupState: {
|
||||
upsert: vi.fn().mockRejectedValue(new Error("pool timeout")),
|
||||
},
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
};
|
||||
|
||||
const report = await runOffhostBackup(
|
||||
prisma as never,
|
||||
s3,
|
||||
new Date("2026-05-08T00:00:00Z"),
|
||||
);
|
||||
|
||||
// The object is in the bucket. Only the record of it is missing, and that
|
||||
// is a different fact: counting it as a failure would tell the operator
|
||||
// their disaster-recovery copy does not exist when it does.
|
||||
expect(report.uploaded).toBe(1);
|
||||
expect(report.failed).toBe(0);
|
||||
expect(report.failures).toEqual([]);
|
||||
expect(s3.store.has("2026-05-08/user-u1.json.enc")).toBe(true);
|
||||
expect(prisma.offhostBackupState.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses an account whose object outgrows one multipart upload", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
offhostBackupState: { upsert: vi.fn() },
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
};
|
||||
mocks.buildFullBackupPayload.mockResolvedValue({
|
||||
@@ -355,6 +419,7 @@ describe("runOffhostBackup", () => {
|
||||
it("reports the largest object it wrote", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
offhostBackupState: { upsert: vi.fn() },
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
};
|
||||
const report = await runOffhostBackup(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* How fresh one account's newest off-host copy is.
|
||||
*
|
||||
* The nightly off-host job reports counts — uploaded, failed, total — and a
|
||||
* count is exactly the shape that hides a single account. A cohort where one
|
||||
* account's object has failed every night for a month reports "99 uploaded, 1
|
||||
* failed" every night, which reads like weather rather than like an account
|
||||
* that has no disaster-recovery copy at all. The per-account ledger the worker
|
||||
* writes (`OffhostBackupState`) turns that into a fact the console can state
|
||||
* per row, and this function is the only place that fact is judged.
|
||||
*
|
||||
* Deliberately pure and deliberately not talking to the bucket: the admin page
|
||||
* computes staleness from the timestamp the worker wrote, so the answer is the
|
||||
* same whether or not the app process holds a read grant on the bucket, and a
|
||||
* listing of somebody's backups never crosses the network to show a page.
|
||||
*
|
||||
* Five states, because the run is nightly and one missed night is not the same
|
||||
* event as four — and because "we have not looked yet" is not a verdict:
|
||||
*
|
||||
* unknown — no nightly run has recorded this account. The ledger starts
|
||||
* empty, so this is what every account on a perfectly healthy host
|
||||
* reads between the upgrade that adds the table and the first run
|
||||
* after it. Saying "never" there would assert something the host
|
||||
* cannot know.
|
||||
* never — a run walked this account and put no object there, and no earlier
|
||||
* run ever did either.
|
||||
* fresh — inside one schedule period plus the grace below. This is what
|
||||
* every account looks like on a host where the cron is doing its
|
||||
* job.
|
||||
* due — past one period, inside two. One night produced nothing: a retry, a
|
||||
* long run, a restart during the window. Worth showing, not worth
|
||||
* alarming about.
|
||||
* stale — past two periods. Two consecutive nights produced nothing for this
|
||||
* account, which no ordinary hiccup explains.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hours between two scheduled off-host runs. Tracks `OFFHOST_BACKUP_CRON`
|
||||
* (`30 2 * * *`) in `src/lib/jobs/reminder/register-maintenance.ts`, which
|
||||
* every schedule runs in `Europe/Berlin` (`tz` in `registrar-shared.ts`).
|
||||
* This is the number the card quotes — the schedule's own period, not the
|
||||
* threshold a verdict is measured against.
|
||||
*/
|
||||
export const OFFHOST_BACKUP_PERIOD_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Slack on top of one period, before a verdict moves.
|
||||
*
|
||||
* Two things make a healthy host miss a bare 24 hours. The schedule is
|
||||
* Berlin-local, so on the DST fall-back night the gap between two 02:30 runs
|
||||
* is 25 hours and every account on every host would cross one period on the
|
||||
* same morning. And the ledger instant is when THAT account's object landed,
|
||||
* deliberately: on a cohort walked one account at a time, an account reached
|
||||
* at 05:30 one night and 06:30 the next is 25 hours old with two entirely
|
||||
* successful runs behind it.
|
||||
*
|
||||
* Six hours is the budget for both. A card whose argument is that a count
|
||||
* trains an operator to stop reading it cannot afford an annual cohort-wide
|
||||
* false `due`.
|
||||
*/
|
||||
export const OFFHOST_BACKUP_GRACE_HOURS = 6;
|
||||
|
||||
export type OffhostBackupFreshness =
|
||||
"unknown" | "never" | "fresh" | "due" | "stale";
|
||||
|
||||
export interface OffhostBackupVerdict {
|
||||
freshness: OffhostBackupFreshness;
|
||||
/** Whole hours since the newest object landed; null when there is none. */
|
||||
ageHours: number | null;
|
||||
}
|
||||
|
||||
export function classifyOffhostBackup(args: {
|
||||
/**
|
||||
* When a run last walked this account, or null when none has. Null is the
|
||||
* only thing that separates `unknown` from `never`.
|
||||
*/
|
||||
lastAttemptAt: Date | null;
|
||||
lastSuccessAt: Date | null;
|
||||
now: Date;
|
||||
/** Defaults to the nightly schedule. Tests pass their own. */
|
||||
periodHours?: number;
|
||||
/** Defaults to `OFFHOST_BACKUP_GRACE_HOURS`. Tests pass their own. */
|
||||
graceHours?: number;
|
||||
}): OffhostBackupVerdict {
|
||||
const { lastAttemptAt, lastSuccessAt, now } = args;
|
||||
if (lastAttemptAt === null) return { freshness: "unknown", ageHours: null };
|
||||
if (lastSuccessAt === null) return { freshness: "never", ageHours: null };
|
||||
|
||||
const periodMs =
|
||||
(args.periodHours ?? OFFHOST_BACKUP_PERIOD_HOURS) * 3_600_000;
|
||||
const graceMs = (args.graceHours ?? OFFHOST_BACKUP_GRACE_HOURS) * 3_600_000;
|
||||
const ageMs = now.getTime() - lastSuccessAt.getTime();
|
||||
// A clock that ran backwards between the upload and this read reports a
|
||||
// negative age. That is a host-clock problem, not a backup problem, and
|
||||
// calling the copy stale over it would point the operator at the wrong
|
||||
// thing.
|
||||
const ageHours = Math.max(0, Math.floor(ageMs / 3_600_000));
|
||||
|
||||
// The grace rides on each threshold rather than on the period, so `due`
|
||||
// still means "one scheduled run produced nothing" and `stale` still means
|
||||
// two — the slack only keeps a long night from being read as a missed one.
|
||||
if (ageMs <= periodMs + graceMs) return { freshness: "fresh", ageHours };
|
||||
if (ageMs <= periodMs * 2 + graceMs) return { freshness: "due", ageHours };
|
||||
return { freshness: "stale", ageHours };
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { createGzip, gunzipSync, gzipSync } from "node:zlib";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { createRawStreamEncryptor, decryptRawStream } from "@/lib/crypto";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
@@ -61,6 +61,27 @@ function decodeBackupKey(raw: string): Buffer {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this host has the five variables the nightly job needs.
|
||||
*
|
||||
* Separate from `loadOffhostConfig()` because a surface that only wants to say
|
||||
* "off-host backup is not set up here" must not decode the encryption key to
|
||||
* find out — `decodeBackupKey` throws on a malformed one, and an operator
|
||||
* whose key has a typo should see the admin page, not a 500.
|
||||
*/
|
||||
export function offhostBackupConfigured(): boolean {
|
||||
return OFFHOST_ENV_VARS.every((name) => Boolean(process.env[name]));
|
||||
}
|
||||
|
||||
/** The five variables the nightly job needs, in one place. */
|
||||
const OFFHOST_ENV_VARS = [
|
||||
"BACKUP_S3_ENDPOINT",
|
||||
"BACKUP_S3_BUCKET",
|
||||
"BACKUP_S3_ACCESS_KEY",
|
||||
"BACKUP_S3_SECRET_KEY",
|
||||
"BACKUP_ENCRYPTION_KEY",
|
||||
] as const;
|
||||
|
||||
export function loadOffhostConfig(): OffhostBackupConfig | null {
|
||||
const endpoint = process.env.BACKUP_S3_ENDPOINT;
|
||||
const bucket = process.env.BACKUP_S3_BUCKET;
|
||||
@@ -555,10 +576,12 @@ export async function runOffhostBackup(
|
||||
let oversized = 0;
|
||||
let largestObjectBytes = 0;
|
||||
const failures: Array<{ userId: string; message: string }> = [];
|
||||
let ledgerWriteFailures = 0;
|
||||
const evt = getEvent();
|
||||
for (const user of users) {
|
||||
let objectBytes: number | null = null;
|
||||
try {
|
||||
const objectBytes = await uploadEncryptedBackup(
|
||||
objectBytes = await uploadEncryptedBackup(
|
||||
s3,
|
||||
`${dateKey}/user-${user.id}.json.enc`,
|
||||
cfg.encryptionKey,
|
||||
@@ -586,6 +609,62 @@ export async function runOffhostBackup(
|
||||
`offhost-backup user ${user.id} failed: ${message.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The per-account ledger, and deliberately NOT inside the upload's `try`.
|
||||
// `uploadEncryptedBackup` resolves only after the multipart completes, so
|
||||
// by here the object is durably in the bucket and this account's backup
|
||||
// has succeeded whatever the database does next. A pool timeout on the
|
||||
// row would otherwise un-count a copy that exists — the run would report
|
||||
// one failure too many and the console would read stale for an account
|
||||
// whose disaster-recovery object is fine, which is exactly the inversion
|
||||
// the ledger exists to remove.
|
||||
//
|
||||
// The row is written whether or not an object came of the walk, because a
|
||||
// ledger keyed only on success cannot tell "no run has reached this
|
||||
// account" from "a run reached it and nothing landed" — and the first of
|
||||
// those is what every account on a running host looks like the day this
|
||||
// table ships. A failed walk touches `lastAttemptAt` and leaves
|
||||
// `lastSuccessAt` exactly as it was, so yesterday's good copy still reads
|
||||
// as the copy this account has.
|
||||
//
|
||||
// `new Date()` rather than the run's `now`, because on a large cohort the
|
||||
// two are hours apart and the row is meant to say when this account was
|
||||
// reached.
|
||||
const at = new Date();
|
||||
try {
|
||||
await prisma.offhostBackupState.upsert({
|
||||
where: { userId: user.id },
|
||||
update:
|
||||
objectBytes === null
|
||||
? { lastAttemptAt: at }
|
||||
: {
|
||||
lastAttemptAt: at,
|
||||
lastSuccessAt: at,
|
||||
sizeBytes: BigInt(objectBytes),
|
||||
},
|
||||
create:
|
||||
objectBytes === null
|
||||
? { userId: user.id, lastAttemptAt: at }
|
||||
: {
|
||||
userId: user.id,
|
||||
lastAttemptAt: at,
|
||||
lastSuccessAt: at,
|
||||
sizeBytes: BigInt(objectBytes),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
ledgerWriteFailures++;
|
||||
const message = (err as Error).message ?? "unknown";
|
||||
evt?.addWarning(
|
||||
`offhost-backup ledger write failed for ${user.id}: ${message.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ledgerWriteFailures > 0) {
|
||||
// A distinct fact from a failed upload, and one an operator has to be able
|
||||
// to tell apart: the copies are in the bucket, the console's per-account
|
||||
// view of them is behind.
|
||||
annotate({ meta: { offhost_ledger_write_failures: ledgerWriteFailures } });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The write end of the delivery ledger.
|
||||
*
|
||||
* The badge is only as honest as what gets recorded, and two things have to
|
||||
* hold: a failure must never carry a remote host's response body into this
|
||||
* database, and a send that could not be recorded must not become a second
|
||||
* error on a path that is already handling one.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const updateMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { appSettings: { update: (...a: unknown[]) => updateMock(...a) } },
|
||||
}));
|
||||
|
||||
import {
|
||||
classifyGlitchtipFailure,
|
||||
GLITCHTIP_DELIVERY_WINDOW_HOURS,
|
||||
recordGlitchtipDelivery,
|
||||
summariseGlitchtipDelivery,
|
||||
} from "../glitchtip-delivery";
|
||||
|
||||
const NOW = new Date("2026-09-10T09:00:00.000Z");
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("classifyGlitchtipFailure", () => {
|
||||
it("keeps the classification short and free of the remote body", () => {
|
||||
expect(
|
||||
classifyGlitchtipFailure({
|
||||
ok: false,
|
||||
status: 403,
|
||||
details: "<html>forbidden, and here is our whole error page</html>",
|
||||
}),
|
||||
).toBe("http_403");
|
||||
expect(
|
||||
classifyGlitchtipFailure({ ok: false, details: "invalid_dsn" }),
|
||||
).toBe("invalid_dsn");
|
||||
expect(classifyGlitchtipFailure({ ok: false })).toBe("network");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordGlitchtipDelivery", () => {
|
||||
it("stamps a success and leaves the failure fields alone", async () => {
|
||||
await recordGlitchtipDelivery({ ok: true, status: 200 });
|
||||
|
||||
const data = updateMock.mock.calls[0]?.[0]?.data as Record<string, unknown>;
|
||||
expect(data.glitchtipLastOkAt).toBeInstanceOf(Date);
|
||||
expect(data).not.toHaveProperty("glitchtipLastFailureAt");
|
||||
});
|
||||
|
||||
it("records a failure by classification, never by response body", async () => {
|
||||
await recordGlitchtipDelivery({
|
||||
ok: false,
|
||||
status: 401,
|
||||
details: "invalid public key: abcdef0123456789",
|
||||
});
|
||||
|
||||
const data = updateMock.mock.calls[0]?.[0]?.data as Record<string, unknown>;
|
||||
expect(data.glitchtipLastFailureReason).toBe("http_401");
|
||||
expect(JSON.stringify(data)).not.toContain("abcdef0123456789");
|
||||
expect(data).not.toHaveProperty("glitchtipLastOkAt");
|
||||
});
|
||||
|
||||
it("swallows a write failure rather than replacing the original error", async () => {
|
||||
updateMock.mockRejectedValue(new Error("no singleton row yet"));
|
||||
await expect(
|
||||
recordGlitchtipDelivery({ ok: false, status: 500 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseGlitchtipDelivery", () => {
|
||||
it("claims delivery only for a success inside the window", () => {
|
||||
const inside = new Date(
|
||||
NOW.getTime() - (GLITCHTIP_DELIVERY_WINDOW_HOURS - 1) * HOUR,
|
||||
);
|
||||
const outside = new Date(
|
||||
NOW.getTime() - (GLITCHTIP_DELIVERY_WINDOW_HOURS + 1) * HOUR,
|
||||
);
|
||||
|
||||
expect(
|
||||
summariseGlitchtipDelivery(
|
||||
{ lastOkAt: inside, lastFailureAt: null, lastFailureReason: null },
|
||||
NOW,
|
||||
).reportsDelivering,
|
||||
).toBe(true);
|
||||
|
||||
const aged = summariseGlitchtipDelivery(
|
||||
{ lastOkAt: outside, lastFailureAt: null, lastFailureReason: null },
|
||||
NOW,
|
||||
);
|
||||
// Still true that something once got through, no longer true that the
|
||||
// target is working — two different claims, and only one is a badge.
|
||||
expect(aged.reportsDelivering).toBe(false);
|
||||
expect(aged.everDelivered).toBe(true);
|
||||
});
|
||||
|
||||
it("says nothing has been proven when the ledger is empty", () => {
|
||||
expect(
|
||||
summariseGlitchtipDelivery(
|
||||
{ lastOkAt: null, lastFailureAt: null, lastFailureReason: null },
|
||||
NOW,
|
||||
),
|
||||
).toEqual({
|
||||
reportsDelivering: false,
|
||||
everDelivered: false,
|
||||
lastFailureReason: null,
|
||||
windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a failure that a later success has superseded", () => {
|
||||
const summary = summariseGlitchtipDelivery(
|
||||
{
|
||||
lastOkAt: new Date(NOW.getTime() - HOUR),
|
||||
lastFailureAt: new Date(NOW.getTime() - 5 * HOUR),
|
||||
lastFailureReason: "http_403",
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
// The target refused a report and then accepted one. Showing the refusal
|
||||
// would report a problem that has since been fixed.
|
||||
expect(summary.lastFailureReason).toBeNull();
|
||||
expect(summary.reportsDelivering).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a failure that is newer than the last success", () => {
|
||||
expect(
|
||||
summariseGlitchtipDelivery(
|
||||
{
|
||||
lastOkAt: new Date(NOW.getTime() - 5 * HOUR),
|
||||
lastFailureAt: new Date(NOW.getTime() - HOUR),
|
||||
lastFailureReason: "http_403",
|
||||
},
|
||||
NOW,
|
||||
).lastFailureReason,
|
||||
).toBe("http_403");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* What actually happened to the last error report this host tried to send.
|
||||
*
|
||||
* The admin card used to derive "configured" from the enabled flag plus a DSN
|
||||
* that parses. Those two say a target was typed; they say nothing about the
|
||||
* target accepting anything. A wrong public key, a wrong project id, a host
|
||||
* that stopped answering — every one of them parses, and every one of them
|
||||
* would have shown a green badge and a line naming the host, which is the
|
||||
* false reassurance an operator only discovers when they go looking for a
|
||||
* crash that was never reported.
|
||||
*
|
||||
* So the outcome is recorded where the DSN already lives, next to the flag it
|
||||
* qualifies, and the badge reads it. `sendGlitchtipEvent` is the one writer —
|
||||
* every caller in the tree goes through it, so no send path can skip the
|
||||
* ledger by construction.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { GlitchtipDeliveryResult } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
/**
|
||||
* How long a successful send stands for. A self-hoster's instance can go days
|
||||
* without an unhandled error, and a badge that expired overnight would train
|
||||
* the operator to ignore it; a fortnight is long enough that only a target
|
||||
* that has genuinely stopped working falls out of it, and the test button
|
||||
* refreshes it on demand.
|
||||
*/
|
||||
export const GLITCHTIP_DELIVERY_WINDOW_HOURS = 14 * 24;
|
||||
|
||||
export interface GlitchtipDeliveryState {
|
||||
lastOkAt: Date | null;
|
||||
lastFailureAt: Date | null;
|
||||
lastFailureReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, safe classification of a failure. Never the response body: that is
|
||||
* text from a remote host, and an incident target's error page has no business
|
||||
* being rendered on the admin page or stored in this database.
|
||||
*/
|
||||
export function classifyGlitchtipFailure(
|
||||
result: GlitchtipDeliveryResult,
|
||||
): string {
|
||||
if (result.details === "invalid_dsn") return "invalid_dsn";
|
||||
if (typeof result.status === "number") return `http_${result.status}`;
|
||||
return "network";
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the outcome of one send. Best-effort by design — a report that could
|
||||
* not be filed must not become a second error, and the singleton row may not
|
||||
* exist yet on a host that has never opened the admin page.
|
||||
*/
|
||||
export async function recordGlitchtipDelivery(
|
||||
result: GlitchtipDeliveryResult,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: result.ok
|
||||
? { glitchtipLastOkAt: now }
|
||||
: {
|
||||
glitchtipLastFailureAt: now,
|
||||
glitchtipLastFailureReason: classifyGlitchtipFailure(result),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Nothing to do and nothing to report to: the caller is already on an
|
||||
// error path, and throwing here would replace the original failure with
|
||||
// this one.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The judgement, resolved server-side.
|
||||
*
|
||||
* The client gets the verdict, not the timestamps: whether a report is inside
|
||||
* the window is a question about a clock, and the process that holds the
|
||||
* ledger is the one that should answer it. A failure older than the last
|
||||
* success is not surfaced at all — it has been superseded by a report that
|
||||
* got through, and showing it would be reporting a fixed problem.
|
||||
*/
|
||||
export interface GlitchtipDeliverySummary {
|
||||
/** A report left this host successfully inside the window. */
|
||||
reportsDelivering: boolean;
|
||||
/** A report has left this host successfully at some point. */
|
||||
everDelivered: boolean;
|
||||
/** Why the last attempt failed, when that failure is the newest fact. */
|
||||
lastFailureReason: string | null;
|
||||
/** How long a success stands for, so the card can say it. */
|
||||
windowHours: number;
|
||||
}
|
||||
|
||||
export function summariseGlitchtipDelivery(
|
||||
state: GlitchtipDeliveryState,
|
||||
now: Date,
|
||||
): GlitchtipDeliverySummary {
|
||||
const windowMs = GLITCHTIP_DELIVERY_WINDOW_HOURS * 3_600_000;
|
||||
const okAge =
|
||||
state.lastOkAt === null ? null : now.getTime() - state.lastOkAt.getTime();
|
||||
const failureIsNewest =
|
||||
state.lastFailureAt !== null &&
|
||||
(state.lastOkAt === null ||
|
||||
state.lastFailureAt.getTime() > state.lastOkAt.getTime());
|
||||
|
||||
return {
|
||||
reportsDelivering: okAge !== null && okAge >= 0 && okAge <= windowMs,
|
||||
everDelivered: state.lastOkAt !== null,
|
||||
lastFailureReason: failureIsNewest ? state.lastFailureReason : null,
|
||||
windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGlitchtipDeliveryState(): Promise<GlitchtipDeliveryState> {
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
glitchtipLastOkAt: true,
|
||||
glitchtipLastFailureAt: true,
|
||||
glitchtipLastFailureReason: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
lastOkAt: settings?.glitchtipLastOkAt ?? null,
|
||||
lastFailureAt: settings?.glitchtipLastFailureAt ?? null,
|
||||
lastFailureReason: settings?.glitchtipLastFailureReason ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { lastOkAt: null, lastFailureAt: null, lastFailureReason: null };
|
||||
}
|
||||
}
|
||||
@@ -214,13 +214,34 @@ async function tryStoreHeader(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what became of one send, in the one place every caller passes
|
||||
* through. Dynamically imported so this module stays free of a database
|
||||
* dependency at import time, and awaited so a test can observe it; the
|
||||
* recorder itself never throws.
|
||||
*/
|
||||
async function record(result: GlitchtipDeliveryResult): Promise<void> {
|
||||
try {
|
||||
const { recordGlitchtipDelivery } =
|
||||
await import("@/lib/monitoring/glitchtip-delivery");
|
||||
await recordGlitchtipDelivery(result);
|
||||
} catch {
|
||||
// The report is the point; its bookkeeping is not worth an exception.
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendGlitchtipEvent(params: {
|
||||
dsn: string;
|
||||
input: GlitchtipEventPayloadInput;
|
||||
}): Promise<GlitchtipDeliveryResult> {
|
||||
const config = parseGlitchtipDsn(params.dsn);
|
||||
if (!config) {
|
||||
return { ok: false, details: "invalid_dsn" };
|
||||
const invalid: GlitchtipDeliveryResult = {
|
||||
ok: false,
|
||||
details: "invalid_dsn",
|
||||
};
|
||||
await record(invalid);
|
||||
return invalid;
|
||||
}
|
||||
|
||||
const eventId = createEventId();
|
||||
@@ -228,15 +249,24 @@ export async function sendGlitchtipEvent(params: {
|
||||
const envelopeBody = buildEnvelopeBody(config, eventId, payload);
|
||||
|
||||
const envelopeResult = await tryEnvelope(config, envelopeBody);
|
||||
if (envelopeResult.ok) return envelopeResult;
|
||||
if (envelopeResult.ok) {
|
||||
await record(envelopeResult);
|
||||
return envelopeResult;
|
||||
}
|
||||
|
||||
const storeQueryResult = await tryStoreQuery(config, payload);
|
||||
if (storeQueryResult.ok) return storeQueryResult;
|
||||
if (storeQueryResult.ok) {
|
||||
await record(storeQueryResult);
|
||||
return storeQueryResult;
|
||||
}
|
||||
|
||||
const storeHeaderResult = await tryStoreHeader(config, payload);
|
||||
if (storeHeaderResult.ok) return storeHeaderResult;
|
||||
if (storeHeaderResult.ok) {
|
||||
await record(storeHeaderResult);
|
||||
return storeHeaderResult;
|
||||
}
|
||||
|
||||
return {
|
||||
const failure: GlitchtipDeliveryResult = {
|
||||
ok: false,
|
||||
method: storeHeaderResult.method,
|
||||
status:
|
||||
@@ -248,4 +278,6 @@ export async function sendGlitchtipEvent(params: {
|
||||
storeQueryResult.details ??
|
||||
envelopeResult.details,
|
||||
};
|
||||
await record(failure);
|
||||
return failure;
|
||||
}
|
||||
Reference in new issue
Block a user