Skip to content

Commit 28be8dd

Browse files
fix(studio): correct save failure telemetry (#3499)
1 parent 0fd70b1 commit 28be8dd

8 files changed

Lines changed: 247 additions & 30 deletions

packages/studio/src/hooks/useEditorSave.test.tsx

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@
22
import { act } from "react";
33
import { createRoot } from "react-dom/client";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5-
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
5+
6+
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
7+
vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
8+
...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
9+
trackStudioSaveFailure,
10+
}));
11+
612
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
13+
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
714

815
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
916

1017
type WriteProjectFile = (path: string, content: string, expectedContent?: string) => Promise<void>;
1118

1219
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
1320
const captured: { handle: EditorSaveHandle | null } = { handle: null };
21+
const showToast = vi.fn();
1422

1523
function Probe() {
1624
captured.handle = useEditorSave({
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
2129
recordEdit: vi.fn(async () => undefined),
2230
domEditSaveTimestampRef: { current: 0 },
2331
setRefreshKey: vi.fn(),
24-
showToast: vi.fn(),
32+
showToast,
2533
});
2634
return null;
2735
}
@@ -32,20 +40,25 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
3240

3341
return {
3442
handle: captured.handle,
43+
showToast,
3544
unmount: () => act(async () => root.unmount()),
3645
};
3746
}
3847

3948
describe("useEditorSave pending work", () => {
4049
beforeEach(() => {
50+
trackStudioSaveFailure.mockClear();
4151
vi.stubGlobal(
4252
"requestAnimationFrame",
4353
vi.fn(() => 41),
4454
);
4555
vi.stubGlobal("cancelAnimationFrame", vi.fn());
4656
});
4757

48-
afterEach(() => vi.unstubAllGlobals());
58+
afterEach(() => {
59+
vi.restoreAllMocks();
60+
vi.unstubAllGlobals();
61+
});
4962

5063
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
5164
const writeProjectFile = vi.fn(async () => undefined);
@@ -111,7 +124,72 @@ describe("useEditorSave pending work", () => {
111124
status: "conflict",
112125
error: conflict,
113126
});
127+
expect(trackStudioSaveFailure).toHaveBeenCalledWith({
128+
source: "code_editor",
129+
error: conflict,
130+
filePath: "index.html",
131+
});
132+
133+
await mounted.unmount();
134+
});
135+
136+
it("emits one identical failure per five-second burst", async () => {
137+
vi.spyOn(Date, "now").mockReturnValue(1_000);
138+
const error = new Error("Load failed");
139+
const mounted = await mountEditorSave(async () => {
140+
throw error;
141+
});
142+
143+
act(() => mounted.handle.handleContentChange("first candidate"));
144+
await mounted.handle.flushPendingSave();
145+
vi.spyOn(Date, "now").mockReturnValue(2_000);
146+
act(() => mounted.handle.handleContentChange("second candidate"));
147+
await mounted.handle.flushPendingSave();
148+
149+
expect(trackStudioSaveFailure).toHaveBeenCalledOnce();
150+
expect(mounted.showToast).toHaveBeenCalledOnce();
151+
await mounted.unmount();
152+
});
153+
154+
it("emits a changed failure immediately and repeats after the burst window", async () => {
155+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
156+
const writeProjectFile = vi
157+
.fn<WriteProjectFile>()
158+
.mockRejectedValueOnce(new Error("Load failed"))
159+
.mockRejectedValueOnce(new Error("Failed to fetch"))
160+
.mockRejectedValueOnce(new Error("Failed to fetch"));
161+
const mounted = await mountEditorSave(writeProjectFile);
162+
163+
act(() => mounted.handle.handleContentChange("first candidate"));
164+
await mounted.handle.flushPendingSave();
165+
now.mockReturnValue(2_000);
166+
act(() => mounted.handle.handleContentChange("second candidate"));
167+
await mounted.handle.flushPendingSave();
168+
now.mockReturnValue(8_000);
169+
act(() => mounted.handle.handleContentChange("third candidate"));
170+
await mounted.handle.flushPendingSave();
171+
172+
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(3);
173+
await mounted.unmount();
174+
});
175+
176+
it("emits the same failure again after a successful save", async () => {
177+
vi.spyOn(Date, "now").mockReturnValue(1_000);
178+
const writeProjectFile = vi
179+
.fn<WriteProjectFile>()
180+
.mockRejectedValueOnce(new Error("Load failed"))
181+
.mockResolvedValueOnce(undefined)
182+
.mockRejectedValueOnce(new Error("Load failed"));
183+
const mounted = await mountEditorSave(writeProjectFile);
184+
185+
act(() => mounted.handle.handleContentChange("first candidate"));
186+
await mounted.handle.flushPendingSave();
187+
act(() => mounted.handle.handleContentChange("successful candidate"));
188+
await mounted.handle.flushPendingSave();
189+
act(() => mounted.handle.handleContentChange("third candidate"));
190+
await mounted.handle.flushPendingSave();
114191

192+
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(2);
115193
await mounted.unmount();
116194
});
117195

packages/studio/src/hooks/useEditorSave.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { useCallback, useRef } from "react";
22
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
33
import type { EditHistoryKind } from "../utils/editHistory";
4-
import { trackStudioEvent } from "../utils/studioTelemetry";
54
import {
65
StudioFileConflictError,
6+
buildStudioSaveFailureProperties,
7+
trackStudioSaveFailure,
78
type StudioSaveDrainResult,
89
} from "../utils/studioSaveDiagnostics";
910

11+
const FAILURE_BURST_MS = 5_000;
12+
1013
interface RecordEditInput {
1114
label: string;
1215
kind: EditHistoryKind;
@@ -58,19 +61,40 @@ export function useEditorSave({
5861
const refreshRafRef = useRef<number | null>(null);
5962
// One error toast per burst of failures — every keystroke retries the save,
6063
// and error toasts persist until dismissed, so don't stack duplicates.
61-
const lastFailureToastAtRef = useRef(0);
64+
const lastFailureToastAtRef = useRef<number | null>(null);
65+
const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null);
6266
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
6367
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
6468
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
6569

6670
const reportFailure = useCallback(
6771
(path: string, error: unknown) => {
68-
trackStudioEvent("save_failure", {
72+
const now = Date.now();
73+
const properties = buildStudioSaveFailureProperties({
6974
source: "code_editor",
70-
error_message: error instanceof Error ? error.message : "unknown",
75+
error,
76+
filePath: path,
7177
});
72-
const now = Date.now();
73-
if (now - lastFailureToastAtRef.current > 5000) {
78+
const errorName = error instanceof Error ? error.name : typeof error;
79+
const fingerprint = JSON.stringify([
80+
path,
81+
errorName,
82+
properties.error_message,
83+
properties.status_code,
84+
]);
85+
const previous = lastFailureReportRef.current;
86+
if (
87+
previous === null ||
88+
previous.fingerprint !== fingerprint ||
89+
now - previous.emittedAt >= FAILURE_BURST_MS
90+
) {
91+
trackStudioSaveFailure({ source: "code_editor", error, filePath: path });
92+
lastFailureReportRef.current = { fingerprint, emittedAt: now };
93+
}
94+
if (
95+
lastFailureToastAtRef.current === null ||
96+
now - lastFailureToastAtRef.current >= FAILURE_BURST_MS
97+
) {
7498
lastFailureToastAtRef.current = now;
7599
showToast(
76100
`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({
95119
})
96120
.then<EditorSaveDrainResult>(() => {
97121
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
122+
lastFailureReportRef.current = null;
98123
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
99124
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
100125
return { status: "clean" };
Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,74 @@
11
// @vitest-environment happy-dom
22

33
import React, { act } from "react";
4-
import { describe, expect, it, vi } from "vitest";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import type { DomEditSelection } from "../components/editor/domEditingTypes";
66
import { mountReactHarness } from "./domSelectionTestHarness";
77
import { GsapEditBlockedError } from "./gsapEditOutcome";
88

99
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1010

11-
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
12-
vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
11+
const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
12+
trackStudioEditBlocked: vi.fn(),
13+
trackStudioSaveFailure: vi.fn(),
14+
}));
15+
vi.mock("../utils/studioSaveDiagnostics", () => ({
16+
trackStudioEditBlocked,
17+
trackStudioSaveFailure,
18+
}));
1319

1420
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
1521

22+
const selection = {
23+
id: "clip",
24+
selector: "#clip",
25+
element: document.createElement("div"),
26+
} as unknown as DomEditSelection;
27+
28+
function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
29+
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
30+
function Harness() {
31+
report = useGsapInteractionFailureTelemetry("index.html", showToast);
32+
return null;
33+
}
34+
const root = mountReactHarness(<Harness />);
35+
return { report, root };
36+
}
37+
1638
describe("useGsapInteractionFailureTelemetry", () => {
17-
it("surfaces the blocked reason instead of a generic save failure", () => {
39+
beforeEach(() => {
40+
trackStudioEditBlocked.mockClear();
41+
trackStudioSaveFailure.mockClear();
42+
});
43+
44+
it("tracks an expected edit block separately from save failures", () => {
1845
const showToast = vi.fn();
19-
const selection = {
20-
id: "clip",
21-
selector: "#clip",
22-
element: document.createElement("div"),
23-
} as unknown as DomEditSelection;
24-
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
25-
function Harness() {
26-
report = useGsapInteractionFailureTelemetry("index.html", showToast);
27-
return null;
28-
}
29-
const root = mountReactHarness(<Harness />);
46+
const { report, root } = mountFailureTelemetry(showToast);
3047

3148
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
3249

3350
expect(showToast).toHaveBeenCalledWith(
3451
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
3552
"error",
3653
);
37-
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
54+
expect(trackStudioEditBlocked).toHaveBeenCalledWith(
3855
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
3956
);
57+
expect(trackStudioSaveFailure).not.toHaveBeenCalled();
58+
act(() => root.unmount());
59+
});
60+
61+
it("keeps unexpected GSAP persistence errors in save_failure", () => {
62+
const showToast = vi.fn();
63+
const { report, root } = mountFailureTelemetry(showToast);
64+
const error = new Error("network dropped");
65+
66+
act(() => report(error, selection, "drag", "Move"));
67+
68+
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
69+
expect.objectContaining({ source: "gsap_commit", error, mutationType: "drag" }),
70+
);
71+
expect(trackStudioEditBlocked).not.toHaveBeenCalled();
4072
act(() => root.unmount());
4173
});
4274
});

packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback } from "react";
22
import type { DomEditSelection } from "../components/editor/domEditing";
3-
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
3+
import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
44
import { isGsapEditBlockedError } from "./gsapEditOutcome";
55

66
export function useGsapInteractionFailureTelemetry(
@@ -9,7 +9,10 @@ export function useGsapInteractionFailureTelemetry(
99
) {
1010
return useCallback(
1111
(error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
12-
trackStudioSaveFailure({
12+
const report = isGsapEditBlockedError(error)
13+
? trackStudioEditBlocked
14+
: trackStudioSaveFailure;
15+
report({
1316
source: "gsap_commit",
1417
error,
1518
filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",

packages/studio/src/utils/studioFileVersion.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
33
consumeStudioWriteToken,
4+
createStudioWriteToken,
45
markStudioWriteToken,
56
resetStudioWriteTokens,
67
studioExpectedFileVersion,
78
studioFileContentVersion,
9+
studioWriteHeaders,
810
} from "./studioFileVersion";
911

12+
afterEach(() => vi.unstubAllGlobals());
13+
1014
describe("studioFileContentVersion", () => {
1115
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
1216
await expect(studioFileContentVersion("abc")).resolves.toBe(
@@ -42,6 +46,38 @@ describe("studioFileContentVersion", () => {
4246
});
4347

4448
describe("studio write-token echo identity", () => {
49+
it("prefers the platform randomUUID implementation", () => {
50+
const randomUUID = vi.fn(() => "11111111-2222-4333-8444-555555555555");
51+
vi.stubGlobal("crypto", { randomUUID });
52+
resetStudioWriteTokens();
53+
54+
expect(studioWriteHeaders()).toEqual({
55+
"X-Hyperframes-Write-Token": "11111111-2222-4333-8444-555555555555",
56+
});
57+
expect(randomUUID).toHaveBeenCalledOnce();
58+
expect(consumeStudioWriteToken("11111111-2222-4333-8444-555555555555")).toBe(true);
59+
});
60+
61+
it("creates an RFC 4122 UUID-v4 token from getRandomValues when randomUUID is unavailable", () => {
62+
const source = Uint8Array.from({ length: 16 }, (_, index) => index);
63+
vi.stubGlobal("crypto", {
64+
getRandomValues: vi.fn((target: Uint8Array) => {
65+
target.set(source);
66+
return target;
67+
}),
68+
});
69+
70+
expect(createStudioWriteToken()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
71+
});
72+
73+
it("fails explicitly when Web Crypto cannot provide secure random bytes", () => {
74+
vi.stubGlobal("crypto", {});
75+
76+
expect(() => createStudioWriteToken()).toThrow(
77+
"Web Crypto getRandomValues is required for Studio write identity",
78+
);
79+
});
80+
4581
it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
4682
resetStudioWriteTokens();
4783
markStudioWriteToken("studio-write-1");

packages/studio/src/utils/studioFileVersion.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,18 @@ export async function studioExpectedFileVersion(
4848
return versions.get(path);
4949
}
5050

51-
function createStudioWriteToken(): string {
52-
return globalThis.crypto.randomUUID();
51+
export function createStudioWriteToken(): string {
52+
const webCrypto = globalThis.crypto;
53+
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
54+
if (typeof webCrypto?.getRandomValues !== "function") {
55+
throw new Error("Web Crypto getRandomValues is required for Studio write identity");
56+
}
57+
58+
const bytes = webCrypto.getRandomValues(new Uint8Array(16));
59+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
60+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
61+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
62+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
5363
}
5464

5565
/**

0 commit comments

Comments
 (0)