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:
1627
docs/superpowers/plans/2026-05-01-v023-telemetry.md
Normal file
1627
docs/superpowers/plans/2026-05-01-v023-telemetry.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 채택). |
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
127
src/main/services/TelemetryService.ts
Normal file
127
src/main/services/TelemetryService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
34
src/main/services/telemetryEvents.ts
Normal file
34
src/main/services/telemetryEvents.ts
Normal 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);
|
||||
}
|
||||
73
src/main/services/telemetryStats.ts
Normal file
73
src/main/services/telemetryStats.ts
Normal 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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
209
tests/unit/TelemetryService.test.ts
Normal file
209
tests/unit/TelemetryService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
89
tests/unit/telemetryEvents.test.ts
Normal file
89
tests/unit/telemetryEvents.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
68
tests/unit/telemetryStats.test.ts
Normal file
68
tests/unit/telemetryStats.test.ts
Normal 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 |');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user