feat(play): add error observability analytics for mobile diagnostics
Track play_error and play_visibility_lost events via Umami to distinguish mobile vs desktop failure modes. Each error event captures orientation, connection type, visibility state, elapsed time bucket, and error classification — all categorical, no free text. Includes postJson "HTTP \d+" status parsing for the new engineClient dual-path architecture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+107
-2
@@ -372,6 +372,7 @@ function prefetchScenePath(
|
||||
|
||||
const specSession = buildSpeculativeSession(baseSession, steps);
|
||||
const abort = new AbortController();
|
||||
const prefetchT0 = Date.now();
|
||||
const promise = (async () => {
|
||||
const data = await requestScene({ session: specSession, clientTts });
|
||||
if (abort.signal.aborted) throw new Error("aborted");
|
||||
@@ -430,7 +431,20 @@ function prefetchScenePath(
|
||||
return data;
|
||||
})();
|
||||
|
||||
promise.catch(() => {});
|
||||
promise.catch((e) => {
|
||||
if ((e as { name?: string }).name === "AbortError") return;
|
||||
const { kind, http_status } = classifyError(e);
|
||||
track("play_error", {
|
||||
source: "prefetch" as const,
|
||||
kind,
|
||||
http_status,
|
||||
orientation: baseSession.orientation ?? "landscape",
|
||||
connection: getConnectionType(),
|
||||
was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
|
||||
scene_index: baseSession.history.length,
|
||||
elapsed_bucket: elapsedBucket(prefetchT0),
|
||||
});
|
||||
});
|
||||
pool.set(key, { promise, abort });
|
||||
}
|
||||
|
||||
@@ -496,6 +510,51 @@ async function resolveByoVoice(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error observability helpers ────────────────────────────────────────
|
||||
|
||||
type ErrorSource = "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch";
|
||||
|
||||
function classifyError(
|
||||
e: unknown,
|
||||
res?: Response,
|
||||
): { kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown"; http_status: number } {
|
||||
if (res) {
|
||||
const s = res.status;
|
||||
if (s >= 500) return { kind: "http_5xx", http_status: s };
|
||||
if (s >= 400) return { kind: "http_4xx", http_status: s };
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
if (e.name === "AbortError") return { kind: "abort", http_status: 0 };
|
||||
if (e instanceof TypeError && /fetch|network/i.test(e.message))
|
||||
return { kind: "network", http_status: 0 };
|
||||
if (/timeout/i.test(e.message)) return { kind: "timeout", http_status: 0 };
|
||||
const httpMatch = e.message.match(/^HTTP (\d+)$/);
|
||||
if (httpMatch) {
|
||||
const s = Number(httpMatch[1]);
|
||||
if (s >= 500) return { kind: "http_5xx", http_status: s };
|
||||
if (s >= 400) return { kind: "http_4xx", http_status: s };
|
||||
}
|
||||
}
|
||||
return { kind: "unknown", http_status: 0 };
|
||||
}
|
||||
|
||||
function elapsedBucket(startMs: number): "<5s" | "5-30s" | "30-60s" | "60-120s" | "120s+" {
|
||||
const s = (Date.now() - startMs) / 1000;
|
||||
if (s < 5) return "<5s";
|
||||
if (s < 30) return "5-30s";
|
||||
if (s < 60) return "30-60s";
|
||||
if (s < 120) return "60-120s";
|
||||
return "120s+";
|
||||
}
|
||||
|
||||
function getConnectionType(): "4g" | "3g" | "2g" | "slow-2g" | "unknown" {
|
||||
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
||||
const conn = (nav as { connection?: { effectiveType?: string } } | undefined)?.connection;
|
||||
const et = conn?.effectiveType;
|
||||
if (et === "4g" || et === "3g" || et === "2g" || et === "slow-2g") return et;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -566,6 +625,7 @@ function PlayInner() {
|
||||
// 首页「语音配音 关闭」会把 muted 初值置为 true(见上方 useState 初始化),
|
||||
// 不再单独维护 audioEnabledRef —— 单一来源避免两个 flag 漂移。
|
||||
const mutedRef = useRef<boolean>(muted);
|
||||
const phaseRef = useRef<Phase>(phase);
|
||||
|
||||
// Resolved bring-your-own Xiaomi TTS config (region preset + key), read once
|
||||
// from localStorage. When non-null, the browser provisions + synths voices
|
||||
@@ -647,10 +707,27 @@ function PlayInner() {
|
||||
useEffect(() => {
|
||||
mutedRef.current = muted;
|
||||
}, [muted]);
|
||||
useEffect(() => {
|
||||
phaseRef.current = phase;
|
||||
}, [phase]);
|
||||
useEffect(() => {
|
||||
setVisionClickEnabled(readStoredVisionClick());
|
||||
}, []);
|
||||
|
||||
function trackPlayError(source: ErrorSource, e: unknown, startMs: number, res?: Response) {
|
||||
const { kind, http_status } = classifyError(e, res);
|
||||
track("play_error", {
|
||||
source,
|
||||
kind,
|
||||
http_status,
|
||||
orientation,
|
||||
connection: getConnectionType(),
|
||||
was_hidden: document.visibilityState === "hidden",
|
||||
scene_index: session?.history.length ?? 0,
|
||||
elapsed_bucket: elapsedBucket(startMs),
|
||||
});
|
||||
}
|
||||
|
||||
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
||||
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
||||
// while the tab is visible) gives Umami the timestamps to derive real
|
||||
@@ -666,6 +743,20 @@ function PlayInner() {
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onVisChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
const p = phaseRef.current;
|
||||
track("play_visibility_lost", {
|
||||
phase: p,
|
||||
had_pending_fetch: p !== "ready",
|
||||
});
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisChange);
|
||||
return () => document.removeEventListener("visibilitychange", onVisChange);
|
||||
}, []);
|
||||
|
||||
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
|
||||
useEffect(() => {
|
||||
if (!currentBeatId) return;
|
||||
@@ -1186,6 +1277,7 @@ function PlayInner() {
|
||||
|
||||
if (isShare) {
|
||||
(async () => {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY);
|
||||
if (!raw) throw new Error("没有找到要载入的剧情文件。");
|
||||
@@ -1244,6 +1336,7 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: 1 });
|
||||
} catch (e) {
|
||||
trackPlayError("start", e, t0);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
@@ -1314,6 +1407,7 @@ function PlayInner() {
|
||||
? "firstact-portrait"
|
||||
: "firstact";
|
||||
|
||||
const startT0 = Date.now();
|
||||
const fetchStart: Promise<PrebakedFirstAct> = cardName
|
||||
? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then(
|
||||
async (r) => {
|
||||
@@ -1381,7 +1475,10 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: initial.history.length });
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
.catch((e) => {
|
||||
trackPlayError("start", e, startT0);
|
||||
setError(String(e));
|
||||
});
|
||||
}, [params, router]);
|
||||
|
||||
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
|
||||
@@ -1450,6 +1547,7 @@ function PlayInner() {
|
||||
visitedForCurrent: string[],
|
||||
exitLabel: string,
|
||||
) {
|
||||
const sceneT0 = Date.now();
|
||||
setPhase("transitioning");
|
||||
setPendingClick(null);
|
||||
try {
|
||||
@@ -1507,6 +1605,7 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
return;
|
||||
}
|
||||
trackPlayError("scene", e, sceneT0);
|
||||
setError(String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
@@ -1536,6 +1635,7 @@ function PlayInner() {
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const replayT0 = Date.now();
|
||||
setPhase("transitioning");
|
||||
setPendingClick(null);
|
||||
try {
|
||||
@@ -1582,6 +1682,7 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: nextSession.history.length });
|
||||
} catch (e) {
|
||||
trackPlayError("scene", e, replayT0);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
@@ -1734,6 +1835,7 @@ function PlayInner() {
|
||||
text_length: text.length,
|
||||
});
|
||||
|
||||
const freeformT0 = Date.now();
|
||||
setPhase("vision-thinking");
|
||||
|
||||
try {
|
||||
@@ -1822,6 +1924,7 @@ function PlayInner() {
|
||||
setPendingClick(null);
|
||||
void performSceneTransition(promise, exit, visited, decision.freeformAction);
|
||||
} catch (e) {
|
||||
trackPlayError("freeform", e, freeformT0);
|
||||
setError(String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
@@ -1830,6 +1933,7 @@ function PlayInner() {
|
||||
async function onBackgroundClick(click: { x: number; y: number }) {
|
||||
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
const visionT0 = Date.now();
|
||||
setPhase("vision-thinking");
|
||||
setPendingClick(click);
|
||||
|
||||
@@ -1927,6 +2031,7 @@ function PlayInner() {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
trackPlayError("vision", e, visionT0);
|
||||
setError(String(e));
|
||||
setPendingClick(null);
|
||||
setPhase("ready");
|
||||
|
||||
@@ -54,6 +54,20 @@ type AnalyticsEventData = {
|
||||
fullscreen_toggle: { on: boolean };
|
||||
play_heartbeat: never;
|
||||
gallery_export: { scene_count: number; audio_count: number };
|
||||
play_error: {
|
||||
source: "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch";
|
||||
kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown";
|
||||
http_status: number;
|
||||
orientation: "portrait" | "landscape";
|
||||
connection: "4g" | "3g" | "2g" | "slow-2g" | "unknown";
|
||||
was_hidden: boolean;
|
||||
scene_index: number;
|
||||
elapsed_bucket: "<5s" | "5-30s" | "30-60s" | "60-120s" | "120s+";
|
||||
};
|
||||
play_visibility_lost: {
|
||||
phase: "loading-first" | "ready" | "transitioning" | "vision-thinking" | "inserting-beat";
|
||||
had_pending_fetch: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type AnalyticsEvent = keyof AnalyticsEventData;
|
||||
|
||||
Reference in New Issue
Block a user