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:
Vance Ingalls
2026-07-05 23:27:58 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 232d591479
commit e9076324e7
10 changed files with 153 additions and 5 deletions
+4 -1
View File
@@ -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 });
});
},
});
+22 -1
View File
@@ -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);
+9 -1
View File
@@ -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/);
});
});
+9 -1
View File
@@ -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,
});
});
},
});
+39
View File
@@ -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 }),
);
});
});
+27
View File
@@ -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`
+1
View File
@@ -8,5 +8,6 @@ export {
trackBrowserInstall,
trackCliError,
trackCommandResult,
trackFigmaImport,
} from "./events.js";
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";