feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3)

This commit is contained in:
altair823
2026-05-01 14:18:59 +09:00
parent 0a0ef11327
commit 93e278b241
2 changed files with 116 additions and 0 deletions

View File

@@ -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<void> {
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;
}
}
}

View File

@@ -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();
});
});