Merge pull request 'feat(telemetry): #7 telemetry skeleton (v0.2.3 1/7)' (#13) from feat/v023-telemetry into main

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-05-01 10:37:55 +00:00
14 changed files with 2812 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 `<profileDir>/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 등)
- 트레이 콜백 (main 내부 — 별도 IPC 채널 불필요)
- 단위 테스트: 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-<slug>.md
├─ writing-plans ← TDD 구현 계획
├─ 구현 (executing-plans 또는 직접)
│ - 브랜치: feat/v023-<slug> (예: 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-<slug>` 단명. 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) `<dbFile>.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` + `<profileDir>` 보존됨 |
---
## 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 채택). |

View File

@@ -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<void>;
}
export interface AiWorkerOptions {
backoffsMs?: number[];
onUpdate?: (note: Note) => void;
@@ -24,6 +44,7 @@ export interface AiWorkerOptions {
error: (msg: string, meta?: Record<string, unknown>) => 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<AiWorkerOptions['logger']>;
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<void> {
@@ -95,6 +118,7 @@ export class AiWorker {
private async processJob(job: Job): Promise<void> {
const max = this.backoffsMs.length;
for (let attempt = job.attempts; attempt < max; attempt++) {
const startMs = this.now().getTime();
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: this.now().getTime() - startMs,
attempts: attempt + 1
}
}).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;
}

View File

@@ -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,11 @@ 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 }))
.catch((e) => logger.warn('telemetry.cleanup.failed', { reason: String(e) }));
if (app.isPackaged && process.platform === 'win32') {
const initFlag = join(paths.profileDir, '.autostart-init');
if (!existsSync(initFlag)) {
@@ -72,7 +78,8 @@ app.whenReady().then(async () => {
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
refreshTray(repo.countToday());
},
logger
logger,
telemetry
});
const notify = new NotificationService({
@@ -84,7 +91,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 +291,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();
}
}
);

View File

@@ -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<void>;
}
export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
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 };

View File

@@ -0,0 +1,127 @@
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;
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 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<void> {
// 회차 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(nowDate)}.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;
}
}
async readAllRecent(): Promise<TelemetryEvent[]> {
const events: TelemetryEvent[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return events;
}
const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
const cutoffIso = todayKstIso(new Date(cutoffMs));
// 회차 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) => {
const m = datePattern.exec(n);
return m !== null && m[1]! >= 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;
}
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 };
}
}

View File

@@ -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<typeof TelemetryEventSchema>;
export type TelemetryKind = TelemetryEvent['kind'];
export function validateEvent(raw: unknown): TelemetryEvent {
return TelemetryEventSchema.parse(raw);
}

View File

@@ -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<string, DailyRow>();
let aiSucceeded = 0;
let aiFailed = 0;
let durationSum = 0;
let durationN = 0;
for (const ev of events) {
const day = kstDate(ev.ts);
let row = byDay.get(day);
if (!row) {
row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0 };
byDay.set(day, row);
}
if (ev.kind === 'capture') row.capture += 1;
else if (ev.kind === 'ai_succeeded') {
row.ai_succeeded += 1;
aiSucceeded += 1;
durationSum += ev.payload.durationMs;
durationN += 1;
} else if (ev.kind === 'ai_failed') {
row.ai_failed += 1;
aiFailed += 1;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
const lines: string[] = [];
lines.push('# Inkling Telemetry Stats');
lines.push('');
lines.push(`생성: ${generatedAt.toISOString()}`);
lines.push(`총 이벤트: ${eventCount}`);
lines.push('');
lines.push('## 일자별 카운트');
lines.push('');
lines.push('| 일자 | capture | ai_succeeded | ai_failed |');
lines.push('|------|---------|--------------|-----------|');
for (const row of days) {
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} |`);
}
lines.push('');
lines.push('## 핵심 ratio');
lines.push('');
lines.push(`- AI 성공률: ${successRate}`);
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
lines.push('');
return { md: lines.join('\n'), eventCount };
}

View File

@@ -9,6 +9,7 @@ let _runBackup: () => void = () => {};
let _runExport: () => void = () => {};
let _runImport: () => void = () => {};
let _runSync: () => void = () => {};
let _runExportTelemetry: () => void = () => {};
let _todayCount = 0;
function buildMenu() {
@@ -25,6 +26,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 +54,8 @@ export function createTray(
runBackup: () => void,
runExport: () => void,
runImport: () => void,
runSync: () => void
runSync: () => void,
runExportTelemetry: () => void
): TrayType {
_showInbox = showInbox;
_showCapture = showCapture;
@@ -60,6 +63,7 @@ export function createTray(
_runExport = runExport;
_runImport = runImport;
_runSync = runSync;
_runExportTelemetry = runExportTelemetry;
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);

View File

@@ -193,3 +193,88 @@ 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);
// attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1.
// 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미.
expect(succeeded!.payload.attempts).toBe(1);
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');
});
});

View File

@@ -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
});
});

View File

@@ -0,0 +1,209 @@
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 () => {
// 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(
blockingFile,
() => new Date('2026-05-01T12:00:00Z'),
14,
{ silent: true }
);
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();
});
});
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);
});
});
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([]);
});
});
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');
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,68 @@
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');
});
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 |');
});
});