From 75fd2a461302a5881fdce8247d24b816a41d6714 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Sun, 18 Oct 2026 19:21:49 +0100 Subject: [PATCH] feat(error-boundary): global resilient error boundary & crash recovery (#1333)\n\n- Add telemetry module with automated error logging, session tracking, recovery actions\n- Enhance ErrorBoundary with session rescue (reset all stores), export state, copy stack\n- Add copy error stack button to ErrorFallback (inline/card/toast variants)\n- Update GlobalError boundary to log telemetry and preserve layout shells\n\nCloses #1333 --- frontend/src/app/error.tsx | 15 +- frontend/src/components/ui/ErrorBoundary.tsx | 134 ++++++++++++++--- frontend/src/components/ui/ErrorFallback.tsx | 95 ++++++++++-- frontend/src/lib/telemetry.ts | 144 +++++++++++++++++++ 4 files changed, 357 insertions(+), 31 deletions(-) create mode 100644 frontend/src/lib/telemetry.ts diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx index a821f58b6..af442ac99 100644 --- a/frontend/src/app/error.tsx +++ b/frontend/src/app/error.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { useGlobalStore } from '@/stores/globalStore'; import { ErrorFallback } from '@/components/ui/ErrorFallback'; +import { logTelemetryError } from '@/lib/telemetry'; export default function GlobalError({ error, @@ -14,9 +15,17 @@ export default function GlobalError({ const setGlobalError = useGlobalStore((state) => state.setGlobalError); useEffect(() => { + // Automated telemetry logging for route-level errors + const event = logTelemetryError({ + error: error as Error & { componentStack?: string }, + componentName: 'GlobalErrorBoundary', + isRecovered: false, + }); + // Report unexpected errors through the existing client-safe monitoring path setGlobalError({ message: error.message, + code: event.id, timestamp: Date.now(), }); // We log the error to the console for development visibility @@ -25,11 +34,15 @@ export default function GlobalError({ return (
+ {/* Preserve navbar/shell: only this route segment errors; layout shells above remain mounted */} { + reset(); + }} onReturnHome={true} + telemetryEventId={error.digest || undefined} />
); diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx index 011d04e47..43c4e571a 100644 --- a/frontend/src/components/ui/ErrorBoundary.tsx +++ b/frontend/src/components/ui/ErrorBoundary.tsx @@ -1,25 +1,35 @@ 'use client'; import React from 'react'; -import { AlertTriangle, RefreshCw, Home, ArrowLeft } from 'lucide-react'; +import { AlertTriangle, RefreshCw, Home, ArrowLeft, Save, RotateCcw } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useGlobalStore } from '@/stores/globalStore'; +import { logTelemetryError, recordRecoveryAction } from '@/lib/telemetry'; interface ErrorBoundaryProps { children: React.ReactNode; fallback?: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode); onError?: (error: Error, errorInfo: React.ErrorInfo) => void; resetKeys?: unknown[]; + componentName?: string; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; + errorInfo: React.ErrorInfo | null; + telemetryEventId: string | null; } export class ErrorBoundary extends React.Component { constructor(props: ErrorBoundaryProps) { super(props); - this.state = { hasError: false, error: null }; + this.state = { + hasError: false, + error: null, + errorInfo: null, + telemetryEventId: null, + }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { @@ -29,6 +39,18 @@ export class ErrorBoundary extends React.Component ({ + errorInfo, + telemetryEventId: prevState.telemetryEventId || event.id, + })); } componentDidUpdate(prevProps: ErrorBoundaryProps) { @@ -43,32 +65,76 @@ export class ErrorBoundary extends React.Component { - this.setState({ hasError: false, error: null }); + if (this.state.telemetryEventId) { + recordRecoveryAction(this.state.telemetryEventId, 'reset'); + } + this.setState({ hasError: false, error: null, errorInfo: null }); + }; + + rescueSession = () => { + if (this.state.telemetryEventId) { + recordRecoveryAction(this.state.telemetryEventId, 'reset'); + } + useGlobalStore.getState().resetAllStores(); + this.setState({ hasError: false, error: null, errorInfo: null }); + }; + + exportSession = () => { + const stateJson = useGlobalStore.getState().exportState(); + const blob = new Blob([stateJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `soroban-session-rescue-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); }; render() { if (this.state.hasError && this.state.error) { if (this.props.fallback) { if (typeof this.props.fallback === 'function') { - return this.props.fallback(this.state.error, this.reset); + return this.props.fallback(this.state.error, this.reset, { + telemetryEventId: this.state.telemetryEventId, + rescueSession: this.rescueSession, + exportSession: this.exportSession, + }); } return this.props.fallback; } - return ; + return ( + + ); } return this.props.children; } } +interface DefaultErrorFallbackProps { + error: Error; + reset: () => void; + telemetryEventId: string | null; + rescueSession: () => void; + exportSession: () => void; +} + function DefaultErrorFallback({ error, reset, -}: { - error: Error; - reset: () => void; -}) { + telemetryEventId, + rescueSession, + exportSession, +}: DefaultErrorFallbackProps) { return (
@@ -81,32 +147,64 @@ function DefaultErrorFallback({

- An unexpected error occurred. You can try reloading this section or go back to safety. + An unexpected error occurred. Your editor state is preserved — try resetting this section, + rescuing the full session, or going back to safety.

{process.env.NODE_ENV === 'development' && (
-

- Error details -

+
+

+ Error details +

+ {error.stack && ( + + )} +

{error.message}

)} -
+
+ + + + +
)} {onRetry && (
{onDismiss && ( + )} {onRetry && (
{onDismiss && ( + )} +
+ )} -
+
{onRetry && ( )} + {onDismiss && ( + + )} {onReturnHome && ( Return to Dashboard diff --git a/frontend/src/lib/telemetry.ts b/frontend/src/lib/telemetry.ts new file mode 100644 index 000000000..9965c8be0 --- /dev/null +++ b/frontend/src/lib/telemetry.ts @@ -0,0 +1,144 @@ +'use client'; + +import { useGlobalStore } from '@/stores/globalStore'; + +export interface TelemetryErrorEvent { + id: string; + timestamp: number; + environment: string; + appVersion: string; + buildNumber: string; + url: string; + userAgent: string; + errorName: string; + errorMessage: string; + errorStack?: string; + componentStack?: string; + sessionId: string; + recoveryAction?: 'reset' | 'dismiss' | 'home' | 'copy'; + isRecovered: boolean; +} + +let sessionId: string; + +function getSessionId(): string { + if (sessionId) return sessionId; + if (typeof window !== 'undefined' && window.sessionStorage) { + const existing = window.sessionStorage.getItem('soroban_playground_session_id'); + if (existing) { + sessionId = existing; + return sessionId; + } + sessionId = crypto.randomUUID?.() || Math.random().toString(36).slice(2); + window.sessionStorage.setItem('soroban_playground_session_id', sessionId); + return sessionId; + } + sessionId = Math.random().toString(36).slice(2); + return sessionId; +} + +function getAppInfo() { + if (typeof window === 'undefined') { + return { environment: 'ssr', appVersion: '', buildNumber: '', url: '' }; + } + const store = useGlobalStore.getState(); + return { + environment: store.environment, + appVersion: store.appVersion, + buildNumber: store.buildNumber, + url: window.location.href, + }; +} + +/** + * Logs an error telemetry event to the global store and console. + * In production this can be wired up to a remote endpoint. + */ +export function logTelemetryError(params: { + error: Error & { componentStack?: string }; + componentName?: string; + isRecovered?: boolean; +}): TelemetryErrorEvent { + const { error, componentName, isRecovered = false } = params; + const { environment, appVersion, buildNumber, url } = getAppInfo(); + const event: TelemetryErrorEvent = { + id: crypto.randomUUID?.() || Math.random().toString(36).slice(2), + timestamp: Date.now(), + environment, + appVersion, + buildNumber, + url, + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'ssr', + errorName: error.name || 'Error', + errorMessage: error.message, + errorStack: error.stack, + componentStack: error.componentStack, + sessionId: getSessionId(), + recoveryAction: undefined, + isRecovered, + }; + + // Persist to global store for dashboard visibility + try { + const currentErrors = JSON.parse( + typeof sessionStorage !== 'undefined' + ? sessionStorage.getItem('soroban_telemetry_errors') || '[]' + : '[]' + ); + currentErrors.push(event); + if (currentErrors.length > 50) currentErrors.shift(); + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('soroban_telemetry_errors', JSON.stringify(currentErrors)); + } + } catch { + // sessionStorage may be disabled; ignore + } + + console.error('[Telemetry] Error captured:', { + id: event.id, + component: componentName, + error: event.errorName, + message: event.errorMessage, + sessionId: event.sessionId, + recovered: event.isRecovered, + }); + + return event; +} + +/** + * Records a recovery action taken by the user on a telemetry error. + */ +export function recordRecoveryAction(eventId: string, action: TelemetryErrorEvent['recoveryAction']) { + try { + const raw = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('soroban_telemetry_errors') : null; + if (!raw) return; + const errors: TelemetryErrorEvent[] = JSON.parse(raw); + const idx = errors.findIndex((e) => e.id === eventId); + if (idx !== -1) { + errors[idx].recoveryAction = action; + errors[idx].isRecovered = true; + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('soroban_telemetry_errors', JSON.stringify(errors)); + } + } + } catch { + // ignore + } +} + +/** + * Retrieves all captured telemetry errors for the current session. + */ +export function getTelemetryErrors(): TelemetryErrorEvent[] { + try { + const raw = + typeof sessionStorage !== 'undefined' + ? sessionStorage.getItem('soroban_telemetry_errors') + : null; + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +}