feat(expiry): telemetry 2 events — expired_banner_shown / expired_batch_trash (#5 v0.2.3)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<typeof TelemetryEventSchema>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user