diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index c643802..c2e887a 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -1122,13 +1122,26 @@ export interface PatchDownloadEventInput { timingEntryPresent?: boolean | null; probeSameOrigin?: "ok" | "failed" | "timeout" | "skipped" | null; probePatchHost?: "ok" | "failed" | "timeout" | "skipped" | null; + failurePhase?: "request" | "response" | "body" | null; + responseStatus?: number | null; + contentLength?: number | null; + contentEncoding?: string | null; + contentType?: string | null; + encodedBodySize?: number | null; + decodedBodySize?: number | null; + pageOrigin?: string | null; + correlationId?: string | null; + sampleRate?: number | 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 PATCH_DOWNLOAD_FAILURE_PHASES = new Set(["request", "response", "body"]); const HACK_SLUG_MAX_LENGTH = 200; const HACK_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DEV_CORRELATION_ID_PATTERN = /^dev-\d+$/; function truncate(value: string | null | undefined, max: number): string | null { if (value == null) return null; @@ -1158,6 +1171,48 @@ function sanitizeProbe( return value as "ok" | "failed" | "timeout" | "skipped"; } +function sanitizeFailurePhase( + value: string | null | undefined, +): "request" | "response" | "body" | null { + if (value == null || !PATCH_DOWNLOAD_FAILURE_PHASES.has(value)) return null; + return value as "request" | "response" | "body"; +} + +function sanitizeResponseStatus(value: number | null | undefined): number | null { + if (value == null || !Number.isInteger(value) || value < 100 || value > 599) return null; + return value; +} + +function sanitizeNonNegativeInteger(value: number | null | undefined): number | null { + if (value == null || !Number.isSafeInteger(value) || value < 0) return null; + return value; +} + +function sanitizeSampleRate(value: number | null | undefined): number | null { + if (value == null || !Number.isFinite(value) || value <= 0 || value > 1) return null; + return value; +} + +function sanitizePageOrigin(value: string | null | undefined): string | null { + const origin = truncate(value, 200); + if (origin == null) return null; + try { + const parsed = new URL(origin); + if (parsed.origin !== origin) return null; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return origin; + } catch { + return null; + } +} + +function sanitizeCorrelationId(value: string | null | undefined): string | null { + const id = truncate(value?.trim(), 64); + if (id == null || id.length === 0) return null; + if (UUID_PATTERN.test(id) || DEV_CORRELATION_ID_PATTERN.test(id)) return id; + return null; +} + export async function reportPatchDownloadEvent( input: PatchDownloadEventInput, ): Promise<{ ok: boolean }> { @@ -1190,6 +1245,16 @@ export async function reportPatchDownloadEvent( probe_same_origin: sanitizeProbe(input.probeSameOrigin), probe_patch_host: sanitizeProbe(input.probePatchHost), country, + failure_phase: sanitizeFailurePhase(input.failurePhase), + response_status: sanitizeResponseStatus(input.responseStatus), + content_length: sanitizeNonNegativeInteger(input.contentLength), + content_encoding: truncate(input.contentEncoding, 100), + content_type: truncate(input.contentType, 200), + encoded_body_size: sanitizeNonNegativeInteger(input.encodedBodySize), + decoded_body_size: sanitizeNonNegativeInteger(input.decodedBodySize), + page_origin: sanitizePageOrigin(input.pageOrigin), + correlation_id: sanitizeCorrelationId(input.correlationId), + sample_rate: sanitizeSampleRate(input.sampleRate), }); if (error) { diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index 9c4c974..d6cb307 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -14,7 +14,14 @@ import { isAnyRomExtension, } from "@/utils/romFile"; import { getOrCreateDeviceId } from "@/utils/deviceId"; -import { collectFetchDiagnostics, HTTPError, runConnectivityProbes } from "@/utils/patches/download-telemetry"; +import { + collectFetchDiagnostics, + collectResponseMetadata, + HTTPError, + runConnectivityProbes, + type FetchFailurePhase, + type ResponseMetadata, +} 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"; @@ -27,6 +34,20 @@ function deferReport(payload: Parameters[0]) { }, 50); } +function getPageOrigin(): string | null { + return typeof window !== "undefined" ? window.location.origin : null; +} + +function createFetchSessionId(): string | null { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + if (process.env.NODE_ENV === "development") { + return `dev-${Date.now()}`; + } + return null; +} + interface HackActionsProps { title: string; version: string; @@ -65,6 +86,16 @@ const HackActions: React.FC = ({ const [romErrorModal, setRomErrorModal] = React.useState(null); const [isVerifyingRom, setIsVerifyingRom] = React.useState(false); const [selectedPatchId, setSelectedPatchId] = React.useState(patcherSelector.defaultPatchId); + const fetchSessionIdRef = React.useRef(undefined); + + function getFetchSessionId(): string | null { + if (fetchSessionIdRef.current !== undefined) { + return fetchSessionIdRef.current; + } + const id = createFetchSessionId(); + fetchSessionIdRef.current = id; + return id; + } const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]); const effectivePlatform = React.useMemo( () => platform ?? baseRoms.find(r => r.id === baseRomId)?.platform, @@ -241,6 +272,8 @@ const HackActions: React.FC = ({ errorName: "ServerError", errorMessage: result.error, online, + pageOrigin: getPageOrigin(), + correlationId: getFetchSessionId(), probeSameOrigin: "skipped", probePatchHost: "skipped", }); @@ -259,6 +292,8 @@ const HackActions: React.FC = ({ errorName: e?.name ?? null, errorMessage: e?.message ?? null, online, + pageOrigin: getPageOrigin(), + correlationId: getFetchSessionId(), probeSameOrigin: "skipped", probePatchHost: "skipped", }); @@ -270,9 +305,24 @@ const HackActions: React.FC = ({ setTermsAgreed(true); const fetchStartedAt = performance.now(); + let failurePhase: FetchFailurePhase = "request"; + let responseMeta: ResponseMetadata = { + responseStatus: null, + contentLength: null, + contentEncoding: null, + contentType: null, + }; + const sessionFields = { + pageOrigin: getPageOrigin(), + correlationId: getFetchSessionId(), + }; + try { const res = await fetch(signedUrl); + failurePhase = "response"; + responseMeta = collectResponseMetadata(res); if (!res.ok) throw new HTTPError(res.status); + failurePhase = "body"; const blob = await res.blob(); setPatchBlob(blob); @@ -281,6 +331,22 @@ const HackActions: React.FC = ({ setStatus("idle"); } + if (Math.random() < 0.1) { + const elapsed = performance.now() - fetchStartedAt; + const diagnostics = collectFetchDiagnostics(signedUrl, elapsed); + deferReport({ + patchId: eventPatchId, + hackSlug, + stage: "fetch", + outcome: "success", + online, + sampleRate: 0.1, + ...sessionFields, + ...responseMeta, + ...diagnostics, + }); + } + return { url: signedUrl, blob, format: signedFormat }; } catch (e: any) { setError(e?.name === "HTTPError" ? "Failed to fetch patch" : (e?.message || "Failed to fetch patch URL")); @@ -297,13 +363,13 @@ const HackActions: React.FC = ({ hackSlug, stage: "fetch", outcome: "failure", + failurePhase, errorName: e?.name ?? null, errorMessage: e?.message ?? null, online, - nextHopProtocol: diagnostics.nextHopProtocol, - transferSize: diagnostics.transferSize, - durationMs: diagnostics.durationMs, - timingEntryPresent: diagnostics.timingEntryPresent, + ...sessionFields, + ...responseMeta, + ...diagnostics, probeSameOrigin: probes.probeSameOrigin, probePatchHost: probes.probePatchHost, }); @@ -467,6 +533,8 @@ const HackActions: React.FC = ({ errorName: e?.name ?? null, errorMessage: e?.message ?? null, online: typeof navigator !== "undefined" ? navigator.onLine : null, + pageOrigin: getPageOrigin(), + correlationId: getFetchSessionId(), probeSameOrigin: "skipped", probePatchHost: "skipped", }); diff --git a/src/types/db.ts b/src/types/db.ts index 19ac692..e21ad9e 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -347,57 +347,87 @@ export type Database = { } patch_download_events: { Row: { + content_encoding: string | null + content_length: number | null + content_type: string | null + correlation_id: string | null country: string | null created_at: string + decoded_body_size: number | null duration_ms: number | null + encoded_body_size: number | null error_message: string | null error_name: string | null + failure_phase: string | null hack_slug: string | null id: number next_hop_protocol: string | null online: boolean | null outcome: string + page_origin: string | null patch: number | null probe_patch_host: string | null probe_same_origin: string | null + response_status: number | null + sample_rate: number | null stage: string timing_entry_present: boolean | null transfer_size: number | null user_agent: string | null } Insert: { + content_encoding?: string | null + content_length?: number | null + content_type?: string | null + correlation_id?: string | null country?: string | null created_at?: string + decoded_body_size?: number | null duration_ms?: number | null + encoded_body_size?: number | null error_message?: string | null error_name?: string | null + failure_phase?: string | null hack_slug?: string | null id?: number next_hop_protocol?: string | null online?: boolean | null outcome: string + page_origin?: string | null patch?: number | null probe_patch_host?: string | null probe_same_origin?: string | null + response_status?: number | null + sample_rate?: number | null stage: string timing_entry_present?: boolean | null transfer_size?: number | null user_agent?: string | null } Update: { + content_encoding?: string | null + content_length?: number | null + content_type?: string | null + correlation_id?: string | null country?: string | null created_at?: string + decoded_body_size?: number | null duration_ms?: number | null + encoded_body_size?: number | null error_message?: string | null error_name?: string | null + failure_phase?: string | null hack_slug?: string | null id?: number next_hop_protocol?: string | null online?: boolean | null outcome?: string + page_origin?: string | null patch?: number | null probe_patch_host?: string | null probe_same_origin?: string | null + response_status?: number | null + sample_rate?: number | null stage?: string timing_entry_present?: boolean | null transfer_size?: number | null diff --git a/src/utils/patches/download-telemetry.ts b/src/utils/patches/download-telemetry.ts index 32253d9..4412ad7 100644 --- a/src/utils/patches/download-telemetry.ts +++ b/src/utils/patches/download-telemetry.ts @@ -1,5 +1,23 @@ export type ProbeResult = "ok" | "failed" | "timeout" | "skipped"; +export type FetchFailurePhase = "request" | "response" | "body"; + +export type FetchDiagnostics = { + nextHopProtocol: string | null; + transferSize: number | null; + durationMs: number | null; + timingEntryPresent: boolean; + encodedBodySize: number | null; + decodedBodySize: number | null; +}; + +export type ResponseMetadata = { + responseStatus: number | null; + contentLength: number | null; + contentEncoding: string | null; + contentType: string | null; +}; + export class HTTPError extends Error { readonly status: number; @@ -10,51 +28,70 @@ export class HTTPError extends Error { } } +function emptyDiagnostics( + fallbackDurationMs: number | null, + timingEntryPresent: boolean, +): FetchDiagnostics { + return { + nextHopProtocol: null, + transferSize: null, + durationMs: fallbackDurationMs, + timingEntryPresent, + encodedBodySize: null, + decodedBodySize: null, + }; +} + +function parseContentLength(raw: string | null): number | null { + if (raw == null) return null; + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const n = Number(trimmed); + if (!Number.isSafeInteger(n) || n < 0) return null; + return n; +} + +export function collectResponseMetadata(res: Response): ResponseMetadata { + return { + responseStatus: Number.isInteger(res.status) ? res.status : null, + contentLength: parseContentLength(res.headers.get("content-length")), + contentEncoding: res.headers.get("content-encoding") || null, + contentType: res.headers.get("content-type") || null, + }; +} + export function collectFetchDiagnostics( url: string, fallbackDurationMs: number, -): { - nextHopProtocol: string | null; - transferSize: number | null; - durationMs: number | null; - timingEntryPresent: boolean; -} { +): FetchDiagnostics { try { if (typeof performance === "undefined" || typeof performance.getEntriesByName !== "function") { - return { - nextHopProtocol: null, - transferSize: null, - durationMs: fallbackDurationMs, - timingEntryPresent: false, - }; + return emptyDiagnostics(fallbackDurationMs, 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, - }; + return emptyDiagnostics(fallbackDurationMs, false); } + // nextHopProtocol is empty when the Timing-Allow-Origin check fails; size + // fields are then 0 and must not be stored as real measurements. const nextHopProtocol = last.nextHopProtocol || null; + const taoAllowed = Boolean(nextHopProtocol); return { nextHopProtocol, transferSize: - nextHopProtocol && Number.isFinite(last.transferSize) ? last.transferSize : null, + taoAllowed && Number.isFinite(last.transferSize) ? last.transferSize : null, durationMs: Number.isFinite(last.duration) ? last.duration : fallbackDurationMs, timingEntryPresent: true, + encodedBodySize: + taoAllowed && Number.isFinite(last.encodedBodySize) ? last.encodedBodySize : null, + decodedBodySize: + taoAllowed && Number.isFinite(last.decodedBodySize) ? last.decodedBodySize : null, }; } catch { - return { - nextHopProtocol: null, - transferSize: null, - durationMs: null, - timingEntryPresent: false, - }; + return emptyDiagnostics(null, false); } } diff --git a/supabase/migrations/20260826165700_patch_download_event_details.sql b/supabase/migrations/20260826165700_patch_download_event_details.sql new file mode 100644 index 0000000..e692e0a --- /dev/null +++ b/supabase/migrations/20260826165700_patch_download_event_details.sql @@ -0,0 +1,19 @@ +-- Additive diagnostics for patch download telemetry. Original table is unchanged. + +alter table public.patch_download_events + add column if not exists failure_phase text + check (failure_phase in ('request', 'response', 'body')), + add column if not exists response_status integer + check (response_status is null or (response_status >= 100 and response_status <= 599)), + add column if not exists content_length bigint + check (content_length is null or content_length >= 0), + add column if not exists content_encoding text, + add column if not exists content_type text, + add column if not exists encoded_body_size bigint + check (encoded_body_size is null or encoded_body_size >= 0), + add column if not exists decoded_body_size bigint + check (decoded_body_size is null or decoded_body_size >= 0), + add column if not exists page_origin text, + add column if not exists correlation_id text, + add column if not exists sample_rate double precision + check (sample_rate is null or (sample_rate > 0 and sample_rate <= 1));