Files
HealthLog/src/lib/monitoring/glitchtip-delivery.ts
T
Marc-André Bombeck 9d18b8fd25 fix(admin): let the error-reporting badge follow reports, not a parseable URL
The badge read the enabled flag plus a DSN that parses. Neither says anything
about the target accepting a report: a wrong public key, a wrong project id and
a host that stopped answering all parse, and every one of them showed green
with a line naming the host. That is the false reassurance an operator finds
out about when they go looking for a crash that was never reported.

Nothing in the tree recorded an outcome, so there was nothing to read. The
sender now stamps one next to the DSN it used — instant on a success, a short
classification on a failure. Never the response body: that is text from a
remote host and has no business in this database or on this page.

The badge is green only while a report has left inside the last fortnight,
amber while a target is set and nothing has come back, and it names the reason
underneath. The clock lives with the ledger, so the verdict is resolved in the
route and the card paints what it is handed. A failure that a later success
superseded is not shown at all. Pressing Test records like any other send, so
the button proves the target on demand.
2026-09-10 10:28:57 +02:00

133 lines
4.9 KiB
TypeScript

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