From f76ca06d9e302e2594d82213073207c78fe7be1f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:08:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(expiry):=20telemetry=202=20events=20?= =?UTF-8?q?=E2=80=94=20expired=5Fbanner=5Fshown=20/=20expired=5Fbatch=5Ftr?= =?UTF-8?q?ash=20(#5=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/TelemetryService.ts | 4 ++- src/main/services/telemetryEvents.ts | 12 ++++++++- src/main/services/telemetryStats.ts | 27 ++++++++++++++++--- tests/unit/TelemetryService.test.ts | 10 ++++--- tests/unit/telemetryEvents.test.ts | 38 +++++++++++++++++++++++++++ tests/unit/telemetryStats.test.ts | 23 ++++++++++++++++ 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 936a80e..6fb4600 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -22,7 +22,9 @@ export type EmitInput = | { kind: 'trash'; payload: { noteId: string } } | { kind: 'restore'; payload: { noteId: string } } | { kind: 'permanent_delete'; payload: { noteId: string } } - | { kind: 'empty_trash'; payload: { count: number } }; + | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } + | { kind: 'expired_batch_trash'; payload: { count: number } }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 0d1bcf9..378f4ee 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -28,6 +28,14 @@ const EmptyTrashPayload = z.object({ count: z.number().int().nonnegative() }).strict(); +const ExpiredBannerShownPayload = z.object({ + candidateCount: z.number().int().nonnegative() +}).strict(); + +const ExpiredBatchTrashPayload = z.object({ + count: 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(), @@ -35,7 +43,9 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 0fbf768..c5dedc4 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -18,6 +18,8 @@ interface DailyRow { restore: number; permanent_delete: number; empty_trash: number; + expired_banner_shown: number; + expired_batch_trash: number; } export interface StatsResult { @@ -34,11 +36,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta let durationN = 0; let trashCount = 0; let restoreCount = 0; + let expiredBannerShownCandidatesSum = 0; + let expiredBatchTrashCountSum = 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, trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0 }; + row = { + date: day, + capture: 0, ai_succeeded: 0, ai_failed: 0, + trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0, + expired_banner_shown: 0, expired_batch_trash: 0 + }; byDay.set(day, row); } if (ev.kind === 'capture') row.capture += 1; @@ -60,6 +69,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta row.permanent_delete += 1; } else if (ev.kind === 'empty_trash') { row.empty_trash += 1; + } else if (ev.kind === 'expired_banner_shown') { + row.expired_banner_shown += 1; + expiredBannerShownCandidatesSum += ev.payload.candidateCount; + } else if (ev.kind === 'expired_batch_trash') { + row.expired_batch_trash += 1; + expiredBatchTrashCountSum += ev.payload.count; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); @@ -69,6 +84,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta const trashRecoveryRate = trashCount === 0 ? 'N/A' : `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`; + const expiredTrashRatio = expiredBannerShownCandidatesSum === 0 + ? 'N/A' + : `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`; const lines: string[] = []; lines.push('# Inkling Telemetry Stats'); lines.push(''); @@ -77,10 +95,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(''); lines.push('## 일자별 카운트'); lines.push(''); - lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |'); - lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|'); + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |'); + lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|'); for (const row of days) { - lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} |`); + lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} |`); } lines.push(''); lines.push('## 핵심 ratio'); @@ -88,6 +106,7 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(`- AI 성공률: ${successRate}`); lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`); lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`); + lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`); lines.push(''); return { md: lines.join('\n'), eventCount }; } diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 5a3198c..8b7d1c8 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -146,8 +146,12 @@ describe('TelemetryService.readAllRecent', () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(3); - // discriminant narrowing — empty_trash 같은 noteId 없는 kind 가 섞이면 명시적으로 실패 - expect(events.map((e) => e.kind === 'empty_trash' ? null : e.payload.noteId)).toEqual(['a', 'b', 'b']); + // discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패 + expect(events.map((e) => + (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash') + ? null + : e.payload.noteId + )).toEqual(['a', 'b', 'b']); }); it('skips malformed lines (silent — invariant)', async () => { @@ -160,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(1); const ev = events[0]!; expect(ev.kind).toBe('capture'); - if (ev.kind !== 'empty_trash') expect(ev.payload.noteId).toBe('a'); + if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash') expect(ev.payload.noteId).toBe('a'); }); it('returns [] when dir missing', async () => { diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 0b6ba41..f1a0187 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -149,3 +149,41 @@ describe('validateEvent — trash family (v0.2.3 #4)', () => { })).toThrow(); }); }); + +describe('expired_banner_shown / expired_batch_trash events', () => { + it('parses valid expired_banner_shown', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_banner_shown', + payload: { candidateCount: 7 } + }); + if (ev.kind !== 'expired_banner_shown') throw new Error('discriminant'); + expect(ev.payload.candidateCount).toBe(7); + }); + + it('parses valid expired_batch_trash', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_batch_trash', + payload: { count: 3 } + }); + if (ev.kind !== 'expired_batch_trash') throw new Error('discriminant'); + expect(ev.payload.count).toBe(3); + }); + + it('rejects expired_banner_shown with extra payload field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_banner_shown', + payload: { candidateCount: 7, rawText: 'leak' } + })).toThrow(); + }); + + it('rejects expired_batch_trash with negative count', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'expired_batch_trash', + payload: { count: -1 } + })).toThrow(); + }); +}); diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index ab16994..181b0d7 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -101,3 +101,26 @@ describe('aggregateStats — trash family (v0.2.3 #4)', () => { expect(r.md).toContain('휴지통 회수율: N/A'); }); }); + +describe('aggregateStats — expired_banner_shown / expired_batch_trash', () => { + it('counts both kinds per day and computes 만료 trash ratio', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 5 } }, + { ts: '2026-05-01T01:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 3 } }, + { ts: '2026-05-01T02:00:00.000Z', kind: 'expired_batch_trash' as const, payload: { count: 4 } } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + expect(r.md).toContain('expired_banner_shown'); + expect(r.md).toContain('expired_batch_trash'); + // 4 / (5 + 3) = 50.0% + expect(r.md).toMatch(/만료 trash ratio.*50\.0%/); + }); + + it('shows N/A when 만료 배너 노출 0건', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'capture' as const, payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + expect(r.md).toMatch(/만료 trash ratio.*N\/A/); + }); +});