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:
parent
f04fbe8b1f
commit
f340c0d084
6 changed files with 103 additions and 85 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 }> => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue