feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3)
This commit is contained in:
42
src/main/services/TelemetryService.ts
Normal file
42
src/main/services/TelemetryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
74
tests/unit/TelemetryService.test.ts
Normal file
74
tests/unit/TelemetryService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user