기존 4-line narrowing 체인 (e.kind !== 'empty_trash' && ... && ...) 이 union 확장 시 길어짐 → hasNoteId(ev) type predicate 로 통합. - telemetryEvents.ts: NO_NOTE_ID_KINDS Set + hasNoteId(ev): ev is ... export - TelemetryService.test.ts: 2 narrowing callsite 단축 - 단위 +2 cases (noteId-bearing / noteId-less) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
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<typeof AiFailedReasonSchema>;
|
|
|
|
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<typeof TelemetryEventSchema>;
|
|
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<TelemetryKind>([
|
|
'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<TelemetryEvent, { payload: { noteId: string } }> {
|
|
return !NO_NOTE_ID_KINDS.has(ev.kind);
|
|
}
|