From 0a7d026e93a91c3008ee4870c3b359e8e78ba9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 26 Aug 2026 08:10:00 +0000 Subject: [PATCH] fix(studio): correct save failure telemetry --- .../studio/src/hooks/useEditorSave.test.tsx | 84 ++++++++++++++++++- packages/studio/src/hooks/useEditorSave.ts | 37 ++++++-- ...seGsapInteractionFailureTelemetry.test.tsx | 64 ++++++++++---- .../useGsapInteractionFailureTelemetry.ts | 7 +- .../src/utils/studioFileVersion.test.ts | 38 ++++++++- .../studio/src/utils/studioFileVersion.ts | 14 +++- .../src/utils/studioSaveDiagnostics.test.ts | 29 +++++++ .../studio/src/utils/studioSaveDiagnostics.ts | 4 + 8 files changed, 247 insertions(+), 30 deletions(-) diff --git a/packages/studio/src/hooks/useEditorSave.test.tsx b/packages/studio/src/hooks/useEditorSave.test.tsx index ee5b7bf82e..f122ef0484 100644 --- a/packages/studio/src/hooks/useEditorSave.test.tsx +++ b/packages/studio/src/hooks/useEditorSave.test.tsx @@ -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()), + 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,7 +124,72 @@ 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() + .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() + .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(); }); diff --git a/packages/studio/src/hooks/useEditorSave.ts b/packages/studio/src/hooks/useEditorSave.ts index f00fd92465..7bb9486038 100644 --- a/packages/studio/src/hooks/useEditorSave.ts +++ b/packages/studio/src/hooks/useEditorSave.ts @@ -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(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(null); + const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null); const pendingCandidateRef = useRef(null); const inFlightRef = useRef | null>(null); const inFlightCandidateRef = useRef(null); const reportFailure = useCallback( (path: string, error: unknown) => { - trackStudioEvent("save_failure", { + const now = Date.now(); + const properties = buildStudioSaveFailureProperties({ source: "code_editor", - error_message: error instanceof Error ? error.message : "unknown", + error, + filePath: path, }); - const now = Date.now(); - if (now - lastFailureToastAtRef.current > 5000) { + 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(() => { 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" }; diff --git a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.test.tsx b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.test.tsx index 789840ff2f..34e1ef06e2 100644 --- a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +++ b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.test.tsx @@ -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) { + let report!: ReturnType; + function Harness() { + report = useGsapInteractionFailureTelemetry("index.html", showToast); + return null; + } + const root = mountReactHarness(); + 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; - function Harness() { - report = useGsapInteractionFailureTelemetry("index.html", showToast); - return null; - } - const root = mountReactHarness(); + 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()); }); }); diff --git a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts index cce77f55b1..8502e27e5a 100644 --- a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts +++ b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts @@ -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", diff --git a/packages/studio/src/utils/studioFileVersion.test.ts b/packages/studio/src/utils/studioFileVersion.test.ts index f17b395013..e33b342b1c 100644 --- a/packages/studio/src/utils/studioFileVersion.test.ts +++ b/packages/studio/src/utils/studioFileVersion.test.ts @@ -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"); diff --git a/packages/studio/src/utils/studioFileVersion.ts b/packages/studio/src/utils/studioFileVersion.ts index 685cb95924..46b3bee8f1 100644 --- a/packages/studio/src/utils/studioFileVersion.ts +++ b/packages/studio/src/utils/studioFileVersion.ts @@ -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)}`; } /** diff --git a/packages/studio/src/utils/studioSaveDiagnostics.test.ts b/packages/studio/src/utils/studioSaveDiagnostics.test.ts index 7d110b59e2..84a5486c07 100644 --- a/packages/studio/src/utils/studioSaveDiagnostics.test.ts +++ b/packages/studio/src/utils/studioSaveDiagnostics.test.ts @@ -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 }; diff --git a/packages/studio/src/utils/studioSaveDiagnostics.ts b/packages/studio/src/utils/studioSaveDiagnostics.ts index f635d02c9b..865723193c 100644 --- a/packages/studio/src/utils/studioSaveDiagnostics.ts +++ b/packages/studio/src/utils/studioSaveDiagnostics.ts @@ -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,