11 task TDD plan — events schema/privacy invariant, JSONL emit/rotation, 14d cleanup, readAllRecent, stats aggregator, exportTo(folder), CaptureService/AiWorker hooks, tray menu, index.ts wiring, gates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
58 KiB
#7 Telemetry skeleton 구현 plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: v0.2.3 의 첫 항목 — 사용자 동작/결과의 append-only 로컬 로그 (<profileDir>/telemetry/events-YYYY-MM-DD.jsonl) 와 trayexport (events.jsonl + stats.md) 인프라를 박는다. 다른 v0.2.3 항목 (#4~#6) 이 emit hook 만 추가하면 되도록 skeleton + 3 기본 이벤트 (capture / ai_succeeded / ai_failed) 까지 wiring.
Architecture: zod discriminatedUnion + .strict() payload schema 가 privacy invariant (raw_text / title / summary / intent / tag name 미포함) 를 강제. JSONL append-only, KST 일자 rotation, 14일 후 rolling 삭제. Stats 는 별 파일 (telemetryStats.ts) 의 순수 함수. Tray 메뉴 → folder dialog → 2 파일 (events.jsonl concat + stats.md) 출력 (zip 미사용 — 신규 dep 0 정책).
Tech Stack: TypeScript / electron-vite / better-sqlite3 (간접) / zod 4.3.6 / vitest 4 / Node node:fs/promises node:path. 신규 dep 없음.
선행 spec: docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md §1·§3 #7·§4.1·§6.2
File Structure
| 경로 | 책임 |
|---|---|
src/main/services/telemetryEvents.ts (new) |
zod 이벤트 schema (capture/ai_succeeded/ai_failed discriminated union, .strict() payload). TelemetryEvent / TelemetryKind type export. validateEvent() parser. |
src/main/services/telemetryStats.ts (new) |
순수 함수 aggregateStats(events: TelemetryEvent[]): { md: string; eventCount: number }. |
src/main/services/TelemetryService.ts (new) |
emit (append JSONL) / cleanupOldFiles / readAllRecent / exportTo(folder). profileDir 의 telemetry/ 디렉터리 자동 생성. KST 일자 rotation. |
src/main/index.ts (modify) |
TelemetryService 인스턴스 생성 + 시작 시 cleanupOldFiles + capture/ai 이벤트 hook + tray 콜백 추가. |
src/main/services/CaptureService.ts (modify) |
submit() 성공 시 capture emit. dep 으로 telemetry 주입. |
src/main/ai/AiWorker.ts (modify) |
success 경로 ai_succeeded emit (durationMs/attempts), 마지막 실패 경로 ai_failed emit (reason 분류). dep 으로 telemetry 주입 (옵션). |
src/main/tray.ts (modify) |
7번째 콜백 runExportTelemetry 추가, 메뉴 항목 "사용 로그 내보내기..." 추가. |
tests/unit/telemetryEvents.test.ts (new) |
3 이벤트 valid path + privacy invariant (rawText / title / summary / userIntent / tag name 포함 시 zod 거부). |
tests/unit/telemetryStats.test.ts (new) |
aggregateStats: 일자별 카운트 표 + AI 성공률 + 평균 durationMs. |
tests/unit/TelemetryService.test.ts (new) |
emit (KST 일자 파일명), cleanupOldFiles (14일 경계), readAllRecent (concat), exportTo (folder 출력 2 파일). |
tests/unit/CaptureService.test.ts (modify) |
기존 케이스 손대지 않고 telemetry hook fire 검증 추가. |
tests/unit/AiWorker.test.ts (modify) |
success/failure 시 telemetry emit 검증 추가. |
Task 1: 이벤트 schema + privacy invariant
Files:
-
Create:
src/main/services/telemetryEvents.ts -
Test:
tests/unit/telemetryEvents.test.ts -
Step 1: 실패 테스트 작성
// tests/unit/telemetryEvents.test.ts
import { describe, it, expect } from 'vitest';
import { validateEvent } from '@main/services/telemetryEvents.js';
describe('validateEvent — happy path', () => {
it('accepts capture event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 12, hasMedia: false }
});
expect(e.kind).toBe('capture');
});
it('accepts ai_succeeded event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_succeeded',
payload: { noteId: 'n1', durationMs: 1234, attempts: 0 }
});
expect(e.kind).toBe('ai_succeeded');
});
it('accepts ai_failed event with reason enum', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_failed',
payload: { noteId: 'n1', reason: 'unreachable', attempts: 3 }
});
expect(e.kind).toBe('ai_failed');
});
});
describe('validateEvent — privacy invariant', () => {
it('rejects payload with rawText leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, rawText: 'leak' }
})).toThrow();
});
it('rejects payload with title leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_succeeded',
payload: { noteId: 'n1', durationMs: 1, attempts: 0, title: 'leak' }
})).toThrow();
});
it('rejects payload with summary leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_succeeded',
payload: { noteId: 'n1', durationMs: 1, attempts: 0, summary: 'leak' }
})).toThrow();
});
it('rejects payload with userIntent leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, userIntent: 'leak' }
})).toThrow();
});
it('rejects payload with tag name leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, tagNames: ['일정'] }
})).toThrow();
});
it('rejects unknown reason in ai_failed', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'ai_failed',
payload: { noteId: 'n1', reason: 'unicorn', attempts: 1 }
})).toThrow();
});
it('rejects unknown event kind', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'mystery',
payload: {}
})).toThrow();
});
});
- Step 2: 테스트 실행 — FAIL 확인
Run: npm test -- tests/unit/telemetryEvents.test.ts
Expected: FAIL — 모듈 미존재
- Step 3: 구현
// src/main/services/telemetryEvents.ts
import { z } from 'zod';
const CapturePayload = z.object({
noteId: z.string().min(1),
rawTextLength: z.number().int().nonnegative(),
hasMedia: z.boolean()
}).strict();
const AiSucceededPayload = z.object({
noteId: z.string().min(1),
durationMs: z.number().nonnegative(),
attempts: z.number().int().nonnegative()
}).strict();
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
const AiFailedPayload = z.object({
noteId: z.string().min(1),
reason: AiFailedReason,
attempts: z.number().int().nonnegative()
}).strict();
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict()
]);
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
export type TelemetryKind = TelemetryEvent['kind'];
export function validateEvent(raw: unknown): TelemetryEvent {
return TelemetryEventSchema.parse(raw);
}
- Step 4: 테스트 실행 — PASS 확인
Run: npm test -- tests/unit/telemetryEvents.test.ts
Expected: PASS — 9 케이스 모두 그린
- Step 5: 커밋
git add src/main/services/telemetryEvents.ts tests/unit/telemetryEvents.test.ts
git commit -m "feat(telemetry): event schema + privacy invariant (#7 v0.2.3)"
Task 2: TelemetryService.emit + KST 일자 rotation
Files:
-
Create:
src/main/services/TelemetryService.ts -
Test:
tests/unit/TelemetryService.test.ts -
Step 1: 실패 테스트 작성
// tests/unit/TelemetryService.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TelemetryService } from '@main/services/TelemetryService.js';
describe('TelemetryService.emit', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('appends a JSONL line to events-YYYY-MM-DD.jsonl (KST date)', async () => {
// 2026-05-01 12:00 UTC → 2026-05-01 21:00 KST
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
const file = join(dir, 'events-2026-05-01.jsonl');
expect(existsSync(file)).toBe(true);
const content = readFileSync(file, 'utf8').trim();
const parsed = JSON.parse(content);
expect(parsed.kind).toBe('capture');
expect(parsed.payload.noteId).toBe('n1');
expect(typeof parsed.ts).toBe('string');
});
it('uses KST date even when UTC date differs (around midnight)', async () => {
// 2026-05-01 23:30 UTC → 2026-05-02 08:30 KST
const svc = new TelemetryService(dir, () => new Date('2026-05-01T23:30:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n2', rawTextLength: 1, hasMedia: false } });
expect(existsSync(join(dir, 'events-2026-05-02.jsonl'))).toBe(true);
});
it('appends multiple events to same-day file', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
await svc.emit({ kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 100, attempts: 0 } });
const lines = readFileSync(join(dir, 'events-2026-05-01.jsonl'), 'utf8').trim().split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]!).kind).toBe('capture');
expect(JSON.parse(lines[1]!).kind).toBe('ai_succeeded');
});
it('creates telemetry dir if absent', async () => {
const fresh = join(dir, 'nested', 'telem');
const svc = new TelemetryService(fresh, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } });
expect(existsSync(fresh)).toBe(true);
});
it('rejects malformed event (privacy invariant) — does NOT write file', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false, rawText: 'leak' } as never
})).rejects.toThrow();
// No file should have been created
expect(readdirSync(dir).filter((f) => f.startsWith('events-'))).toEqual([]);
});
it('emit is silent (does not throw) when fs write fails — invariant: telemetry never breaks app', async () => {
// Pass a non-writable path; emit should swallow the error.
const svc = new TelemetryService(
'/proc/0/no-such-thing-readonly', // platform-agnostic invalid path
() => new Date('2026-05-01T12:00:00Z'),
14,
{ silent: true } // explicit opt-in for silent mode
);
// Should resolve, not throw
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
})).resolves.toBeUndefined();
});
});
- Step 2: 테스트 실행 — FAIL 확인
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: FAIL — TelemetryService 모듈 미존재
- Step 3: 구현
// src/main/services/TelemetryService.ts
import { mkdir, appendFile, readFile, readdir, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { validateEvent, type TelemetryEvent, type TelemetryKind } from './telemetryEvents.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
function todayKstIso(now: Date): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
export interface TelemetryServiceOptions {
silent?: boolean;
}
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } };
export class TelemetryService {
constructor(
private dir: string,
private now: () => Date = () => new Date(),
private retentionDays: number = 14,
private opts: TelemetryServiceOptions = {}
) {}
async emit(input: EmitInput): Promise<void> {
const ts = this.now().toISOString();
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
const filePath = join(this.dir, `events-${todayKstIso(this.now())}.jsonl`);
try {
await mkdir(this.dir, { recursive: true });
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
} catch (err) {
if (this.opts.silent) return;
throw err;
}
}
}
Note: zod parse 자체는 silent 미적용 — 입력이 잘못된 건 코드 결함이라 나타나야 함. fs 실패만 silent 모드 대상.
- Step 4: 테스트 실행 — PASS 확인
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: PASS — 6 케이스 그린. 단, "rejects malformed event" 케이스에서 zod throw 가 silent 와 충돌하면 안됨 — 위 구현은 zod throw 는 그대로 던지고 fs 만 silent 처리.
- Step 5: 커밋
git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts
git commit -m "feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3)"
Task 3: cleanupOldFiles (14일 retention)
Files:
-
Modify:
src/main/services/TelemetryService.ts -
Modify:
tests/unit/TelemetryService.test.ts -
Step 1: 실패 테스트 추가
// tests/unit/TelemetryService.test.ts — 기존 import 아래에 추가
describe('TelemetryService.cleanupOldFiles', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('removes events-*.jsonl older than retentionDays', async () => {
// 시드: 오래된 파일 + 최근 파일
writeFileSync(join(dir, 'events-2026-04-01.jsonl'), '{}\n'); // 30일 전
writeFileSync(join(dir, 'events-2026-04-25.jsonl'), '{}\n'); // 6일 전 (retain)
writeFileSync(join(dir, 'events-2026-05-01.jsonl'), '{}\n'); // 오늘 (retain)
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual(['events-2026-04-01.jsonl']);
expect(existsSync(join(dir, 'events-2026-04-25.jsonl'))).toBe(true);
expect(existsSync(join(dir, 'events-2026-05-01.jsonl'))).toBe(true);
});
it('returns empty when no files match prefix', async () => {
writeFileSync(join(dir, 'unrelated.txt'), 'x');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual([]);
expect(existsSync(join(dir, 'unrelated.txt'))).toBe(true);
});
it('handles missing dir gracefully (no throw)', async () => {
const ghost = join(dir, 'ghost');
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual([]);
});
it('boundary: file exactly retentionDays old is retained', async () => {
// 2026-04-17 = 14일 전 (boundary, retain)
writeFileSync(join(dir, 'events-2026-04-17.jsonl'), '{}\n');
// 2026-04-16 = 15일 전 (delete)
writeFileSync(join(dir, 'events-2026-04-16.jsonl'), '{}\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual(['events-2026-04-16.jsonl']);
expect(existsSync(join(dir, 'events-2026-04-17.jsonl'))).toBe(true);
});
});
- Step 2: 테스트 실행 — FAIL 확인
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: FAIL — cleanupOldFiles 미정의
- Step 3: 구현
src/main/services/TelemetryService.ts 의 TelemetryService 클래스에 메서드 추가:
async cleanupOldFiles(): Promise<{ removed: string[] }> {
const removed: string[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return { removed };
}
const cutoff = new Date(this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000);
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
for (const name of entries) {
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
if (!m) continue;
const fileDate = m[1]!;
if (fileDate < cutoffIso) {
try {
await unlink(join(this.dir, name));
removed.push(name);
} catch {
// ignore — best-effort cleanup
}
}
}
return { removed };
}
상단 import 에 readdir, unlink 가 이미 있음 (Task 2 에서 import 했으니 확인).
- Step 4: 테스트 실행 — PASS 확인
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: PASS — 4 신규 케이스 + 기존 6 그린
- Step 5: 커밋
git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts
git commit -m "feat(telemetry): cleanupOldFiles with 14-day KST retention (#7 v0.2.3)"
Task 4: readAllRecent — 14일 범위 events concat
Files:
-
Modify:
src/main/services/TelemetryService.ts -
Modify:
tests/unit/TelemetryService.test.ts -
Step 1: 실패 테스트 추가
describe('TelemetryService.readAllRecent', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('reads events from all files within retentionDays', async () => {
writeFileSync(join(dir, 'events-2026-04-25.jsonl'),
JSON.stringify({ ts: '2026-04-25T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'b', rawTextLength: 2, hasMedia: false } }) + '\n' +
JSON.stringify({ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'b', durationMs: 100, attempts: 0 } }) + '\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(3);
expect(events.map((e) => e.payload.noteId)).toEqual(['a', 'b', 'b']);
});
it('skips malformed lines (silent — invariant)', async () => {
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
'not-json\n' +
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n' +
'{}\n'); // valid JSON but invalid event
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(1);
expect(events[0]!.payload.noteId).toBe('a');
});
it('returns [] when dir missing', async () => {
const ghost = join(dir, 'ghost');
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toEqual([]);
});
it('returns [] when dir empty', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
expect(await svc.readAllRecent()).toEqual([]);
});
});
- Step 2: 테스트 실행 — FAIL
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: FAIL — readAllRecent 미정의
- Step 3: 구현
TelemetryService 에 추가:
async readAllRecent(): Promise<TelemetryEvent[]> {
const events: TelemetryEvent[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return events;
}
const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
const cutoffIso = todayKstIso(new Date(cutoffMs));
const fileNames = entries
.filter((n) => /^events-\d{4}-\d{2}-\d{2}\.jsonl$/.test(n))
.filter((n) => n.slice(7, 17) >= cutoffIso)
.sort();
for (const name of fileNames) {
let raw: string;
try {
raw = await readFile(join(this.dir, name), 'utf8');
} catch {
continue;
}
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
try {
events.push(validateEvent(parsed));
} catch {
continue;
}
}
}
return events;
}
import 에 readFile 있는지 확인 — Task 2 의 node:fs/promises import 에 추가:
import { mkdir, appendFile, readFile, readdir, unlink } from 'node:fs/promises';
- Step 4: 테스트 실행 — PASS
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: PASS
- Step 5: 커밋
git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts
git commit -m "feat(telemetry): readAllRecent with malformed-line tolerance (#7 v0.2.3)"
Task 5: telemetryStats — aggregateStats 순수 함수
Files:
-
Create:
src/main/services/telemetryStats.ts -
Test:
tests/unit/telemetryStats.test.ts -
Step 1: 실패 테스트 작성
// tests/unit/telemetryStats.test.ts
import { describe, it, expect } from 'vitest';
import { aggregateStats } from '@main/services/telemetryStats.js';
import type { TelemetryEvent } from '@main/services/telemetryEvents.js';
const e = (ts: string, kind: TelemetryEvent['kind'], payload: TelemetryEvent['payload']): TelemetryEvent =>
({ ts, kind, payload } as TelemetryEvent);
describe('aggregateStats', () => {
it('produces empty stats for empty input', () => {
const r = aggregateStats([], new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(0);
expect(r.md).toContain('총 이벤트: 0');
});
it('counts events per KST day per kind', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T12:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 5, hasMedia: false }),
e('2026-05-01T12:01:00Z', 'capture', { noteId: 'n2', rawTextLength: 3, hasMedia: true }),
e('2026-05-01T12:02:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1000, attempts: 0 }),
e('2026-05-02T00:00:00Z', 'ai_failed', { noteId: 'n2', reason: 'unreachable', attempts: 3 })
];
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(4);
expect(r.md).toContain('| 2026-05-01 | 2 | 1 | 0 |');
expect(r.md).toContain('| 2026-05-02 | 0 | 0 | 1 |');
});
it('computes AI 성공률', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1, attempts: 0 }),
e('2026-05-01T00:00:01Z', 'ai_succeeded', { noteId: 'n2', durationMs: 1, attempts: 0 }),
e('2026-05-01T00:00:02Z', 'ai_succeeded', { noteId: 'n3', durationMs: 1, attempts: 0 }),
e('2026-05-01T00:00:03Z', 'ai_failed', { noteId: 'n4', reason: 'other', attempts: 1 })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('AI 성공률: 75.0%');
expect(r.md).toContain('3/4');
});
it('AI 성공률 N/A when no AI events', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('AI 성공률: N/A');
});
it('computes 평균 ai_succeeded durationMs', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1000, attempts: 0 }),
e('2026-05-01T00:00:01Z', 'ai_succeeded', { noteId: 'n2', durationMs: 2000, attempts: 0 }),
e('2026-05-01T00:00:02Z', 'ai_succeeded', { noteId: 'n3', durationMs: 3000, attempts: 0 })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('평균 ai_succeeded durationMs: 2000');
});
});
- Step 2: 테스트 실행 — FAIL
Run: npm test -- tests/unit/telemetryStats.test.ts
Expected: FAIL — 모듈 미존재
- Step 3: 구현
// src/main/services/telemetryStats.ts
import type { TelemetryEvent } from './telemetryEvents.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
function kstDate(ts: string): string {
const d = new Date(ts);
const k = new Date(d.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
interface DailyRow {
date: string;
capture: number;
ai_succeeded: number;
ai_failed: number;
}
export interface StatsResult {
md: string;
eventCount: number;
}
export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult {
const eventCount = events.length;
const byDay = new Map<string, DailyRow>();
let aiSucceeded = 0;
let aiFailed = 0;
let durationSum = 0;
let durationN = 0;
for (const ev of events) {
const day = kstDate(ev.ts);
let row = byDay.get(day);
if (!row) {
row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0 };
byDay.set(day, row);
}
if (ev.kind === 'capture') row.capture += 1;
else if (ev.kind === 'ai_succeeded') {
row.ai_succeeded += 1;
aiSucceeded += 1;
durationSum += ev.payload.durationMs;
durationN += 1;
} else if (ev.kind === 'ai_failed') {
row.ai_failed += 1;
aiFailed += 1;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
const lines: string[] = [];
lines.push('# Inkling Telemetry Stats');
lines.push('');
lines.push(`생성: ${generatedAt.toISOString()}`);
lines.push(`총 이벤트: ${eventCount}`);
lines.push('');
lines.push('## 일자별 카운트');
lines.push('');
lines.push('| 일자 | capture | ai_succeeded | ai_failed |');
lines.push('|------|---------|--------------|-----------|');
for (const row of days) {
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} |`);
}
lines.push('');
lines.push('## 핵심 ratio');
lines.push('');
lines.push(`- AI 성공률: ${successRate}`);
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
lines.push('');
return { md: lines.join('\n'), eventCount };
}
- Step 4: 테스트 실행 — PASS
Run: npm test -- tests/unit/telemetryStats.test.ts
Expected: PASS
- Step 5: 커밋
git add src/main/services/telemetryStats.ts tests/unit/telemetryStats.test.ts
git commit -m "feat(telemetry): telemetryStats.aggregateStats (#7 v0.2.3)"
Task 6: TelemetryService.exportTo(folder)
Files:
-
Modify:
src/main/services/TelemetryService.ts -
Modify:
tests/unit/TelemetryService.test.ts -
Step 1: 실패 테스트 추가
describe('TelemetryService.exportTo', () => {
let dir: string;
let outDir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'inkling-telem-'));
outDir = mkdtempSync(join(tmpdir(), 'inkling-export-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
rmSync(outDir, { recursive: true, force: true });
});
it('writes events.jsonl (concat) + stats.md to folder', async () => {
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.exportTo(outDir);
expect(r.eventCount).toBe(1);
expect(existsSync(join(outDir, 'events.jsonl'))).toBe(true);
expect(existsSync(join(outDir, 'stats.md'))).toBe(true);
const events = readFileSync(join(outDir, 'events.jsonl'), 'utf8').trim().split('\n');
expect(events).toHaveLength(1);
const stats = readFileSync(join(outDir, 'stats.md'), 'utf8');
expect(stats).toContain('총 이벤트: 1');
});
it('handles empty input — writes 0-event stats', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.exportTo(outDir);
expect(r.eventCount).toBe(0);
expect(readFileSync(join(outDir, 'events.jsonl'), 'utf8')).toBe('');
expect(readFileSync(join(outDir, 'stats.md'), 'utf8')).toContain('총 이벤트: 0');
});
});
- Step 2: 테스트 실행 — FAIL
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: FAIL — exportTo 미정의
- Step 3: 구현
TelemetryService.ts 상단 import 에 writeFile 추가:
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
telemetryStats import 도 추가:
import { aggregateStats } from './telemetryStats.js';
TelemetryService 에 메서드 추가:
async exportTo(outDir: string): Promise<{ eventCount: number }> {
const events = await this.readAllRecent();
await mkdir(outDir, { recursive: true });
const eventsContent = events.map((e) => JSON.stringify(e)).join('\n') + (events.length > 0 ? '\n' : '');
await writeFile(join(outDir, 'events.jsonl'), eventsContent, 'utf8');
const stats = aggregateStats(events, this.now());
await writeFile(join(outDir, 'stats.md'), stats.md, 'utf8');
return { eventCount: stats.eventCount };
}
테스트의 빈 케이스에 맞게 events 0건일 때 trailing newline 없도록 — 위 코드 + (events.length > 0 ? '\n' : '') 가 처리.
- Step 4: 테스트 실행 — PASS
Run: npm test -- tests/unit/TelemetryService.test.ts
Expected: PASS
- Step 5: 커밋
git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts
git commit -m "feat(telemetry): exportTo writes events.jsonl + stats.md (#7 v0.2.3)"
Task 7: CaptureService 의 capture emit hook
Files:
-
Modify:
src/main/services/CaptureService.ts -
Modify:
tests/unit/CaptureService.test.ts -
Step 1: 실패 테스트 추가
tests/unit/CaptureService.test.ts 의 기존 case 들을 손대지 않고 신규 describe 블록 추가:
// tests/unit/CaptureService.test.ts — 파일 끝에 추가
describe('CaptureService telemetry emit', () => {
it('emits capture event with noteId/rawTextLength/hasMedia', async () => {
// 기존 fixture 유틸을 재사용 (있으면). 없으면 inline 으로 minimal stubs:
const repo = {
create: ({ rawText }: { rawText: string }) => ({ id: 'n-test' }),
insertMedia: () => {}
} as unknown as import('@main/repository/NoteRepository.js').NoteRepository;
const store = {
saveImage: async () => ({ relPath: 'x', mime: 'image/png', bytes: 1 }),
deleteNoteDirectory: async () => {}
} as unknown as import('@main/services/MediaStore.js').MediaStore;
const events: Array<{ kind: string; payload: any }> = [];
const telemetry = {
emit: async (ev: { kind: string; payload: any }) => { events.push(ev); }
};
const { CaptureService } = await import('@main/services/CaptureService.js');
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry
});
await svc.submit({ text: 'hi there', images: [] });
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('capture');
expect(events[0]!.payload).toMatchObject({
noteId: 'n-test',
rawTextLength: 'hi there'.length,
hasMedia: false
});
});
it('emits hasMedia=true when images present', async () => {
const repo = {
create: () => ({ id: 'n-img' }),
insertMedia: () => {}
} as unknown as import('@main/repository/NoteRepository.js').NoteRepository;
const store = {
saveImage: async () => ({ relPath: 'x', mime: 'image/png', bytes: 1 }),
deleteNoteDirectory: async () => {}
} as unknown as import('@main/services/MediaStore.js').MediaStore;
const events: Array<{ kind: string; payload: any }> = [];
const { CaptureService } = await import('@main/services/CaptureService.js');
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
await svc.submit({ text: 'with image', images: [new ArrayBuffer(8)] });
expect(events[0]!.payload.hasMedia).toBe(true);
});
it('does NOT emit when telemetry dep absent (backward compat)', async () => {
const repo = {
create: () => ({ id: 'n-back' }),
insertMedia: () => {}
} as unknown as import('@main/repository/NoteRepository.js').NoteRepository;
const store = { saveImage: async () => ({ relPath: 'x', mime: 'image/png', bytes: 1 }), deleteNoteDirectory: async () => {} } as unknown as import('@main/services/MediaStore.js').MediaStore;
const { CaptureService } = await import('@main/services/CaptureService.js');
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {}
});
await expect(svc.submit({ text: 'no telem', images: [] })).resolves.toMatchObject({ noteId: 'n-back' });
});
});
- Step 2: 테스트 실행 — FAIL
Run: npm test -- tests/unit/CaptureService.test.ts
Expected: FAIL — telemetry dep 미인식 (TypeScript 또는 runtime)
- Step 3: 구현
src/main/services/CaptureService.ts 변경:
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
): Promise<void>;
}
export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
celebrate: (noteId: string) => void;
telemetry?: TelemetryEmitter;
}
export interface SubmitInput {
text: string;
images: ArrayBuffer[];
}
export class CaptureService {
constructor(
private repo: NoteRepository,
private store: MediaStore,
private deps: CaptureDeps
) {}
async submit(input: SubmitInput): Promise<{ noteId: string }> {
const trimmed = input.text.trim();
if (trimmed.length === 0 && input.images.length === 0) {
throw new Error('empty submission');
}
const { id } = this.repo.create({ rawText: input.text });
if (input.images.length > 0) {
const rows = [];
for (const img of input.images) {
const buf = Buffer.from(img);
const saved = await this.store.saveImage(id, buf, 'image/png');
rows.push({
noteId: id,
kind: 'image' as const,
relPath: saved.relPath,
mime: saved.mime,
bytes: saved.bytes
});
}
this.repo.insertMedia(rows);
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'capture',
payload: {
noteId: id,
rawTextLength: input.text.length,
hasMedia: input.images.length > 0
}
});
}
await this.deps.enqueue(id);
this.deps.celebrate(id);
return { noteId: id };
}
async deleteNote(noteId: string): Promise<void> {
this.repo.delete(noteId);
await this.store.deleteNoteDirectory(noteId);
}
}
- Step 4: 테스트 실행 — PASS
Run: npm test -- tests/unit/CaptureService.test.ts
Expected: PASS — 신규 3 케이스 + 기존 케이스 모두 그린
- Step 5: 커밋
git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts
git commit -m "feat(telemetry): CaptureService emits capture event (#7 v0.2.3)"
Task 8: AiWorker 의 ai_succeeded / ai_failed emit hook + reason 분류
Files:
-
Modify:
src/main/ai/AiWorker.ts -
Modify:
tests/unit/AiWorker.test.ts -
Step 1: 실패 테스트 추가
tests/unit/AiWorker.test.ts 끝에 추가 (기존 fixture 유틸 사용 패턴 따라):
describe('AiWorker telemetry emit', () => {
it('emits ai_succeeded with durationMs/attempts on success', async () => {
// 기존 fixture: in-memory db + repo + working provider
const { db, repo } = makeRepo(); // 기존 helper
repo.create({ rawText: '수요일 회의 메모' });
const ids = repo.findRecent(10).map((n) => n.id);
const noteId = ids[0]!;
const provider = {
name: 'fake/v1',
generate: async () => ({ title: '회의 메모', summary: '한 줄\n\n', tags: [], dueDate: null })
};
const events: Array<{ kind: string; payload: any }> = [];
const telemetry = { emit: async (ev: any) => { events.push(ev); } };
const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, { telemetry });
await worker.enqueue(noteId);
await worker.drain();
expect(events.find((e) => e.kind === 'ai_succeeded')).toBeDefined();
const ev = events.find((e) => e.kind === 'ai_succeeded')!;
expect(ev.payload.noteId).toBe(noteId);
expect(ev.payload.attempts).toBe(0);
expect(ev.payload.durationMs).toBeGreaterThanOrEqual(0);
});
it('emits ai_failed with reason=unreachable on network error', async () => {
const { db, repo } = makeRepo();
repo.create({ rawText: '메모' });
const noteId = repo.findRecent(10)[0]!.id;
const provider = {
name: 'fake/v1',
generate: async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); }
};
const events: Array<{ kind: string; payload: any }> = [];
const telemetry = { emit: async (ev: any) => { events.push(ev); } };
const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, {
telemetry,
backoffsMs: [0, 0, 0] // 빠른 실패
});
await worker.enqueue(noteId);
await worker.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed!.payload.reason).toBe('unreachable');
});
it('emits ai_failed with reason=schema on zod failure', async () => {
const { db, repo } = makeRepo();
repo.create({ rawText: '메모' });
const noteId = repo.findRecent(10)[0]!.id;
const provider = {
name: 'fake/v1',
generate: async () => { throw new (await import('zod')).ZodError([]); }
};
const events: Array<{ kind: string; payload: any }> = [];
const telemetry = { emit: async (ev: any) => { events.push(ev); } };
const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, {
telemetry,
backoffsMs: [0, 0, 0]
});
await worker.enqueue(noteId);
await worker.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed!.payload.reason).toBe('schema');
});
it('emits ai_failed with reason=other on unrecognized error', async () => {
const { db, repo } = makeRepo();
repo.create({ rawText: '메모' });
const noteId = repo.findRecent(10)[0]!.id;
const provider = {
name: 'fake/v1',
generate: async () => { throw new Error('mystery'); }
};
const events: Array<{ kind: string; payload: any }> = [];
const telemetry = { emit: async (ev: any) => { events.push(ev); } };
const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, {
telemetry,
backoffsMs: [0, 0, 0]
});
await worker.enqueue(noteId);
await worker.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed!.payload.reason).toBe('other');
});
});
makeRepo() helper 가 기존 tests/unit/AiWorker.test.ts 에 있는지 확인. 없으면 기존 setup 패턴 그대로 쓰는 inline 으로 대체. (실제 테스트 작성 시 첫 단계에서 파일 head 확인 후 헬퍼 재사용 결정.)
- Step 2: 테스트 실행 — FAIL
Run: npm test -- tests/unit/AiWorker.test.ts
Expected: FAIL — telemetry opt 미인식
- Step 3: 구현
src/main/ai/AiWorker.ts 변경:
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
function todayKstAsDate(now: Date): Date {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
}
function todayKstAsIso(now: Date): string {
return todayKstAsDate(now).toISOString().slice(0, 10);
}
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
if (err instanceof ZodError) return 'schema';
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
return 'unreachable';
}
if (msg.includes('timeout') || msg.includes('timedout') || msg.includes('aborted')) {
return 'timeout';
}
return 'other';
}
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
): Promise<void>;
}
export interface AiWorkerOptions {
backoffsMs?: number[];
onUpdate?: (note: Note) => void;
logger?: {
info: (msg: string, meta?: Record<string, unknown>) => void;
warn: (msg: string, meta?: Record<string, unknown>) => void;
error: (msg: string, meta?: Record<string, unknown>) => void;
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
}
interface Job { noteId: string; attempts: number; }
export class AiWorker {
private queue: Job[] = [];
private running = false;
private drainResolvers: Array<() => void> = [];
private backoffsMs: number[];
private onUpdate?: (note: Note) => void;
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
constructor(
private repo: NoteRepository,
private provider: InferenceProvider,
opts: AiWorkerOptions = {}
) {
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
this.onUpdate = opts.onUpdate;
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
}
async enqueue(noteId: string): Promise<void> {
this.queue.push({ noteId, attempts: 0 });
this.kick();
}
async loadFromDb(): Promise<void> {
for (const j of this.repo.getAllPendingJobs()) {
this.queue.push({ noteId: j.noteId, attempts: j.attempts });
}
this.kick();
}
async drain(): Promise<void> {
if (!this.running && this.queue.length === 0) return;
await new Promise<void>((resolve) => {
this.drainResolvers.push(resolve);
this.kick();
});
}
private kick(): void {
if (this.running) return;
if (this.queue.length === 0) { this.resolveDrainers(); return; }
this.running = true;
void this.loop();
}
private async loop(): Promise<void> {
try {
while (this.queue.length > 0) {
const job = this.queue.shift()!;
await this.processJob(job);
}
} finally {
this.running = false;
this.resolveDrainers();
}
}
private resolveDrainers(): void {
const r = this.drainResolvers.splice(0);
for (const fn of r) fn();
}
private async processJob(job: Job): Promise<void> {
const max = this.backoffsMs.length;
for (let attempt = job.attempts; attempt < max; attempt++) {
const startMs = Date.now();
try {
const note = this.repo.findById(job.noteId);
if (!note || note.aiStatus !== 'pending') return;
const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate);
const candidates = parseAllCandidates(note.rawText, todayDate);
const res = await this.provider.generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates
});
this.repo.updateAiResult(job.noteId, {
title: res.title,
summary: res.summary,
tags: res.tags,
provider: this.provider.name,
dueDate: res.dueDate ?? null
});
this.logger.info('ai.done', {
noteId: job.noteId,
attempt,
dueDateSource: res.dueDate !== null ? 'ai' : 'none',
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
durationMs: Date.now() - startMs,
attempts: attempt
}
}).catch(() => {});
}
this.emit(job.noteId);
return;
} catch (err) {
const isLast = attempt === max - 1;
const msg = (err as Error).message;
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg });
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_failed',
payload: {
noteId: job.noteId,
reason: classifyReason(err),
attempts: attempt + 1
}
}).catch(() => {});
}
this.emit(job.noteId);
return;
}
await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
}
}
}
private emit(noteId: string): void {
if (!this.onUpdate) return;
const note = this.repo.findById(noteId);
if (note) this.onUpdate(note);
}
private sleep(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((r) => setTimeout(r, ms));
}
}
- Step 4: 테스트 실행 — PASS
Run: npm test -- tests/unit/AiWorker.test.ts
Expected: PASS — 신규 4 케이스 + 기존 케이스 모두 그린
- Step 5: 커밋
git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts
git commit -m "feat(telemetry): AiWorker emits ai_succeeded/ai_failed with reason (#7 v0.2.3)"
Task 9: Tray menu "사용 로그 내보내기..." + 콜백
Files:
-
Modify:
src/main/tray.ts -
Step 1: 변경
기존 createTray 시그니처에 7번째 콜백 추가, 메뉴 항목 1줄 추가:
// src/main/tray.ts
import electron from 'electron';
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
const { app, Tray, Menu, nativeImage } = electron;
let tray: TrayType | null = null;
let _showInbox: () => void = () => {};
let _showCapture: () => void = () => {};
let _runBackup: () => void = () => {};
let _runExport: () => void = () => {};
let _runImport: () => void = () => {};
let _runSync: () => void = () => {};
let _runExportTelemetry: () => void = () => {};
let _todayCount = 0;
function buildMenu() {
const items: MenuItemConstructorOptions[] = [];
if (_todayCount > 0) {
items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false });
items.push({ type: 'separator' });
}
items.push({ label: '보관한 메모 보기', click: _showInbox });
items.push({ label: '한 줄 적기', click: _showCapture });
items.push({ type: 'separator' });
items.push({ label: '지금 백업', click: _runBackup });
items.push({ label: '내보내기...', click: _runExport });
items.push({ label: '백업에서 복원...', click: _runImport });
items.push({ label: '지금 동기화', click: _runSync });
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
if (app.isPackaged) {
const { openAtLogin } = app.getLoginItemSettings();
items.push({
label: '윈도우 시작 시 자동 실행',
type: 'checkbox',
checked: openAtLogin,
click: (item) => {
app.setLoginItemSettings({
openAtLogin: item.checked,
args: ['--hidden']
});
}
});
items.push({ type: 'separator' });
} else {
items.push({ type: 'separator' });
}
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
return Menu.buildFromTemplate(items);
}
export function createTray(
showInbox: () => void,
showCapture: () => void,
runBackup: () => void,
runExport: () => void,
runImport: () => void,
runSync: () => void,
runExportTelemetry: () => void
): TrayType {
_showInbox = showInbox;
_showCapture = showCapture;
_runBackup = runBackup;
_runExport = runExport;
_runImport = runImport;
_runSync = runSync;
_runExportTelemetry = runExportTelemetry;
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
tray.setContextMenu(buildMenu());
tray.on('click', showInbox);
return tray;
}
export function refreshTray(todayCount: number): void {
_todayCount = todayCount;
if (tray === null) return;
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
tray.setContextMenu(buildMenu());
}
- Step 2: typecheck
Run: npm run typecheck
Expected: FAIL — index.ts 의 createTray 호출이 6 args 로 끝나서 runExportTelemetry 누락
이 실패는 Task 10 에서 해결. 일단 typecheck 깨진 상태로 진행.
- Step 3: 커밋
git add src/main/tray.ts
git commit -m "feat(telemetry): tray menu '사용 로그 내보내기...' (#7 v0.2.3)"
pre-commit hook 이 typecheck 강제하면 잠시 통과 안 될 수 있음 — Task 10 직전까지는 단일 커밋으로 묶어 한 번에 머지하는 옵션도 있다. 본 plan 은 분리 commit 권장.
Task 10: index.ts 에 TelemetryService wire-up + tray 콜백
Files:
-
Modify:
src/main/index.ts -
Step 1: 변경
src/main/index.ts 의 main entry 에:
(1) Import 추가:
import { TelemetryService } from './services/TelemetryService.js';
(2) paths 결정 후 TelemetryService 생성 + cleanupOldFiles 1회:
// 기존 const paths = resolveProfilePaths('default'); 직후
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
void telemetry.cleanupOldFiles().then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }));
(3) AiWorker 생성 시 telemetry 주입:
const worker = new AiWorker(repo, provider, {
onUpdate: (note) => {
pushNoteUpdated(getInboxWindow, note);
refreshTray(repo.countToday());
},
logger,
telemetry // 추가
});
(4) CaptureService 생성 시 telemetry 주입:
const capture = new CaptureService(repo, store, {
enqueue: (id) => worker.enqueue(id),
celebrate: (id) => notify.celebrate(id),
telemetry // 추가
});
(5) createTray(...) 호출에 7번째 콜백 추가:
createTray(
() => createInboxWindow(),
() => showQuickCapture(),
/* runBackup */ async () => { /* 기존 콜백 그대로 */ },
/* runExport */ async () => { /* 기존 콜백 그대로 */ },
/* runImport */ async () => { /* 기존 콜백 그대로 */ },
/* runSync */ async () => { /* 기존 콜백 그대로 */ },
/* runExportTelemetry */ async () => {
const win = getInboxWindow();
const dialogOpts: Electron.OpenDialogOptions = {
title: '사용 로그를 내보낼 폴더 선택',
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
buttonLabel: '여기로 내보내기',
properties: ['openDirectory', 'createDirectory']
};
const result = win
? await dialog.showOpenDialog(win, dialogOpts)
: await dialog.showOpenDialog(dialogOpts);
if (result.canceled || result.filePaths.length === 0) return;
try {
const r = await telemetry.exportTo(result.filePaths[0]!);
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
new Notification({
title: 'Inkling',
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
silent: true
}).show();
} catch (e) {
logger.warn('telemetry.export.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '사용 로그 내보내기를 완료하지 못했습니다.',
silent: true
}).show();
}
}
);
콜백 5종 (Backup/Export/Import/Sync) 의 본문은 기존 코드 (src/main/index.ts:149-287) 와 동일 — 한 줄도 변경 없음. 위 예시는 7번째만 신규.
- Step 2: typecheck — 0 errors
Run: npm run typecheck
Expected: PASS — 0 errors
- Step 3: 단위 테스트 — all pass
Run: npm test
Expected: PASS — 205 (기존) + 신규 테스트 (25개) 모두 그린. 정확한 수는 Task 18 의 신규 케이스 합계 + 1.
- Step 4: e2e smoke
Run: npm run test:e2e
Expected: PASS — 1/1. e2e 가 quickcapture/inbox 흐름만 보므로 telemetry 의 silent emit 는 깨지 않아야 함.
- Step 5: 수동 sanity check (개발 환경)
npm run dev
-
노트 1건 캡처
-
<userData>/Inkling/profiles/default/telemetry/events-YYYY-MM-DD.jsonl존재 +capture라인 1줄 +ai_succeeded또는ai_failed1줄 (ollama 상태 따라) 확인 -
트레이 → "사용 로그 내보내기..." → 빈 폴더 → 알림 +
events.jsonl(몇 줄) +stats.md확인 -
events.jsonl의 어떤 라인에도rawText,title,summary,userIntent,tagNamessubstring 없음 확인 (grep -E 'rawText|"title"|"summary"|userIntent|tagNames' events.jsonl→ 0 hits) -
Step 6: 커밋
git add src/main/index.ts
git commit -m "feat(telemetry): wire TelemetryService + tray export (#7 v0.2.3)"
Task 11: 종료 게이트 + roadmap 항목 #7 closure
Files:
-
Modify:
docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md(작은 closure 표시) -
Step 1: 게이트 종합 실행
npm run typecheck && npm test && npm run test:e2e
Expected: 모두 PASS.
- Step 2: 신규 테스트 카운트 확인
npm test 출력의 총 테스트 수가 v0.2.2 기준선 205 보다 ≥ 25 증가 했는지 확인 (Task 별 케이스: T1=9, T2=6, T3=4, T4=4, T5=5, T6=2, T7=3, T8=4 = 합 37 — 단, T8 helper 재사용 등으로 일부 변동 가능).
신규 카운트가 예상보다 너무 적으면 (예: 30 미만) 어떤 describe 가 skip 되었는지 점검.
- Step 3: roadmap 의 항목 #7 closure 마커 추가
docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md 의 §3 #7 Telemetry 헤더 옆에 ✓ 추가 (다른 항목 도착 전까지는 #7 만 closed):
### #7 Telemetry skeleton (1번) ✓ 완료
- Step 4: closure 커밋
git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
git commit -m "docs(spec): mark #7 telemetry as completed (v0.2.3 1/7)"
Self-Review (작성 후 즉시)
Spec coverage (roadmap §3 #7 In 항목 vs plan task 매핑):
| spec In 항목 | plan task | 상태 |
|---|---|---|
| TelemetryService.emit + JSONL append | T2 | ✓ |
| 일자별 KST rotation | T2 | ✓ |
| 14일 후 rolling 삭제 | T3 | ✓ |
| write 실패 시 silent log only | T2 (silent opt + 별 케이스) | ✓ |
| zod schema + privacy invariant | T1 | ✓ |
capture 기본 hook |
T7 | ✓ |
ai_succeeded 기본 hook |
T8 | ✓ |
ai_failed 기본 hook + reason 분류 |
T8 | ✓ |
| 트레이 메뉴 "사용 로그 내보내기..." | T9 + T10 | ✓ |
| folder dialog → events.jsonl + stats.md | T10 | ✓ |
| stats.md 집계 (항목별 ratio) | T5 | ✓ |
IPC tray:exportTelemetry |
T10 (트레이 콜백이 직접 호출 → 별 IPC 핸들러 불필요) | ✓ — IPC 채널 없이 main process 안에서 처리. Roadmap §3 의 "IPC: tray:exportTelemetry" 표현은 인접 명명일 뿐, 실제 트레이는 main 내부 콜백이라 IPC 채널 없이도 동작. spec 의 IPC 라인은 인접 이름 fictitious — closure 시 spec 수정 필요할 수 있음. T11 에서 closure 마커 추가하며 검토. |
Placeholder scan: "TODO" / "TBD" / "implement later" — 0 hit. 단, T8 의 makeRepo() helper 가 기존 tests/unit/AiWorker.test.ts 에 존재하는지 미확인. 실제 작업 시 테스트 파일 head 1회 읽고 헬퍼 패턴 일치시킬 것.
Type consistency: TelemetryEvent / EmitInput / TelemetryEmitter (CaptureService 의) / AiTelemetryEmitter (AiWorker 의) — 두 emitter interface 가 살짝 다름 (서비스마다 자기 kind 만). 이는 의도적 (consumer 별 narrow type). TelemetryService.emit 의 EmitInput 이 둘의 union 이라 호환. 충돌 없음.
Spec 'IPC tray:exportTelemetry' note: 위에서 언급한 대로, 트레이는 main 내부 콜백이므로 별 IPC 채널 불필요. spec §3 #7 의 "IPC: tray:exportTelemetry" 한 줄은 closure 시 "트레이 콜백 (main 내부)" 으로 정정 권장. 일단 plan 은 IPC 없이 진행.