diff --git a/packages/cli/src/commands/figma/cliError.ts b/packages/cli/src/commands/figma/cliError.ts index 33e34ae01..ddcfbfa03 100644 --- a/packages/cli/src/commands/figma/cliError.ts +++ b/packages/cli/src/commands/figma/cliError.ts @@ -29,6 +29,7 @@ export async function withFigmaErrors(command: string, fn: () => Promise): stack_trace: err.stack, command, kind: "command_error", + endpoint: err instanceof FigmaClientError ? err.endpoint : undefined, }); await telemetry.flush(); } catch { diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index a9cbd1b5a..490c50f71 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -431,6 +431,21 @@ describe("trackCliError", () => { expect(props.error_message).toContain("[path]"); expect(props.stack_trace).not.toContain("/Users/alice"); }); + + it("forwards the figma endpoint label when supplied", () => { + trackCliError({ + error_name: "RATE_LIMITED", + error_message: "figma rate limit hit (429)", + command: "figma asset", + kind: "command_error", + endpoint: "images", + }); + + expect(trackEvent).toHaveBeenCalledWith( + "cli_error", + expect.objectContaining({ endpoint: "images" }), + ); + }); }); describe("trackCommandFailure", () => { diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 23ea37327..38128caa1 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -490,6 +490,9 @@ export function trackCliError(props: { stack_trace?: string; command?: string; kind: "uncaught_exception" | "unhandled_rejection" | "command_error"; + /** Low-cardinality figma REST call label (e.g. "images", "files_nodes") — + * which endpoint failed, for FigmaClientError-backed failures only. */ + endpoint?: string; }): void { trackEvent("cli_error", { error_name: props.error_name, @@ -502,6 +505,7 @@ export function trackCliError(props: { : undefined, command: props.command, kind: props.kind, + endpoint: props.endpoint, }); } diff --git a/packages/core/src/figma/client.test.ts b/packages/core/src/figma/client.test.ts index 3af01728a..c1dc0808e 100644 --- a/packages/core/src/figma/client.test.ts +++ b/packages/core/src/figma/client.test.ts @@ -271,6 +271,54 @@ describe("error mapping", () => { }); }); +describe("endpoint attribution", () => { + it("labels each call's error with a low-cardinality endpoint, never the raw fileKey/nodeId", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(500, {})).fetch, + }); + const cases: Array<[string, () => Promise]> = [ + ["images", () => client.renderNode({ fileKey: "SECRET", nodeId: "1:2" }, { format: "png" })], + ["images", () => client.renderNodes("SECRET", ["1:2"], { format: "png" })], + ["files_images", () => client.imageFills("SECRET")], + ["variables_local", () => client.variables("SECRET")], + ["styles", () => client.styles("SECRET")], + ["files_nodes", () => client.nodeTree({ fileKey: "SECRET", nodeId: "1:2" })], + ["file_meta", () => client.fileVersion("SECRET")], + ]; + for (const [expected, call] of cases) { + const err = await call().catch((e: unknown) => e); + expect(err).toBeInstanceOf(FigmaClientError); + if (err instanceof FigmaClientError) { + expect(err.endpoint).toBe(expected); + expect(err.endpoint).not.toContain("SECRET"); + } + } + }); + + it("still labels RENDER_FAILED and NODE_NOT_FOUND (thrown outside the shared get())", async () => { + const nullRender = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(200, { images: { "1:2": null } })).fetch, + }); + const renderErr = await nullRender + .renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "svg" }) + .catch((e: unknown) => e); + expect(renderErr).toBeInstanceOf(FigmaClientError); + if (renderErr instanceof FigmaClientError) expect(renderErr.endpoint).toBe("images"); + + const missingNode = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(200, { nodes: {} })).fetch, + }); + const notFoundErr = await missingNode + .nodeTree({ fileKey: "F", nodeId: "9:9" }) + .catch((e: unknown) => e); + expect(notFoundErr).toBeInstanceOf(FigmaClientError); + if (notFoundErr instanceof FigmaClientError) expect(notFoundErr.endpoint).toBe("files_nodes"); + }); +}); + describe("renderNodes (batch)", () => { it("fetches many nodes in ONE /v1/images call and maps each url", async () => { const stub = fetchStub(() => diff --git a/packages/core/src/figma/client.ts b/packages/core/src/figma/client.ts index 29efadd6d..8d8b2eed1 100644 --- a/packages/core/src/figma/client.ts +++ b/packages/core/src/figma/client.ts @@ -14,12 +14,16 @@ export type FigmaClientErrorCode = export class FigmaClientError extends Error { readonly code: FigmaClientErrorCode; readonly status?: number; + /** Low-cardinality REST call label (e.g. "images", "files_nodes") for + * telemetry attribution — never the raw fileKey/nodeId path. */ + readonly endpoint?: string; - constructor(code: FigmaClientErrorCode, message: string, status?: number) { + constructor(code: FigmaClientErrorCode, message: string, status?: number, endpoint?: string) { super(message); this.name = "FigmaClientError"; this.code = code; this.status = status; + this.endpoint = endpoint; } } @@ -217,6 +221,8 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { enterpriseGated?: boolean; /** scope named in a FORBIDDEN message so the user knows which to add. */ scopeHint?: string; + /** low-cardinality call label carried onto any thrown FigmaClientError. */ + endpoint: string; } /** Map a 403 to the right typed error using figma's own response body: @@ -233,18 +239,21 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { "BAD_TOKEN", "figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.", 403, + opts.endpoint, ); if (opts.enterpriseGated) return new FigmaClientError( "REQUIRES_ENTERPRISE", "figma variables require an Enterprise plan (403) — fall back to styles", 403, + opts.endpoint, ); if (body && /scope/i.test(body)) return new FigmaClientError( "FORBIDDEN", `figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`, 403, + opts.endpoint, ); const scopeLine = opts.scopeHint ? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.` @@ -253,6 +262,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { "FORBIDDEN", `figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`, 403, + opts.endpoint, ); } @@ -264,6 +274,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { "BAD_TOKEN", "figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.", 401, + opts.endpoint, ); if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts); if (res.status === 429) @@ -271,15 +282,17 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { "RATE_LIMITED", `figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`, 429, + opts.endpoint, ); throw new FigmaClientError( "HTTP_ERROR", `figma request failed: HTTP ${res.status} ${path}`, res.status, + opts.endpoint, ); } - async function get(path: string, opts: GetOptions = {}): Promise { + async function get(path: string, opts: GetOptions): Promise { // Retry 429 with backoff before surfacing RATE_LIMITED — figma's limit is // per-minute, so a couple of imports in quick succession hit it and a // short wait clears it. Honor Retry-After when present, else exponential. @@ -302,6 +315,8 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { throw new FigmaClientError( "RENDER_FAILED", `figma could not render node ${nodeId} as ${opts.format}`, + undefined, + "images", ); return { url: result.url, ext: opts.format }; }, @@ -314,6 +329,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { if (opts.scale !== undefined) params.set("scale", String(opts.scale)); const body = await get(`/v1/images/${fileKey}?${params}`, { scopeHint: SCOPE_HINTS.fileContent, + endpoint: "images", }); const images = isRecord(body) && isRecord(body.images) ? body.images : {}; return nodeIds.map((nodeId) => { @@ -327,7 +343,10 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { }, async imageFills(fileKey) { - const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent }); + const body = await get(`/v1/files/${fileKey}/images`, { + scopeHint: SCOPE_HINTS.fileContent, + endpoint: "files_images", + }); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const images = isRecord(meta.images) ? meta.images : {}; const out = new Map(); @@ -338,7 +357,10 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { }, async variables(fileKey) { - const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true }); + const body = await get(`/v1/files/${fileKey}/variables/local`, { + enterpriseGated: true, + endpoint: "variables_local", + }); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const variables = isRecord(meta.variables) ? meta.variables : {}; const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {}; @@ -353,6 +375,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { async styles(fileKey) { const body = await get(`/v1/files/${fileKey}/styles`, { scopeHint: SCOPE_HINTS.libraryContent, + endpoint: "styles", }); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const styles = Array.isArray(meta.styles) ? meta.styles : []; @@ -370,6 +393,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { const params = new URLSearchParams({ ids: nodeId, geometry: "paths" }); const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, { scopeHint: SCOPE_HINTS.fileContent, + endpoint: "files_nodes", }); const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {}; const entry = nodes[nodeId]; @@ -380,13 +404,19 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { typeof doc.name !== "string" || typeof doc.type !== "string" ) - throw new FigmaClientError("NODE_NOT_FOUND", `node ${nodeId} not found in ${ref.fileKey}`); + throw new FigmaClientError( + "NODE_NOT_FOUND", + `node ${nodeId} not found in ${ref.fileKey}`, + undefined, + "files_nodes", + ); return { ...doc, id: doc.id, name: doc.name, type: doc.type }; }, async fileVersion(fileKey) { const body = await get(`/v1/files/${fileKey}?depth=1`, { scopeHint: SCOPE_HINTS.fileMetadata, + endpoint: "file_meta", }); const version = isRecord(body) && typeof body.version === "string" ? body.version : ""; const lastModified =