mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
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:
5
src/app/api/health/route.ts
Normal file
5
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ ok: true }, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
119
src/utils/patches/download-telemetry.ts
Normal file
119
src/utils/patches/download-telemetry.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
30
supabase/migrations/20260817235259_patch_download_events.sql
Normal file
30
supabase/migrations/20260817235259_patch_download_events.sql
Normal 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;
|
||||
Reference in New Issue
Block a user