diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx
index a821f58b..af442ac9 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 011d04e4..43c4e571 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 && (
+
{
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
+ navigator.clipboard.writeText(error.stack);
+ }
+ }}
+ className="text-xs font-medium text-blue-400 underline underline-offset-2 hover:text-blue-300 ml-2"
+ aria-label="Copy error stack"
+ >
+ Copy stack
+
+ )}
+
{error.message}
)}
-
+
- Try again
+ Reset section
+
+
+
+
+ Rescue session
+
+
+
+
+ Export state
window.history.back()}
- className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-[var(--text-strong)] transition-colors hover:bg-white/10"
+ className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-[var(--text-strong)] transition-colors hover:bg-white/10"
>
Go back
@@ -114,7 +212,7 @@ function DefaultErrorFallback({
Home
diff --git a/frontend/src/components/ui/ErrorFallback.tsx b/frontend/src/components/ui/ErrorFallback.tsx
index 7c0df4b5..0b0a2e36 100644
--- a/frontend/src/components/ui/ErrorFallback.tsx
+++ b/frontend/src/components/ui/ErrorFallback.tsx
@@ -2,7 +2,8 @@
import React from 'react';
import { cn } from '@/lib/utils';
-import { AlertCircle, X } from 'lucide-react';
+import { AlertCircle, Copy, X } from 'lucide-react';
+import { logTelemetryError, recordRecoveryAction } from '@/lib/telemetry';
interface ErrorFallbackProps {
error?: Error | null;
@@ -12,6 +13,7 @@ interface ErrorFallbackProps {
onReturnHome?: boolean;
variant?: 'inline' | 'card' | 'toast';
className?: string;
+ telemetryEventId?: string;
}
export function ErrorFallback({
@@ -22,7 +24,32 @@ export function ErrorFallback({
onReturnHome,
variant = 'card',
className,
+ telemetryEventId,
}: ErrorFallbackProps) {
+ const handleCopyStack = () => {
+ if (!error?.stack) return;
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
+ navigator.clipboard.writeText(error.stack);
+ }
+ };
+
+ const handleDismiss = () => {
+ if (telemetryEventId) recordRecoveryAction(telemetryEventId, 'dismiss');
+ onDismiss?.();
+ };
+
+ const handleRetry = () => {
+ if (telemetryEventId) recordRecoveryAction(telemetryEventId, 'reset');
+ onRetry?.();
+ };
+
+ const handleHome = () => {
+ if (telemetryEventId) recordRecoveryAction(telemetryEventId, 'home');
+ if (onReturnHome) {
+ if (typeof window !== 'undefined') window.location.href = '/dashboard';
+ }
+ };
+
if (variant === 'inline') {
return (
{message}
{error && process.env.NODE_ENV === 'development' && (
-
- {error.message}
-
+
+
+ {error.message}
+
+
+ Copy stack
+
+
)}
{onRetry && (
Retry
@@ -51,7 +87,7 @@ export function ErrorFallback({
{onDismiss && (
@@ -77,9 +113,18 @@ export function ErrorFallback({
{error?.message || 'Please try again later'}
+ {error && process.env.NODE_ENV === 'development' && error.stack && (
+
+ Copy stack
+
+ )}
{onRetry && (
Retry
@@ -88,7 +133,7 @@ export function ErrorFallback({
{onDismiss && (
@@ -114,20 +159,46 @@ export function ErrorFallback({
Error
{message}
{error && process.env.NODE_ENV === 'development' && (
- {error.message}
+
+
+ Error details
+
+
+
+ {error.message}
+
+ {error.stack && (
+
+ Copy stack
+
+ )}
+
+
)}
-
+
{onRetry && (
Try again
)}
+ {onDismiss && (
+
+ Dismiss
+
+ )}
{onReturnHome && (
Return to Dashboard
diff --git a/frontend/src/lib/telemetry.ts b/frontend/src/lib/telemetry.ts
new file mode 100644
index 00000000..9965c8be
--- /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 [];
+ }
+}