Watch
1
0
Fork
You've already forked RedFlag
0

web: log errors at the boundary, ditch the toast-logging wrapper (C6)

API errors already log via the axios interceptor — wrapping 78 toast calls
would just double-log and turn validation prompts into noise. Wrapper gone;
ErrorBoundary + window error/rejection handlers log to clientErrorLogger,
skipping axios errors so the log POST can't loop. setupApiInstance logs now too.
This commit is contained in:
Fimeg 2026-06-15 09:28:31 -04:00
commit f340c0d084
6 changed files with 103 additions and 85 deletions

View file

@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { clientErrorLogger } from '@/lib/client-error-logger';
interface Props {
children: ReactNode;
@ -22,6 +23,15 @@ class ErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('[ErrorBoundary] Uncaught error:', error, errorInfo);
// ETHOS #1: render crashes are history too. Ship to the backend log at the
// boundary where the crash surfaces — not coupled to the toast layer.
clientErrorLogger.logError({
subsystem: 'web',
error_type: 'javascript_error',
message: error.message,
stack_trace: error.stack,
metadata: { component_stack: errorInfo.componentStack ?? undefined },
}).catch(() => {});
}
render(): ReactNode {

View file

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { toastWithLogging } from '@/lib/toast-with-logging';
import toast from 'react-hot-toast';
interface ScanState {
isScanning: boolean;
@ -19,7 +19,7 @@ export function useScanState(agentId: string, subsystem: string) {
const triggerScan = useCallback(async () => {
if (state.isScanning) {
toastWithLogging.info('Scan already in progress');
toast('Scan already in progress');
return;
}
@ -38,7 +38,7 @@ export function useScanState(agentId: string, subsystem: string) {
setState({ isScanning: false, commandId: result.data.command_id });
toastWithLogging.success(`${subsystem} scan completed`);
toast.success(`${subsystem} scan completed`);
} catch (error: any) {
const isAlreadyRunning = error.response?.status === 409;
@ -50,7 +50,7 @@ export function useScanState(agentId: string, subsystem: string) {
error: 'Scan already in progress',
});
toastWithLogging.info(`Scan already running (command: ${existingCommandId})`);
toast(`Scan already running (command: ${existingCommandId})`);
} else {
const errorMessage = error.response?.data?.error || error.message;
setState({
@ -58,7 +58,9 @@ export function useScanState(agentId: string, subsystem: string) {
error: errorMessage,
});
toastWithLogging.error(`Failed to trigger scan: ${errorMessage}`, { subsystem });
// The underlying api.post failure is already logged as api_error by the
// response interceptor (lib/api.ts) — this is presentation only.
toast.error(`Failed to trigger scan: ${errorMessage}`);
}
}
}, [agentId, subsystem, state.isScanning]);

View file

@ -610,6 +610,26 @@ const setupApiInstance = axios.create({
},
});
// Boundary logging for the setup flow (this instance carried no interceptors,
// so checkHealth/configure failures bypassed client_errors entirely).
setupApiInstance.interceptors.response.use(
(response) => response,
async (error) => {
clientErrorLogger.logError({
subsystem: 'setup',
error_type: 'api_error',
message: error.message,
metadata: {
status_code: error.response?.status,
endpoint: error.config?.url,
method: error.config?.method,
response_data: error.response?.data,
},
}).catch(() => {});
return Promise.reject(error);
}
);
export const setupApi = {
// Check server health and status
checkHealth: async (): Promise<{ status: string }> => {

View file

@ -19,18 +19,45 @@ export class ClientErrorLogger {
private baseDelayMs = 1000;
private localStorageKey = 'redflag-error-queue';
private offlineBuffer: ClientErrorLog[] = [];
private isOnline = navigator.onLine;
private isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
// Throttle identical errors so a render loop / error storm can't flood the
// backend or the localStorage queue. Key = error_type|subsystem|message.
private dedupWindowMs = 10000;
private recentlyLogged = new Map<string, number>();
private maxQueueLength = 100;
constructor() {
// Listen for online/offline events
window.addEventListener('online', () => this.flushOfflineBuffer());
window.addEventListener('offline', () => { this.isOnline = false; });
// Guard against non-DOM import contexts (vitest without jsdom, any future
// SSR/prerender pass) — the singleton is constructed at module import.
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
this.isOnline = true;
this.flushOfflineBuffer().catch(() => {});
});
window.addEventListener('offline', () => { this.isOnline = false; });
}
}
/**
* Log an error with automatic retry and offline queuing
*/
async logError(errorData: Omit<ClientErrorLog, 'url' | 'timestamp'>): Promise<void> {
// Suppress an identical error seen within the dedup window. Matters now that
// global error/unhandledrejection handlers can fire logError automatically.
const key = `${errorData.error_type}|${errorData.subsystem}|${errorData.message}`;
const now = Date.now();
const last = this.recentlyLogged.get(key);
if (last !== undefined && now - last < this.dedupWindowMs) {
return;
}
this.recentlyLogged.set(key, now);
// Keep the dedup map bounded under high message variety.
if (this.recentlyLogged.size > 200) {
for (const [k, t] of this.recentlyLogged) {
if (now - t >= this.dedupWindowMs) this.recentlyLogged.delete(k);
}
}
const fullError: ClientErrorLog = {
...errorData,
url: window.location.href,
@ -83,6 +110,11 @@ export class ClientErrorLogger {
queuedAt: new Date().toISOString(),
});
// Cap the queue so an error storm can't grow localStorage without bound.
if (queue.length > this.maxQueueLength) {
queue.splice(0, queue.length - this.maxQueueLength);
}
// Save to localStorage for persistence
localStorage.setItem(this.localStorageKey, JSON.stringify(queue));

View file

@ -1,76 +0,0 @@
import toast, { ToastOptions } from 'react-hot-toast';
import { clientErrorLogger } from './client-error-logger';
/**
* Extract subsystem from current route
*/
function getCurrentSubsystem(): string {
if (typeof window === 'undefined') return 'unknown';
const path = window.location.pathname;
// Map routes to subsystems
if (path.includes('/storage')) return 'storage';
if (path.includes('/system')) return 'system';
if (path.includes('/docker')) return 'docker';
if (path.includes('/updates')) return 'updates';
if (path.includes('/agent/')) return 'agent';
return 'unknown';
}
/**
* Wrap toast.error to automatically log errors to backend
* Implements ETHOS #1: Errors are History
*/
export const toastWithLogging = {
error: (message: string, options?: ToastOptions & { subsystem?: string }) => {
const subsystem = options?.subsystem || getCurrentSubsystem();
// Log to backend asynchronously - don't block UI
clientErrorLogger.logError({
subsystem,
error_type: 'ui_error',
message: message.substring(0, 5000), // Prevent excessively long messages
metadata: {
component: options?.id,
duration: options?.duration,
position: options?.position,
timestamp: new Date().toISOString(),
},
}).catch(() => {
// Silently ignore logging failures - don't crash the UI
});
// Show toast to user
return toast.error(message, options);
},
// Passthrough methods
success: toast.success,
info: (message: string, options?: ToastOptions) => toast(message, options),
warning: (message: string, options?: ToastOptions) => toast(message, options),
loading: toast.loading,
dismiss: toast.dismiss,
remove: toast.remove,
promise: toast.promise,
};
/**
* React hook for toast with automatic subsystem detection
*/
export function useToastWithLogging() {
return {
error: (message: string, options?: ToastOptions & { subsystem?: string }) => {
return toastWithLogging.error(message, {
...options,
subsystem: options?.subsystem || getCurrentSubsystem(),
});
},
success: toast.success,
info: (message: string, options?: ToastOptions) => toast(message, options),
warning: (message: string, options?: ToastOptions) => toast(message, options),
loading: toast.loading,
dismiss: toast.dismiss,
};
}

View file

@ -3,10 +3,40 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import axios from 'axios'
import App from './App.tsx'
import { ConfirmProvider } from '@/components/primitives'
import { clientErrorLogger } from '@/lib/client-error-logger'
import './index.css'
// Global safety net for errors React's ErrorBoundary can't catch — event
// handlers, async callbacks, and uncaught promise rejections. Axios errors are
// deliberately excluded: they're already logged at the response-interceptor
// boundary (lib/api.ts), so logging them here would double-log AND risk a loop
// with the log POST's own failures (the header guard only covers the interceptor).
window.addEventListener('error', (event) => {
// Skip resource-load (img/script 404) and cross-origin "Script error." noise.
if (!(event.error instanceof Error)) return;
clientErrorLogger.logError({
subsystem: 'web',
error_type: 'javascript_error',
message: event.error.message,
stack_trace: event.error.stack,
metadata: { filename: event.filename, lineno: event.lineno, colno: event.colno },
}).catch(() => {});
});
window.addEventListener('unhandledrejection', (event) => {
if (axios.isAxiosError(event.reason)) return; // already logged by the api interceptor
const err = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
clientErrorLogger.logError({
subsystem: 'web',
error_type: 'javascript_error',
message: err.message,
stack_trace: err.stack,
}).catch(() => {});
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {