mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): figma import telemetry — subcommand labels, typed error codes, figma_import event (#1979)
Closes the observability gaps on the figma integration: - withFigmaErrors takes a command label (figma:asset|tokens|component) and reports the failure inline before its process.exit — the top-level trackCommandFailures wrapper never sees self-exiting commands, so typed codes (NO_TOKEN, BAD_TOKEN, FORBIDDEN, RATE_LIMITED) were invisible. FigmaClientError codes surface as the error name for dashboarding the first-run funnel (NO_TOKEN -> later success = onboarding conversion). - new figma_import event per import: phase, duration, reused (dedup effectiveness), tokens variables-vs-styles mode + entry count (Enterprise gating rate), unresolved-binding + rasterized-node counts (fidelity degradation). No fileKeys, node ids, names, or descriptions. - /figma skill fires the events beacon (figma-motion / figma-shaders / figma-storyboard) for the MCP phases that never touch the CLI. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
232d591479
commit
e9076324e7
@@ -179,7 +179,8 @@ export default defineCommand({
|
|||||||
dir: { type: "string", description: "project directory", default: "." },
|
dir: { type: "string", description: "project directory", default: "." },
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
await withFigmaErrors(async () => {
|
await withFigmaErrors("figma:asset", async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
const token = process.env.FIGMA_TOKEN ?? "";
|
const token = process.env.FIGMA_TOKEN ?? "";
|
||||||
const client = createFigmaClient({ token });
|
const client = createFigmaClient({ token });
|
||||||
const result = await runAssetImport(
|
const result = await runAssetImport(
|
||||||
@@ -195,6 +196,8 @@ export default defineCommand({
|
|||||||
const verb = result.reused ? "reused" : "imported";
|
const verb = result.reused ? "reused" : "imported";
|
||||||
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
|
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
|
||||||
console.log(result.snippet.html);
|
console.log(result.snippet.html);
|
||||||
|
const { trackFigmaImport } = await import("../../telemetry/index.js");
|
||||||
|
trackFigmaImport({ phase: "asset", reused: result.reused, durationMs: Date.now() - t0 });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,36 @@
|
|||||||
* format) all carry actionable, user-facing messages — present them via
|
* format) all carry actionable, user-facing messages — present them via
|
||||||
* the CLI's standard errorBox, not a stack trace. Non-Error throws still
|
* the CLI's standard errorBox, not a stack trace. Non-Error throws still
|
||||||
* surface raw.
|
* surface raw.
|
||||||
|
*
|
||||||
|
* Because this exits the process itself, it must ALSO report the failure
|
||||||
|
* inline (the top-level trackCommandFailures wrapper never sees it) — the
|
||||||
|
* typed error name (FigmaClientError code) is the whole first-run funnel:
|
||||||
|
* NO_TOKEN → later success is onboarding conversion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { FigmaClientError } from "@hyperframes/core/figma";
|
||||||
import { errorBox } from "../../ui/format.js";
|
import { errorBox } from "../../ui/format.js";
|
||||||
|
|
||||||
export async function withFigmaErrors(fn: () => Promise<void>): Promise<void> {
|
export async function withFigmaErrors(command: string, fn: () => Promise<void>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
try {
|
||||||
|
const telemetry = await import("../../telemetry/index.js");
|
||||||
|
// Surface the typed code (NO_TOKEN, BAD_TOKEN, RATE_LIMITED, …) as the
|
||||||
|
// error name — `FigmaClientError` alone says nothing in a dashboard.
|
||||||
|
telemetry.trackCliError({
|
||||||
|
error_name: err instanceof FigmaClientError ? err.code : err.name,
|
||||||
|
error_message: err.message,
|
||||||
|
stack_trace: err.stack,
|
||||||
|
command,
|
||||||
|
kind: "command_error",
|
||||||
|
});
|
||||||
|
await telemetry.flush();
|
||||||
|
} catch {
|
||||||
|
// Telemetry must never mask the real command failure.
|
||||||
|
}
|
||||||
const [title = "figma command failed", ...rest] = err.message.split("\n");
|
const [title = "figma command failed", ...rest] = err.message.split("\n");
|
||||||
errorBox(title, rest.length > 0 ? rest.join("\n") : undefined);
|
errorBox(title, rest.length > 0 ? rest.join("\n") : undefined);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ export default defineCommand({
|
|||||||
dir: { type: "string", description: "project directory", default: "." },
|
dir: { type: "string", description: "project directory", default: "." },
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
await withFigmaErrors(async () => {
|
await withFigmaErrors("figma:component", async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
||||||
const result = await runComponentImport(args.ref, {
|
const result = await runComponentImport(args.ref, {
|
||||||
projectDir: args.dir,
|
projectDir: args.dir,
|
||||||
@@ -145,6 +146,13 @@ export default defineCommand({
|
|||||||
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
|
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const { trackFigmaImport } = await import("../../telemetry/index.js");
|
||||||
|
trackFigmaImport({
|
||||||
|
phase: "component",
|
||||||
|
unresolvedBindings: result.unresolved.length,
|
||||||
|
rasterizedNodes: result.rasterized.length,
|
||||||
|
durationMs: Date.now() - t0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
// Semantic pin for the /figma skill's telemetry instructions: the MCP-only
|
||||||
|
// phases (motion/shaders/storyboards) have NO CLI touchpoint, so the beacon
|
||||||
|
// wording in SKILL.md is the only thing that produces their usage signal. The
|
||||||
|
// manifest hash proves the skill changed; this proves a future prompt edit
|
||||||
|
// didn't silently drop the beacon slugs or the completion event.
|
||||||
|
const SKILL_MD = readFileSync(
|
||||||
|
join(
|
||||||
|
fileURLToPath(new URL(".", import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"skills",
|
||||||
|
"figma",
|
||||||
|
"SKILL.md",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("figma SKILL.md telemetry beacons", () => {
|
||||||
|
it("instructs the beacon for every MCP-only phase", () => {
|
||||||
|
expect(SKILL_MD).toContain("figma-motion");
|
||||||
|
expect(SKILL_MD).toContain("figma-shaders");
|
||||||
|
expect(SKILL_MD).toContain("figma-storyboard");
|
||||||
|
expect(SKILL_MD).toContain("hyperframes events");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instructs the completion beacon with an outcome", () => {
|
||||||
|
expect(SKILL_MD).toContain("--event=skill_completed");
|
||||||
|
expect(SKILL_MD).toMatch(/--outcome=success\|error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,7 +78,8 @@ export default defineCommand({
|
|||||||
dir: { type: "string", description: "project directory", default: "." },
|
dir: { type: "string", description: "project directory", default: "." },
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
await withFigmaErrors(async () => {
|
await withFigmaErrors("figma:tokens", async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
||||||
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
|
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
|
||||||
if (result.mode === "styles") {
|
if (result.mode === "styles") {
|
||||||
@@ -91,6 +92,13 @@ export default defineCommand({
|
|||||||
console.log("add to data-composition-variables:");
|
console.log("add to data-composition-variables:");
|
||||||
console.log(JSON.stringify(result.entries, null, 2));
|
console.log(JSON.stringify(result.entries, null, 2));
|
||||||
}
|
}
|
||||||
|
const { trackFigmaImport } = await import("../../telemetry/index.js");
|
||||||
|
trackFigmaImport({
|
||||||
|
phase: "tokens",
|
||||||
|
tokensMode: result.mode,
|
||||||
|
entryCount: result.entries.length,
|
||||||
|
durationMs: Date.now() - t0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
trackRenderObservation,
|
trackRenderObservation,
|
||||||
trackCommandFailure,
|
trackCommandFailure,
|
||||||
trackCliError,
|
trackCliError,
|
||||||
|
trackFigmaImport,
|
||||||
trackRenderFeedback,
|
trackRenderFeedback,
|
||||||
trackRenderPreflightRejected,
|
trackRenderPreflightRejected,
|
||||||
} = await import("./events.js");
|
} = await import("./events.js");
|
||||||
@@ -187,3 +188,41 @@ describe("trackCommandFailure", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("trackFigmaImport", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
trackEvent.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits figma_import with phase + quality counters, no identifiers", () => {
|
||||||
|
trackFigmaImport({
|
||||||
|
phase: "component",
|
||||||
|
durationMs: 1234,
|
||||||
|
unresolvedBindings: 2,
|
||||||
|
rasterizedNodes: 3,
|
||||||
|
});
|
||||||
|
expect(trackEvent).toHaveBeenCalledWith("figma_import", {
|
||||||
|
phase: "component",
|
||||||
|
duration_ms: 1234,
|
||||||
|
unresolved_bindings: 2,
|
||||||
|
rasterized_nodes: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries reused for the asset phase and omits absent props entirely", () => {
|
||||||
|
trackFigmaImport({ phase: "asset", durationMs: 42, reused: true });
|
||||||
|
expect(trackEvent).toHaveBeenCalledWith("figma_import", {
|
||||||
|
phase: "asset",
|
||||||
|
duration_ms: 42,
|
||||||
|
reused: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries tokens mode + entry count for the tokens phase", () => {
|
||||||
|
trackFigmaImport({ phase: "tokens", durationMs: 10, tokensMode: "styles", entryCount: 0 });
|
||||||
|
expect(trackEvent).toHaveBeenCalledWith(
|
||||||
|
"figma_import",
|
||||||
|
expect.objectContaining({ phase: "tokens", tokens_mode: "styles", entry_count: 0 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -338,6 +338,33 @@ export function trackCliError(props: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One figma import outcome (asset/tokens/component). Carries capability mix,
|
||||||
|
* dedup effectiveness, and fidelity-degradation counts — never fileKeys,
|
||||||
|
* node ids, names, or descriptions.
|
||||||
|
*/
|
||||||
|
export function trackFigmaImport(props: {
|
||||||
|
phase: "asset" | "tokens" | "component";
|
||||||
|
durationMs: number;
|
||||||
|
reused?: boolean;
|
||||||
|
tokensMode?: "variables" | "styles";
|
||||||
|
entryCount?: number;
|
||||||
|
unresolvedBindings?: number;
|
||||||
|
rasterizedNodes?: number;
|
||||||
|
}): void {
|
||||||
|
trackEvent("figma_import", {
|
||||||
|
phase: props.phase,
|
||||||
|
duration_ms: props.durationMs,
|
||||||
|
...(props.reused !== undefined ? { reused: props.reused } : {}),
|
||||||
|
...(props.tokensMode !== undefined ? { tokens_mode: props.tokensMode } : {}),
|
||||||
|
...(props.entryCount !== undefined ? { entry_count: props.entryCount } : {}),
|
||||||
|
...(props.unresolvedBindings !== undefined
|
||||||
|
? { unresolved_bindings: props.unresolvedBindings }
|
||||||
|
: {}),
|
||||||
|
...(props.rasterizedNodes !== undefined ? { rasterized_nodes: props.rasterizedNodes } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Report why a command failed before it exits non-zero. cli_command_result
|
// Report why a command failed before it exits non-zero. cli_command_result
|
||||||
// records the failure but not the reason; this fills that gap via cli_error so
|
// records the failure but not the reason; this fills that gap via cli_error so
|
||||||
// command failures are diagnosable. Enqueues synchronously — the process `exit`
|
// command failures are diagnosable. Enqueues synchronously — the process `exit`
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export {
|
|||||||
trackBrowserInstall,
|
trackBrowserInstall,
|
||||||
trackCliError,
|
trackCliError,
|
||||||
trackCommandResult,
|
trackCommandResult,
|
||||||
|
trackFigmaImport,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";
|
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"files": 18
|
"files": 18
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
"hash": "0adc2a1e01767db7",
|
"hash": "4c3977e79a17d498",
|
||||||
"files": 1
|
"files": 1
|
||||||
},
|
},
|
||||||
"general-video": {
|
"general-video": {
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ Node tree → editable HTML at exact figma geometry, packaged as a registry item
|
|||||||
|
|
||||||
## Motion (Phase 4 — MCP, the headline)
|
## Motion (Phase 4 — MCP, the headline)
|
||||||
|
|
||||||
|
**Usage beacon:** MCP phases have no CLI touchpoint, so fire the skill beacon at start and finish (anonymous, consent-gated, never fails): `npx hyperframes events --skill=figma-motion` when you begin, `npx hyperframes events --skill=figma-motion --event=skill_completed --outcome=success|error` when done. Same for shaders (`figma-shaders`) and storyboards (`figma-storyboard`).
|
||||||
|
|
||||||
No REST equivalent exists. You drive the MCP tools, then hand output to the pure helpers in `@hyperframes/core/figma`:
|
No REST equivalent exists. You drive the MCP tools, then hand output to the pure helpers in `@hyperframes/core/figma`:
|
||||||
|
|
||||||
1. `get_motion_context(fileKey, nodeId)` — use `recursive:true` on the parent frame (one call for the whole scene, not one per element). Save the raw JSON next to the project (`.media/figma-cache/`) so retranslation is free.
|
1. `get_motion_context(fileKey, nodeId)` — use `recursive:true` on the parent frame (one call for the whole scene, not one per element). Save the raw JSON next to the project (`.media/figma-cache/`) so retranslation is free.
|
||||||
|
|||||||
Reference in New Issue
Block a user