feat(telemetry): #7 telemetry skeleton (v0.2.3 1/7) #13
Reference in New Issue
Block a user
Delete Branch "feat/v023-telemetry"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
v0.2.3 의 첫 항목 — local-only telemetry 인프라. 다른 v0.2.3 항목 (#4 휴지통 / #5 만료 / #1 ollama / #2 retry / #3 vocab / #6 recall) 이 emit hook 만 추가하면 되도록 skeleton + 3 기본 이벤트 (
capture/ai_succeeded/ai_failed) wiring.<profileDir>/telemetry/events-YYYY-MM-DD.jsonlappend-only, KST 일자 rotation, 14일 rolling 삭제.strict()가 payload 의 raw_text/title/summary/userIntent/tagNames 거부. 단위 테스트로 고정CaptureService.submit→capture { noteId, rawTextLength, hasMedia }AiWorker.processJob→ai_succeeded { noteId, durationMs, attempts }/ai_failed { noteId, reason: unreachable|schema|timeout|other, attempts }(reason 분류 helper 포함)Spec / Plan
Gates
Test Plan
Earlier test used '/proc/0/...' as the unwritable dir. On Windows this resolved to 'C:\proc\0\...' and mkdir({recursive: true}) silently created it — the silent code path was never exercised, plus filesystem side-effect leaked outside the test tmpdir. Replace with a path that points to an existing file (mkdir on a file path fails on every platform). Also add a companion test that confirms silent is opt-in: without {silent: true}, the same fs failure DOES throw. 7 tests pass (was 6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — Telemetry skeleton 의 schema-단계 privacy invariant 와 KST 처리 일관성, 커밋 그래프의 task 단위 분할, silent fs error 와 KST near-midnight 회귀 테스트 같은 미래 안전망이 인상적입니다.
다만 actionable 한 한 가지: AiWorker 의
attempts필드가 success 경로에서 0-index, failure 경로에서 시도 횟수 (count) 로 비대칭 의미를 갖습니다. stats markdown 에서 평균/분포를 뽑을 때 해석이 깨지므로 둘 다attempt + 1(실제 시도한 횟수) 로 통일하는 것을 권장합니다. 그 외는 suggestion 수준 (now() DI 우회, 매직 슬라이스, aborted 분류 edge case, 트레이 메뉴 카테고리 분리) 이고, 칭찬 코멘트도 함께 남겼습니다.@@ -18,0 +22,4 @@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')) {'aborted'를 timeout 으로 분류하는데, 이는AbortController로 사용자가 명시적으로 취소한 케이스도 timeout 으로 둔갑시킵니다. 현재 코드에서 user-cancel 경로가 없다면 OK 지만 v0.2.x 에서 quick-capture cancel 등이 들어오면 false-positive 가 됩니다. 별도 reason'cancelled'추가 또는'aborted'매칭을 좀 더 좁히는 방향을 backlog 에 남기는 것을 추천합니다.@@ -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 = Date.now();startMs = Date.now()가 직접 호출되어opts.nowDI 를 우회합니다. 테스트가toBeGreaterThanOrEqual(0)으로만 검증하니 통과는 하지만, 추후 durationMs 분포에 대한 테스트를 작성하려 할 때 mock 가 안 됩니다.this.now().getTime()으로 바꾸거나, monotonic 이 더 적절하면performance.now()사용을 고려해 주세요.@@ -124,0 +151,4 @@payload: {noteId: job.noteId,durationMs: Date.now() - startMs,attempts: attempt성공 경로의
attempts: attempt는 0-index (첫 시도 성공 = 0) 인데, 실패 경로(line 175)는attempts: attempt + 1즉 시도 횟수(3 = 모든 backoff 소진) 입니다. 동일 필드명에 다른 의미라 stats markdown 에서 평균/분포를 뽑을 때 혼란이 큽니다. 둘 다attempts= '실제 시도한 횟수' (attempt + 1) 로 통일하는 것을 추천합니다.@@ -0,0 +57,4 @@async emit(input: EmitInput): Promise<void> {const ts = this.now().toISOString();const event = validateEvent({ ts, kind: input.kind, payload: input.payload });const filePath = join(this.dir, `events-${todayKstIso(this.now())}.jsonl`);emit안에서this.now()가 line 58 (ts) 과 line 60 (filename) 에서 두 번 호출됩니다. 보통 무해하지만 KST 자정 경계에서 ms 단위로 틈이 벌어지면 ts 와 파일명의 일자가 어긋날 수 있습니다.const n = this.now();로 한번 캐시해서 둘 다 그 값을 쓰도록 하는 게 안전합니다.@@ -0,0 +79,4 @@const cutoffIso = todayKstIso(new Date(cutoffMs));const fileNames = entries.filter((n) => /^events-\d{4}-\d{2}-\d{2}\.jsonl$/.test(n)).filter((n) => n.slice(7, 17) >= cutoffIso)n.slice(7, 17)는events-(7 chars) 와 ISO 일자 (10 chars) 라는 매직 넘버 의존입니다. 위 라인의 정규식 캡처 그룹을 재사용하면 둘이 의도적으로 묶이고, 미래에 prefix 가 바뀌어도 한 곳만 고치면 됩니다.@@ -0,0 +20,4 @@attempts: z.number().int().nonnegative()}).strict();export const TelemetryEventSchema = z.discriminatedUnion('kind', [Praise —
discriminatedUnion('kind', [...])+ 외곽.strict()+ payload.strict()이중 잠금이 privacy invariant 를 schema 한 곳으로 모았습니다. 미래에tagNames같은 leak 가 추가되어도 코드 변경 없이 zod 가 즉시 거부하니, downstream emitter 가as never캐스팅으로 우회하지 않는 한 invariant 가 깨지지 않습니다.@@ -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 });메뉴 항목 '사용 로그 내보내기...' 를 동기화 바로 아래에 붙였는데, 백업/내보내기/복원/동기화 와 사용성 카테고리가 다릅니다 (개발자/디버그 친화 액션). 향후 항목이 늘면 separator 또는 submenu(
사용 로그 ▶ 내보내기...) 로 그룹화하는 것을 v0.2.4+ 백로그에 두면 좋을 것 같습니다.@@ -0,0 +57,4 @@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 () => {Praise —
silent분기를 file-as-dir 충돌로 강제하는 트릭이 깔끔하고, 코멘트(2026-05-01/proc/0/...Windows 함정 회상)까지 같이 적어둔 부분이 멋집니다. 두 케이스(silent on/off)를 companion 으로 둔 것도 invariant 가 opt-in 임을 명시적으로 잠가 미래 회귀를 막습니다.@@ -0,0 +55,4 @@expect(r.md).toContain('평균 ai_succeeded durationMs: 2000');});it('buckets near-midnight UTC events on the correct KST day (regression: not naive UTC)', () => {Praise — KST near-midnight regression 테스트(
2026-05-01T15:30:00Z→2026-05-02버킷) 는 naive UTC slice 로 회귀할 때 즉시 빨갛게 터집니다. 한국어 팀에 특히 가치 있는 미래 안전망이고, expect 의 negative assertion(not.toContain)까지 양면으로 잠가둔 점이 좋습니다.회차 2 — 회차 1 코멘트 4건 모두 반영, 새 issue 없음 (수렴).
회차 1 actionable (
attempts의미 비대칭) 은 success/failure 경로 양쪽attempt + 1로 통일됐고 테스트도 의미 일치하게 갱신됐습니다 (성공 첫 시도 = 1, 실패 max 재시도 = 3). suggestion 3건 —Date.now()DI 우회 제거,now()캐시화,slice(7,17)매직 → 정규식 capture — 모두 깔끔하게 적용됐습니다. cleanupOldFiles 와 readAllRecent 가 동일한 정규식 패턴^events-(\d{4}-\d{2}-\d{2})\.jsonl$을 공유해 prefix 변경 시 영향 범위가 좁고, 245/245 PASS 회귀 없음.머지 가능. 사람이 Gitea UI 에서 merge 버튼을 누르면 됩니다.