feat(telemetry): AiWorker emits ai_succeeded/ai_failed with reason (#7 v0.2.3)

This commit is contained in:
altair823
2026-05-01 17:21:08 +09:00
parent f0cef95d3f
commit 01447ddaad
2 changed files with 127 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@@ -15,6 +16,25 @@ function todayKstAsIso(now: Date): string {
return todayKstAsDate(now).toISOString().slice(0, 10);
}
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
if (err instanceof ZodError) return 'schema';
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
return 'unreachable';
}
if (msg.includes('timeout') || msg.includes('timedout') || msg.includes('aborted')) {
return 'timeout';
}
return 'other';
}
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
): Promise<void>;
}
export interface AiWorkerOptions {
backoffsMs?: number[];
onUpdate?: (note: Note) => void;
@@ -24,6 +44,7 @@ export interface AiWorkerOptions {
error: (msg: string, meta?: Record<string, unknown>) => void;
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
}
interface Job { noteId: string; attempts: number; }
@@ -36,6 +57,7 @@ export class AiWorker {
private onUpdate?: (note: Note) => void;
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
constructor(
private repo: NoteRepository,
@@ -46,6 +68,7 @@ export class AiWorker {
this.onUpdate = opts.onUpdate;
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
}
async enqueue(noteId: string): Promise<void> {
@@ -95,6 +118,7 @@ export class AiWorker {
private async processJob(job: Job): Promise<void> {
const max = this.backoffsMs.length;
for (let attempt = job.attempts; attempt < max; attempt++) {
const startMs = Date.now();
try {
const note = this.repo.findById(job.noteId);
if (!note || note.aiStatus !== 'pending') return;
@@ -121,6 +145,16 @@ export class AiWorker {
dueDateSource: res.dueDate !== null ? 'ai' : 'none',
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
durationMs: Date.now() - startMs,
attempts: attempt
}
}).catch(() => {});
}
this.emit(job.noteId);
return;
} catch (err) {
@@ -132,6 +166,16 @@ export class AiWorker {
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_failed',
payload: {
noteId: job.noteId,
reason: classifyReason(err),
attempts: attempt + 1
}
}).catch(() => {});
}
this.emit(job.noteId);
return;
}