Add patch download failure telemetry

Capture actionable diagnostics for signed URL, network, HTTP, and patch application failures while keeping reporting best-effort.
This commit is contained in:
Jared Schoeny
2026-08-18 10:01:15 -06:00
parent e6b91a6045
commit 3ac3a9c4b7
7 changed files with 406 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({ ok: true }, { headers: { "cache-control": "no-store" } });
}

View File

@@ -1111,3 +1111,99 @@ export async function updatePatcherSelectablePatches(
return { ok: true };
}
export interface PatchDownloadEventInput {
patchId: number | null;
hackSlug: string;
stage: "signed_url" | "fetch" | "patch";
outcome: "success" | "failure";
errorName?: string | null;
errorMessage?: string | null;
online?: boolean | null;
nextHopProtocol?: string | null;
transferSize?: number | null;
durationMs?: number | null;
timingEntryPresent?: boolean | null;
probeSameOrigin?: "ok" | "failed" | "timeout" | "skipped" | null;
probePatchHost?: "ok" | "failed" | "timeout" | "skipped" | null;
}
const PATCH_DOWNLOAD_STAGES = new Set(["signed_url", "fetch", "patch"]);
const PATCH_DOWNLOAD_OUTCOMES = new Set(["success", "failure"]);
const PATCH_DOWNLOAD_PROBE_RESULTS = new Set(["ok", "failed", "timeout", "skipped"]);
const HACK_SLUG_MAX_LENGTH = 200;
const HACK_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function truncate(value: string | null | undefined, max: number): string | null {
if (value == null) return null;
return value.length <= max ? value : value.slice(0, max);
}
function sanitizeHackSlug(value: string | null | undefined): string | null {
const slug = truncate(value, HACK_SLUG_MAX_LENGTH);
if (slug == null || !HACK_SLUG_PATTERN.test(slug)) return null;
return slug;
}
function sanitizePatchId(value: number | null | undefined): number | null {
if (value == null || !Number.isSafeInteger(value) || value <= 0) return null;
return value;
}
function sanitizeNonNegativeFinite(value: number | null | undefined): number | null {
if (value == null || !Number.isFinite(value) || value < 0) return null;
return value;
}
function sanitizeProbe(
value: string | null | undefined,
): "ok" | "failed" | "timeout" | "skipped" | null {
if (value == null || !PATCH_DOWNLOAD_PROBE_RESULTS.has(value)) return null;
return value as "ok" | "failed" | "timeout" | "skipped";
}
export async function reportPatchDownloadEvent(
input: PatchDownloadEventInput,
): Promise<{ ok: boolean }> {
try {
if (!PATCH_DOWNLOAD_STAGES.has(input.stage) || !PATCH_DOWNLOAD_OUTCOMES.has(input.outcome)) {
return { ok: false };
}
const hdrs = await headers();
const userAgent = truncate(hdrs.get("user-agent"), 400);
const country = truncate(
hdrs.get("x-vercel-ip-country") ?? hdrs.get("cf-ipcountry") ?? null,
10,
);
const supabase = await createServiceClient();
const { error } = await supabase.from("patch_download_events").insert({
patch: sanitizePatchId(input.patchId),
hack_slug: sanitizeHackSlug(input.hackSlug),
stage: input.stage,
outcome: input.outcome,
error_name: truncate(input.errorName, 100),
error_message: truncate(input.errorMessage, 500),
online: input.online ?? null,
user_agent: userAgent,
next_hop_protocol: truncate(input.nextHopProtocol, 50),
transfer_size: sanitizeNonNegativeFinite(input.transferSize),
duration_ms: sanitizeNonNegativeFinite(input.durationMs),
timing_entry_present: input.timingEntryPresent ?? null,
probe_same_origin: sanitizeProbe(input.probeSameOrigin),
probe_patch_host: sanitizeProbe(input.probePatchHost),
country,
});
if (error) {
console.error("reportPatchDownloadEvent", error);
return { ok: false };
}
return { ok: true };
} catch (e) {
console.error("reportPatchDownloadEvent", e);
return { ok: false };
}
}

View File

@@ -6,17 +6,26 @@ import BaseRomErrorModal, { type BaseRomErrorModalState } from "@/components/Hac
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { baseRoms } from "@/data/baseRoms";
import type { DownloadEventDetail } from "@/types/util";
import { getSignedPatchUrl, updatePatchDownloadCount } from "@/app/hack/[slug]/actions";
import { getSignedPatchUrl, updatePatchDownloadCount, reportPatchDownloadEvent } from "@/app/hack/[slug]/actions";
import { sha1Hex } from "@/utils/hash";
import {
formatRequiredRomExtension,
isArchiveFile,
isAnyRomExtension,
} from "@/utils/romFile";
import { collectFetchDiagnostics, HTTPError, runConnectivityProbes } from "@/utils/patches/download-telemetry";
import type { SelectablePatch } from "@/types/patcher";
import { applyPatch, patchFormatFromFilename, type PatchFormat } from "@/utils/patching";
import { createOutputSink, SaveCancelledError, type OutputSink } from "@/utils/patching/save";
function deferReport(payload: Parameters<typeof reportPatchDownloadEvent>[0]) {
setTimeout(async () => {
try {
await reportPatchDownloadEvent(payload);
} catch {}
}, 50);
}
interface HackActionsProps {
title: string;
version: string;
@@ -207,6 +216,11 @@ const HackActions: React.FC<HackActionsProps> = ({
}
async function onAgreeToTerms(): Promise<{ url: string; blob: Blob; format: PatchFormat } | null> {
const eventPatchId = selectedPatch?.id ?? patchId ?? null;
const online = typeof navigator !== "undefined" ? navigator.onLine : null;
let signedUrl: string;
let signedFormat: PatchFormat;
try {
setError(null);
setStatus("downloading");
@@ -218,15 +232,46 @@ const HackActions: React.FC<HackActionsProps> = ({
if (!result.ok) {
setError(result.error);
setStatus("idle");
deferReport({
patchId: eventPatchId,
hackSlug,
stage: "signed_url",
outcome: "failure",
errorName: "ServerError",
errorMessage: result.error,
online,
probeSameOrigin: "skipped",
probePatchHost: "skipped",
});
return null;
}
signedUrl = result.url;
signedFormat = result.format;
} catch (e: any) {
setError(e?.message || "Failed to fetch patch URL");
setStatus("idle");
deferReport({
patchId: eventPatchId,
hackSlug,
stage: "signed_url",
outcome: "failure",
errorName: e?.name ?? null,
errorMessage: e?.message ?? null,
online,
probeSameOrigin: "skipped",
probePatchHost: "skipped",
});
return null;
}
setPatchUrl(result.url);
setPatchFormat(result.format);
setTermsAgreed(true);
setPatchUrl(signedUrl);
setPatchFormat(signedFormat);
setTermsAgreed(true);
const res = await fetch(result.url);
if (!res.ok) throw new Error("Failed to fetch patch");
const fetchStartedAt = performance.now();
try {
const res = await fetch(signedUrl);
if (!res.ok) throw new HTTPError(res.status);
const blob = await res.blob();
setPatchBlob(blob);
@@ -235,11 +280,35 @@ const HackActions: React.FC<HackActionsProps> = ({
setStatus("idle");
}
return { url: result.url, blob, format: result.format };
return { url: signedUrl, blob, format: signedFormat };
} catch (e: any) {
setError(e?.message || "Failed to fetch patch URL");
setError(e?.name === "HTTPError" ? "Failed to fetch patch" : (e?.message || "Failed to fetch patch URL"));
setStatus("idle");
setTermsAgreed(false);
const elapsed = performance.now() - fetchStartedAt;
setTimeout(async () => {
try {
const probes = await runConnectivityProbes(signedUrl);
const diagnostics = collectFetchDiagnostics(signedUrl, elapsed);
await reportPatchDownloadEvent({
patchId: eventPatchId,
hackSlug,
stage: "fetch",
outcome: "failure",
errorName: e?.name ?? null,
errorMessage: e?.message ?? null,
online,
nextHopProtocol: diagnostics.nextHopProtocol,
transferSize: diagnostics.transferSize,
durationMs: diagnostics.durationMs,
timingEntryPresent: diagnostics.timingEntryPresent,
probeSameOrigin: probes.probeSameOrigin,
probePatchHost: probes.probePatchHost,
});
} catch {}
}, 50);
return null;
}
}
@@ -393,6 +462,17 @@ const HackActions: React.FC<HackActionsProps> = ({
setStatus("idle");
setPatchProgress(null);
console.error(e);
deferReport({
patchId: selectedPatch?.id ?? patchId ?? null,
hackSlug,
stage: "patch",
outcome: "failure",
errorName: e?.name ?? null,
errorMessage: e?.message ?? null,
online: typeof navigator !== "undefined" ? navigator.onLine : null,
probeSameOrigin: "skipped",
probePatchHost: "skipped",
});
}
}

View File

@@ -307,6 +307,74 @@ export type Database = {
}
Relationships: []
}
patch_download_events: {
Row: {
country: string | null
created_at: string
duration_ms: number | null
error_message: string | null
error_name: string | null
hack_slug: string | null
id: number
next_hop_protocol: string | null
online: boolean | null
outcome: string
patch: number | null
probe_patch_host: string | null
probe_same_origin: string | null
stage: string
timing_entry_present: boolean | null
transfer_size: number | null
user_agent: string | null
}
Insert: {
country?: string | null
created_at?: string
duration_ms?: number | null
error_message?: string | null
error_name?: string | null
hack_slug?: string | null
id?: number
next_hop_protocol?: string | null
online?: boolean | null
outcome: string
patch?: number | null
probe_patch_host?: string | null
probe_same_origin?: string | null
stage: string
timing_entry_present?: boolean | null
transfer_size?: number | null
user_agent?: string | null
}
Update: {
country?: string | null
created_at?: string
duration_ms?: number | null
error_message?: string | null
error_name?: string | null
hack_slug?: string | null
id?: number
next_hop_protocol?: string | null
online?: boolean | null
outcome?: string
patch?: number | null
probe_patch_host?: string | null
probe_same_origin?: string | null
stage?: string
timing_entry_present?: boolean | null
transfer_size?: number | null
user_agent?: string | null
}
Relationships: [
{
foreignKeyName: "patch_download_events_patch_fkey"
columns: ["patch"]
isOneToOne: false
referencedRelation: "patches"
referencedColumns: ["id"]
},
]
}
patch_downloads: {
Row: {
created_at: string

View File

@@ -0,0 +1,119 @@
export type ProbeResult = "ok" | "failed" | "timeout" | "skipped";
export class HTTPError extends Error {
readonly status: number;
constructor(status: number) {
super(`HTTP ${status}`);
this.name = "HTTPError";
this.status = status;
}
}
export function collectFetchDiagnostics(
url: string,
fallbackDurationMs: number,
): {
nextHopProtocol: string | null;
transferSize: number | null;
durationMs: number | null;
timingEntryPresent: boolean;
} {
try {
if (typeof performance === "undefined" || typeof performance.getEntriesByName !== "function") {
return {
nextHopProtocol: null,
transferSize: null,
durationMs: fallbackDurationMs,
timingEntryPresent: false,
};
}
const entries = performance.getEntriesByName(url);
const last = entries[entries.length - 1] as PerformanceResourceTiming | undefined;
if (!last) {
return {
nextHopProtocol: null,
transferSize: null,
durationMs: fallbackDurationMs,
timingEntryPresent: false,
};
}
const nextHopProtocol = last.nextHopProtocol || null;
return {
nextHopProtocol,
transferSize:
nextHopProtocol && Number.isFinite(last.transferSize) ? last.transferSize : null,
durationMs: Number.isFinite(last.duration) ? last.duration : fallbackDurationMs,
timingEntryPresent: true,
};
} catch {
return {
nextHopProtocol: null,
transferSize: null,
durationMs: null,
timingEntryPresent: false,
};
}
}
function abortSignalWithTimeout(ms: number): AbortSignal {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function isTimeoutError(e: unknown): boolean {
const name = e && typeof e === "object" && "name" in e ? String((e as { name: unknown }).name) : "";
return name === "TimeoutError" || name === "AbortError";
}
async function probeSameOrigin(): Promise<ProbeResult> {
try {
const res = await fetch("/api/health", {
cache: "no-store",
signal: abortSignalWithTimeout(5000),
});
return res.ok ? "ok" : "failed";
} catch (e) {
return isTimeoutError(e) ? "timeout" : "failed";
}
}
async function probePatchHost(patchUrl: string): Promise<ProbeResult> {
let origin: string;
try {
origin = new URL(patchUrl).origin;
} catch {
return "skipped";
}
try {
await fetch(`${origin}/`, {
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: abortSignalWithTimeout(5000),
});
return "ok";
} catch (e) {
return isTimeoutError(e) ? "timeout" : "failed";
}
}
export async function runConnectivityProbes(
patchUrl: string,
): Promise<{ probeSameOrigin: ProbeResult; probePatchHost: ProbeResult }> {
try {
const [sameOrigin, patchHost] = await Promise.all([
probeSameOrigin(),
probePatchHost(patchUrl),
]);
return { probeSameOrigin: sameOrigin, probePatchHost: patchHost };
} catch {
return { probeSameOrigin: "skipped", probePatchHost: "skipped" };
}
}

View File

@@ -34,6 +34,5 @@ export function buildPatchDownloadUrl(filename: string): string | null {
const token = mintPatchDownloadToken(filename, expiresAtMs, secret);
const u = new URL(filename, `${trimTrailingSlash(base)}/`);
u.searchParams.set("token", token);
console.log("buildPatchDownloadUrl", u.href);
return u.href;
}

View File

@@ -0,0 +1,30 @@
-- Patch download telemetry for diagnosing "Failed to fetch" errors from patches.hackdex.app.
-- Inserts are service-role only (no public RLS policies).
create table if not exists public.patch_download_events (
id bigint generated by default as identity primary key,
created_at timestamptz not null default now(),
patch bigint references public.patches(id) on update cascade on delete set null,
hack_slug text,
stage text not null check (stage in ('signed_url', 'fetch', 'patch')),
outcome text not null check (outcome in ('success', 'failure')),
error_name text,
error_message text,
online boolean,
user_agent text,
next_hop_protocol text,
transfer_size bigint,
duration_ms double precision,
timing_entry_present boolean,
probe_same_origin text check (probe_same_origin in ('ok', 'failed', 'timeout', 'skipped')),
probe_patch_host text check (probe_patch_host in ('ok', 'failed', 'timeout', 'skipped')),
country text
);
create index patch_download_events_created_at_idx
on public.patch_download_events (created_at);
create index patch_download_events_outcome_idx
on public.patch_download_events (outcome, created_at);
alter table public.patch_download_events enable row level security;