diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..5928e9e --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }); +} diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 2105063..e08a270 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -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 }; + } +} diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index b5d3d38..1c133f3 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -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[0]) { + setTimeout(async () => { + try { + await reportPatchDownloadEvent(payload); + } catch {} + }, 50); +} + interface HackActionsProps { title: string; version: string; @@ -207,6 +216,11 @@ const HackActions: React.FC = ({ } 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 = ({ 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 = ({ 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 = ({ 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", + }); } } diff --git a/src/types/db.ts b/src/types/db.ts index 6316d96..cd26288 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -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 diff --git a/src/utils/patches/download-telemetry.ts b/src/utils/patches/download-telemetry.ts new file mode 100644 index 0000000..32253d9 --- /dev/null +++ b/src/utils/patches/download-telemetry.ts @@ -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 { + 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 { + 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" }; + } +} diff --git a/src/utils/patches/patch-download-url.ts b/src/utils/patches/patch-download-url.ts index f8e924d..d67a7f4 100644 --- a/src/utils/patches/patch-download-url.ts +++ b/src/utils/patches/patch-download-url.ts @@ -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; } diff --git a/supabase/migrations/20260817235259_patch_download_events.sql b/supabase/migrations/20260817235259_patch_download_events.sql new file mode 100644 index 0000000..a82096f --- /dev/null +++ b/supabase/migrations/20260817235259_patch_download_events.sql @@ -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;