Skip to content

Commit 6c0666a

Browse files
authored
feat: add anonymous PostHog telemetry (#342)
* feat: add anonymous PostHog telemetry Anonymous, opt-out telemetry via PostHog (separate project from Framelink SaaS). Captures tool_called events for get_figma_data, download_figma_images, and the fetch CLI command, with payload size, node count, transport mode, and sanitized error details. - Random per-session UUID as distinct_id; PostHog GeoIP for rough unique-user dedup. No persistent state, no credential touching. - Disabled by --no-telemetry, FRAMELINK_TELEMETRY=off, or DO_NOT_TRACK=1. - One-line stderr notice on every startup. - Figma API key/OAuth token redacted from error messages before capture. - Graceful flush on SIGINT + SIGTERM in both stdio and HTTP modes. - Fetch CLI uses immediate flushing (flushAt:1, flushInterval:0) for the short-lived process. Bumps engines.node to >=20.0.0 (posthog-node requirement). * refactor(telemetry): address review findings - Discriminated union for ToolCallProperties (compile-time enforcement of per-tool fields; output_format now only on get_figma_data events). - Wrap PostHog capture/shutdown in try/catch — telemetry must never break the tool handler's return value via a finally that throws. - Tighten redaction min length to 8 chars to avoid garbling unrelated text from accidental short secrets. - Reset initialized=false in shutdown() so tests can re-init in the same process. - Extract shared get-figma-data pipeline (fetch → simplify → serialize → measure) into src/services/get-figma-data.ts so the MCP tool and the fetch CLI command don't drift. Tool uses progress hooks; CLI passes none. - Wrap onShutdown() in try/finally inside registerShutdownHandlers so process.exit(0) always runs. * chore: tighten Node engines to >=20.20.0 posthog-node 5.x requires ^20.20.0 || >=22.22.0. Match the lower bound honestly so install doesn't fail on stale Node 20 patches that the previous >=20.0.0 incorrectly claimed to support. * refactor(telemetry): move telemetry to edges via onComplete hooks Domain functions (getFigmaData, downloadFigmaImages) no longer know about telemetry. They fire a single onComplete lifecycle callback with timing + metrics + error; the shell wires it to telemetry (or anything else). - src/services/get-figma-data.ts: add GetFigmaDataOutcome type and onComplete hook fired from a finally. Observer errors swallowed. - src/services/download-figma-images.ts: NEW, extracts the download-plan dedup logic + downloadImages call from the tool handler. Same onComplete shape. Tool keeps param parsing, path validation, and result formatting (edge concerns). - src/services/telemetry.ts: unexport captureToolCall (now internal), export captureGetFigmaDataCall / captureDownloadImagesCall that take the outcome + a small { transport, authMode } context. The tool_called event construction lives here in internal translator functions. - src/mcp/tools/get-figma-data-tool.ts, download-figma-images-tool.ts, src/commands/fetch.ts: strip all telemetry bookkeeping (let-bindings, manual try/catch/finally). Shells are now thin — ~130 lines removed. * refactor(telemetry): generic redactFromErrors, simpler init surface - initTelemetry now takes { optOut, immediateFlush, redactFromErrors }. The credential-named fields (figmaApiKey, figmaOAuthToken) are gone — the shell just hands over any strings it wants scrubbed from error messages, and telemetry filters out empties internally. - initTelemetry returns boolean (resolved enabled state), letting server.ts gate its disclosure notice without a separate getter. - Move resolveTelemetryEnabled from config.ts into telemetry.ts where it belongs. config.ts imports it for the printout. - Drop ServerConfig.telemetryEnabled in favor of passing the raw noTelemetry flag through config. Telemetry owns resolution. - Drop the >= 8 length heuristic — the shell is the right place to decide what counts as a real secret, not telemetry. * feat(telemetry): richer design-shape metrics on get_figma_data Adds style_count, component_count, instance_count, text_node_count, image_node_count, component_property_count, and has_variables to the tool_called event. All computed in one extra walk of the simplified tree plus an early-exiting walk of the raw response for variable detection. Also splits the old node_count into raw/simplified/max_depth and renames node_major -> nodejs_major for clarity. * feat(telemetry): replace style_count with named_style_count The old style_count was counting entries in globalVars.styles — our internal dedup cache, not Figma's named/published styles. That's redundant with the size metrics for "how visually varied is this file." What we actually want is a signal for design-system maturity: how many reusable named styles (from the Figma Styles panel) does this file reference. Read directly from the raw API response's top-level styles dict (or merged across per-node entries for GetFileNodesResponse). * refactor: update imports for telemetry/error-meta file reorganization Point all consumers at new locations after file moves: - ~/services/telemetry.js -> ~/telemetry/index.js - ~/services/error-meta.js -> ~/utils/error-meta.js - Metric helpers extracted from get-figma-data.ts to get-figma-data-metrics.ts - Validation capture extracted from mcp/index.ts to mcp/validation-capture.ts - Add 429 rate-limit user-facing error in figma.ts - Delete old src/services/telemetry.ts * feat(telemetry): error taxonomy, phase timings, client identity, validation capture Structured error analytics: - tagError/getErrorMeta in utils/error-meta.ts — attaches phase, http_status, network_code, is_retryable to thrown errors via a Symbol key; walks the cause chain so wrappers preserve inner metadata - fetch_ms/simplify_ms/serialize_ms phase timings on get_figma_data events - client_name/client_version from MCP initialize handshake (stdio reliable, stateless HTTP best-effort) - Validation reject capture via McpServer.validateToolInput monkey patch with structured validation_field/validation_rule; also captures path-traversal rejects from the download tool - 2000-char error_message truncation - Figma 429 rate limit message with plan-specific guidance Bug fixes: - Race-prone nodesProcessed module-global replaced with per-call NodeCounter on TraversalContext — concurrent HTTP requests no longer corrupt each other - Opt-out init no longer poisons subsequent re-init attempts - "Anonymous telemetry" banner updated to "Usage telemetry" (GeoIP is on) File reorganization: - src/telemetry/{client,capture,types,index}.ts split from services/telemetry.ts - src/services/get-figma-data-metrics.ts extracted from get-figma-data.ts - src/mcp/validation-capture.ts extracted from mcp/index.ts - src/utils/error-meta.ts moved from services/ * fix(telemetry): redact before truncate, dynamic 429 guidance, pin MCP SDK - P3 fix: move error_message truncation to client.ts so it runs AFTER secret redaction — a token straddling the 2000-char cutoff can no longer survive as a partial match. - 429 message now reads Figma's rate-limit response headers (Retry-After, X-Figma-Plan-Tier, X-Figma-Rate-Limit-Type, X-Figma-Upgrade-Link) and gives targeted guidance based on actual seat type and plan tier, instead of generic "free plan → upgrade" advice. - Pin @modelcontextprotocol/sdk to exact 1.27.1 (was ^1.27.1). The validation-capture monkey patch targets a private SDK method that won't surface as a type error if renamed — tests are the only safety net, so SDK upgrades should be deliberate. * chore: upgrade @modelcontextprotocol/sdk to 1.29.0 Upgraded from 1.27.1. The validateToolInput monkey patch still works — validation-reject integration tests pass, confirming the private method signature hasn't changed in this release.
1 parent b1ee6a4 commit 6c0666a

28 files changed

Lines changed: 1857 additions & 281 deletions

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"prepack": "pnpm build"
2929
},
3030
"engines": {
31-
"node": ">=18.0.0"
31+
"node": ">=20.20.0"
3232
},
3333
"packageManager": "pnpm@10.10.0",
3434
"pnpm": {
@@ -56,12 +56,13 @@
5656
"@jimp/js-jpeg": "^1.6.0",
5757
"@jimp/js-png": "^1.6.0",
5858
"@jimp/plugin-crop": "^1.6.0",
59-
"@modelcontextprotocol/sdk": "^1.27.1",
59+
"@modelcontextprotocol/sdk": "1.29.0",
6060
"cleye": "^2.2.1",
6161
"cross-env": "^7.0.3",
6262
"dotenv": "^16.4.7",
6363
"express": "^5.2.1",
6464
"js-yaml": "^4.1.1",
65+
"posthog-node": "^5.29.2",
6566
"remeda": "^2.20.1",
6667
"undici": "^6.24.1",
6768
"zod": "^3.25.76"

pnpm-lock.yaml

Lines changed: 27 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/bin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ const argv = cli({
5050
type: Boolean,
5151
description: "Run in stdio transport mode for MCP clients",
5252
},
53+
noTelemetry: {
54+
type: Boolean,
55+
description: "Disable usage telemetry (telemetry is on by default)",
56+
},
5357
},
5458
commands: [fetchCommand],
5559
});

src/commands/fetch.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { type Command, command } from "cleye";
22
import { loadEnvFile, resolveAuth } from "~/config.js";
33
import { FigmaService } from "~/services/figma.js";
4-
import {
5-
simplifyRawFigmaObject,
6-
allExtractors,
7-
collapseSvgContainers,
8-
} from "~/extractors/index.js";
9-
import { serializeResult } from "~/utils/serialize.js";
104
import { parseFigmaUrl } from "~/utils/figma-url.js";
5+
import {
6+
initTelemetry,
7+
captureGetFigmaDataCall,
8+
shutdown,
9+
type AuthMode,
10+
} from "~/telemetry/index.js";
11+
import { getFigmaData } from "~/services/get-figma-data.js";
1112

1213
export const fetchCommand: Command = command(
1314
{
@@ -43,13 +44,19 @@ export const fetchCommand: Command = command(
4344
type: String,
4445
description: "Path to .env file",
4546
},
47+
noTelemetry: {
48+
type: Boolean,
49+
description: "Disable usage telemetry",
50+
},
4651
},
4752
},
4853
(argv) => {
49-
run(argv.flags, argv._).catch((error) => {
50-
console.error(error instanceof Error ? error.message : String(error));
51-
process.exit(1);
52-
});
54+
run(argv.flags, argv._)
55+
.catch((error) => {
56+
console.error(error instanceof Error ? error.message : String(error));
57+
process.exitCode = 1;
58+
})
59+
.finally(() => shutdown());
5360
},
5461
);
5562

@@ -62,6 +69,7 @@ async function run(
6269
figmaApiKey?: string;
6370
figmaOauthToken?: string;
6471
env?: string;
72+
noTelemetry?: boolean;
6573
},
6674
positionals: string[],
6775
) {
@@ -87,21 +95,25 @@ async function run(
8795

8896
loadEnvFile(flags.env);
8997
const auth = resolveAuth(flags);
90-
const figmaService = new FigmaService(auth);
9198

92-
const depth = flags.depth;
93-
const rawApiResponse = nodeId
94-
? await figmaService.getRawNode(fileKey, nodeId, depth)
95-
: await figmaService.getRawFile(fileKey, depth);
96-
97-
const simplifiedDesign = await simplifyRawFigmaObject(rawApiResponse, allExtractors, {
98-
maxDepth: depth,
99-
afterChildren: collapseSvgContainers,
99+
// Initialize telemetry only after input validation succeeds, so every
100+
// captured event corresponds to an actual fetch attempt (not a usage error).
101+
initTelemetry({
102+
optOut: flags.noTelemetry,
103+
immediateFlush: true,
104+
redactFromErrors: [auth.figmaApiKey, auth.figmaOAuthToken],
100105
});
101106

102-
const { nodes, globalVars, ...metadata } = simplifiedDesign;
103-
const result = { metadata, nodes, globalVars };
104-
107+
const authMode: AuthMode = auth.useOAuth ? "oauth" : "api_key";
105108
const outputFormat = flags.json ? "json" : "yaml";
106-
console.log(serializeResult(result, outputFormat));
109+
110+
const result = await getFigmaData(
111+
new FigmaService(auth),
112+
{ fileKey, nodeId, depth: flags.depth },
113+
outputFormat,
114+
{
115+
onComplete: (outcome) => captureGetFigmaDataCall(outcome, { transport: "cli", authMode }),
116+
},
117+
);
118+
console.log(result.formatted);
107119
}

src/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { config as loadEnv } from "dotenv";
22
import { resolve as resolvePath } from "path";
33
import type { FigmaAuthOptions } from "./services/figma.js";
4+
import { resolveTelemetryEnabled } from "./telemetry/index.js";
45

56
export type Source = "cli" | "env" | "default";
67

@@ -20,6 +21,7 @@ export interface ServerFlags {
2021
imageDir?: string;
2122
proxy?: string;
2223
stdio?: boolean;
24+
noTelemetry?: boolean;
2325
}
2426

2527
export interface ServerConfig {
@@ -31,6 +33,7 @@ export interface ServerConfig {
3133
skipImageDownloads: boolean;
3234
imageDir: string;
3335
isStdioMode: boolean;
36+
noTelemetry: boolean;
3437
configSources: Record<string, Source>;
3538
}
3639

@@ -134,6 +137,14 @@ export function getServerConfig(flags: ServerFlags): ServerConfig {
134137

135138
const isStdioMode = flags.stdio === true;
136139

140+
const noTelemetry = flags.noTelemetry ?? false;
141+
const telemetrySource: Source =
142+
flags.noTelemetry === true
143+
? "cli"
144+
: process.env.FRAMELINK_TELEMETRY !== undefined || process.env.DO_NOT_TRACK !== undefined
145+
? "env"
146+
: "default";
147+
137148
const configSources: Record<string, Source> = {
138149
envFile: envFileSource,
139150
figmaApiKey: figmaApiKey.source,
@@ -144,6 +155,7 @@ export function getServerConfig(flags: ServerFlags): ServerConfig {
144155
outputFormat: outputFormat.source,
145156
skipImageDownloads: skipImageDownloads.source,
146157
imageDir: imageDir.source,
158+
telemetry: telemetrySource,
147159
};
148160

149161
if (!isStdioMode) {
@@ -168,6 +180,10 @@ export function getServerConfig(flags: ServerFlags): ServerConfig {
168180
`- SKIP_IMAGE_DOWNLOADS: ${skipImageDownloads.value} (source: ${configSources.skipImageDownloads})`,
169181
);
170182
console.log(`- IMAGE_DIR: ${imageDir.value} (source: ${configSources.imageDir})`);
183+
const telemetryEnabled = resolveTelemetryEnabled(noTelemetry);
184+
console.log(
185+
`- TELEMETRY: ${telemetryEnabled ? "enabled" : "disabled"} (source: ${configSources.telemetry})`,
186+
);
171187
console.log();
172188
}
173189

@@ -180,6 +196,7 @@ export function getServerConfig(flags: ServerFlags): ServerConfig {
180196
skipImageDownloads: skipImageDownloads.value,
181197
imageDir: imageDir.value,
182198
isStdioMode,
199+
noTelemetry,
183200
configSources,
184201
};
185202
}

src/extractors/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Types
22
export type {
33
ExtractorFn,
4+
NodeCounter,
45
SimplifiedNode,
56
TraversalContext,
67
TraversalOptions,
@@ -10,7 +11,7 @@ export type {
1011
} from "./types.js";
1112

1213
// Core traversal function
13-
export { extractFromDesign, getNodesProcessed } from "./node-walker.js";
14+
export { extractFromDesign } from "./node-walker.js";
1415

1516
// Design-level extraction (unified nodes + components)
1617
export { simplifyRawFigmaObject } from "./design-extractor.js";

0 commit comments

Comments
 (0)