feat(telemetry): #7 telemetry skeleton (v0.2.3 1/7) #13

Merged
altair823 merged 17 commits from feat/v023-telemetry into main 2026-05-01 10:37:57 +00:00
Owner

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.jsonl append-only, KST 일자 rotation, 14일 rolling 삭제
  • Privacy invariant (slice §1.1 invariant 4 강화) — zod .strict() 가 payload 의 raw_text/title/summary/userIntent/tagNames 거부. 단위 테스트로 고정
  • CaptureService.submitcapture { noteId, rawTextLength, hasMedia }
  • AiWorker.processJobai_succeeded { noteId, durationMs, attempts } / ai_failed { noteId, reason: unreachable|schema|timeout|other, attempts } (reason 분류 helper 포함)
  • 트레이 메뉴 "사용 로그 내보내기..." → 폴더 다이얼로그 → `events.jsonl` (concat) + `stats.md` (집계 마크다운)
  • 신규 dep 0 (zip 회피로 폴더 + 2 파일 정책)

Spec / Plan

  • spec: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #7
  • plan: `docs/superpowers/plans/2026-05-01-v023-telemetry.md` (11 task TDD)

Gates

  • typecheck: 0 errors
  • 단위 테스트: 205 → 245 (+40)
  • e2e smoke: 1/1
  • 신규 npm dep: 0

Test Plan

  • dev 실행 후 노트 1건 캡처 → `/Inkling/profiles/default/telemetry/events-YYYY-MM-DD.jsonl` 에 `capture` + `ai_succeeded` 또는 `ai_failed` 라인 생성 확인
  • 트레이 → "사용 로그 내보내기..." → 빈 폴더 → `events.jsonl` + `stats.md` 생성 + 알림 확인
  • events.jsonl grep — `rawText|"title"|"summary"|userIntent|tagNames` 0 hits (privacy invariant 실제 fs 검증)
  • ollama 끄고 노트 캡처 → 3회 retry 후 `ai_failed` reason='unreachable' 기록 확인
## 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.jsonl` append-only, KST 일자 rotation, 14일 rolling 삭제 - **Privacy invariant** (slice §1.1 invariant 4 강화) — zod `.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 포함) - 트레이 메뉴 "사용 로그 내보내기..." → 폴더 다이얼로그 → \`events.jsonl\` (concat) + \`stats.md\` (집계 마크다운) - 신규 dep 0 (zip 회피로 폴더 + 2 파일 정책) ## Spec / Plan - spec: \`docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md\` §3 #7 - plan: \`docs/superpowers/plans/2026-05-01-v023-telemetry.md\` (11 task TDD) ## Gates - typecheck: 0 errors - 단위 테스트: 205 → **245** (+40) - e2e smoke: 1/1 - 신규 npm dep: 0 ## Test Plan - [ ] dev 실행 후 노트 1건 캡처 → \`<userData>/Inkling/profiles/default/telemetry/events-YYYY-MM-DD.jsonl\` 에 \`capture\` + \`ai_succeeded\` 또는 \`ai_failed\` 라인 생성 확인 - [ ] 트레이 → "사용 로그 내보내기..." → 빈 폴더 → \`events.jsonl\` + \`stats.md\` 생성 + 알림 확인 - [ ] events.jsonl grep — \`rawText|"title"|"summary"|userIntent|tagNames\` 0 hits (privacy invariant 실제 fs 검증) - [ ] ollama 끄고 노트 캡처 → 3회 retry 후 \`ai_failed\` reason='unreachable' 기록 확인
altair823 added 16 commits 2026-05-01 08:43:40 +00:00
v0.2.2 dogfood 7항목 (#7 telemetry 신설 + #1~#6) 단일 cut 로드맵.
데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B),
trash↔backup/export B 정책, #6 = 1 spike 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 task TDD plan — events schema/privacy invariant, JSONL emit/rotation,
14d cleanup, readAllRecent, stats aggregator, exportTo(folder),
CaptureService/AiWorker hooks, tray menu, index.ts wiring, gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Original 'counts events per KST day' test used UTC times that bucket
identically under both KST and naive UTC slice — would not catch a regression
where kstDate was replaced with ev.ts.slice(0,10). Add an explicit
near-midnight case (2026-05-01T15:30Z = 2026-05-02 00:30 KST) that fails
under naive UTC and passes under correct KST conversion.

6 tests pass (was 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The T9 full-file replacement accidentally dropped the inline comment
documenting why the count label is conditional on _todayCount > 0
(F4-C UX rationale). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add .catch(...) to telemetry.cleanupOldFiles fire-and-forget for consistency
  with backup.runDaily pattern (M1 from T10 code review).
- Mark Roadmap §3 #7 as completed (✓).
- Correct spec: tray:exportTelemetry was never an IPC channel — tray callbacks
  run in main process directly. Replace with "트레이 콜백 (main 내부)".

Closes v0.2.3 task 1 of 7. Next task: #4 휴지통 (migration v3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-01 09:39:38 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

회차 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, 트레이 메뉴 카테고리 분리) 이고, 칭찬 코멘트도 함께 남겼습니다.

회차 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 에 남기는 것을 추천합니다.

`'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.now DI 를 우회합니다. 테스트가 toBeGreaterThanOrEqual(0) 으로만 검증하니 통과는 하지만, 추후 durationMs 분포에 대한 테스트를 작성하려 할 때 mock 가 안 됩니다. this.now().getTime() 으로 바꾸거나, monotonic 이 더 적절하면 performance.now() 사용을 고려해 주세요.

`startMs = Date.now()` 가 직접 호출되어 `opts.now` DI 를 우회합니다. 테스트가 `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) 로 통일하는 것을 추천합니다.

성공 경로의 `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(); 로 한번 캐시해서 둘 다 그 값을 쓰도록 하는 게 안전합니다.

`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 가 바뀌어도 한 곳만 고치면 됩니다.

`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 가 깨지지 않습니다.

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+ 백로그에 두면 좋을 것 같습니다.

메뉴 항목 '사용 로그 내보내기...' 를 동기화 바로 아래에 붙였는데, 백업/내보내기/복원/동기화 와 사용성 카테고리가 다릅니다 (개발자/디버그 친화 액션). 향후 항목이 늘면 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 임을 명시적으로 잠가 미래 회귀를 막습니다.

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:00Z2026-05-02 버킷) 는 naive UTC slice 로 회귀할 때 즉시 빨갛게 터집니다. 한국어 팀에 특히 가치 있는 미래 안전망이고, expect 의 negative assertion(not.toContain)까지 양면으로 잠가둔 점이 좋습니다.

Praise — KST near-midnight regression 테스트(`2026-05-01T15:30:00Z` → `2026-05-02` 버킷) 는 naive UTC slice 로 회귀할 때 즉시 빨갛게 터집니다. 한국어 팀에 특히 가치 있는 미래 안전망이고, expect 의 negative assertion(`not.toContain`)까지 양면으로 잠가둔 점이 좋습니다.
altair823 added 1 commit 2026-05-01 09:41:35 +00:00
PR #13 회차 1 리뷰의 actionable 1건 + suggestion 3건 반영.

- `AiWorker` 의 `attempts` 필드가 success/failure 경로에서 비대칭 의미 (0-index vs count) 였던 문제. 둘 다 `attempt + 1` (실제 시도 횟수, 1-based) 로 통일. stats markdown 의 평균/분포 해석이 일관됨.
- `Date.now()` 직접 호출이 `opts.now` DI 를 우회하던 두 곳을 `this.now().getTime()` 으로 교체. 추후 durationMs 분포 테스트 작성 가능.
- `TelemetryService.emit` 의 `this.now()` 두 번 호출을 한 번 캐시로 통합. KST 자정 경계에서 ts 와 파일명 일자 불일치 가능성 제거.
- `readAllRecent` 의 `n.slice(7, 17)` 매직 슬라이스를 정규식 capture 그룹으로 교체. prefix 변경 시 한 곳만 수정.

테스트: AiWorker 성공 케이스의 `attempts: 0` → `attempts: 1` 갱신.
게이트: typecheck 0 errors, 245/245 unit tests pass.

Deferred (v0.2.4 backlog): 'aborted' user-cancel false-positive, tray menu submenu 분리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-01 09:42:54 +00:00
claude-reviewer-01 left a comment
Member

회차 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 버튼을 누르면 됩니다.

회차 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 버튼을 누르면 됩니다.
altair823 merged commit 6f8ae75ff7 into main 2026-05-01 10:37:57 +00:00
altair823 deleted branch feat/v023-telemetry 2026-05-01 10:37:58 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/inkling#13