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: "." },
|
||||
},
|
||||
async run({ args }) {
|
||||
await withFigmaErrors(async () => {
|
||||
await withFigmaErrors("figma:asset", async () => {
|
||||
const t0 = Date.now();
|
||||
const token = process.env.FIGMA_TOKEN ?? "";
|
||||
const client = createFigmaClient({ token });
|
||||
const result = await runAssetImport(
|
||||
@@ -195,6 +196,8 @@ export default defineCommand({
|
||||
const verb = result.reused ? "reused" : "imported";
|
||||
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
|
||||
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
|
||||
* the CLI's standard errorBox, not a stack trace. Non-Error throws still
|
||||
* 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";
|
||||
|
||||
export async function withFigmaErrors(fn: () => Promise<void>): Promise<void> {
|
||||
export async function withFigmaErrors(command: string, fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
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");
|
||||
errorBox(title, rest.length > 0 ? rest.join("\n") : undefined);
|
||||
process.exit(1);
|
||||
|
||||
@@ -130,7 +130,8 @@ export default defineCommand({
|
||||
dir: { type: "string", description: "project directory", default: "." },
|
||||
},
|
||||
async run({ args }) {
|
||||
await withFigmaErrors(async () => {
|
||||
await withFigmaErrors("figma:component", async () => {
|
||||
const t0 = Date.now();
|
||||
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
||||
const result = await runComponentImport(args.ref, {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
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: "." },
|
||||
},
|
||||
async run({ args }) {
|
||||
await withFigmaErrors(async () => {
|
||||
await withFigmaErrors("figma:tokens", async () => {
|
||||
const t0 = Date.now();
|
||||
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
||||
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
|
||||
if (result.mode === "styles") {
|
||||
@@ -91,6 +92,13 @@ export default defineCommand({
|
||||
console.log("add to data-composition-variables:");
|
||||
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,
|
||||
trackCommandFailure,
|
||||
trackCliError,
|
||||
trackFigmaImport,
|
||||
trackRenderFeedback,
|
||||
trackRenderPreflightRejected,
|
||||
} = 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
|
||||
// records the failure but not the reason; this fills that gap via cli_error so
|
||||
// command failures are diagnosable. Enqueues synchronously — the process `exit`
|
||||
|
||||
@@ -8,5 +8,6 @@ export {
|
||||
trackBrowserInstall,
|
||||
trackCliError,
|
||||
trackCommandResult,
|
||||
trackFigmaImport,
|
||||
} from "./events.js";
|
||||
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";
|
||||
|
||||
Reference in New Issue
Block a user