import type { ConnectorHealth } from '@rareindex/shared'; import { missingRequirements, type EngineStats, type HealthContext } from './types.js'; /** * Derive a ConnectorHealth snapshot (§105, SPEC §12–13) from recent runs. Anomaly detectors that need * data distributions (price drift, duplicate explosion, field-null drift) run in the normalizer and * arrive here as run anomalies. Result-count collapse and HTTP/challenge statistics are computed here. */ export function healthFromRuns(ctx: HealthContext, schemaVersion: string): ConnectorHealth { const now = Date.now(); const runs24h = ctx.recentRuns.filter((r) => now - r.startedAt.getTime() <= 24 * 3600_000); const runs7d = ctx.recentRuns.filter((r) => now - r.startedAt.getTime() <= 7 * 24 * 3600_000); const attempted = runs24h.reduce((a, r) => a + r.pagesAttempted, 0); const success = runs24h.reduce((a, r) => a + r.pagesSuccess, 0); const rawRecords = runs24h.reduce((a, r) => a + r.recordsRaw, 0); const dupes = runs24h.reduce((a, r) => a + r.recordsDuplicate, 0); const fc = sumEngine(runs24h, 'firecrawl'); const sf = sumEngine(runs24h, 'scrapfly'); const lastOk = [...ctx.recentRuns].reverse().find((r) => r.status === 'success' || r.status === 'partial'); const lastErr = [...ctx.recentRuns].reverse().find((r) => r.error)?.error ?? null; const anomalies = [...new Set(runs24h.flatMap((r) => r.anomalies))]; const successRate = attempted > 0 ? success / attempted : null; const failing = ctx.recentRuns.slice(-3).length >= 3 && ctx.recentRuns.slice(-3).every((r) => r.status === 'failed'); // HTTP / latency / challenge statistics across engines (SPEC §12) const httpErrors: Record = {}; let challenges = 0; let circuit = 0; let totalMs = 0; let totalAttempts = 0; for (const r of runs24h) { for (const s of Object.values(r.engineStats ?? {})) { totalMs += s.ms; totalAttempts += s.attempts; challenges += s.blocked ?? 0; circuit += s.circuitOpen ?? 0; for (const [code, n] of Object.entries(s.statuses ?? {})) if (Number(code) >= 400) httpErrors[code] = (httpErrors[code] ?? 0) + n; } } const rateLimited = httpErrors['429'] ?? 0; // Result-count collapse (SPEC §13): today's volume far below the trailing week's daily average. const days7 = Math.max(1, Math.min(7, Math.ceil((now - (runs7d[0]?.startedAt.getTime() ?? now)) / 86_400_000) || 1)); const raw7d = runs7d.reduce((a, r) => a + r.recordsRaw, 0); const dailyAvg = runs7d.length ? raw7d / days7 : null; const collapsed = dailyAvg !== null && dailyAvg >= 50 && runs24h.length > 0 && rawRecords < dailyAvg * 0.2 && !anomalies.some((a) => a.startsWith('time_budget')); if (collapsed) anomalies.push(`result_count_collapse: ${rawRecords} vs ${Math.round(dailyAvg)}/day`); const drift = anomalies.filter((a) => /^schema_drift|^selector_missing|^parse_failure_page|^pagination_failure|^price_parse_failure/.test(a)); const missing = missingRequirements(ctx.meta); let status: ConnectorHealth['status'] = 'unknown'; if (missing.length) status = 'disabled'; else if (ctx.recentRuns.length === 0) status = 'unknown'; else if (failing) status = 'failing'; else if (successRate !== null && successRate < 0.5) status = 'failing'; else if (collapsed || drift.length) status = 'degraded'; else if ((successRate !== null && successRate < 0.85) || anomalies.length > 0) status = 'degraded'; else status = 'healthy'; return { connector: ctx.meta.id, status, success_rate_24h: successRate === null ? null : round3(successRate), pages_attempted: attempted, pages_success: success, firecrawl_success_rate: fc.attempts ? round3(fc.success / fc.attempts) : null, scrapfly_fallback_rate: attempted ? round3(sf.attempts / attempted) : null, parse_failure_rate: rawRecords ? round3(runs24h.reduce((a, r) => a + r.anomalies.filter((x) => x.startsWith('parse_failure')).length, 0) / rawRecords) : null, last_success: lastOk ? lastOk.startedAt.toISOString() : null, last_error: lastErr, schema_version: schemaVersion, records_24h: rawRecords, duplicates_24h: dupes, anomalies, requests_24h: totalAttempts, latency_ms_avg: totalAttempts ? Math.round(totalMs / totalAttempts) : null, http_errors: httpErrors, challenges_24h: challenges, circuit_refusals_24h: circuit, rate_limited_24h: rateLimited, records_7d_daily_avg: dailyAvg === null ? null : Math.round(dailyAvg), schema_drift: drift, missing_requirements: missing, }; } function sumEngine(runs: HealthContext['recentRuns'], engine: string) { return runs.reduce( (acc, r) => { const s: EngineStats | undefined = r.engineStats[engine]; if (s) { acc.attempts += s.attempts; acc.success += s.success; } return acc; }, { attempts: 0, success: 0 }, ); } function round3(x: number): number { return Math.round(x * 1000) / 1000; } /** * Detect abnormal price distributions between a baseline and a new batch (§105): median shift * beyond a factor or a collapse of dispersion suggests a parser/currency problem. */ export function priceDistributionAnomaly(baseline: number[], batch: number[], factor = 3): string | null { if (baseline.length < 20 || batch.length < 10) return null; const med = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]!; }; const b = med(baseline); const n = med(batch); if (b > 0 && (n / b > factor || b / n > factor)) return `price_distribution_shift: median ${b.toFixed(2)} → ${n.toFixed(2)}`; return null; } /** Duplicate explosion: share of duplicate raw records in a run is far above baseline. */ export function duplicateExplosion(records: number, duplicates: number, threshold = 0.9): string | null { if (records < 50) return null; const share = duplicates / records; return share >= threshold ? `duplicate_explosion: ${(share * 100).toFixed(0)}% duplicates` : null; } /** Fields whose null-rate is tracked per connector for schema-drift detection (SPEC §13). */ export const DRIFT_FIELDS = ['price', 'currency', 'date', 'images', 'title', 'identifiers', 'grade', 'number', 'set', 'year', 'description'] as const; export type DriftField = (typeof DRIFT_FIELDS)[number]; /** Presence vector of the drift fields for one normalised record (any kind). */ export function driftPresence(rec: Record): Record { const attrs = (rec.attributes ?? {}) as Record; const grade = (rec.grade ?? {}) as Record; const has = (v: unknown) => v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0) && !(typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0); return { price: has(rec.price) || has(rec.currentBid) || has(rec.estimateLow) || has(rec.total), currency: has(rec.currency) || rec.kind === 'catalog_item' || rec.kind === 'population_report' || rec.kind === 'news_item', date: has(rec.saleDate) || has(rec.observationDate) || has(rec.listedAt) || has(rec.endsAt) || has(rec.reportDate) || has(rec.releaseDate) || has(rec.publishedAt), images: has(rec.imageUrls) || has(rec.imageUrl), title: has(rec.rawTitle) || has(rec.title), identifiers: has(attrs.identifiers), grade: has(grade.grade) || has(grade.grader), number: has(attrs.number), set: has(attrs.set) || has(attrs.setCode), year: has(attrs.year), description: has(rec.description) || has(rec.summary), }; } /** * Compare today's null rates against the trailing baseline. A field whose null rate rose by more * than `delta` (absolute) on ≥ `minRecords` records is reported as schema drift. */ export function fieldNullDrift(baseline: Record, today: Record, opts: { delta?: number; minRecords?: number } = {}): string[] { const delta = opts.delta ?? 0.3; const minRecords = opts.minRecords ?? 50; const out: string[] = []; for (const [field, t] of Object.entries(today)) { const b = baseline[field]; if (!b || t.total < minRecords || b.total < minRecords) continue; const bRate = b.nulls / b.total; const tRate = t.nulls / t.total; if (tRate - bRate >= delta && bRate < 0.5) out.push(`schema_drift:${field}: null rate ${(bRate * 100).toFixed(0)}% → ${(tRate * 100).toFixed(0)}%`); } return out; }