mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-28 19:45:06 -05:00
Tell request failures from truncated patch downloads (#81)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
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:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user