import { z } from 'zod'; const CapturePayload = z.object({ noteId: z.string().min(1), rawTextLength: z.number().int().nonnegative(), hasMedia: z.boolean() }).strict(); const AiSucceededPayload = z.object({ noteId: z.string().min(1), durationMs: z.number().nonnegative(), attempts: z.number().int().nonnegative() }).strict(); export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']); export type AiFailedReason = z.infer; const AiFailedPayload = z.object({ noteId: z.string().min(1), reason: AiFailedReasonSchema, attempts: z.number().int().nonnegative() }).strict(); const NoteIdPayload = z.object({ noteId: z.string().min(1) }).strict(); const EmptyTrashPayload = z.object({ count: z.number().int().nonnegative() }).strict(); const ExpiredBannerShownPayload = z.object({ candidateCount: z.number().int().nonnegative() }).strict(); const ExpiredBatchTrashPayload = z.object({ count: z.number().int().nonnegative() }).strict(); const OllamaUnreachablePayload = z.object({ reason: z.string().min(1).max(500) }).strict(); const OllamaRecoveredPayload = z.object({ downtimeMs: z.number().nonnegative() }).strict(); const EmptyPayload = z.object({}).strict(); 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(); const RecallShownPayload = z.object({ noteId: z.string().min(1), ageDays: 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(), z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(), 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('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict() ]); export type TelemetryEvent = z.infer; export type TelemetryKind = TelemetryEvent['kind']; export function validateEvent(raw: unknown): TelemetryEvent { return TelemetryEventSchema.parse(raw); } /** * v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow. * union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신. */ const NO_NOTE_ID_KINDS = new Set([ 'empty_trash', 'expired_banner_shown', 'expired_batch_trash', 'ollama_unreachable', 'ollama_recovered', 'ollama_recheck_manual', 'ai_retry_manual', 'tag_vocab_hit', 'tag_vocab_miss' ]); export function hasNoteId(ev: TelemetryEvent): ev is Extract { return !NO_NOTE_ID_KINDS.has(ev.kind); }