FABRIC/src/errorGrouping.ts
jedarden 9938630bdd feat(web): add ErrorGroupPanel with grouped error cards and similar past errors
Port TUI ErrorGroupPanel to React — groups errors by signature with
occurrence count, affected workers, time span, severity badges, and
expandable detail cards. Links to similar past errors from fabric.db
error_history via /api/errors/history/similar endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 06:16:46 -04:00

551 lines
14 KiB
TypeScript

/**
* FABRIC Error Grouping Module
*
* Clusters similar errors together to reduce noise and highlight unique issues.
* Uses pattern matching on error messages and stack traces to group related errors.
*/
import { LogEvent, ErrorFingerprint, ErrorCategory, ErrorGroup, ErrorGroupingOptions } from './types.js';
// Simple hash function for signatures
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).padStart(8, '0');
}
// Pattern matchers for error categorization
interface ErrorPattern {
category: ErrorCategory;
patterns: RegExp[];
normalizers: Array<(msg: string) => string>;
}
const ERROR_PATTERNS: ErrorPattern[] = [
{
category: 'network',
patterns: [
/ECONNREFUSED/i,
/ECONNRESET/i,
/EPIPE/i,
/ENOTFOUND/i,
/EAI_AGAIN/i,
/ETIMEDOUT.*connect/i,
/socket hang up/i,
/network unreachable/i,
/connection refused/i,
/connection reset/i,
/connection closed/i,
/getaddrinfo/i,
/DNS/i,
],
normalizers: [
// Normalize host:port patterns
(msg) => msg.replace(/(\d{1,3}\.){3}\d{1,3}(:\d+)?/g, '*:*'),
(msg) => msg.replace(/[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(:\d+)?/g, '*:*'),
// Normalize connection IDs
(msg) => msg.replace(/connection #[\d]+/gi, 'connection #*'),
],
},
{
category: 'permission',
patterns: [
/EACCES/i,
/EPERM/i,
/permission denied/i,
/access denied/i,
/unauthorized/i,
/forbidden/i,
/authentication failed/i,
/invalid.*credentials/i,
/not authorized/i,
/insufficient permissions/i,
/403/i,
/401/i,
],
normalizers: [
// Normalize file paths
(msg) => msg.replace(/\/[\w./-]+/g, '/*'),
// Normalize usernames
(msg) => msg.replace(/user ['"][\w@.-]+['"]/gi, 'user "*"'),
],
},
{
category: 'not_found',
patterns: [
/ENOENT/i,
/no such file/i,
/not found/i,
/does not exist/i,
/404/i,
/resource not found/i,
/no matching/i,
],
normalizers: [
// Normalize file paths
(msg) => msg.replace(/['"]\/[\w./-]+['"]/g, '"/*"'),
(msg) => msg.replace(/['"][\w-]+\.[\w]+['"]/g, '"*"'),
],
},
{
category: 'timeout',
patterns: [
/ETIMEDOUT/i,
/timed? out/i,
/timeout expired/i,
/deadline exceeded/i,
/request timeout/i,
],
normalizers: [
// Normalize durations
(msg) => msg.replace(/\d+\s*(ms|seconds?|minutes?|s|m)/gi, '*ms'),
(msg) => msg.replace(/after \d+/gi, 'after *'),
],
},
{
category: 'resource',
patterns: [
/ENOMEM/i,
/out of memory/i,
/disk full/i,
/no space left/i,
/quota exceeded/i,
/limit exceeded/i,
/rate limit/i,
/too many requests/i,
/429/i,
/resource exhausted/i,
],
normalizers: [
// Normalize numbers
(msg) => msg.replace(/\d+(?:\.\d+)?\s*(bytes?|kb|mb|gb|tb)/gi, '*B'),
(msg) => msg.replace(/limit of \d+/gi, 'limit of *'),
],
},
// Syntax errors must be checked BEFORE validation errors
// because "SyntaxError: unexpected token" should match syntax, not validation
{
category: 'syntax',
patterns: [
/SyntaxError/i,
/parse error/i,
/JSON parse/i,
/YAML parse/i,
/invalid format/i,
],
normalizers: [
// Normalize line numbers
(msg) => msg.replace(/at line \d+/gi, 'at line *'),
(msg) => msg.replace(/position \d+/gi, 'position *'),
// Normalize quoted strings
(msg) => msg.replace(/['"][^'"]{1,30}['"]/g, '"*"'),
],
},
{
category: 'validation',
patterns: [
/invalid/i,
/malformed/i,
/unexpected token/i,
/expected.*but got/i,
/validation failed/i,
/schema validation/i,
/type error/i,
/cannot read/i,
/cannot set/i,
/undefined is not/i,
/null is not/i,
/is not a function/i,
/is not defined/i,
],
normalizers: [
// Normalize property names
(msg) => msg.replace(/property ['"][\w.]+['"]/gi, 'property "*"'),
(msg) => msg.replace(/['"][\w.]+['"] is not/gi, '"*" is not'),
// Normalize types
(msg) => msg.replace(/expected [\w<>[\]]+/gi, 'expected *'),
],
},
{
category: 'tool',
patterns: [
/tool.*failed/i,
/tool.*error/i,
/execution failed/i,
/command failed/i,
/exit code \d+/i,
/non-zero exit/i,
/spawn.*error/i,
/child process/i,
],
normalizers: [
// Normalize command arguments
(msg) => msg.replace(/--[\w-]+=\S+/g, '--*=*'),
(msg) => msg.replace(/exit code \d+/gi, 'exit code *'),
],
},
];
/**
* Default grouping options
*/
const DEFAULT_OPTIONS: Required<ErrorGroupingOptions> = {
activeWindowMs: 5 * 60 * 1000, // 5 minutes
highSeverityThreshold: 5,
criticalSeverityThreshold: 10,
maxGroups: 100,
};
/** Maximum events retained per error group. */
const MAX_EVENTS_PER_GROUP = 50;
/**
* Categorize an error message
*/
export function categorizeError(message: string): ErrorCategory {
for (const pattern of ERROR_PATTERNS) {
for (const regex of pattern.patterns) {
if (regex.test(message)) {
return pattern.category;
}
}
}
return 'unknown';
}
/**
* Generate a fingerprint for an error event
*/
export function fingerprintError(event: LogEvent): ErrorFingerprint {
const message = event.error || event.msg;
// Find matching category and normalizers
let category: ErrorCategory = 'unknown';
let normalizers: Array<(msg: string) => string> = [];
for (const pattern of ERROR_PATTERNS) {
for (const regex of pattern.patterns) {
if (regex.test(message)) {
category = pattern.category;
normalizers = pattern.normalizers;
break;
}
}
if (category !== 'unknown') break;
}
// Apply normalizers to create signature
let signature = message;
for (const normalize of normalizers) {
signature = normalize(signature);
}
// Also normalize common patterns
signature = signature
// UUIDs
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '*UUID*')
// Hex strings (longer than 8 chars)
.replace(/0x[0-9a-f]{8,}/gi, '*HEX*')
// Numbers
.replace(/\b\d{4,}\b/g, '*')
// Timestamps
.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/g, '*TIMESTAMP*')
// Stack traces - keep first line only
.split('\n')[0]
.trim();
// Truncate very long signatures
if (signature.length > 200) {
signature = signature.substring(0, 200) + '...';
}
const hash = simpleHash(signature + ':' + category);
return {
signature,
category,
sampleMessage: message,
hash,
};
}
/**
* Check if two fingerprints represent the same error group
*/
export function fingerprintsMatch(a: ErrorFingerprint, b: ErrorFingerprint): boolean {
return a.hash === b.hash;
}
/**
* Calculate severity based on count and recency
*/
function calculateSeverity(
count: number,
lastSeen: number,
options: Required<ErrorGroupingOptions>
): 'low' | 'medium' | 'high' | 'critical' {
const now = Date.now();
const isActive = (now - lastSeen) < options.activeWindowMs;
if (!isActive) {
return 'low';
}
if (count >= options.criticalSeverityThreshold) {
return 'critical';
}
if (count >= options.highSeverityThreshold) {
return 'high';
}
if (count >= 2) {
return 'medium';
}
return 'low';
}
/**
* Generate a unique group ID
*/
function generateGroupId(): string {
return `eg-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
}
/**
* Error Group Manager
*
* Manages error groups and provides grouping functionality.
*/
export class ErrorGroupManager {
private groups: Map<string, ErrorGroup> = new Map();
private hashToGroup: Map<string, string> = new Map(); // hash -> groupId
private options: Required<ErrorGroupingOptions>;
constructor(options: ErrorGroupingOptions = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/**
* Add an error event to the appropriate group
*/
addError(event: LogEvent): ErrorGroup {
const fingerprint = fingerprintError(event);
const existingGroupId = this.hashToGroup.get(fingerprint.hash);
if (existingGroupId) {
// Add to existing group (cap events to prevent unbounded growth)
const group = this.groups.get(existingGroupId)!;
if (group.events.length < MAX_EVENTS_PER_GROUP) {
group.events.push(event);
}
group.count++;
group.lastSeen = event.ts;
if (!group.affectedWorkers.includes(event.worker)) {
group.affectedWorkers.push(event.worker);
}
// Update severity
group.severity = calculateSeverity(group.count, group.lastSeen, this.options);
group.isActive = (Date.now() - group.lastSeen) < this.options.activeWindowMs;
return group;
}
// Create new group
const groupId = generateGroupId();
const group: ErrorGroup = {
id: groupId,
fingerprint,
events: [event],
firstSeen: event.ts,
lastSeen: event.ts,
count: 1,
affectedWorkers: [event.worker],
isActive: true,
severity: 'low',
};
this.groups.set(groupId, group);
this.hashToGroup.set(fingerprint.hash, groupId);
// Trim if over limit
if (this.groups.size > this.options.maxGroups) {
this.trimGroups();
}
return group;
}
/**
* Trim oldest/inactive groups when over limit
*/
private trimGroups(): void {
// Sort groups by lastSeen (oldest first)
const sortedGroups = Array.from(this.groups.entries())
.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
// Remove oldest inactive groups first
const toRemove: string[] = [];
for (const [groupId, group] of sortedGroups) {
if (!group.isActive && this.groups.size - toRemove.length > this.options.maxGroups * 0.8) {
toRemove.push(groupId);
}
}
// If still over limit, remove oldest regardless of status
while (this.groups.size - toRemove.length > this.options.maxGroups) {
const oldest = sortedGroups.find(([id]) => !toRemove.includes(id));
if (oldest) {
toRemove.push(oldest[0]);
} else {
break;
}
}
// Remove selected groups
for (const groupId of toRemove) {
const group = this.groups.get(groupId);
if (group) {
this.hashToGroup.delete(group.fingerprint.hash);
this.groups.delete(groupId);
}
}
}
/**
* Get all error groups
*/
getGroups(): ErrorGroup[] {
// Update active status before returning
const now = Date.now();
for (const group of this.groups.values()) {
group.isActive = (now - group.lastSeen) < this.options.activeWindowMs;
group.severity = calculateSeverity(group.count, group.lastSeen, this.options);
}
return Array.from(this.groups.values())
.sort((a, b) => {
// Sort by: active first, then severity, then count
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
if (severityOrder[a.severity] !== severityOrder[b.severity]) {
return severityOrder[a.severity] - severityOrder[b.severity];
}
return b.count - a.count;
});
}
/**
* Get active error groups only
*/
getActiveGroups(): ErrorGroup[] {
return this.getGroups().filter(g => g.isActive);
}
/**
* Get a specific group by ID
*/
getGroup(groupId: string): ErrorGroup | undefined {
return this.groups.get(groupId);
}
/**
* Get groups affecting a specific worker
*/
getWorkerGroups(workerId: string): ErrorGroup[] {
return this.getGroups().filter(g => g.affectedWorkers.includes(workerId));
}
/**
* Get groups by category
*/
getGroupsByCategory(category: ErrorCategory): ErrorGroup[] {
return this.getGroups().filter(g => g.fingerprint.category === category);
}
/**
* Get summary statistics
*/
getStats(): {
totalGroups: number;
activeGroups: number;
totalErrors: number;
byCategory: Record<ErrorCategory, number>;
bySeverity: Record<string, number>;
} {
const groups = this.getGroups();
const byCategory: Record<ErrorCategory, number> = {
network: 0,
permission: 0,
validation: 0,
resource: 0,
not_found: 0,
timeout: 0,
syntax: 0,
tool: 0,
unknown: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
let totalErrors = 0;
for (const group of groups) {
byCategory[group.fingerprint.category]++;
bySeverity[group.severity]++;
totalErrors += group.count;
}
return {
totalGroups: groups.length,
activeGroups: groups.filter(g => g.isActive).length,
totalErrors,
byCategory,
bySeverity,
};
}
/**
* Clear all groups
*/
clear(): void {
this.groups.clear();
this.hashToGroup.clear();
}
/**
* Get number of tracked groups
*/
get size(): number {
return this.groups.size;
}
}
/**
* Create a singleton manager instance
*/
let globalManager: ErrorGroupManager | undefined;
export function getErrorGroupManager(): ErrorGroupManager {
if (!globalManager) {
globalManager = new ErrorGroupManager();
}
return globalManager;
}
export function resetErrorGroupManager(): void {
globalManager = undefined;
}
// Re-export types
export type { ErrorFingerprint, ErrorCategory, ErrorGroup, ErrorGroupingOptions };