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 } | { 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 { // 회차 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 { 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 }; } }