diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 94e3c5f..66471e7 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -50,6 +50,15 @@ const AiRetryManualPayload = z.object({ failedCount: z.number().int().positive() }).strict(); +const TagVocabHitPayload = z.object({ + tagId: z.number().int().positive(), + vocabSize: z.number().int().nonnegative() +}).strict(); + +const TagVocabMissPayload = z.object({ + vocabSize: z.number().int().nonnegative() +}).strict(); + export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(), @@ -63,7 +72,9 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index f41bdef..5266346 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -278,3 +278,31 @@ describe('ai_retry_manual event', () => { })).toThrow(); }); }); + +describe('validateEvent — tag vocab', () => { + it('accepts tag_vocab_hit event', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_hit'); + }); + + it('accepts tag_vocab_miss event without tagId', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_miss', + payload: { vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_miss'); + }); + + it('rejects tag_vocab_hit with extra field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17, tagName: 'leak' } + })).toThrow(); + }); +});