Files
inkling/src/main/services/TelemetryService.ts
altair823 a2c17a8b0d refactor(v026): #5 AiFailedReason union 단일 export 통합
기존 'unreachable' | 'schema' | 'timeout' | 'other' literal 이 3곳에 분산:
- telemetryEvents.ts (zod enum AiFailedReason)
- TelemetryService.ts (EmitInput 안 inline literal)
- AiWorker.ts (classifyReason 반환 + AiTelemetryEmitter inline literal)

zod enum z.infer 통해 type 파생, 단일 export AiFailedReason 으로 통합.
- AiFailedReasonSchema (zod enum) + AiFailedReason (type) 둘 다 export
- TelemetryService EmitInput / AiWorker classifyReason / AiTelemetryEmitter
  모두 import type AiFailedReason 사용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:29:11 +09:00

138 lines
5.1 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 type { AiFailedReason } from './telemetryEvents.js';
import { aggregateStats } from './telemetryStats.js';
import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js';
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: AiFailedReason; 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 * DAY_MS);
const cutoffIso = kstTodayIso(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-${kstTodayIso(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 * DAY_MS;
const cutoffIso = kstTodayIso(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 };
}
}