diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 6fb4600..0e3a00e 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -24,7 +24,10 @@ export type EmitInput = | { kind: 'permanent_delete'; payload: { noteId: string } } | { kind: 'empty_trash'; payload: { count: number } } | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } - | { kind: 'expired_batch_trash'; payload: { count: number } }; + | { kind: 'expired_batch_trash'; payload: { count: number } } + | { kind: 'ollama_unreachable'; payload: { reason: string } } + | { kind: 'ollama_recovered'; payload: { downtimeMs: number } } + | { kind: 'ollama_recheck_manual'; payload: Record }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 378f4ee..f608866 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -36,6 +36,16 @@ const ExpiredBatchTrashPayload = z.object({ count: z.number().int().nonnegative() }).strict(); +const OllamaUnreachablePayload = z.object({ + reason: z.string().min(1).max(500) +}).strict(); + +const OllamaRecoveredPayload = z.object({ + downtimeMs: z.number().nonnegative() +}).strict(); + +const EmptyPayload = z.object({}).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(), @@ -45,7 +55,10 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ 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('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index c5dedc4..62f3173 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -20,6 +20,9 @@ interface DailyRow { empty_trash: number; expired_banner_shown: number; expired_batch_trash: number; + ollama_unreachable: number; + ollama_recovered: number; + ollama_recheck_manual: number; } export interface StatsResult { @@ -38,6 +41,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta let restoreCount = 0; let expiredBannerShownCandidatesSum = 0; let expiredBatchTrashCountSum = 0; + let ollamaDowntimeSum = 0; + let ollamaRecoveredCount = 0; + let ollamaRecheckManualCount = 0; for (const ev of events) { const day = kstDate(ev.ts); let row = byDay.get(day); @@ -46,7 +52,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta 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 + expired_banner_shown: 0, expired_batch_trash: 0, + ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0 }; byDay.set(day, row); } @@ -75,6 +82,15 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta } else if (ev.kind === 'expired_batch_trash') { row.expired_batch_trash += 1; expiredBatchTrashCountSum += ev.payload.count; + } else if (ev.kind === 'ollama_unreachable') { + row.ollama_unreachable += 1; + } else if (ev.kind === 'ollama_recovered') { + row.ollama_recovered += 1; + ollamaDowntimeSum += ev.payload.downtimeMs; + ollamaRecoveredCount += 1; + } else if (ev.kind === 'ollama_recheck_manual') { + row.ollama_recheck_manual += 1; + ollamaRecheckManualCount += 1; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); @@ -87,6 +103,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta const expiredTrashRatio = expiredBannerShownCandidatesSum === 0 ? 'N/A' : `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`; + const avgDowntime = ollamaRecoveredCount === 0 + ? 'N/A' + : `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`; + const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0); const lines: string[] = []; lines.push('# Inkling Telemetry Stats'); lines.push(''); @@ -95,10 +115,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 | expired_banner_shown | expired_batch_trash |'); - lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|'); + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual |'); + 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} | ${row.expired_banner_shown} | ${row.expired_batch_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} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} |`); } lines.push(''); lines.push('## 핵심 ratio'); @@ -107,6 +127,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`); lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`); lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`); + lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}건`); + lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`); + lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`); lines.push(''); return { md: lines.join('\n'), eventCount }; } diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 8b7d1c8..183dadf 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -148,7 +148,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(3); // 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') + (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual') ? null : e.payload.noteId )).toEqual(['a', 'b', 'b']); @@ -164,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(1); const ev = events[0]!; expect(ev.kind).toBe('capture'); - if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash') expect(ev.payload.noteId).toBe('a'); + if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual') 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 12020a6..1c74727 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -195,3 +195,58 @@ describe('expired_banner_shown / expired_batch_trash events', () => { })).toThrow(); }); }); + +describe('ollama_unreachable / ollama_recovered / ollama_recheck_manual events', () => { + it('parses valid ollama_unreachable', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ollama_unreachable', + payload: { reason: 'connection refused' } + }); + if (ev.kind !== 'ollama_unreachable') throw new Error('discriminant'); + expect(ev.payload.reason).toBe('connection refused'); + }); + + it('parses valid ollama_recovered', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ollama_recovered', + payload: { downtimeMs: 60000 } + }); + if (ev.kind !== 'ollama_recovered') throw new Error('discriminant'); + expect(ev.payload.downtimeMs).toBe(60000); + }); + + it('parses valid ollama_recheck_manual (empty payload)', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ollama_recheck_manual', + payload: {} + }); + expect(ev.kind).toBe('ollama_recheck_manual'); + }); + + it('rejects ollama_unreachable with extra payload field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ollama_unreachable', + payload: { reason: 'refused', rawText: 'leak' } + })).toThrow(); + }); + + it('rejects ollama_recovered with negative downtimeMs', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ollama_recovered', + payload: { downtimeMs: -1 } + })).toThrow(); + }); + + it('rejects ollama_recheck_manual with non-empty payload (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ollama_recheck_manual', + payload: { foo: 'bar' } + })).toThrow(); + }); +}); diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index 181b0d7..e3a2c91 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -124,3 +124,30 @@ describe('aggregateStats — expired_banner_shown / expired_batch_trash', () => expect(r.md).toMatch(/만료 trash ratio.*N\/A/); }); }); + +describe('aggregateStats — ollama_* events', () => { + it('counts 3 kinds per day and computes downtime average', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } }, + { ts: '2026-05-01T01:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 60000 } }, + { ts: '2026-05-01T02:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'timeout' } }, + { ts: '2026-05-01T03:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 120000 } }, + { ts: '2026-05-01T04:00:00.000Z', kind: 'ollama_recheck_manual' as const, payload: {} as Record } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + expect(r.md).toContain('ollama_unreachable'); + expect(r.md).toContain('ollama_recovered'); + expect(r.md).toContain('ollama_recheck_manual'); + // (60000 + 120000) / 2 = 90000 + expect(r.md).toMatch(/평균 downtimeMs.*90000/); + expect(r.md).toMatch(/수동 recheck.*1/); + }); + + it('shows N/A for downtime when no recovered events', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + expect(r.md).toMatch(/평균 downtimeMs.*N\/A/); + }); +});