mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(studio): correct save failure telemetry (#3499)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user