From 22a25cc62286e6ec5b3424bcb3a8b33a77de34f0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 13:56:16 +0900 Subject: [PATCH 01/17] docs(spec): v0.2.3 dogfood feedback roadmap (7 items, single cut) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.2.2 dogfood 7항목 (#7 telemetry 신설 + #1~#6) 단일 cut 로드맵. 데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B), trash↔backup/export B 정책, #6 = 1 spike 흡수. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-01-v023-feedback-roadmap-design.md | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md new file mode 100644 index 0000000..f3a1538 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md @@ -0,0 +1,342 @@ +# v0.2.2 Dogfood 피드백 로드맵 (#7→#6 → v0.2.3) 설계 + +**작성일:** 2026-05-01 +**저자:** 김태현 (dlsrks0734@gmail.com) +**문서 성격:** v0.2.2 dogfood 중 발견된 7개 항목 (`memory/project_v022_feedback.md` #1~#6 + 본 brainstorm 에서 추가된 #7 telemetry) 의 순차 작업 로드맵. 본 문서는 **순서·범위·게이트** 만 정의하며, 각 항목 내부 설계는 항목별 mini-brainstorm + writing-plans 에서 결정. + +**선행 문서:** +- `memory/project_v022_feedback.md` (raw 피드백 6건) +- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` (v0.2.1 로드맵, 본 문서의 패턴 원형) +- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` (slice §1.1 invariant 4 — 본문 미기록) +- `docs/superpowers/strategy/strategy.md` (#6 에서 §2.3·§4.3·§8 동반 갱신 대상) + +--- + +## 1. 결정 요약 + +| 결정 | 값 | 근거 | +|------|-----|------| +| Cut 패턴 | **단일 cut v0.2.3** (7항목 한 묶음) | v0.2.1 패턴 반복. 항목 간 결합도 (특히 #7 → #4~#6 emit) 분리 시 회전 비용. | +| 우선순위 기준 | **데이터 안전 우선** (v0.2.1 패턴) | 측정 인프라 (#7) → schema migration v3 (#4) → 안전망 위에서 기능 진행. | +| 첫 항목 | **#7 Telemetry skeleton** | 다른 6 항목이 emit hook 만 추가. 측정 없는 기능 출시는 다음 cut 까지 1주 본인 라벨링으로 후퇴. | +| 항목당 게이트 | **머지 + 테스트 통과** (typecheck + 205+ 단위 + e2e smoke) | v0.2.2 기준선. | +| 다음 빌드 | **v0.2.3** (7항목 모두 머지 후 단일 cut) | slice §7 strict-pin patch 증분. | +| 신규 dependency | **0 목표** | 모두 stdlib + 기존 better-sqlite3 / electron 으로 충분. | +| Schema 변경 | **migration v3** — 3 컬럼 한 번에: `deleted_at TEXT NULL`, `last_recalled_at TEXT NULL`, `recall_dismissed_at TEXT NULL` | #4 휴지통 + #6 회상 메타 한 묶음. 별 migration 두 번 회피. | +| Trash 와 export/backup | **B 정책** — F6-L1 backup 포함 (byte-for-byte), F5 export 제외, F6-L3 import 시 `deleted_at IS NOT NULL` 우선 (삭제 보존) | 백업은 회복 용도, export 는 외부 노출 형식. | +| Decision-pending 처리 | **항목별 mini-brainstorm** | 본 문서는 순서·In/Out 만, 항목 내부는 per-item. | + +--- + +## 2. 순차 작업 순서 + +``` +v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]──────── + │ +개발 트랙 (main 직접 머지 또는 PR): │ + ① #7 Telemetry skeleton [작음, 인프라 1번] │ + ② #4 휴지통 + migration v3 [중, schema + 정책] │ + ③ #5 만료 추천 [작음, #4 destination]│ + ④ #1 Ollama 회복 polling [작음, 독립] │ + ⑤ #2 AI retry / 수동 trigger [중, AiWorker 정책] │ + ⑥ #3 태그 vocab 주입 [작음, 독립] │ + ⑦ #6 리마인드 1 spike [중, strategy 갱신] │ + │ + ┌──────────┘ + ▼ + v0.2.3 cut (단일) + │ + ▼ + dogfood 재설치 + ≥ 1주 soak + │ + ▼ + telemetry export → 분석 → + v0.2.4 brainstorm +``` + +### 2.1 순서 결정 근거 + +1. **#7 (1번)** — 측정 인프라. 다른 항목이 emit hook 추가만 하도록 skeleton 먼저. Cross-cutting privacy invariant 강제도 1번에서 단위 테스트로 고정. +2. **#4 (2번)** — schema migration v3 가 #6 회상 메타 컬럼 동반. 휴지통 invariant (`deleted_at IS NULL` 모든 active 쿼리) 가 다른 항목에 영향. 회복 안전망 (pre-v3 snapshot, v0.2.1 메커니즘) 위에서 진행. +3. **#5 (3번)** — #4 휴지통 destination 직접 소비. 같은 영역 (Inbox 상단 배너). +4. **#1 (4번)** — #2 의 reliable health 의존성. polling 인프라 먼저. +5. **#2 (5번)** — #1 health 위에서 retry/manual trigger 정책 변경. AiWorker 의 unreachable infinite retry 로 #1 polling 결과 활용. +6. **#3 (6번)** — 독립 prompt 변경. PROMPT_VERSION 4. AI 영역 마지막에 묶어서 AiWorker 회귀 위험 격리. +7. **#6 (7번)** — strategy.md 갱신 + RecallBanner 1 spike. last_recalled_at / recall_dismissed_at 사용. 가장 마지막에 두는 이유: 다른 항목 telemetry hook 이 모두 박혀야 #6 측정 가치가 살아남. + +--- + +## 3. 항목당 In (PR 범위) / Out (deferred) + +각 항목 PR 범위 라인. 세부 결정 (decision-pending) 은 항목 시작 시 mini-brainstorm. + +### #7 Telemetry skeleton (1번) + +**In:** +- `TelemetryService` (`src/main/services/TelemetryService.ts`): + - `emit(kind, payload)` → 비동기 append to `/telemetry/events-YYYY-MM-DD.jsonl` + - 일자별 rotation (KST 자정), 14일 후 rolling 삭제 + - write 실패 시 silent log only (앱 동작 영향 없음) +- 이벤트 zod schema: `{ ts: ISO string, kind: enum, payload: object }`. payload shape 는 kind 별 fixed. +- **Privacy invariant** (slice §1.1 invariant 4 강화): payload 에 `raw_text` / `ai_title` / `ai_summary` / `user_intent` / 태그 name 포함 시 zod parser 거부. 단위 테스트로 고정. +- 기본 emit hook 박기: + - `capture` (CaptureService.submit 후): `{ noteId, rawTextLength, hasMedia }` + - `ai_succeeded` (AiWorker.processJob 성공): `{ noteId, durationMs, attempts }` + - `ai_failed` (AiWorker.processJob 실패): `{ noteId, reason: "unreachable"|"schema"|"timeout"|"other", attempts }` +- 트레이 메뉴 "사용 로그 내보내기...": + - 폴더 다이얼로그 → `events.jsonl` (최근 14일 concat) + `stats.md` (집계 마크다운) zip + - `stats.md` 내용: 항목별 일자별 카운트 표 + 핵심 ratio (AI 성공률, ollama uptime%, recall opened/shown, expired batched/shown 등) +- IPC: `tray:exportTelemetry` +- 단위 테스트: emit, rotation, privacy invariant 거부, stats 집계, export zip + +**Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정 + +### #4 휴지통 (2번) + +**In:** +- migration v3: `notes.deleted_at TEXT NULL` + `notes.last_recalled_at TEXT NULL` + `notes.recall_dismissed_at TEXT NULL` (3 컬럼 한 번) +- `NoteRepository`: `trash(id)` (`deleted_at = now()`), `restore(id)` (`deleted_at = NULL`), `emptyTrash()` (hard delete + media 정리). 기존 `delete()` 는 deprecate 후 `emptyTrash` 내부에서만 호출. +- **Active 쿼리 일괄 `WHERE deleted_at IS NULL` 추가**: `listNotes`, `countToday`, `findByTag`, search, F5 export, AiWorker `loop()` 진입 시 deleted_at 체크 +- 휴지통 UI: Inbox 상단 탭 ("Inbox · 휴지통(N)") — 정밀 위치는 mini-brainstorm +- 휴지통 비우기 confirm dialog ("N개 영구 삭제. 되돌릴 수 없음.") +- F5 export 가 `deleted_at IS NOT NULL` 제외 +- F6-L3 import 충돌 정책 추가: source 와 dest 중 `deleted_at IS NOT NULL` 우선 (삭제 보존) +- IPC: `inbox:trash` / `inbox:restore` / `inbox:emptyTrash` / `inbox:listTrash` +- Telemetry emit: `trash` / `restore` / `emptyTrash` +- 단위 테스트: trash/restore/emptyTrash, active query 제외, AiWorker skip, F5 export 제외, F6-L3 import 머지 + +**Out:** 자동 비우기 정책 (사용자 트리거만), 휴지통 검색, trash 안 노트 편집, 휴지통 UI 정밀 위치 (mini-brainstorm), per-note 영속 보호 플래그 + +### #5 만료 추천 (3번) + +**In:** +- `NoteRepository.findExpiredCandidates({today})`: + - `WHERE due_date < today AND deleted_at IS NULL AND ai_status = 'done'` + - ORDER BY `created_at DESC` +- Inbox 상단 **만료 배너** (펼침 가능): + - "오늘 기준 만료 N개" 헤더 + - 펼치면 노트 카드 리스트 + 체크박스 멀티선택 + - "선택 휴지통" 버튼 → 일괄 trash + telemetry emit + - "오늘 그만" → in-memory snooze (자정 KST 리셋) +- IPC: `inbox:listExpired`, `inbox:trashBatch` +- Telemetry emit: `expired_banner_shown` (`{ candidateCount }`), `expired_batch_trash` (`{ count }`) +- 단위 테스트: 후보 query, 멀티선택 batch trash, snooze 동작, deleted_at 제외 확인 + +**Out:** 시스템 알림 surface, 별 페이지, snooze 영속화, "안 옮김" 가중치 감소, 만료 임박 (D-7) 추천 + +### #1 Ollama 회복 (4번) + +**In:** +- HealthChecker 주기 polling (기본 60s — mini-brainstorm 에서 주기/backoff 확정): + - `runOnce()` 가 setInterval 로 자동 발화 + - 회복 시 `onUpdate` fire → 구독 (renderer OllamaBanner) 자동 갱신 + - 실패 N회 후 polling 중단 정책 — mini-brainstorm +- 수동 "재확인" 버튼: `OllamaBanner` + 트레이 컨텍스트 메뉴 +- IPC: `inbox:ollamaRecheck` +- Telemetry emit: `ollama_unreachable` (`{ reason }`), `ollama_recovered` (`{ downtimeMs }`), `ollama_recheck_manual` (`{}`) +- 단위 테스트: polling fire, manual recheck, 회복 status 전이 + telemetry emit + +**Out:** 사용자 설정 가능 polling 주기, 회복 toast 알림, 모델 정상성 (tags 외) 체크 + +### #2 AI retry / 수동 trigger (5번) + +**In:** +- `AiWorker.processJob()` 정책 변경: + - **ollama unreachable** 일 때 `attempts` 증가 안 하고 `next_run_at` 만 backoff (무한 retry while unreachable) + - schema fail / invalid response / timeout 만 `attempts` 증가 (기존 max 3 유지) + - reason 분류는 `LocalOllamaProvider` 결과 + zod 결과로 결정 +- `markAiFailed` 한 노트 수동 re-enqueue 가능 (hard fail 도 회수 경로) +- 트레이 + Inbox 메뉴 **"지금 AI 처리 (실패 N건)"** → 모든 ai_status='failed' → pending_jobs 재투입 +- `FailedBanner` (PendingBanner 형제, 실패 N건 + retry 버튼) +- IPC: `inbox:retryAllFailed`, `inbox:failedCount` +- Telemetry emit: `ai_failed` (#7 의 기본 hook 에 reason 분류 추가), `ai_retry_manual` (`{ failedCount }`) +- 단위 테스트: unreachable infinite retry, retry-all trigger, unreachable vs schema fail 구분, attempts 증가 정책 + +**Out:** per-note retry 버튼 (NoteCard), failed reason 별 차등 정책, retry progress UI, retry rate-limit + +### #3 태그 vocab (6번) + +**In:** +- `NoteRepository.getTopUsedTags(N=20)`: + - `SELECT t.name, COUNT(*) c FROM tags t JOIN note_tags nt ON nt.tag_id=t.id JOIN notes n ON n.id=nt.note_id WHERE n.deleted_at IS NULL GROUP BY t.id ORDER BY c DESC LIMIT 20` +- `buildPrompt()` 에 vocab 주입 라인: + - "기존 태그를 우선 재사용. 새 태그는 vocab 에 없는 의미일 때만 만들기:" + kebab-case 리스트 + - vocab 빈 케이스 (신규 사용자) → 라인 자체 생략 +- `PROMPT_VERSION` 3 → **4** +- AI 응답 후 vocab hit/miss 분류 → telemetry emit +- Telemetry emit: `tag_vocab_hit` (`{ tagId, vocabSize }`), `tag_vocab_miss` (`{ vocabSize }`) +- 단위 테스트: vocab 합성, 빈 vocab, 길이 cap, prompt version bump, hit/miss 분류 + +**Out:** 임베딩 유사도 dedup, 사용자 controlled vocabulary 화이트리스트, 자동 normalize ("회의" ↔ "미팅"), top-N 튜닝, vocab cache invalidation 정책 + +### #6 리마인드 1 spike (7번) + +**In:** +- `strategy.md` §2.3 / §4.3 / §8 갱신: Capitalize 본격 진입, "오늘 회상" surface 정의, F4-A/B/D deferred 항목의 측정 인프라 마련 명시 +- Inbox 상단 **`RecallBanner`** — "오늘 회상해볼 노트" 1건 추천: + - algo: `WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','-7 day')) AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','-30 day')) AND ai_status='done' AND deleted_at IS NULL AND (due_date IS NULL OR due_date >= today) ORDER BY created_at ASC LIMIT 1` + - 사용자 액션 3개: + - "열어보기" → 노트 카드 스크롤 + `last_recalled_at = now()` + - "다음에" → in-memory snooze 1일 (영속화 X) + - "더 이상" → `recall_dismissed_at = now()` +- IPC: `inbox:listRecallCandidate`, `inbox:markRecallOpened`, `inbox:dismissRecall` +- Telemetry emit: `recall_shown` (`{ noteId, ageDays }`), `recall_opened`, `recall_dismissed`, `recall_snoozed` +- 단위 테스트: algo selection, dismiss 만료 (30일 후 재추천), last_recalled 갱신, deleted_at 제외, 후보 0건 케이스 + +**Out:** 잠금해제 hook (F4-A), 무작위 토스트 (F4-D), ambient if-then (F4-B), 임베딩 유사도 추천 (#3 vocab 후속), spaced repetition (Leitner/SM-2), 다중 후보 추천 + +### 3.1 공통 게이트 (모든 항목) + +각 항목 머지 전 필수: + +- `npm run typecheck` 통과 (현재 0 에러) +- `npm test` 통과 (현재 205/205, 항목 신규 단위 추가) +- `npm run test:e2e` 통과 (현재 1/1) +- 항목 신규 단위 테스트 ≥ 1개 (TDD) +- main 머지 + +--- + +## 4. 항목당 작업 흐름 + Cross-cutting + +``` +[항목 N 시작] + │ + ├─ mini-brainstorm ← decision-pending 답변 + │ - 본 문서 §3 의 "Out" 후보 일부가 In 으로 승격 가능 + │ - per-item spec doc → docs/superpowers/specs/2026-MM-DD-v023-.md + │ + ├─ writing-plans ← TDD 구현 계획 + │ + ├─ 구현 (executing-plans 또는 직접) + │ - 브랜치: feat/v023- (예: feat/v023-trash, feat/v023-recall) + │ - 게이트 통과 후 main 머지 + │ + └─ 다음 항목 시작 +``` + +### 4.1 Cross-cutting 정책 + +| 영역 | 정책 | +|------|------| +| **버전 관리** | 7개 모두 머지될 때까지 `package.json` `0.2.2` 유지. v0.2.3 cut 은 7번 후 단일. | +| **CHANGELOG** | 기존 `CHANGELOG.md` 에 `[0.2.3]` section append (v0.2.2 에서 확립한 패턴). v0.2.3 cut 직전 한 번만 수정. | +| **브랜치 전략** | `feat/v023-` 단명. main 머지 후 삭제. 작은 항목 (#1, #3, #6 strategy 갱신) 은 main 직접 push 도 허용. | +| **테스트 추가 정책** | 항목당 최소 단위 1개. e2e smoke 영향 시 단언 동기화. AiWorker 변경 (#1, #2, #3) 은 integration (Ollama) 영향 시 검토. | +| **Slice invariant 위반 시** | 본 로드맵 결과로 invariant 변경 — slice §1.1 §7 도 PR 안에 동봉 수정. | +| **신규 dependency** | slice §7 strict-pin 그대로. 0 신규 dep 목표 — 위반 시 PR 안에 §7.2 갱신 + 합리화 동봉. | +| **로깅 정책** | slice §1.1 invariant 4 **강화**: telemetry payload 에 raw_text/title/summary/intent/tag name 포함 절대 금지. 위반 시 silent invariant 위반. #7 단위 테스트로 zod parser 가 거부. | +| **Strategy.md 동반 갱신** | #6 항목 (7번) 에서만. 다른 항목은 strategy.md 미수정. | +| **Schema invariant 추가** | `deleted_at IS NOT NULL` 노트는 모든 active 쿼리 (Inbox 리스트 / 카운트 / 검색 / 태그 필터 / AiWorker 처리 / F5 export) 에서 제외. F6-L1 backup 만 예외. 위반 시 dogfood-feedback 재오픈. | + +--- + +## 5. v0.2.3 Cut 단계 + +7번 항목 머지 후: + +``` +[v0.2.2 dogfood 환경에서] +1. 트레이 → "지금 백업" 1회 클릭 ← F6-L1 첫 실증 +2. 트레이 → "내보내기..." 1회 ← F5 schema-agnostic 백업 +3. 트레이 → "사용 로그 내보내기..." 1회 ← #7 의 첫 실증 (없으면 v0.2.2 raw 데이터 손실) +4. Inkling 종료 + +[빌드 머신에서] +5. package.json version: 0.2.2 → 0.2.3 +6. CHANGELOG.md 에 [0.2.3] section append +7. npm run dist +8. dist/Inkling Setup 0.2.3.exe 검증 + +[dogfood 머신에서] +9. Setup 0.2.3.exe 실행 → 같은 폴더에 설치 +10. 첫 실행 → migration v3 자동 적용 (deleted_at + last_recalled_at + recall_dismissed_at) +11. 트레이 메뉴 "사용 로그 내보내기..." 항목 존재 확인 +12. ≥ 1주 soak 시작 +``` + +### 5.1 업그레이드 안전망 + +| 위험 | 완화 | +|------|------| +| migration v3 결함으로 DB 손상 | 2가지 복원 경로 (v0.2.1 부터): (a) `.pre-v3.bak` 자동 snapshot 으로 SQLite 복원 (v0.2.2 인스톨러 재설치 필요), (b) F5 export → v0.2.3 의 F6-L3 import 로 schema-agnostic 복원 (더 빠름) | +| `deleted_at IS NULL` 누락 — 휴지통 노트가 active 쿼리에 새는 회귀 | 단위 테스트로 모든 active 쿼리 확인. 실수 시 dogfood-feedback 즉시 재오픈. | +| Telemetry payload 에 본문 누출 | `TelemetryService.emit` 의 zod parser 가 거부. CI 단위 테스트로 고정. | +| AiWorker unreachable infinite retry 가 큐 폭주 | next_run_at 의 backoff cap (15분) — mini-brainstorm 에서 확정. | +| 자동시작 토글 / 데이터 디렉터리 손실 | v0.2.1 동일 — `HKCU\...\Run` + `` 보존됨 | + +--- + +## 6. 측정 + +### 6.1 로드맵 측정 + +| 메트릭 | 임계값 | 측정 방법 | +|--------|--------|----------| +| 항목 평균 PR 사이즈 | < 800 lines diff | git log 통계 | +| 항목 평균 머지 간격 | < 5일 | git log 시간차 | +| 회귀 테스트 추가 | 항목당 ≥ 1개 단위 | `tests/unit` 카운트 | +| v0.2.3 cut 후 1주 데이터 손실 | 0회 | telemetry + 본인 라벨링 보강 | +| typecheck/test 회귀 | 0회 | CI · 로컬 | +| Telemetry 본문 누출 | 0건 | events.jsonl grep + zod parser | + +### 6.2 dogfood soak 측정 (#7 의 본격 사용처) + +`stats.md` 가 다음을 답해야 함: + +| 질문 | 데이터 | +|------|--------| +| AI 가 실제로 동작 중인가? | `ai_succeeded / (ai_succeeded + ai_failed)` ratio 일자별 | +| Ollama unreachable 빈도? | `ollama_unreachable` count + 평균 `downtimeMs` | +| 수동 trigger 가 쓰이고 있나? | `ai_retry_manual` / `ollama_recheck_manual` count | +| 휴지통이 회수 도구로 동작? | `restore / trash` ratio | +| 만료 추천이 nudging 으로 동작? | `expired_batch_trash / expired_banner_shown` ratio | +| 회상 spike 가 의미 있나? | `recall_opened / recall_shown` ratio + `recall_dismissed` count | +| Tag vocab 재사용? | `tag_vocab_hit / (hit + miss)` ratio (목표: 시간 흐름에 따라 상승) | + +### 6.3 silent invariant 후보 + +본 로드맵 결과로 slice §1.3 종료 조건에 추가 권장: + +> **"Telemetry 본문 누출 0회"** — events.jsonl 의 어떤 payload 에도 raw_text/title/summary/intent/tag name 미포함. 발생 시 즉시 silent invariant 위반. + +> **"`deleted_at IS NULL` 망각 0회"** — active 쿼리 회귀 시 즉시 dogfood-feedback 재오픈. + +이 추가는 #7 / #4 항목 머지 시 slice §1.3 동봉 수정. + +--- + +## 7. 본 로드맵의 종료 조건 + +**모두 만족해야 종결**: + +1. #7·#4·#5·#1·#2·#3·#6 7개 항목 모두 main 머지 +2. `CHANGELOG.md [0.2.3]` section + `package.json` 0.2.3 + slice §1.3 silent invariant 2개 추가 동봉 갱신 완료 +3. v0.2.3 cut → dogfood 머신 재설치 → migration v3 적용 확인 → 첫 실행 정상 + 트레이 메뉴 신규 항목 ("사용 로그 내보내기...") 동작 확인 +4. ≥ 1주 dogfood soak 완료 (데이터 손실 0회 + telemetry 본문 누출 0건 확인) +5. `events.jsonl` + `stats.md` export → 분석 → v0.2.4 brainstorm 진입 + +5 가 끝나면 본 로드맵 종결. + +--- + +## 8. 미결정 항목 (각 항목 mini-brainstorm 에서 답변) + +본 로드맵은 순서·In/Out 만 정의. 다음 결정들은 빨리 마주치게 됨: + +- **#7**: events.jsonl rotation 주기 (자정 KST 확정), stats.md 집계 ratio 의 정확한 컬럼 list, payload schema 별 zod 파서 합성 정책, write 실패 시 백오프 +- **#4**: 휴지통 UI 정밀 위치 (Inbox 탭 vs 트레이 별 윈도우 vs 별 페이지), 휴지통 비우기 confirm 카피, F5 export 의 trash 옵션 (제외 강제 vs 사용자 토글) +- **#5**: 후보 `due_date_edited_by_user` 필터 여부 (수동 입력만 vs AI 자동 추출 포함), 만료 임박 (D-7) 포함 여부, 멀티선택 default 상태 (전체 선택 vs 비선택) +- **#1**: polling 주기 (10/30/60s), 실패 N회 후 polling 중단, exponential backoff 적용 +- **#2**: unreachable backoff cap (15분 후보), reason 분류 정밀도 (timeout vs unreachable 구분), per-note retry 승격 여부 +- **#3**: top-N 값 (20 후보), vocab cache invalidation 정책 (write-through vs 매 prompt 시 fresh), 빈 vocab 임계값 +- **#6**: dismiss 만료 30일 vs 14일 vs 60일, 후보 0건 시 RecallBanner 숨김 vs 빈 상태 카피 +- **#5+#6 coexistence**: 둘 다 Inbox 상단 배너 noting. stack 순서 (만료 위 → 회상 아래 가 자연 — 시간 민감도 우선), 동시 N건 시 우선 표시 정책, 빈 상태 시 영역 collapse 여부. #5 → #6 순서 머지라 #6 mini-brainstorm 에서 #5 와 통합 layout 결정. + +--- + +## 9. 변경 이력 + +| 일자 | 변경 | +|------|------| +| 2026-05-01 | 초안 — v0.2.2 dogfood 7항목 (#7 telemetry 신설 포함) 단일 cut 로드맵, 데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B 채택), trash↔backup/export B 정책, #6 = 1 spike 흡수 (B 채택). | -- 2.49.1 From 358cada017b5a4be17afa4fcf2d46d8c34b19d5d Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 14:02:48 +0900 Subject: [PATCH 02/17] =?UTF-8?q?docs(plan):=20#7=20telemetry=20skeleton?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20(v0.2.3=201/7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-05-01-v023-telemetry.md | 1627 +++++++++++++++++ 1 file changed, 1627 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-v023-telemetry.md diff --git a/docs/superpowers/plans/2026-05-01-v023-telemetry.md b/docs/superpowers/plans/2026-05-01-v023-telemetry.md new file mode 100644 index 0000000..4fb0991 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-v023-telemetry.md @@ -0,0 +1,1627 @@ +# #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 로컬 로그 (`/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: 실패 테스트 작성** + +```typescript +// 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: 구현** + +```typescript +// 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; +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: 커밋** + +```bash +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: 실패 테스트 작성** + +```typescript +// 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: 구현** + +```typescript +// 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 { + 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: 커밋** + +```bash +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: 실패 테스트 추가** + +```typescript +// 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` 클래스에 메서드 추가: + +```typescript +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: 커밋** + +```bash +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: 실패 테스트 추가** + +```typescript +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` 에 추가: + +```typescript +async readAllRecent(): Promise { + 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 에 추가: + +```typescript +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: 커밋** + +```bash +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: 실패 테스트 작성** + +```typescript +// 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: 구현** + +```typescript +// 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(); + 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: 커밋** + +```bash +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: 실패 테스트 추가** + +```typescript +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` 추가: + +```typescript +import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; +``` + +`telemetryStats` import 도 추가: + +```typescript +import { aggregateStats } from './telemetryStats.js'; +``` + +`TelemetryService` 에 메서드 추가: + +```typescript +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: 커밋** + +```bash +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 블록 추가: + +```typescript +// 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` 변경: + +```typescript +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; +} + +export interface CaptureDeps { + enqueue: (noteId: string) => Promise; + 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 { + 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: 커밋** + +```bash +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 유틸 사용 패턴 따라): + +```typescript +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` 변경: + +```typescript +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; +} + +export interface AiWorkerOptions { + backoffsMs?: number[]; + onUpdate?: (note: Note) => void; + logger?: { + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + error: (msg: string, meta?: Record) => 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; + 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 { + this.queue.push({ noteId, attempts: 0 }); + this.kick(); + } + + async loadFromDb(): Promise { + for (const j of this.repo.getAllPendingJobs()) { + this.queue.push({ noteId: j.noteId, attempts: j.attempts }); + } + this.kick(); + } + + async drain(): Promise { + if (!this.running && this.queue.length === 0) return; + await new Promise((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 { + 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 { + 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 { + 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: 커밋** + +```bash +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줄 추가: + +```typescript +// 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: 커밋** + +```bash +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 추가: + +```typescript +import { TelemetryService } from './services/TelemetryService.js'; +``` + +(2) `paths` 결정 후 TelemetryService 생성 + cleanupOldFiles 1회: + +```typescript +// 기존 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` 주입: + +```typescript +const worker = new AiWorker(repo, provider, { + onUpdate: (note) => { + pushNoteUpdated(getInboxWindow, note); + refreshTray(repo.countToday()); + }, + logger, + telemetry // 추가 +}); +``` + +(4) CaptureService 생성 시 `telemetry` 주입: + +```typescript +const capture = new CaptureService(repo, store, { + enqueue: (id) => worker.enqueue(id), + celebrate: (id) => notify.celebrate(id), + telemetry // 추가 +}); +``` + +(5) `createTray(...)` 호출에 7번째 콜백 추가: + +```typescript +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 1~8 의 신규 케이스 합계 + 1. + +- [ ] **Step 4: e2e smoke** + +Run: `npm run test:e2e` +Expected: PASS — 1/1. e2e 가 quickcapture/inbox 흐름만 보므로 telemetry 의 silent emit 는 깨지 않아야 함. + +- [ ] **Step 5: 수동 sanity check (개발 환경)** + +```bash +npm run dev +``` + +- 노트 1건 캡처 +- `/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: 커밋** + +```bash +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: 게이트 종합 실행** + +```bash +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): + +```markdown +### #7 Telemetry skeleton (1번) ✓ 완료 +``` + +- [ ] **Step 4: closure 커밋** + +```bash +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 없이 진행. -- 2.49.1 From 0a0ef113274984e614bb8c0f2d546c4422d6da6b Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 14:14:19 +0900 Subject: [PATCH 03/17] feat(telemetry): event schema + privacy invariant (#7 v0.2.3) --- src/main/services/telemetryEvents.ts | 34 +++++++++++ tests/unit/telemetryEvents.test.ts | 89 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/main/services/telemetryEvents.ts create mode 100644 tests/unit/telemetryEvents.test.ts diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts new file mode 100644 index 0000000..4a90759 --- /dev/null +++ b/src/main/services/telemetryEvents.ts @@ -0,0 +1,34 @@ +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; +export type TelemetryKind = TelemetryEvent['kind']; + +export function validateEvent(raw: unknown): TelemetryEvent { + return TelemetryEventSchema.parse(raw); +} diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts new file mode 100644 index 0000000..2b8d25b --- /dev/null +++ b/tests/unit/telemetryEvents.test.ts @@ -0,0 +1,89 @@ +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(); + }); +}); -- 2.49.1 From 93e278b24185a9a781b84ede0cbeb5c4ad112322 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 14:18:59 +0900 Subject: [PATCH 04/17] feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3) --- src/main/services/TelemetryService.ts | 42 +++++++++++++++ tests/unit/TelemetryService.test.ts | 74 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/main/services/TelemetryService.ts create mode 100644 tests/unit/TelemetryService.test.ts diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts new file mode 100644 index 0000000..1993981 --- /dev/null +++ b/src/main/services/TelemetryService.ts @@ -0,0 +1,42 @@ +import { mkdir, appendFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { validateEvent } 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 { + 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; + } + } +} diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts new file mode 100644 index 0000000..1c772e9 --- /dev/null +++ b/tests/unit/TelemetryService.test.ts @@ -0,0 +1,74 @@ +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', + () => new Date('2026-05-01T12:00:00Z'), + 14, + { silent: true } + ); + // Should resolve, not throw + await expect(svc.emit({ + kind: 'capture', + payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } + })).resolves.toBeUndefined(); + }); +}); -- 2.49.1 From 50b6d05bcb84be662edb49b8b7218b333bce6ced Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 16:52:11 +0900 Subject: [PATCH 05/17] fix(telemetry): silent-fs-error test exercises the actual code path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier test used '/proc/0/...' as the unwritable dir. On Windows this resolved to 'C:\proc\0\...' and mkdir({recursive: true}) silently created it — the silent code path was never exercised, plus filesystem side-effect leaked outside the test tmpdir. Replace with a path that points to an existing file (mkdir on a file path fails on every platform). Also add a companion test that confirms silent is opt-in: without {silent: true}, the same fs failure DOES throw. 7 tests pass (was 6). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/TelemetryService.test.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 1c772e9..2e2e8f4 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -58,17 +58,32 @@ describe('TelemetryService.emit', () => { }); 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. + // Make the "dir" actually a file so mkdir({recursive:true}) reliably fails on every platform. + // (Earlier draft used /proc/0/... which on Windows resolves to C:\proc\0\... and + // mkdir({recursive:true}) silently *creates* it, leaking filesystem side-effects + the + // silent code path was never exercised.) + const blockingFile = join(dir, 'this-is-a-file-not-a-dir'); + writeFileSync(blockingFile, ''); const svc = new TelemetryService( - '/proc/0/no-such-thing-readonly', + blockingFile, () => new Date('2026-05-01T12:00:00Z'), 14, { silent: true } ); - // Should resolve, not throw await expect(svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } })).resolves.toBeUndefined(); }); + + it('emit DOES throw when fs write fails AND silent is not set (default)', async () => { + // Companion case — confirms silent is opt-in. Without silent, fs failure surfaces. + const blockingFile = join(dir, 'block-default'); + writeFileSync(blockingFile, ''); + const svc = new TelemetryService(blockingFile, () => new Date('2026-05-01T12:00:00Z')); + await expect(svc.emit({ + kind: 'capture', + payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } + })).rejects.toThrow(); + }); }); -- 2.49.1 From 0501bd1762ba32e89c477dbda57adc942c1c713b Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 16:54:36 +0900 Subject: [PATCH 06/17] feat(telemetry): cleanupOldFiles with 14-day KST retention (#7 v0.2.3) --- src/main/services/TelemetryService.ts | 28 ++++++++++++++++- tests/unit/TelemetryService.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 1993981..06e4c0c 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -1,4 +1,4 @@ -import { mkdir, appendFile } from 'node:fs/promises'; +import { mkdir, appendFile, readdir, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { validateEvent } from './telemetryEvents.js'; @@ -27,6 +27,32 @@ export class TelemetryService { private opts: TelemetryServiceOptions = {} ) {} + 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 }; + } + async emit(input: EmitInput): Promise { const ts = this.now().toISOString(); const event = validateEvent({ ts, kind: input.kind, payload: input.payload }); diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 2e2e8f4..50a7668 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -87,3 +87,47 @@ describe('TelemetryService.emit', () => { })).rejects.toThrow(); }); }); + +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); + }); +}); -- 2.49.1 From 729a3f9c470a5dc652327dfccf21672f57c9fee3 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 16:58:45 +0900 Subject: [PATCH 07/17] feat(telemetry): readAllRecent with malformed-line tolerance (#7 v0.2.3) --- src/main/services/TelemetryService.ts | 44 +++++++++++++++++++++++++-- tests/unit/TelemetryService.test.ts | 41 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 06e4c0c..b517530 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -1,6 +1,6 @@ -import { mkdir, appendFile, readdir, unlink } from 'node:fs/promises'; +import { mkdir, appendFile, readFile, readdir, unlink } from 'node:fs/promises'; import { join } from 'node:path'; -import { validateEvent } from './telemetryEvents.js'; +import { validateEvent, TelemetryEvent } from './telemetryEvents.js'; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; @@ -65,4 +65,44 @@ export class TelemetryService { throw err; } } + + async readAllRecent(): Promise { + 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; + } } diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 50a7668..01a1726 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -131,3 +131,44 @@ describe('TelemetryService.cleanupOldFiles', () => { expect(existsSync(join(dir, 'events-2026-04-17.jsonl'))).toBe(true); }); }); + +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([]); + }); +}); -- 2.49.1 From 9a066ed80772f9352400d9408357a5617ccb17bf Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:03:31 +0900 Subject: [PATCH 08/17] feat(telemetry): telemetryStats.aggregateStats (#7 v0.2.3) --- src/main/services/telemetryStats.ts | 73 +++++++++++++++++++++++++++++ tests/unit/telemetryStats.test.ts | 57 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/main/services/telemetryStats.ts create mode 100644 tests/unit/telemetryStats.test.ts diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts new file mode 100644 index 0000000..08bcadf --- /dev/null +++ b/src/main/services/telemetryStats.ts @@ -0,0 +1,73 @@ +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(); + 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 }; +} diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts new file mode 100644 index 0000000..bdd6f65 --- /dev/null +++ b/tests/unit/telemetryStats.test.ts @@ -0,0 +1,57 @@ +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'); + }); +}); -- 2.49.1 From 2036c687d24981690f89df95f82d6e3a46f6de9c Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:06:29 +0900 Subject: [PATCH 09/17] test(telemetry): add KST regression test for near-midnight UTC bucketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original 'counts events per KST day' test used UTC times that bucket identically under both KST and naive UTC slice — would not catch a regression where kstDate was replaced with ev.ts.slice(0,10). Add an explicit near-midnight case (2026-05-01T15:30Z = 2026-05-02 00:30 KST) that fails under naive UTC and passes under correct KST conversion. 6 tests pass (was 5). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/telemetryStats.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index bdd6f65..359efa7 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -54,4 +54,15 @@ describe('aggregateStats', () => { const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); expect(r.md).toContain('평균 ai_succeeded durationMs: 2000'); }); + + it('buckets near-midnight UTC events on the correct KST day (regression: not naive UTC)', () => { + // 2026-05-01T15:30:00Z → 2026-05-02 00:30 KST → KST day 2026-05-02 + // Naive UTC slice(0,10) would put this on 2026-05-01 — this test catches that regression. + const events: TelemetryEvent[] = [ + e('2026-05-01T15:30:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false }) + ]; + const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z')); + expect(r.md).toContain('| 2026-05-02 | 1 | 0 | 0 |'); + expect(r.md).not.toContain('| 2026-05-01 |'); + }); }); -- 2.49.1 From 36a5c67ed67a1b62adf3f9bb37a4a8da8b348097 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:08:34 +0900 Subject: [PATCH 10/17] feat(telemetry): exportTo writes events.jsonl + stats.md (#7 v0.2.3) --- src/main/services/TelemetryService.ts | 13 +++++++++- tests/unit/TelemetryService.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index b517530..2df5b45 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -1,6 +1,7 @@ -import { mkdir, appendFile, readFile, readdir, unlink } from 'node:fs/promises'; +import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { validateEvent, TelemetryEvent } from './telemetryEvents.js'; +import { aggregateStats } from './telemetryStats.js'; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; @@ -105,4 +106,14 @@ export class TelemetryService { } return events; } + + 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 }; + } } diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 01a1726..8da4ea6 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -172,3 +172,38 @@ describe('TelemetryService.readAllRecent', () => { expect(await svc.readAllRecent()).toEqual([]); }); }); + +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'); + }); +}); -- 2.49.1 From f0cef95d3fc2da02b2caddc10444c6cd02995330 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:15:24 +0900 Subject: [PATCH 11/17] feat(telemetry): CaptureService emits capture event (#7 v0.2.3) --- src/main/services/CaptureService.ts | 17 +++++++++ tests/unit/CaptureService.test.ts | 53 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index dc78465..1342603 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -1,9 +1,16 @@ 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; +} + export interface CaptureDeps { enqueue: (noteId: string) => Promise; celebrate: (noteId: string) => void; + telemetry?: TelemetryEmitter; } export interface SubmitInput { @@ -39,6 +46,16 @@ export class CaptureService { } 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 }; diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index 52fd902..f992cc5 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -58,3 +58,56 @@ describe('CaptureService', () => { expect(repo.findById(noteId)).toBeNull(); }); }); + +describe('CaptureService telemetry emit', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let events: Array<{ kind: string; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }>; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + events = []; + }); + + it('emits capture event with noteId/rawTextLength/hasMedia', async () => { + const svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } } + }); + await svc.submit({ text: '안녕하세요', images: [] }); + expect(events).toHaveLength(1); + expect(events[0]!.kind).toBe('capture'); + expect(events[0]!.payload.rawTextLength).toBe('안녕하세요'.length); + expect(events[0]!.payload.hasMedia).toBe(false); + expect(typeof events[0]!.payload.noteId).toBe('string'); + }); + + it('emits hasMedia=true when images present', async () => { + const svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } } + }); + const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer; + await svc.submit({ text: '이미지 메모', images: [img] }); + expect(events).toHaveLength(1); + expect(events[0]!.payload.hasMedia).toBe(true); + }); + + it('does NOT emit when telemetry dep absent (backward compat)', async () => { + const svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {} + }); + const result = await svc.submit({ text: 'no telem', images: [] }); + expect(typeof result.noteId).toBe('string'); + expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired + }); +}); -- 2.49.1 From 01447ddaaddb9236fb87ea205b99400b5ee4cebe Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:21:08 +0900 Subject: [PATCH 12/17] feat(telemetry): AiWorker emits ai_succeeded/ai_failed with reason (#7 v0.2.3) --- src/main/ai/AiWorker.ts | 44 ++++++++++++++++++++ tests/unit/AiWorker.test.ts | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index a626f56..7eef0a8 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -2,6 +2,7 @@ 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; @@ -15,6 +16,25 @@ 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; +} + export interface AiWorkerOptions { backoffsMs?: number[]; onUpdate?: (note: Note) => void; @@ -24,6 +44,7 @@ export interface AiWorkerOptions { error: (msg: string, meta?: Record) => void; }; now?: () => Date; + telemetry?: AiTelemetryEmitter; } interface Job { noteId: string; attempts: number; } @@ -36,6 +57,7 @@ export class AiWorker { private onUpdate?: (note: Note) => void; private logger: NonNullable; private now: () => Date; + private telemetry?: AiTelemetryEmitter; constructor( private repo: NoteRepository, @@ -46,6 +68,7 @@ export class AiWorker { 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 { @@ -95,6 +118,7 @@ export class AiWorker { private async processJob(job: Job): Promise { 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; @@ -121,6 +145,16 @@ export class AiWorker { 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) { @@ -132,6 +166,16 @@ export class AiWorker { 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; } diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 9bef385..ed490be 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -193,3 +193,86 @@ describe('AiWorker', () => { expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레 }); }); + +describe('AiWorker telemetry emit', () => { + let db: Database.Database; + let repo: NoteRepository; + let events: Array<{ kind: string; payload: { noteId: string; durationMs?: number; reason?: string; attempts: number } }>; + const collectingTelemetry = { + emit: async (ev: { kind: string; payload: { noteId: string; durationMs?: number; reason?: string; attempts: number } }) => { + events.push(ev); + } + }; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + events = []; + }); + + it('emits ai_succeeded with durationMs/attempts on success', async () => { + const { id } = repo.create({ rawText: '수요일 회의 메모' }); + const w = new AiWorker(repo, makeProvider(), { + backoffsMs: [0, 0, 0], + telemetry: collectingTelemetry + }); + await w.enqueue(id); + await w.drain(); + const succeeded = events.find((e) => e.kind === 'ai_succeeded'); + expect(succeeded).toBeDefined(); + expect(succeeded!.payload.noteId).toBe(id); + expect(succeeded!.payload.attempts).toBe(0); + expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('emits ai_failed with reason=unreachable on network error', async () => { + const { id } = repo.create({ rawText: '메모' }); + const provider = makeProvider({ + generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); }) + }); + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: collectingTelemetry + }); + await w.enqueue(id); + await w.drain(); + const failed = events.find((e) => e.kind === 'ai_failed'); + expect(failed).toBeDefined(); + expect(failed!.payload.reason).toBe('unreachable'); + expect(failed!.payload.attempts).toBe(3); + }); + + it('emits ai_failed with reason=schema on zod failure', async () => { + const { id } = repo.create({ rawText: '메모' }); + const { ZodError } = await import('zod'); + const provider = makeProvider({ + generate: vi.fn(async () => { throw new ZodError([]); }) + }); + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: collectingTelemetry + }); + await w.enqueue(id); + await w.drain(); + const failed = events.find((e) => e.kind === 'ai_failed'); + expect(failed).toBeDefined(); + expect(failed!.payload.reason).toBe('schema'); + }); + + it('emits ai_failed with reason=other on unrecognized error', async () => { + const { id } = repo.create({ rawText: '메모' }); + const provider = makeProvider({ + generate: vi.fn(async () => { throw new Error('mystery'); }) + }); + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: collectingTelemetry + }); + await w.enqueue(id); + await w.drain(); + const failed = events.find((e) => e.kind === 'ai_failed'); + expect(failed).toBeDefined(); + expect(failed!.payload.reason).toBe('other'); + }); +}); -- 2.49.1 From 4213745dc71e1b4e770303e0aee6c22e410d76c8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:25:52 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat(telemetry):=20tray=20menu=20'?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EA=B7=B8=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0...'=20(#7=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/tray.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/tray.ts b/src/main/tray.ts index c45b99f..3fe0efa 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -9,11 +9,11 @@ let _runBackup: () => void = () => {}; let _runExport: () => void = () => {}; let _runImport: () => void = () => {}; let _runSync: () => void = () => {}; +let _runExportTelemetry: () => void = () => {}; let _todayCount = 0; function buildMenu() { const items: MenuItemConstructorOptions[] = []; - // F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작. if (_todayCount > 0) { items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false }); items.push({ type: 'separator' }); @@ -25,6 +25,7 @@ function buildMenu() { 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({ @@ -52,7 +53,8 @@ export function createTray( runBackup: () => void, runExport: () => void, runImport: () => void, - runSync: () => void + runSync: () => void, + runExportTelemetry: () => void ): TrayType { _showInbox = showInbox; _showCapture = showCapture; @@ -60,6 +62,7 @@ export function createTray( _runExport = runExport; _runImport = runImport; _runSync = runSync; + _runExportTelemetry = runExportTelemetry; const icon = nativeImage.createEmpty(); tray = new Tray(icon); tray.setToolTip(`Inkling — 오늘 ${_todayCount}`); -- 2.49.1 From dca6aed44e335f80d6eeb05ea3e830f894f13659 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:28:04 +0900 Subject: [PATCH 14/17] docs(tray): restore F4-C identity-signal intent comment The T9 full-file replacement accidentally dropped the inline comment documenting why the count label is conditional on _todayCount > 0 (F4-C UX rationale). No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/tray.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/tray.ts b/src/main/tray.ts index 3fe0efa..3a1b322 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -14,6 +14,7 @@ let _todayCount = 0; function buildMenu() { const items: MenuItemConstructorOptions[] = []; + // F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작. if (_todayCount > 0) { items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false }); items.push({ type: 'separator' }); -- 2.49.1 From fe24ff577fd3a1742e16f8b973bd5b068340df2d Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:30:54 +0900 Subject: [PATCH 15/17] feat(telemetry): wire TelemetryService + tray export (#7 v0.2.3) Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ab2de69..a0a0160 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,6 +28,7 @@ import { BackupService } from './services/BackupService.js'; import { ExportService } from './services/ExportService.js'; import { ImportService } from './services/ImportService.js'; import { SyncService } from './services/SyncService.js'; +import { TelemetryService } from './services/TelemetryService.js'; const HIDDEN_ARG = '--hidden'; const startedHidden = process.argv.includes(HIDDEN_ARG); @@ -43,6 +44,9 @@ app.whenReady().then(async () => { 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 })); + if (app.isPackaged && process.platform === 'win32') { const initFlag = join(paths.profileDir, '.autostart-init'); if (!existsSync(initFlag)) { @@ -72,7 +76,8 @@ app.whenReady().then(async () => { // F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신. refreshTray(repo.countToday()); }, - logger + logger, + telemetry }); const notify = new NotificationService({ @@ -84,7 +89,8 @@ app.whenReady().then(async () => { const capture = new CaptureService(repo, store, { enqueue: (id) => worker.enqueue(id), - celebrate: (id) => notify.celebrate(id) + celebrate: (id) => notify.celebrate(id), + telemetry }); registerCaptureApi(capture, getQuickCaptureWindow); @@ -283,6 +289,35 @@ app.whenReady().then(async () => { logger.warn('sync.exception', { reason: String(e) }); new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show(); } + }, + /* 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(); + } } ); -- 2.49.1 From 5c97397cbe78b081ee1b7de9f261fb50d9808ff0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 17:37:00 +0900 Subject: [PATCH 16/17] =?UTF-8?q?chore(telemetry):=20#7=20closure=20?= =?UTF-8?q?=E2=80=94=20gate=20verification=20+=20.catch=20consistency=20+?= =?UTF-8?q?=20spec=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .catch(...) to telemetry.cleanupOldFiles fire-and-forget for consistency with backup.runDaily pattern (M1 from T10 code review). - Mark Roadmap §3 #7 as completed (✓). - Correct spec: tray:exportTelemetry was never an IPC channel — tray callbacks run in main process directly. Replace with "트레이 콜백 (main 내부)". Closes v0.2.3 task 1 of 7. Next task: #4 휴지통 (migration v3). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-01-v023-feedback-roadmap-design.md | 4 ++-- src/main/index.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md index f3a1538..eda25a0 100644 --- a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md @@ -70,7 +70,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── 각 항목 PR 범위 라인. 세부 결정 (decision-pending) 은 항목 시작 시 mini-brainstorm. -### #7 Telemetry skeleton (1번) +### #7 Telemetry skeleton (1번) ✓ 완료 **In:** - `TelemetryService` (`src/main/services/TelemetryService.ts`): @@ -86,7 +86,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── - 트레이 메뉴 "사용 로그 내보내기...": - 폴더 다이얼로그 → `events.jsonl` (최근 14일 concat) + `stats.md` (집계 마크다운) zip - `stats.md` 내용: 항목별 일자별 카운트 표 + 핵심 ratio (AI 성공률, ollama uptime%, recall opened/shown, expired batched/shown 등) -- IPC: `tray:exportTelemetry` +- 트레이 콜백 (main 내부 — 별도 IPC 채널 불필요) - 단위 테스트: emit, rotation, privacy invariant 거부, stats 집계, export zip **Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정 diff --git a/src/main/index.ts b/src/main/index.ts index a0a0160..0abca87 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -45,7 +45,9 @@ app.whenReady().then(async () => { 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 })); + void telemetry.cleanupOldFiles() + .then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length })) + .catch((e) => logger.warn('telemetry.cleanup.failed', { reason: String(e) })); if (app.isPackaged && process.platform === 'win32') { const initFlag = join(paths.profileDir, '.autostart-init'); -- 2.49.1 From 7e8e2b598d509001dbd0368c95dab769d5fe57e2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 18:41:26 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix(telemetry):=20=ED=9A=8C=EC=B0=A8=201?= =?UTF-8?q?=20review=20=EB=B0=98=EC=98=81=20=E2=80=94=20attempts=20?= =?UTF-8?q?=EC=9D=98=EB=AF=B8=20=ED=86=B5=EC=9D=BC=20+=20DI=20=EC=9A=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=EA=B1=B0=20+=20=EB=A7=A4=EC=A7=81=20?= =?UTF-8?q?=EC=8A=AC=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #13 회차 1 리뷰의 actionable 1건 + suggestion 3건 반영. - `AiWorker` 의 `attempts` 필드가 success/failure 경로에서 비대칭 의미 (0-index vs count) 였던 문제. 둘 다 `attempt + 1` (실제 시도 횟수, 1-based) 로 통일. stats markdown 의 평균/분포 해석이 일관됨. - `Date.now()` 직접 호출이 `opts.now` DI 를 우회하던 두 곳을 `this.now().getTime()` 으로 교체. 추후 durationMs 분포 테스트 작성 가능. - `TelemetryService.emit` 의 `this.now()` 두 번 호출을 한 번 캐시로 통합. KST 자정 경계에서 ts 와 파일명 일자 불일치 가능성 제거. - `readAllRecent` 의 `n.slice(7, 17)` 매직 슬라이스를 정규식 capture 그룹으로 교체. prefix 변경 시 한 곳만 수정. 테스트: AiWorker 성공 케이스의 `attempts: 0` → `attempts: 1` 갱신. 게이트: typecheck 0 errors, 245/245 unit tests pass. Deferred (v0.2.4 backlog): 'aborted' user-cancel false-positive, tray menu submenu 분리. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 6 +++--- src/main/services/TelemetryService.ts | 16 ++++++++++++---- tests/unit/AiWorker.test.ts | 4 +++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index 7eef0a8..fe1df6e 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -118,7 +118,7 @@ export class AiWorker { private async processJob(job: Job): Promise { const max = this.backoffsMs.length; for (let attempt = job.attempts; attempt < max; attempt++) { - const startMs = Date.now(); + const startMs = this.now().getTime(); try { const note = this.repo.findById(job.noteId); if (!note || note.aiStatus !== 'pending') return; @@ -150,8 +150,8 @@ export class AiWorker { kind: 'ai_succeeded', payload: { noteId: job.noteId, - durationMs: Date.now() - startMs, - attempts: attempt + durationMs: this.now().getTime() - startMs, + attempts: attempt + 1 } }).catch(() => {}); } diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 2df5b45..9b2778a 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -55,9 +55,12 @@ export class TelemetryService { } async emit(input: EmitInput): Promise { - const ts = this.now().toISOString(); + // 회차 1 review (PR #13) — `now()` 한 번만 호출. KST 자정 경계에서 ts 와 파일명 일자가 + // 어긋나는 것을 방지. + const nowDate = this.now(); + const ts = nowDate.toISOString(); const event = validateEvent({ ts, kind: input.kind, payload: input.payload }); - const filePath = join(this.dir, `events-${todayKstIso(this.now())}.jsonl`); + const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`); try { await mkdir(this.dir, { recursive: true }); await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8'); @@ -77,9 +80,14 @@ export class TelemetryService { } const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000; const cutoffIso = todayKstIso(new Date(cutoffMs)); + // 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로 + // 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨. + const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/; const fileNames = entries - .filter((n) => /^events-\d{4}-\d{2}-\d{2}\.jsonl$/.test(n)) - .filter((n) => n.slice(7, 17) >= cutoffIso) + .filter((n) => { + const m = datePattern.exec(n); + return m !== null && m[1]! >= cutoffIso; + }) .sort(); for (const name of fileNames) { let raw: string; diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index ed490be..7e3803f 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -222,7 +222,9 @@ describe('AiWorker telemetry emit', () => { const succeeded = events.find((e) => e.kind === 'ai_succeeded'); expect(succeeded).toBeDefined(); expect(succeeded!.payload.noteId).toBe(id); - expect(succeeded!.payload.attempts).toBe(0); + // attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1. + // 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미. + expect(succeeded!.payload.attempts).toBe(1); expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0); }); -- 2.49.1