- DailyRow +4 cols (recall_shown/opened/dismissed/snoozed)
- accumulators + 4 branches + recallAgeDaysSum
- table 컬럼 +4
- summary lines: "- 회상 추천: shown N / opened O / dismissed D / snoozed S (열림율 X%)"
"- 회상 평균 ageDays: avg"
- TelemetryService.EmitInput union 15 → 19
- 단위 +2 cases
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
|
|
import { aggregateStats } from './telemetryStats.js';
|
|
|
|
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
|
|
function todayKstIso(now: Date): string {
|
|
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
|
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
|
.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export interface TelemetryServiceOptions {
|
|
silent?: boolean;
|
|
}
|
|
|
|
export type EmitInput =
|
|
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
|
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
|
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
|
| { kind: 'trash'; payload: { noteId: string } }
|
|
| { kind: 'restore'; payload: { noteId: string } }
|
|
| { 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: 'ollama_unreachable'; payload: { reason: string } }
|
|
| { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
|
|
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
|
|
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
|
|
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
|
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
|
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
|
|
| { kind: 'recall_opened'; payload: { noteId: string } }
|
|
| { kind: 'recall_dismissed'; payload: { noteId: string } }
|
|
| { kind: 'recall_snoozed'; payload: { noteId: string } };
|
|
|
|
export class TelemetryService {
|
|
constructor(
|
|
private dir: string,
|
|
private now: () => Date = () => new Date(),
|
|
private retentionDays: number = 14,
|
|
private opts: TelemetryServiceOptions = {}
|
|
) {}
|
|
|
|
async cleanupOldFiles(): Promise<{ removed: string[] }> {
|
|
const removed: string[] = [];
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(this.dir);
|
|
} catch {
|
|
return { removed };
|
|
}
|
|
const cutoff = new Date(this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000);
|
|
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
|
|
for (const name of entries) {
|
|
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
|
if (!m) continue;
|
|
const fileDate = m[1]!;
|
|
if (fileDate < cutoffIso) {
|
|
try {
|
|
await unlink(join(this.dir, name));
|
|
removed.push(name);
|
|
} catch {
|
|
// ignore — best-effort cleanup
|
|
}
|
|
}
|
|
}
|
|
return { removed };
|
|
}
|
|
|
|
async emit(input: EmitInput): Promise<void> {
|
|
// 회차 1 review (PR #13) — `now()` 한 번만 호출. KST 자정 경계에서 ts 와 파일명 일자가
|
|
// 어긋나는 것을 방지.
|
|
const nowDate = this.now();
|
|
const ts = nowDate.toISOString();
|
|
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
|
|
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
|
|
try {
|
|
await mkdir(this.dir, { recursive: true });
|
|
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
|
|
} catch (err) {
|
|
if (this.opts.silent) return;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async readAllRecent(): Promise<TelemetryEvent[]> {
|
|
const events: TelemetryEvent[] = [];
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(this.dir);
|
|
} catch {
|
|
return events;
|
|
}
|
|
const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
|
const cutoffIso = todayKstIso(new Date(cutoffMs));
|
|
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
|
|
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
|
|
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
|
const fileNames = entries
|
|
.filter((n) => {
|
|
const m = datePattern.exec(n);
|
|
return m !== null && m[1]! >= cutoffIso;
|
|
})
|
|
.sort();
|
|
for (const name of fileNames) {
|
|
let raw: string;
|
|
try {
|
|
raw = await readFile(join(this.dir, name), 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const line of raw.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.length === 0) continue;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
continue;
|
|
}
|
|
try {
|
|
events.push(validateEvent(parsed));
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
async exportTo(outDir: string): Promise<{ eventCount: number }> {
|
|
const events = await this.readAllRecent();
|
|
await mkdir(outDir, { recursive: true });
|
|
const eventsContent = events.map((e) => JSON.stringify(e)).join('\n') + (events.length > 0 ? '\n' : '');
|
|
await writeFile(join(outDir, 'events.jsonl'), eventsContent, 'utf8');
|
|
const stats = aggregateStats(events, this.now());
|
|
await writeFile(join(outDir, 'stats.md'), stats.md, 'utf8');
|
|
return { eventCount: stats.eventCount };
|
|
}
|
|
}
|