fix(studio): correct save failure telemetry (#3499)

This commit is contained in:
Miguel Ángel
2026-08-30 00:24:46 -04:00
committed by GitHub
parent 0fd70b1d21
commit 28be8dddfa
8 changed files with 248 additions and 31 deletions
@@ -2,8 +2,15 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
trackStudioSaveFailure,
}));
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -11,6 +18,7 @@ type WriteProjectFile = (path: string, content: string, expectedContent?: string
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
const captured: { handle: EditorSaveHandle | null } = { handle: null };
const showToast = vi.fn();
function Probe() {
captured.handle = useEditorSave({
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
recordEdit: vi.fn(async () => undefined),
domEditSaveTimestampRef: { current: 0 },
setRefreshKey: vi.fn(),
showToast: vi.fn(),
showToast,
});
return null;
}
@@ -32,12 +40,14 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
return {
handle: captured.handle,
showToast,
unmount: () => act(async () => root.unmount()),
};
}
describe("useEditorSave pending work", () => {
beforeEach(() => {
trackStudioSaveFailure.mockClear();
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 41),
@@ -45,7 +55,10 @@ describe("useEditorSave pending work", () => {
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
const writeProjectFile = vi.fn(async () => undefined);
@@ -111,10 +124,75 @@ describe("useEditorSave pending work", () => {
status: "conflict",
error: conflict,
});
expect(trackStudioSaveFailure).toHaveBeenCalledWith({
source: "code_editor",
error: conflict,
filePath: "index.html",
});
await mounted.unmount();
});
it("emits one identical failure per five-second burst", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_000);
const error = new Error("Load failed");
const mounted = await mountEditorSave(async () => {
throw error;
});
act(() => mounted.handle.handleContentChange("first candidate"));
await mounted.handle.flushPendingSave();
vi.spyOn(Date, "now").mockReturnValue(2_000);
act(() => mounted.handle.handleContentChange("second candidate"));
await mounted.handle.flushPendingSave();
expect(trackStudioSaveFailure).toHaveBeenCalledOnce();
expect(mounted.showToast).toHaveBeenCalledOnce();
await mounted.unmount();
});
it("emits a changed failure immediately and repeats after the burst window", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const writeProjectFile = vi
.fn<WriteProjectFile>()
.mockRejectedValueOnce(new Error("Load failed"))
.mockRejectedValueOnce(new Error("Failed to fetch"))
.mockRejectedValueOnce(new Error("Failed to fetch"));
const mounted = await mountEditorSave(writeProjectFile);
act(() => mounted.handle.handleContentChange("first candidate"));
await mounted.handle.flushPendingSave();
now.mockReturnValue(2_000);
act(() => mounted.handle.handleContentChange("second candidate"));
await mounted.handle.flushPendingSave();
now.mockReturnValue(8_000);
act(() => mounted.handle.handleContentChange("third candidate"));
await mounted.handle.flushPendingSave();
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(3);
await mounted.unmount();
});
it("emits the same failure again after a successful save", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_000);
const writeProjectFile = vi
.fn<WriteProjectFile>()
.mockRejectedValueOnce(new Error("Load failed"))
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("Load failed"));
const mounted = await mountEditorSave(writeProjectFile);
act(() => mounted.handle.handleContentChange("first candidate"));
await mounted.handle.flushPendingSave();
act(() => mounted.handle.handleContentChange("successful candidate"));
await mounted.handle.flushPendingSave();
act(() => mounted.handle.handleContentChange("third candidate"));
await mounted.handle.flushPendingSave();
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(2);
await mounted.unmount();
});
it("discards an rAF-buffered candidate without persisting it", async () => {
const writeProjectFile = vi.fn(async () => undefined);
const mounted = await mountEditorSave(writeProjectFile);
+32 -7
View File
@@ -1,12 +1,15 @@
import { useCallback, useRef } from "react";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { trackStudioEvent } from "../utils/studioTelemetry";
import {
StudioFileConflictError,
buildStudioSaveFailureProperties,
trackStudioSaveFailure,
type StudioSaveDrainResult,
} from "../utils/studioSaveDiagnostics";
const FAILURE_BURST_MS = 5_000;
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
@@ -58,19 +61,40 @@ export function useEditorSave({
const refreshRafRef = useRef<number | null>(null);
// One error toast per burst of failures — every keystroke retries the save,
// and error toasts persist until dismissed, so don't stack duplicates.
const lastFailureToastAtRef = useRef(0);
const lastFailureToastAtRef = useRef<number | null>(null);
const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null);
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
const reportFailure = useCallback(
(path: string, error: unknown) => {
trackStudioEvent("save_failure", {
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
const properties = buildStudioSaveFailureProperties({
source: "code_editor",
error,
filePath: path,
});
const errorName = error instanceof Error ? error.name : typeof error;
const fingerprint = JSON.stringify([
path,
errorName,
properties.error_message,
properties.status_code,
]);
const previous = lastFailureReportRef.current;
if (
previous === null ||
previous.fingerprint !== fingerprint ||
now - previous.emittedAt >= FAILURE_BURST_MS
) {
trackStudioSaveFailure({ source: "code_editor", error, filePath: path });
lastFailureReportRef.current = { fingerprint, emittedAt: now };
}
if (
lastFailureToastAtRef.current === null ||
now - lastFailureToastAtRef.current >= FAILURE_BURST_MS
) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
@@ -95,6 +119,7 @@ export function useEditorSave({
})
.then<EditorSaveDrainResult>(() => {
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
lastFailureReportRef.current = null;
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
return { status: "clean" };
@@ -1,32 +1,49 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { mountReactHarness } from "./domSelectionTestHarness";
import { GsapEditBlockedError } from "./gsapEditOutcome";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
trackStudioEditBlocked: vi.fn(),
trackStudioSaveFailure: vi.fn(),
}));
vi.mock("../utils/studioSaveDiagnostics", () => ({
trackStudioEditBlocked,
trackStudioSaveFailure,
}));
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
const selection = {
id: "clip",
selector: "#clip",
element: document.createElement("div"),
} as unknown as DomEditSelection;
function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
function Harness() {
report = useGsapInteractionFailureTelemetry("index.html", showToast);
return null;
}
const root = mountReactHarness(<Harness />);
return { report, root };
}
describe("useGsapInteractionFailureTelemetry", () => {
it("surfaces the blocked reason instead of a generic save failure", () => {
beforeEach(() => {
trackStudioEditBlocked.mockClear();
trackStudioSaveFailure.mockClear();
});
it("tracks an expected edit block separately from save failures", () => {
const showToast = vi.fn();
const selection = {
id: "clip",
selector: "#clip",
element: document.createElement("div"),
} as unknown as DomEditSelection;
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
function Harness() {
report = useGsapInteractionFailureTelemetry("index.html", showToast);
return null;
}
const root = mountReactHarness(<Harness />);
const { report, root } = mountFailureTelemetry(showToast);
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
@@ -34,9 +51,24 @@ describe("useGsapInteractionFailureTelemetry", () => {
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
"error",
);
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
expect(trackStudioEditBlocked).toHaveBeenCalledWith(
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
);
expect(trackStudioSaveFailure).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("keeps unexpected GSAP persistence errors in save_failure", () => {
const showToast = vi.fn();
const { report, root } = mountFailureTelemetry(showToast);
const error = new Error("network dropped");
act(() => report(error, selection, "drag", "Move"));
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
expect.objectContaining({ source: "gsap_commit", error, mutationType: "drag" }),
);
expect(trackStudioEditBlocked).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { isGsapEditBlockedError } from "./gsapEditOutcome";
export function useGsapInteractionFailureTelemetry(
@@ -9,7 +9,10 @@ export function useGsapInteractionFailureTelemetry(
) {
return useCallback(
(error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
trackStudioSaveFailure({
const report = isGsapEditBlockedError(error)
? trackStudioEditBlocked
: trackStudioSaveFailure;
report({
source: "gsap_commit",
error,
filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
@@ -1,12 +1,16 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
consumeStudioWriteToken,
createStudioWriteToken,
markStudioWriteToken,
resetStudioWriteTokens,
studioExpectedFileVersion,
studioFileContentVersion,
studioWriteHeaders,
} from "./studioFileVersion";
afterEach(() => vi.unstubAllGlobals());
describe("studioFileContentVersion", () => {
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
await expect(studioFileContentVersion("abc")).resolves.toBe(
@@ -42,6 +46,38 @@ describe("studioFileContentVersion", () => {
});
describe("studio write-token echo identity", () => {
it("prefers the platform randomUUID implementation", () => {
const randomUUID = vi.fn(() => "11111111-2222-4333-8444-555555555555");
vi.stubGlobal("crypto", { randomUUID });
resetStudioWriteTokens();
expect(studioWriteHeaders()).toEqual({
"X-Hyperframes-Write-Token": "11111111-2222-4333-8444-555555555555",
});
expect(randomUUID).toHaveBeenCalledOnce();
expect(consumeStudioWriteToken("11111111-2222-4333-8444-555555555555")).toBe(true);
});
it("creates an RFC 4122 UUID-v4 token from getRandomValues when randomUUID is unavailable", () => {
const source = Uint8Array.from({ length: 16 }, (_, index) => index);
vi.stubGlobal("crypto", {
getRandomValues: vi.fn((target: Uint8Array) => {
target.set(source);
return target;
}),
});
expect(createStudioWriteToken()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
});
it("fails explicitly when Web Crypto cannot provide secure random bytes", () => {
vi.stubGlobal("crypto", {});
expect(() => createStudioWriteToken()).toThrow(
"Web Crypto getRandomValues is required for Studio write identity",
);
});
it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
resetStudioWriteTokens();
markStudioWriteToken("studio-write-1");
+12 -2
View File
@@ -48,8 +48,18 @@ export async function studioExpectedFileVersion(
return versions.get(path);
}
function createStudioWriteToken(): string {
return globalThis.crypto.randomUUID();
export function createStudioWriteToken(): string {
const webCrypto = globalThis.crypto;
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
if (typeof webCrypto?.getRandomValues !== "function") {
throw new Error("Web Crypto getRandomValues is required for Studio write identity");
}
const bytes = webCrypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
@@ -1,4 +1,8 @@
import { describe, expect, it, vi } from "vitest";
const trackStudioEvent = vi.hoisted(() => vi.fn());
vi.mock("./studioTelemetry", () => ({ trackStudioEvent }));
import {
StudioFileConflictError,
StudioSaveHttpError,
@@ -6,6 +10,7 @@ import {
buildStudioSaveFailureProperties,
getStudioSaveStatusCode,
retryStudioSave,
trackStudioEditBlocked,
} from "./studioSaveDiagnostics";
describe("studio save diagnostics", () => {
@@ -51,6 +56,30 @@ describe("studio save diagnostics", () => {
});
});
it("emits expected direct-edit refusals on edit_blocked", () => {
const error = new Error("This animation is computed at runtime");
trackStudioEditBlocked({
source: "gsap_commit",
error,
filePath: "index.html",
mutationType: "drag",
});
expect(trackStudioEvent).toHaveBeenCalledWith("edit_blocked", {
source: "gsap_commit",
error_message: error.message,
status_code: null,
file_path: "index.html",
mutation_type: "drag",
attempt: undefined,
label: undefined,
target_id: undefined,
target_selector: undefined,
target_source_file: undefined,
});
});
it("reads nested status codes from error causes", () => {
const cause = new StudioSaveHttpError("Too many requests", 429);
const error = new Error("retry wrapper") as Error & { cause?: unknown };
@@ -172,6 +172,10 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
}
export function trackStudioEditBlocked(input: StudioSaveFailureInput): void {
trackStudioEvent("edit_blocked", buildStudioSaveFailureProperties(input));
}
export async function createStudioSaveHttpError(
response: Response,
fallbackMessage: string,