Files
fusion/packages/dashboard/src/reliability-metrics.ts
Fusion fc5061afdd feat(FN-4608): complete Step 2 — handle empty-sample reliability days
Fusion-Task-Id: FN-4608
Fusion-Task-Lineage: 6cabc509-38a1-4cb8-be6f-51c53d5753ff
2026-05-15 09:52:57 -07:00

225 lines
7.8 KiB
TypeScript

import type { ActivityLogEntry, RunAuditEvent } from "@fusion/core";
/**
* Discovery notes (FN-4360):
* - post-merge audit failures are not emitted via recordRunAuditEvent in merger post-merge audit path; represented as no-audit-coverage.
* - FileScopeViolationError is thrown/handled in merger but no dedicated run_audit emission was found for invariant failures; represented as no-audit-coverage.
* - recoverAlreadyMergedReviewTasks currently has no run_audit emission in self-healing; represented as no-audit-coverage.
* - merge attempts are inferred from git-domain run_audit events with metadata.phase matching /^merge-attempt-/.
*/
export type NullMetricReason = "no-audit-coverage" | "insufficient-samples" | "no-in-review-entries";
export interface NullableMetric<T> {
value: T | null;
reason?: NullMetricReason;
}
export interface MergeAttemptsMetric {
mean: number | null;
max: number | null;
histogram: Record<string, number>;
reason?: NullMetricReason;
}
export interface InReviewDurationMetric {
p50Ms: number | null;
p95Ms: number | null;
sampleCount: number;
reason?: NullMetricReason;
}
export interface ReliabilityPerDayCounts {
tasksEnteredInReview: number;
tasksBouncedToInProgress: number;
postMergeAuditFailures: { block: number; warn: number; off: number } | null;
fileScopeInvariantFailures: number | null;
recoverAlreadyMergedReviewTasksRecoveries: number | null;
}
const DAY_MS = 86_400_000;
export function bucketByDay(timestamp: string): string {
return new Date(timestamp).toISOString().slice(0, 10);
}
function inWindow(timestamp: string, startMs: number, endMs: number): boolean {
const ms = new Date(timestamp).getTime();
return Number.isFinite(ms) && ms >= startMs && ms <= endMs;
}
function metadataColumn(entry: ActivityLogEntry, key: "from" | "to"): string | undefined {
const raw = entry.metadata?.[key];
return typeof raw === "string" ? raw : undefined;
}
function collectTaskMovedEvents(activity: ActivityLogEntry[], startMs: number, endMs: number): ActivityLogEntry[] {
return activity.filter((entry) => entry.type === "task:moved" && inWindow(entry.timestamp, startMs, endMs));
}
function incrementDayCount(counts: Record<string, number>, day: string): void {
counts[day] = (counts[day] ?? 0) + 1;
}
export function tasksEnteredInReviewPerDay(activity: ActivityLogEntry[], startMs: number, endMs: number): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of collectTaskMovedEvents(activity, startMs, endMs)) {
if (metadataColumn(entry, "to") === "in-review") {
incrementDayCount(counts, bucketByDay(entry.timestamp));
}
}
return counts;
}
export function tasksBouncedToInProgressPerDay(activity: ActivityLogEntry[], startMs: number, endMs: number): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of collectTaskMovedEvents(activity, startMs, endMs)) {
if (metadataColumn(entry, "from") === "in-review" && metadataColumn(entry, "to") === "in-progress") {
incrementDayCount(counts, bucketByDay(entry.timestamp));
}
}
return counts;
}
export function postMergeAuditFailuresPerDay(_events: RunAuditEvent[], _startMs: number, _endMs: number): NullableMetric<Record<string, { block: number; warn: number; off: number }>> {
return { value: null, reason: "no-audit-coverage" };
}
export function fileScopeInvariantFailuresPerDay(_events: RunAuditEvent[], _startMs: number, _endMs: number): NullableMetric<Record<string, number>> {
return { value: null, reason: "no-audit-coverage" };
}
export function recoverAlreadyMergedReviewTasksRecoveriesPerDay(_events: RunAuditEvent[], _startMs: number, _endMs: number): NullableMetric<Record<string, number>> {
return { value: null, reason: "no-audit-coverage" };
}
function percentile(sortedValues: number[], p: number): number {
if (sortedValues.length === 0) {
return 0;
}
const index = Math.ceil((p / 100) * sortedValues.length) - 1;
return sortedValues[Math.min(sortedValues.length - 1, Math.max(0, index))] ?? 0;
}
export function inReviewDurationMetrics(activity: ActivityLogEntry[], startMs: number, endMs: number): InReviewDurationMetric {
const moved = activity
.filter((entry) => entry.type === "task:moved")
.map((entry) => ({ entry, ms: new Date(entry.timestamp).getTime() }))
.filter((item) => Number.isFinite(item.ms))
.sort((a, b) => a.ms - b.ms);
const latestInReviewEntryByTask = new Map<string, number>();
const durations: number[] = [];
for (const { entry, ms } of moved) {
const taskId = entry.taskId;
if (!taskId) {
continue;
}
const from = metadataColumn(entry, "from");
const to = metadataColumn(entry, "to");
if (to === "in-review") {
latestInReviewEntryByTask.set(taskId, ms);
continue;
}
if (from === "in-review" && to === "done" && ms >= startMs && ms <= endMs) {
const start = latestInReviewEntryByTask.get(taskId);
if (typeof start === "number" && ms >= start) {
durations.push(ms - start);
}
}
}
if (durations.length < 3) {
return { p50Ms: null, p95Ms: null, sampleCount: durations.length, reason: "insufficient-samples" };
}
const sorted = [...durations].sort((a, b) => a - b);
return {
p50Ms: percentile(sorted, 50),
p95Ms: percentile(sorted, 95),
sampleCount: sorted.length,
};
}
export function mergeAttemptsPerMergedTask(events: RunAuditEvent[], activity: ActivityLogEntry[], startMs: number, endMs: number): MergeAttemptsMetric {
const mergedTaskIds = new Set(
activity
.filter((entry) => entry.type === "task:merged" && entry.taskId && inWindow(entry.timestamp, startMs, endMs))
.map((entry) => entry.taskId as string),
);
const phasesByTask = new Map<string, Set<string>>();
for (const event of events) {
if (event.domain !== "git" || !event.taskId || !inWindow(event.timestamp, startMs, endMs)) {
continue;
}
const phaseRaw = event.metadata?.phase;
if (typeof phaseRaw !== "string" || !/^merge-attempt-/.test(phaseRaw)) {
continue;
}
const taskPhases = phasesByTask.get(event.taskId) ?? new Set<string>();
taskPhases.add(phaseRaw);
phasesByTask.set(event.taskId, taskPhases);
}
const attemptCounts = Array.from(phasesByTask.entries())
.filter(([taskId]) => mergedTaskIds.has(taskId))
.map(([, phases]) => phases.size);
if (attemptCounts.length === 0) {
return { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" };
}
const total = attemptCounts.reduce((sum, count) => sum + count, 0);
const max = Math.max(...attemptCounts);
const histogram: Record<string, number> = {};
for (const count of attemptCounts) {
const key = count > 5 ? ">5" : String(count);
histogram[key] = (histogram[key] ?? 0) + 1;
}
return {
mean: total / attemptCounts.length,
max,
histogram,
};
}
export function dayHasSamples(counts: ReliabilityPerDayCounts): boolean {
if (counts.tasksEnteredInReview > 0 || counts.tasksBouncedToInProgress > 0) {
return true;
}
if (counts.postMergeAuditFailures) {
const { block, warn, off } = counts.postMergeAuditFailures;
if (block + warn + off > 0) {
return true;
}
}
return (counts.fileScopeInvariantFailures ?? 0) > 0 || (counts.recoverAlreadyMergedReviewTasksRecoveries ?? 0) > 0;
}
export function inReviewFailureRate7d(enteredByDay: Record<string, number>, bouncedByDay: Record<string, number>, endMs: number): NullableMetric<number> {
let entered = 0;
let bounced = 0;
for (let i = 0; i < 7; i += 1) {
const day = new Date(endMs - i * DAY_MS).toISOString().slice(0, 10);
entered += enteredByDay[day] ?? 0;
bounced += bouncedByDay[day] ?? 0;
}
if (entered === 0) {
return { value: null, reason: "no-in-review-entries" };
}
return { value: bounced / entered };
}