diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 68106fe17..065ed1100 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -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 diff --git a/messages/de.json b/messages/de.json index 581a7792e..b6a0d292c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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)", diff --git a/messages/en.json b/messages/en.json index ba0b8ac2b..2b0fd25c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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)", diff --git a/messages/es.json b/messages/es.json index ffb0affc5..bb58351cd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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)", diff --git a/messages/fr.json b/messages/fr.json index 2de62005f..50b11dad6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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)", diff --git a/messages/it.json b/messages/it.json index ddefd908c..3b2d11d01 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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)", diff --git a/messages/ko.json b/messages/ko.json index 7e66cb7e3..e242f2eb3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6580,6 +6580,10 @@ "glitchtipTargetOn": "오류 보고서는 {host}(으)로 전송됩니다.", "glitchtipTargetOff": "오류 보고서는 이 호스트에 남습니다. 아무것도 전송되지 않습니다.", "glitchtipTargetNoTarget": "오류 추적은 켜져 있지만 대상이 없어 아무것도 전송되지 않습니다.", + "glitchtipUnproven": "미확인", + "glitchtipDeliveryUnproven": "대상이 설정되어 있지만 아직 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 수락하는지 확인하세요.", + "glitchtipDeliveryStale": "지난 {hours}시간 동안 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 대상이 여전히 수락하는지 확인하세요.", + "glitchtipDeliveryFailed": "마지막 보고서가 전달되지 않았습니다 ({reason}).", "webPushVapidDescription": "환경 변수 없이 브라우저 푸시가 동작하도록 VAPID 키와 subject를 여기서 설정하세요.", "webPushVapidPublicKey": "VAPID 공개 키", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/pl.json b/messages/pl.json index a8af51993..1dbd67f6d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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)", diff --git a/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql b/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql new file mode 100644 index 000000000..75e271b60 --- /dev/null +++ b/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35d9336e6..ea050d2f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index bfbaf9b89..3c32e5953 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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, diff --git a/src/components/admin/__tests__/glitchtip-delivery-badge.test.tsx b/src/components/admin/__tests__/glitchtip-delivery-badge.test.tsx new file mode 100644 index 000000000..1a4e70b1d --- /dev/null +++ b/src/components/admin/__tests__/glitchtip-delivery-badge.test.tsx @@ -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 | 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(); + 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; + +function render(settings: Partial | null): string { + settingsResult.current = settings; + return renderToStaticMarkup( + + + , + ); +} + +describe(" 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"); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 906262788..40b41064f 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -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". diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index e5e7fd903..611860584 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -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( @@ -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 ? : null} + status={ + deliveryState === "delivering" ? ( + + ) : deliveryState === "unproven" ? ( + + {t("admin.glitchtipUnproven")} + + ) : null + } />
@@ -116,13 +168,23 @@ export function GlitchtipSection() { sent" while the answer is still loading is a claim, not a placeholder. */} {settings ? ( -

- {reporting && targetHost - ? t("admin.glitchtipTargetOn", { host: targetHost }) - : settings.glitchtipEnabled - ? t("admin.glitchtipTargetNoTarget") - : t("admin.glitchtipTargetOff")} -

+
+

+ {deliveryState !== "off" && targetHost + ? t("admin.glitchtipTargetOn", { host: targetHost }) + : settings.glitchtipEnabled + ? t("admin.glitchtipTargetNoTarget") + : t("admin.glitchtipTargetOff")} +

+ {unprovenReason ? ( +

+ {unprovenReason} +

+ ) : null} +
) : null}
diff --git a/src/lib/monitoring/__tests__/glitchtip-delivery.test.ts b/src/lib/monitoring/__tests__/glitchtip-delivery.test.ts new file mode 100644 index 000000000..da8e799f4 --- /dev/null +++ b/src/lib/monitoring/__tests__/glitchtip-delivery.test.ts @@ -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: "forbidden, and here is our whole error page", + }), + ).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; + 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; + 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"); + }); +}); diff --git a/src/lib/monitoring/glitchtip-delivery.ts b/src/lib/monitoring/glitchtip-delivery.ts new file mode 100644 index 000000000..c14f15749 --- /dev/null +++ b/src/lib/monitoring/glitchtip-delivery.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/src/lib/monitoring/glitchtip.ts b/src/lib/monitoring/glitchtip.ts index bfcbb91ac..779d45daa 100644 --- a/src/lib/monitoring/glitchtip.ts +++ b/src/lib/monitoring/glitchtip.ts @@ -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 { + 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 { 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; }