Tell request failures from truncated patch downloads (#81)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled

* Expand patch download diagnostics

Separate request and body failures, capture response sizes, and sample successful fetches so protocol and truncation patterns can be compared.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move the details migration after hack review threads.

CI rejects new migration filenames that sort before the tip on main.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-08-26 11:34:37 -06:00
committed by GitHub
parent 73fa5837e6
commit 567f79c00e
5 changed files with 249 additions and 30 deletions

View File

@@ -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) {

View File

@@ -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<typeof reportPatchDownloadEvent>[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<HackActionsProps> = ({
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
const [isVerifyingRom, setIsVerifyingRom] = React.useState(false);
const [selectedPatchId, setSelectedPatchId] = React.useState<number | null>(patcherSelector.defaultPatchId);
const fetchSessionIdRef = React.useRef<string | null | undefined>(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<HackActionsProps> = ({
errorName: "ServerError",
errorMessage: result.error,
online,
pageOrigin: getPageOrigin(),
correlationId: getFetchSessionId(),
probeSameOrigin: "skipped",
probePatchHost: "skipped",
});
@@ -259,6 +292,8 @@ const HackActions: React.FC<HackActionsProps> = ({
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<HackActionsProps> = ({
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<HackActionsProps> = ({
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<HackActionsProps> = ({
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<HackActionsProps> = ({
errorName: e?.name ?? null,
errorMessage: e?.message ?? null,
online: typeof navigator !== "undefined" ? navigator.onLine : null,
pageOrigin: getPageOrigin(),
correlationId: getFetchSessionId(),
probeSameOrigin: "skipped",
probePatchHost: "skipped",
});

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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));