Files
inkling/docs/superpowers/plans/2026-05-01-v023-telemetry.md
altair823 358cada017 docs(plan): #7 telemetry skeleton 구현 계획 (v0.2.3 1/7)
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>
2026-05-01 14:02:48 +09:00

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.tsTelemetryService 클래스에 메서드 추가:

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.tscreateTray 호출이 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_failed 1줄 (ollama 상태 따라) 확인

  • 트레이 → "사용 로그 내보내기..." → 빈 폴더 → 알림 + events.jsonl (몇 줄) + stats.md 확인

  • events.jsonl 의 어떤 라인에도 rawText, title, summary, userIntent, tagNames substring 없음 확인 (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.emitEmitInput 이 둘의 union 이라 호환. 충돌 없음.

Spec 'IPC tray:exportTelemetry' note: 위에서 언급한 대로, 트레이는 main 내부 콜백이므로 별 IPC 채널 불필요. spec §3 #7 의 "IPC: tray:exportTelemetry" 한 줄은 closure 시 "트레이 콜백 (main 내부)" 으로 정정 권장. 일단 plan 은 IPC 없이 진행.