Skip to content

Commit 954c660

Browse files
author
JooHyung Park
committed
[ai-assisted] chore: release v2.0.14
1 parent db2f27f commit 954c660

7 files changed

Lines changed: 279 additions & 9 deletions

File tree

.github/workflows/build.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ jobs:
287287
- name: Create GitHub Release
288288
uses: softprops/action-gh-release@v2
289289
with:
290+
tag_name: ${{ env.TAG_NAME }}
290291
draft: false
291292
prerelease: false
292293
generate_release_notes: true
@@ -318,3 +319,14 @@ jobs:
318319
artifacts/macos-arm64-zip/**/*.zip
319320
artifacts/windows-build/**/*.exe
320321
artifacts/windows-build/**/*.nupkg
322+
323+
- name: Ensure release is published and latest
324+
env:
325+
GH_TOKEN: ${{ github.token }}
326+
run: |
327+
RELEASE_ID=$(gh api "repos/${{ github.repository }}/releases/tags/${{ env.TAG_NAME }}" --jq '.id')
328+
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
329+
-f draft=false \
330+
-f prerelease=false \
331+
-f make_latest=true >/dev/null
332+
echo "Release ${{ env.TAG_NAME }} published with draft=false and make_latest=true"

ANALYTICS_EVENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ Triggered when an MCP tool is invoked.
6464
| success | boolean | Whether the tool call succeeded |
6565
| duration_ms | number | (optional) Time in ms from request received to response returned |
6666
| error_message | string | (optional) Error description if failed |
67+
| batched | boolean | (optional) `true` when this event contains a success batch |
68+
| batch_size | number | (optional) Number of successful tool calls included in the batch |
69+
| idle_window_sec | number | (optional) Idle window used before flushing successful calls (default 60) |
70+
| batch_reason | string | (optional) `idle_timeout` \| `before_failure` \| `app_quit` |
71+
| duration_total_ms | number | (optional) Sum of known durations in a batch |
72+
| batch_id | string | (optional) Batch identifier shared across chunked events |
73+
| batch_chunk_index | number | (optional) 1-based index when a batch is split into multiple events |
74+
| batch_chunk_count | number | (optional) Total number of chunked events for the batch |
75+
| `<tool_name>` | number | (optional) Dynamic property where key is tool name and value is accumulated duration in ms (example: `join_channel: 120`) |
76+
77+
Notes:
78+
- Failed or timed-out tool calls are still tracked immediately with `success=false`.
79+
- Batched success events reuse the same event name and use `tool_name="__batch__"` for compatibility.
80+
- If a batch would exceed the configured property count, it is flushed as multiple chunked events (`batch_chunk_*`) with the same `batch_id`.
81+
- Dynamic tool keys are normalized to lowercase snake_case and capped at 40 chars; if two tools collide after normalization/truncation, suffixes (`_2`, `_3`, ...) are appended.
6782

6883
---
6984

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "talktofigma-desktop",
33
"productName": "TalkToFigma Desktop",
4-
"version": "2.0.13",
4+
"version": "2.0.14",
55
"description": "Bridge between Figma and AI tools (Cursor, Claude Code) using Model Context Protocol",
66
"type": "module",
77
"main": ".vite/build/main.cjs",

src/main.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { initialize } from '@aptabase/electron/main';
1212
import { registerIpcHandlers, setAuthManager, emitToRenderer } from './main/ipc-handlers';
1313
import { createLogger, setMainWindow } from './main/utils/logger';
1414
import { TalkToFigmaService, TalkToFigmaServerManager, TalkToFigmaTray } from './main/server';
15-
import { trackAppStart, trackAppQuit, trackUserEngagement, trackFirstOpenIfNeeded, trackAppException, trackOAuthAction, APTABASE_APP_KEY } from './main/analytics';
15+
import { trackAppStart, trackAppQuit, trackUserEngagement, trackFirstOpenIfNeeded, trackAppException, trackOAuthAction, APTABASE_APP_KEY, flushMCPToolSuccessBatch } from './main/analytics';
1616
import { FigmaOAuthService } from './main/figma/oauth/FigmaOAuthService';
1717
import { FigmaApiClient } from './main/figma/api/FigmaApiClient';
1818
import { IPC_CHANNELS, STORE_KEYS } from './shared/constants';
@@ -371,6 +371,13 @@ app.on('activate', () => {
371371
app.on('before-quit', async () => {
372372
logger.info('App shutting down...');
373373

374+
// Flush buffered MCP success analytics before app shutdown.
375+
try {
376+
flushMCPToolSuccessBatch('app_quit');
377+
} catch (error) {
378+
logger.warn('Failed to flush MCP success batch on before-quit:', { error });
379+
}
380+
374381
// Track app quit
375382
trackAppQuit();
376383

@@ -411,3 +418,12 @@ app.on('before-quit', async () => {
411418

412419
logger.info('App shutdown complete');
413420
});
421+
422+
app.on('will-quit', () => {
423+
// Best-effort second flush in case before-quit flow was interrupted.
424+
try {
425+
flushMCPToolSuccessBatch('app_quit');
426+
} catch (error) {
427+
logger.warn('Failed to flush MCP success batch on will-quit:', { error });
428+
}
429+
});

src/main/analytics/analytics-service.ts

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,193 @@ import { getStore } from '../utils/store';
1818
import { STORE_KEYS } from '@/shared/constants';
1919

2020
const logger = createLogger('Analytics');
21+
const MCP_SUCCESS_IDLE_WINDOW_MS = 60_000;
22+
const MAX_PROP_KEY_LENGTH = 40;
23+
const MAX_PROPS_PER_BATCH_EVENT = 25;
24+
25+
type AnalyticsPropertyValue = string | number | boolean;
26+
type AnalyticsProperties = Record<string, AnalyticsPropertyValue>;
27+
export type MCPToolBatchFlushReason = 'idle_timeout' | 'before_failure' | 'app_quit';
28+
29+
interface McpToolSuccessEvent {
30+
tool_name: string;
31+
duration_ms?: number;
32+
ts: number;
33+
}
34+
35+
class McpToolSuccessBuffer {
36+
private buffer: McpToolSuccessEvent[] = [];
37+
private lastSuccessAt: number | null = null;
38+
private idleTimer: NodeJS.Timeout | null = null;
39+
40+
recordSuccess(toolName: string, durationMs?: number): void {
41+
this.buffer.push({
42+
tool_name: toolName,
43+
...(durationMs !== undefined && { duration_ms: durationMs }),
44+
ts: Date.now(),
45+
});
46+
this.lastSuccessAt = Date.now();
47+
this.resetIdleTimer();
48+
}
49+
50+
flush(reason: MCPToolBatchFlushReason): void {
51+
if (this.buffer.length === 0) {
52+
this.clearIdleTimer();
53+
return;
54+
}
55+
56+
this.clearIdleTimer();
57+
58+
const durationValues = this.buffer
59+
.map((event) => event.duration_ms)
60+
.filter((value): value is number => value !== undefined);
61+
const durationTotalMs = durationValues.reduce((acc, value) => acc + value, 0);
62+
const batchId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
63+
64+
const toolDurationTotals = new Map<string, number>();
65+
for (const event of this.buffer) {
66+
const toolName = event.tool_name || 'unknown_tool';
67+
const value = event.duration_ms ?? 0;
68+
toolDurationTotals.set(toolName, (toolDurationTotals.get(toolName) ?? 0) + value);
69+
}
70+
71+
const baseParams: AnalyticsProperties = {
72+
tool_name: '__batch__',
73+
success: true,
74+
batched: true,
75+
batch_size: this.buffer.length,
76+
idle_window_sec: MCP_SUCCESS_IDLE_WINDOW_MS / 1000,
77+
batch_reason: reason,
78+
batch_id: batchId,
79+
};
80+
81+
if (this.lastSuccessAt !== null) {
82+
baseParams.last_success_at_ts = this.lastSuccessAt;
83+
}
84+
85+
if (durationValues.length > 0) {
86+
baseParams.duration_total_ms = durationTotalMs;
87+
}
88+
89+
const maxToolPropsPerChunk = Math.max(
90+
1,
91+
MAX_PROPS_PER_BATCH_EVENT - Object.keys(baseParams).length - 2 // chunk index + count
92+
);
93+
const entries = this.buildUniqueToolDurationEntries(toolDurationTotals);
94+
const chunks = this.chunkEntries(entries, maxToolPropsPerChunk);
95+
96+
chunks.forEach((chunk, index) => {
97+
const params: AnalyticsProperties = {
98+
...baseParams,
99+
batch_chunk_index: index + 1,
100+
batch_chunk_count: chunks.length,
101+
};
102+
103+
for (const [toolKey, totalMs] of chunk) {
104+
params[toolKey] = Math.round(totalMs);
105+
}
106+
107+
trackAptabaseEvent(AnalyticsEvents.MCP_TOOL_CALL, params);
108+
});
109+
110+
logger.debug(
111+
`Flushed MCP success batch (${reason}) with ${this.buffer.length} events (${chunks.length} chunk(s))`
112+
);
113+
114+
this.buffer = [];
115+
this.lastSuccessAt = null;
116+
}
117+
118+
private buildUniqueToolDurationEntries(
119+
durationTotalsByToolName: Map<string, number>
120+
): Array<[string, number]> {
121+
const collisionCountByBaseKey = new Map<string, number>();
122+
const entries: Array<[string, number]> = [];
123+
124+
const sortedByToolName = Array.from(durationTotalsByToolName.entries())
125+
.sort((a, b) => a[0].localeCompare(b[0]));
126+
127+
for (const [toolName, totalDurationMs] of sortedByToolName) {
128+
const baseKey = this.toToolDurationBaseKey(toolName);
129+
const collisionIndex = (collisionCountByBaseKey.get(baseKey) ?? 0) + 1;
130+
collisionCountByBaseKey.set(baseKey, collisionIndex);
131+
132+
const resolvedKey = collisionIndex === 1
133+
? baseKey
134+
: this.withCollisionSuffix(baseKey, collisionIndex);
135+
136+
entries.push([resolvedKey, totalDurationMs]);
137+
}
138+
139+
return entries;
140+
}
141+
142+
private toToolDurationBaseKey(toolName: string): string {
143+
const normalized = toolName
144+
.trim()
145+
.toLowerCase()
146+
.replace(/[^a-z0-9_]/g, '_')
147+
.replace(/_+/g, '_')
148+
.replace(/^_+|_+$/g, '');
149+
150+
const key = normalized || 'unknown_tool';
151+
const reservedKeys = new Set([
152+
'tool_name',
153+
'success',
154+
'batched',
155+
'batch_size',
156+
'idle_window_sec',
157+
'batch_reason',
158+
'batch_id',
159+
'last_success_at_ts',
160+
'duration_total_ms',
161+
'batch_chunk_index',
162+
'batch_chunk_count',
163+
'distribution_channel',
164+
]);
165+
166+
const safeKey = reservedKeys.has(key) ? `tool_${key}` : key;
167+
return safeKey.slice(0, MAX_PROP_KEY_LENGTH);
168+
}
169+
170+
private withCollisionSuffix(baseKey: string, collisionIndex: number): string {
171+
const suffix = `_${collisionIndex}`;
172+
const maxBaseLength = Math.max(1, MAX_PROP_KEY_LENGTH - suffix.length);
173+
const trimmedBase = baseKey.slice(0, maxBaseLength).replace(/_+$/g, '');
174+
return `${trimmedBase}${suffix}`;
175+
}
176+
177+
private chunkEntries(
178+
entries: Array<[string, number]>,
179+
chunkSize: number
180+
): Array<Array<[string, number]>> {
181+
if (entries.length === 0) {
182+
return [[]];
183+
}
184+
185+
const chunks: Array<Array<[string, number]>> = [];
186+
for (let i = 0; i < entries.length; i += chunkSize) {
187+
chunks.push(entries.slice(i, i + chunkSize));
188+
}
189+
return chunks;
190+
}
191+
192+
private resetIdleTimer(): void {
193+
this.clearIdleTimer();
194+
this.idleTimer = setTimeout(() => {
195+
this.flush('idle_timeout');
196+
}, MCP_SUCCESS_IDLE_WINDOW_MS);
197+
}
198+
199+
private clearIdleTimer(): void {
200+
if (this.idleTimer) {
201+
clearTimeout(this.idleTimer);
202+
this.idleTimer = null;
203+
}
204+
}
205+
}
206+
207+
const mcpToolSuccessBuffer = new McpToolSuccessBuffer();
21208

22209
/**
23210
* Track event to both analytics services
@@ -102,7 +289,7 @@ export function trackMCPToolCall(
102289
errorMessage?: string,
103290
durationMs?: number
104291
): void {
105-
const params: Record<string, string | number | boolean> = {
292+
const params: AnalyticsProperties = {
106293
tool_name: toolName,
107294
success: success,
108295
};
@@ -115,7 +302,23 @@ export function trackMCPToolCall(
115302
params.duration_ms = durationMs;
116303
}
117304

118-
trackEvent(AnalyticsEvents.MCP_TOOL_CALL, params);
305+
if (success) {
306+
mcpToolSuccessBuffer.recordSuccess(toolName, durationMs);
307+
trackGA4Event(AnalyticsEvents.MCP_TOOL_CALL, params);
308+
return;
309+
}
310+
311+
// Keep failure/timeout events immediate and flush pending successes first.
312+
mcpToolSuccessBuffer.flush('before_failure');
313+
trackAptabaseEvent(AnalyticsEvents.MCP_TOOL_CALL, params);
314+
trackGA4Event(AnalyticsEvents.MCP_TOOL_CALL, params);
315+
}
316+
317+
/**
318+
* Flush buffered successful MCP tool events to Aptabase.
319+
*/
320+
export function flushMCPToolSuccessBatch(reason: MCPToolBatchFlushReason): void {
321+
mcpToolSuccessBuffer.flush(reason);
119322
}
120323

121324
/**

0 commit comments

Comments
 (0)