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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 10:28:57 +02:00
1 parent 25f0310596
commit 9d18b8fd25
17 files changed
+594 -19

No files matched your search

+9
View File
@@ -155,6 +155,15 @@ For a maintainer-operated instance, set the DSN there after the first deploy —
it lives in the database, not in the environment, so it survives an image
upgrade and does not need a compose change.
The badge on that card tracks delivery, not intent. A DSN that is set and
parses is not the same fact as reports arriving: a wrong public key, a wrong
project id and a host that stopped answering all parse. So the card is green
only while a report has actually left this host inside the last fortnight,
amber while a target is set and nothing has come back from it, and it names the
reason underneath when the last attempt was refused. Press **Test** to prove
the target on demand — that send is recorded like any other, so the badge
answers immediately.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
+4
View File
@@ -6600,6 +6600,10 @@
"glitchtipTargetOn": "Fehlerberichte gehen an {host}.",
"glitchtipTargetOff": "Fehlerberichte bleiben auf diesem Host. Es wird nichts verschickt.",
"glitchtipTargetNoTarget": "Fehler-Tracking ist an, aber kein Ziel gesetzt, also wird nichts verschickt.",
"glitchtipUnproven": "Unbestätigt",
"glitchtipDeliveryUnproven": "Ein Ziel ist gesetzt, aber noch kein Bericht hat diesen Host verlassen. Drücke Test, um zu sehen, ob es einen annimmt.",
"glitchtipDeliveryStale": "In den letzten {hours} h hat kein Bericht diesen Host verlassen. Drücke Test, um zu prüfen, ob das Ziel noch annimmt.",
"glitchtipDeliveryFailed": "Der letzte Bericht kam nicht durch ({reason}).",
"webPushVapidDescription": "Konfiguriere VAPID-Schlüssel und Subject direkt hier, damit Browser-Push ohne ENV-Variablen funktioniert.",
"webPushVapidPublicKey": "VAPID Public Key",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
+4
View File
@@ -6600,6 +6600,10 @@
"glitchtipTargetOn": "Error reports go to {host}.",
"glitchtipTargetOff": "Error reports stay on this host. Nothing is sent anywhere.",
"glitchtipTargetNoTarget": "Error tracking is on, but no target is set, so nothing is sent.",
"glitchtipUnproven": "Unproven",
"glitchtipDeliveryUnproven": "A target is set, but no report has left this host yet. Press Test to find out whether it accepts one.",
"glitchtipDeliveryStale": "No report has left this host in the last {hours} h. Press Test to check the target still accepts one.",
"glitchtipDeliveryFailed": "The last report did not get through ({reason}).",
"webPushVapidDescription": "Configure VAPID keys and subject here so browser push works without environment variables.",
"webPushVapidPublicKey": "VAPID public key",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
+4
View File
@@ -6600,6 +6600,10 @@
"glitchtipTargetOn": "Los informes de errores van a {host}.",
"glitchtipTargetOff": "Los informes de errores se quedan en este host. No se envía nada.",
"glitchtipTargetNoTarget": "El seguimiento de errores está activado, pero no hay destino, así que no se envía nada.",
"glitchtipUnproven": "Sin confirmar",
"glitchtipDeliveryUnproven": "Hay un destino definido, pero ningún informe ha salido aún de este host. Pulsa Probar para ver si lo acepta.",
"glitchtipDeliveryStale": "Ningún informe ha salido de este host en las últimas {hours} h. Pulsa Probar para comprobar que el destino sigue aceptándolos.",
"glitchtipDeliveryFailed": "El último informe no llegó ({reason}).",
"webPushVapidDescription": "Configura aquí las claves VAPID y el subject para que el push del navegador funcione sin variables de entorno.",
"webPushVapidPublicKey": "Clave pública VAPID",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
+4
View File
@@ -6600,6 +6600,10 @@
"glitchtipTargetOn": "Les rapports d'erreur vont vers {host}.",
"glitchtipTargetOff": "Les rapports d'erreur restent sur cet hôte. Rien n'est envoyé.",
"glitchtipTargetNoTarget": "Le suivi des erreurs est activé, mais aucune cible n'est définie, donc rien n'est envoyé.",
"glitchtipUnproven": "Non confirmé",
"glitchtipDeliveryUnproven": "Une cible est définie, mais aucun rapport n'a encore quitté cet hôte. Appuyez sur Tester pour voir si elle en accepte un.",
"glitchtipDeliveryStale": "Aucun rapport n'a quitté cet hôte depuis {hours} h. Appuyez sur Tester pour vérifier que la cible en accepte toujours.",
"glitchtipDeliveryFailed": "Le dernier rapport n'est pas passé ({reason}).",
"webPushVapidDescription": "Configurez ici les clés VAPID et le subject pour que le push du navigateur fonctionne sans variables d'environnement.",
"webPushVapidPublicKey": "Clé publique VAPID",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
+4
View File
@@ -6600,6 +6600,10 @@
"glitchtipTargetOn": "Le segnalazioni di errore vanno a {host}.",
"glitchtipTargetOff": "Le segnalazioni di errore restano su questo host. Non viene inviato nulla.",
"glitchtipTargetNoTarget": "Il tracciamento degli errori è attivo, ma non c'è una destinazione, quindi non viene inviato nulla.",
"glitchtipUnproven": "Non confermato",
"glitchtipDeliveryUnproven": "Una destinazione è impostata, ma nessuna segnalazione ha ancora lasciato questo host. Premi Prova per vedere se ne accetta una.",
"glitchtipDeliveryStale": "Nessuna segnalazione ha lasciato questo host nelle ultime {hours} h. Premi Prova per verificare che la destinazione le accetti ancora.",
"glitchtipDeliveryFailed": "L'ultima segnalazione non è arrivata ({reason}).",
"webPushVapidDescription": "Configura qui le chiavi VAPID e il subject affinché il push del browser funzioni senza variabili d'ambiente.",
"webPushVapidPublicKey": "Chiave pubblica VAPID",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
+4
View File
@@ -6580,6 +6580,10 @@
"glitchtipTargetOn": "오류 보고서는 {host}(으)로 전송됩니다.",
"glitchtipTargetOff": "오류 보고서는 이 호스트에 남습니다. 아무것도 전송되지 않습니다.",
"glitchtipTargetNoTarget": "오류 추적은 켜져 있지만 대상이 없어 아무것도 전송되지 않습니다.",
"glitchtipUnproven": "미확인",
"glitchtipDeliveryUnproven": "대상이 설정되어 있지만 아직 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 수락하는지 확인하세요.",
"glitchtipDeliveryStale": "지난 {hours}시간 동안 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 대상이 여전히 수락하는지 확인하세요.",
"glitchtipDeliveryFailed": "마지막 보고서가 전달되지 않았습니다 ({reason}).",
"webPushVapidDescription": "환경 변수 없이 브라우저 푸시가 동작하도록 VAPID 키와 subject를 여기서 설정하세요.",
"webPushVapidPublicKey": "VAPID 공개 키",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
+4
View File
@@ -6600,6 +6600,10 @@
"glitchtipTargetOn": "Zgłoszenia błędów trafiają do {host}.",
"glitchtipTargetOff": "Zgłoszenia błędów zostają na tym hoście. Nic nie jest wysyłane.",
"glitchtipTargetNoTarget": "Śledzenie błędów jest włączone, ale nie ustawiono celu, więc nic nie jest wysyłane.",
"glitchtipUnproven": "Niepotwierdzone",
"glitchtipDeliveryUnproven": "Cel jest ustawiony, ale żaden raport nie opuścił jeszcze tego hosta. Naciśnij Testuj, aby sprawdzić, czy zostanie przyjęty.",
"glitchtipDeliveryStale": "Przez ostatnie {hours} h żaden raport nie opuścił tego hosta. Naciśnij Testuj, aby sprawdzić, czy cel nadal je przyjmuje.",
"glitchtipDeliveryFailed": "Ostatni raport nie dotarł ({reason}).",
"webPushVapidDescription": "Skonfiguruj tutaj klucze VAPID i subject, aby push przeglądarki działał bez zmiennych środowiskowych.",
"webPushVapidPublicKey": "Klucz publiczny VAPID",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
@@ -0,0 +1,8 @@
-- Delivery outcome of the last error report this host tried to send.
-- "A DSN is set and it parses" and "reports are arriving" are different
-- facts, and only the second one is worth a green badge: a wrong public key,
-- a wrong project id and an unreachable host all parse.
ALTER TABLE "app_settings"
ADD COLUMN "glitchtip_last_ok_at" TIMESTAMP(3),
ADD COLUMN "glitchtip_last_failure_at" TIMESTAMP(3),
ADD COLUMN "glitchtip_last_failure_reason" TEXT;
+15
View File
@@ -4701,6 +4701,21 @@ model AppSettings {
glitchtipDsn String? @map("glitchtip_dsn")
glitchtipEnvironment String? @map("glitchtip_environment")
// Delivery outcome of the last error report this host tried to send.
// Without it the admin card can only say a DSN is set and parses, which is
// not the same fact as reports arriving: a wrong public key, a wrong project
// id or an unreachable host all parse perfectly and deliver nothing.
/// Instant the last report left successfully. Consumer: the GlitchTip card's
/// badge, which is only green while this is inside the recent window.
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
/// Instant the last attempt failed. Consumer: the GlitchTip card's status
/// line, which names the failure rather than showing a reassuring badge.
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
/// Short classification of that failure (`invalid_dsn`, `http_403`,
/// `network`) — never a response body, which is a remote host's text.
/// Consumer: the same status line.
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes")
+37
View File
@@ -16,9 +16,32 @@ import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
import { summariseGlitchtipDelivery } from "@/lib/monitoring/glitchtip-delivery";
export const dynamic = "force-dynamic";
/** The delivery verdict, flattened onto the settings payload. */
function summariseGlitchtipDeliveryFields(state: {
lastOkAt?: Date | null;
lastFailureAt?: Date | null;
lastFailureReason?: string | null;
}) {
const summary = summariseGlitchtipDelivery(
{
lastOkAt: state.lastOkAt ?? null,
lastFailureAt: state.lastFailureAt ?? null,
lastFailureReason: state.lastFailureReason ?? null,
},
new Date(),
);
return {
glitchtipReportsDelivering: summary.reportsDelivering,
glitchtipEverDelivered: summary.everDelivered,
glitchtipLastFailureReason: summary.lastFailureReason,
glitchtipDeliveryWindowHours: summary.windowHours,
};
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.settings.get" } });
@@ -48,6 +71,15 @@ export const GET = apiHandler(async () => {
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
glitchtipDsn: settings?.glitchtipDsn ?? null,
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
// The delivery outcome, not the intent, and resolved here rather than on
// the client: a DSN that is set and parses says a target was typed, only
// this says something left the host, and whether it left recently enough
// is a question about a clock that belongs with the ledger.
...summariseGlitchtipDeliveryFields({
lastOkAt: settings?.glitchtipLastOkAt ?? null,
lastFailureAt: settings?.glitchtipLastFailureAt ?? null,
lastFailureReason: settings?.glitchtipLastFailureReason ?? null,
}),
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
// Document vault limits (per-file cap + per-user quota default). BigInt
@@ -274,6 +306,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
...summariseGlitchtipDeliveryFields({
lastOkAt: settings.glitchtipLastOkAt,
lastFailureAt: settings.glitchtipLastFailureAt,
lastFailureReason: settings.glitchtipLastFailureReason,
}),
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
documentMaxFileBytes: settings.documentMaxFileBytes,
@@ -0,0 +1,97 @@
/**
* The GlitchTip badge answers "are reports arriving", not "did somebody type
* a DSN".
*
* The two questions used to have one answer: enabled plus a URL that parses.
* A wrong public key, a wrong project id and a host that stopped answering all
* parse perfectly, so a green badge over any of them is the false reassurance
* an operator only discovers when they go looking for a crash that was never
* reported. The card now reads the delivery outcome this host recorded.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminSettings } from "../_shared";
const settingsResult = vi.hoisted(() => ({
current: null as Partial<AdminSettings> | null,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: settingsResult.current }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("../_shared", async (importOriginal) => {
const actual = await importOriginal<typeof import("../_shared")>();
return {
...actual,
useAdminSettings: () => ({ data: settingsResult.current }),
useUpdateSettings: () => ({ mutate: vi.fn(), isPending: false }),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { GlitchtipSection } from "../glitchtip-section";
const BASE = {
glitchtipEnabled: true,
glitchtipDsn: "https://public-key@glitchtip.example.com/7",
glitchtipEnvironment: "production",
glitchtipReportsDelivering: false,
glitchtipEverDelivered: false,
glitchtipLastFailureReason: null,
glitchtipDeliveryWindowHours: 336,
} satisfies Partial<AdminSettings>;
function render(settings: Partial<AdminSettings> | null): string {
settingsResult.current = settings;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<GlitchtipSection />
</I18nProvider>,
);
}
describe("<GlitchtipSection> delivery badge", () => {
it("does not call a parseable DSN configured until something has left the host", () => {
const html = render(BASE);
expect(html).toContain("Unproven");
expect(html).not.toContain(">Configured<");
expect(html).toContain("no report has left this host yet");
});
it("names the failure when the target refused the last report", () => {
const html = render({ ...BASE, glitchtipLastFailureReason: "http_403" });
expect(html).toContain("Unproven");
expect(html).toContain("did not get through");
expect(html).toContain("http_403");
});
it("goes green only for a report that left inside the window", () => {
const html = render({
...BASE,
glitchtipReportsDelivering: true,
glitchtipEverDelivered: true,
});
expect(html).toContain(">Configured<");
expect(html).toContain("glitchtip.example.com");
expect(html).not.toContain('data-slot="glitchtip-delivery"');
});
it("stops claiming delivery once the last success ages out of the window", () => {
const html = render({
...BASE,
glitchtipDeliveryWindowHours: 24,
glitchtipEverDelivered: true,
});
expect(html).toContain("Unproven");
expect(html).toContain("No report has left this host in the last");
});
it("says nothing is sent when the switch is off", () => {
const html = render({ ...BASE, glitchtipEnabled: false });
expect(html).toContain("Error reports stay on this host");
expect(html).not.toContain("Unproven");
});
});
+11
View File
@@ -101,6 +101,17 @@ export interface AdminSettings {
glitchtipEnabled: boolean;
glitchtipDsn: string | null;
glitchtipEnvironment: string | null;
/** A report left this host successfully inside the delivery window. */
glitchtipReportsDelivering: boolean;
/** A report has left this host successfully at some point. */
glitchtipEverDelivered: boolean;
/**
* Why the last attempt failed, when that failure is newer than the last
* success. A short classification, never a remote response body.
*/
glitchtipLastFailureReason: string | null;
/** How long a successful send stands for before the badge stops claiming it. */
glitchtipDeliveryWindowHours: number;
reminderLateMinutes: number;
reminderMissedMinutes: number;
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
+76 -14
View File
@@ -1,12 +1,13 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -18,9 +19,11 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export function GlitchtipSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState<string | null>(
@@ -53,11 +56,44 @@ export function GlitchtipSection() {
}
})();
// "Configured" has to mean reports are actually going somewhere. A DSN with
// the switch off sends nothing, and a green badge over that is the kind of
// reassurance an operator only discovers was wrong when they go looking for
// a crash that was never reported.
const reporting = Boolean(settings?.glitchtipEnabled && targetHost);
/**
* Three states, because "a target was typed" and "reports are arriving" are
* different facts and only the second earns a green badge.
*
* off — the switch is off, or no target parses. Nothing is sent.
* unproven — a target is set and nothing has come back from it inside the
* window. A wrong public key, a wrong project id and a host
* that stopped answering all look exactly like this, which is
* why it is not green.
* delivering — a report left successfully inside the window. The test
* button is the cheap way to prove it on demand.
*/
const deliveryState = ((): "off" | "unproven" | "delivering" => {
if (!settings?.glitchtipEnabled || !targetHost) return "off";
// Whether the last success is recent enough is decided where the ledger
// lives, not here: a render is not the place to read a clock.
return settings.glitchtipReportsDelivering ? "delivering" : "unproven";
})();
/**
* Why the badge is not green, in words. A reason the host recorded beats a
* generic "unproven": an operator who typed a DSN with the wrong project id
* gets told the target refused it rather than left to guess.
*/
const unprovenReason = ((): string | null => {
if (deliveryState !== "unproven") return null;
if (settings?.glitchtipLastFailureReason) {
return t("admin.glitchtipDeliveryFailed", {
reason: settings.glitchtipLastFailureReason,
});
}
if (settings?.glitchtipEverDelivered) {
return t("admin.glitchtipDeliveryStale", {
hours: settings.glitchtipDeliveryWindowHours,
});
}
return t("admin.glitchtipDeliveryUnproven");
})();
const testGlitchtip = useMutation({
mutationFn: async () => {
@@ -68,6 +104,11 @@ export function GlitchtipSection() {
},
onSuccess: (message) => {
toast.success(message);
// The send just recorded an outcome. Re-read it, so the badge answers
// the question the operator pressed the button to ask.
void queryClient.invalidateQueries({
queryKey: queryKeys.adminSettings(),
});
},
onError: (error) => {
toast.error(
@@ -75,6 +116,9 @@ export function GlitchtipSection() {
? error.message
: t("admin.monitoringTestFailed"),
);
void queryClient.invalidateQueries({
queryKey: queryKeys.adminSettings(),
});
},
});
@@ -99,7 +143,15 @@ export function GlitchtipSection() {
icon={AlertTriangle}
title={t("admin.glitchtipTitle")}
description={t("admin.glitchtipDescription")}
status={reporting ? <ConfiguredBadge /> : null}
status={
deliveryState === "delivering" ? (
<ConfiguredBadge />
) : deliveryState === "unproven" ? (
<Badge className="border-warning/30 bg-warning/15 text-warning">
{t("admin.glitchtipUnproven")}
</Badge>
) : null
}
/>
<div className="space-y-3">
@@ -116,13 +168,23 @@ export function GlitchtipSection() {
sent" while the answer is still loading is a claim, not a
placeholder. */}
{settings ? (
<p className="text-sm" data-slot="glitchtip-target">
{reporting && targetHost
? t("admin.glitchtipTargetOn", { host: targetHost })
: settings.glitchtipEnabled
? t("admin.glitchtipTargetNoTarget")
: t("admin.glitchtipTargetOff")}
</p>
<div className="space-y-1">
<p className="text-sm" data-slot="glitchtip-target">
{deliveryState !== "off" && targetHost
? t("admin.glitchtipTargetOn", { host: targetHost })
: settings.glitchtipEnabled
? t("admin.glitchtipTargetNoTarget")
: t("admin.glitchtipTargetOff")}
</p>
{unprovenReason ? (
<p
className="text-muted-foreground text-xs"
data-slot="glitchtip-delivery"
>
{unprovenReason}
</p>
) : null}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
@@ -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");
});
});
+132
View File
@@ -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 };
}
}
+37 -5
View File
@@ -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;
}