feat(telemetry): telemetryStats.aggregateStats (#7 v0.2.3)
This commit is contained in:
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 };
|
||||
}
|
||||
57
tests/unit/telemetryStats.test.ts
Normal file
57
tests/unit/telemetryStats.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user