From 93e278b24185a9a781b84ede0cbeb5c4ad112322 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 14:18:59 +0900 Subject: [PATCH] feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3) --- src/main/services/TelemetryService.ts | 42 +++++++++++++++ tests/unit/TelemetryService.test.ts | 74 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/main/services/TelemetryService.ts create mode 100644 tests/unit/TelemetryService.test.ts diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts new file mode 100644 index 0000000..1993981 --- /dev/null +++ b/src/main/services/TelemetryService.ts @@ -0,0 +1,42 @@ +import { mkdir, appendFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { validateEvent } from './telemetryEvents.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 } }; + +export class TelemetryService { + constructor( + private dir: string, + private now: () => Date = () => new Date(), + private retentionDays: number = 14, + private opts: TelemetryServiceOptions = {} + ) {} + + async emit(input: EmitInput): Promise { + const ts = this.now().toISOString(); + const event = validateEvent({ ts, kind: input.kind, payload: input.payload }); + const filePath = join(this.dir, `events-${todayKstIso(this.now())}.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; + } + } +} diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts new file mode 100644 index 0000000..1c772e9 --- /dev/null +++ b/tests/unit/TelemetryService.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { TelemetryService } from '@main/services/TelemetryService.js'; + +describe('TelemetryService.emit', () => { + let dir: string; + + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('appends a JSONL line to events-YYYY-MM-DD.jsonl (KST date)', async () => { + // 2026-05-01 12:00 UTC → 2026-05-01 21:00 KST + const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z')); + await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); + const file = join(dir, 'events-2026-05-01.jsonl'); + expect(existsSync(file)).toBe(true); + const content = readFileSync(file, 'utf8').trim(); + const parsed = JSON.parse(content); + expect(parsed.kind).toBe('capture'); + expect(parsed.payload.noteId).toBe('n1'); + expect(typeof parsed.ts).toBe('string'); + }); + + it('uses KST date even when UTC date differs (around midnight)', async () => { + // 2026-05-01 23:30 UTC → 2026-05-02 08:30 KST + const svc = new TelemetryService(dir, () => new Date('2026-05-01T23:30:00Z')); + await svc.emit({ kind: 'capture', payload: { noteId: 'n2', rawTextLength: 1, hasMedia: false } }); + expect(existsSync(join(dir, 'events-2026-05-02.jsonl'))).toBe(true); + }); + + it('appends multiple events to same-day file', async () => { + const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z')); + await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); + await svc.emit({ kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 100, attempts: 0 } }); + const lines = readFileSync(join(dir, 'events-2026-05-01.jsonl'), 'utf8').trim().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]!).kind).toBe('capture'); + expect(JSON.parse(lines[1]!).kind).toBe('ai_succeeded'); + }); + + it('creates telemetry dir if absent', async () => { + const fresh = join(dir, 'nested', 'telem'); + const svc = new TelemetryService(fresh, () => new Date('2026-05-01T12:00:00Z')); + await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } }); + expect(existsSync(fresh)).toBe(true); + }); + + it('rejects malformed event (privacy invariant) — does NOT write file', async () => { + const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z')); + await expect(svc.emit({ + kind: 'capture', + payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false, rawText: 'leak' } as never + })).rejects.toThrow(); + // No file should have been created + expect(readdirSync(dir).filter((f) => f.startsWith('events-'))).toEqual([]); + }); + + it('emit is silent (does not throw) when fs write fails — invariant: telemetry never breaks app', async () => { + // Pass a non-writable path; emit should swallow the error. + const svc = new TelemetryService( + '/proc/0/no-such-thing-readonly', + () => new Date('2026-05-01T12:00:00Z'), + 14, + { silent: true } + ); + // Should resolve, not throw + await expect(svc.emit({ + kind: 'capture', + payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } + })).resolves.toBeUndefined(); + }); +});