From 42a2461963f35775a74a8eb5907c352d9dc527ef Mon Sep 17 00:00:00 2001 From: felladaniel36-hash Date: Wed, 19 Aug 2026 01:45:24 +0100 Subject: [PATCH] #531 Add resilient global error boundaries and route-level diagnostics FIXED --- .../__tests__/errorReporter.smoke.test.ts | 63 +++++++- .../src/__tests__/errorRecovery.test.tsx | 98 ++++++++++++ app/frontend/src/app/error.tsx | 62 ++++++++ app/frontend/src/app/global-error.tsx | 59 +++++++ app/frontend/src/app/layout.tsx | 2 +- app/frontend/src/app/marketplace/page.tsx | 140 +++++++++++++---- app/frontend/src/app/page.tsx | 23 ++- .../src/app/pay/PaymentPageClient.tsx | 7 +- app/frontend/src/app/settings/page.tsx | 70 ++++++++- app/frontend/src/components/ErrorBoundary.tsx | 75 +++++++-- .../src/components/ErrorReportingShell.tsx | 61 +++++++- .../components/NotificationCenterProvider.tsx | 67 +++++--- .../__tests__/ErrorBoundary.test.tsx | 148 ++++++++++++++++++ .../__tests__/ErrorReportingShell.test.tsx | 97 ++++++++++++ .../__tests__/usePersistentState.test.tsx | 2 +- app/frontend/src/hooks/analyticsApi.ts | 59 +++++-- app/frontend/src/lib/errorReporter.ts | 86 +++++++++- 17 files changed, 1014 insertions(+), 105 deletions(-) create mode 100644 app/frontend/src/__tests__/errorRecovery.test.tsx create mode 100644 app/frontend/src/app/error.tsx create mode 100644 app/frontend/src/app/global-error.tsx create mode 100644 app/frontend/src/components/__tests__/ErrorBoundary.test.tsx create mode 100644 app/frontend/src/components/__tests__/ErrorReportingShell.test.tsx diff --git a/app/frontend/__tests__/errorReporter.smoke.test.ts b/app/frontend/__tests__/errorReporter.smoke.test.ts index e1814b5b1..9109de400 100644 --- a/app/frontend/__tests__/errorReporter.smoke.test.ts +++ b/app/frontend/__tests__/errorReporter.smoke.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-nocheck import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorReporter, redactPII } from "@/lib/errorReporter"; +import { errorReporter, redactPII, extractCodeOrigin } from "@/lib/errorReporter"; describe("errorReporter", () => { beforeEach(() => { @@ -36,25 +36,67 @@ describe("errorReporter", () => { ).toContain("[REDACTED_PHONE]"); }); + it("redacts Stellar secret keys, Bearer tokens, JWTs, API keys, passwords, and sensitive object keys", () => { + const sensitivePayload = { + secretKey: "SBEXAMPLESECRETKEY12345678901234567890123456789012345678901234", + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.doNotLeakSignatureHere123", + authorization: "Bearer secret-token-abc-123", + password: "SuperSecretPassword123!", + apiKey: "api_key_live_998877665544332211", + regularField: "Hello World", + nestedSecrets: { + rawMessage: "Using secret=my_db_secret and password=hidden_pwd with SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + }; + + const redacted = redactPII(sensitivePayload) as Record; + + expect(redacted.secretKey).toBe("[REDACTED]"); + expect(redacted.token).toBe("[REDACTED]"); + expect(redacted.authorization).toBe("[REDACTED]"); + expect(redacted.password).toBe("[REDACTED]"); + expect(redacted.apiKey).toBe("[REDACTED]"); + expect(redacted.regularField).toBe("Hello World"); + expect( + (redacted.nestedSecrets as Record).rawMessage + ).toContain("[REDACTED_SECRET]"); + expect( + (redacted.nestedSecrets as Record).rawMessage + ).toContain("[REDACTED_PASSWORD]"); + expect( + (redacted.nestedSecrets as Record).rawMessage + ).toContain("[REDACTED_SECRET_KEY]"); + }); + + it("extracts code origin from stack trace", () => { + const fakeStack = `Error: Something failed\n at DashboardView (Dashboard.tsx:42:15)\n at renderWithHooks (react-dom.js:123:45)`; + const origin = extractCodeOrigin(fakeStack); + expect(origin).toBe("at DashboardView (Dashboard.tsx:42:15)"); + }); + it("does not send when reporting is disabled", async () => { process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED = "false"; await errorReporter.captureError(new Error("test")); expect(global.fetch).not.toHaveBeenCalled(); }); - it("sends payload when reporting is enabled", async () => { + it("sends payload with route context, code origin, and metadata when reporting is enabled", async () => { process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED = "true"; process.env.NEXT_PUBLIC_ERROR_REPORTING_URL = "https://example.com/api/errors"; const mockFetch = vi.fn().mockResolvedValue({ ok: true }); global.fetch = mockFetch as unknown as typeof fetch; - await errorReporter.captureError(new Error("Server failed to load"), { + const error = new Error("Server failed to load"); + error.stack = "Error: Server failed to load\n at PaymentProcessor (PaymentProcessor.tsx:88:12)"; + + await errorReporter.captureError(error, { requestId: "req-123", correlationId: "corr-456", userId: "user-789", route: "/dashboard", + codeOrigin: "PaymentProcessor.tsx", componentStack: "at Dashboard (Dashboard.tsx:10)", - extra: { feature: "payment" }, + extra: { feature: "payment", password: "should-be-redacted" }, }); expect(mockFetch).toHaveBeenCalledTimes(1); @@ -72,7 +114,20 @@ describe("errorReporter", () => { expect(body.error.message).toBe("Server failed to load"); expect(body.context.requestId).toBe("req-123"); expect(body.context.correlationId).toBe("corr-456"); + expect(body.context.route).toBe("/dashboard"); + expect(body.context.codeOrigin).toBe("PaymentProcessor.tsx"); + expect(body.context.extra.password).toBe("[REDACTED]"); expect(body.appVersion).toBe("test-version"); expect(body.environment).toBe("preview"); }); + + it("gracefully catches fetch errors without throwing", async () => { + process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED = "true"; + process.env.NEXT_PUBLIC_ERROR_REPORTING_URL = "https://example.com/api/errors"; + global.fetch = vi.fn().mockRejectedValue(new Error("Network connection down")); + + await expect( + errorReporter.captureError(new Error("Component crashed")) + ).resolves.not.toThrow(); + }); }); diff --git a/app/frontend/src/__tests__/errorRecovery.test.tsx b/app/frontend/src/__tests__/errorRecovery.test.tsx new file mode 100644 index 000000000..fd213ea61 --- /dev/null +++ b/app/frontend/src/__tests__/errorRecovery.test.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + NotificationCenterProvider, + useNotificationCenter, +} from "@/components/NotificationCenterProvider"; +import { NOTIFICATION_STORAGE_KEY } from "@/lib/notifications"; +import { fetchAnalytics } from "@/hooks/analyticsApi"; +import { errorReporter } from "@/lib/errorReporter"; + +function NotificationViewer() { + const { notifications, unreadCount, markAllAsRead } = useNotificationCenter(); + return ( +
+

{unreadCount}

+

{notifications.length}

+ +
+ ); +} + +describe("Client Error Recovery & Resilience", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + describe("NotificationCenterProvider resilience", () => { + it("safely recovers from invalid JSON in localStorage and reports the error", () => { + const captureSpy = vi.spyOn(errorReporter, "captureError"); + localStorage.setItem(NOTIFICATION_STORAGE_KEY, "invalid-json-string{{"); + + render( + + + + ); + + // Should not throw, should fall back to initial notifications + expect(screen.getByTestId("total")).toBeInTheDocument(); + expect(captureSpy).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + codeOrigin: "NotificationCenterProvider.deserialize", + }) + ); + }); + + it("safely recovers when stored value is not an array (e.g. string/number/object)", () => { + localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify({ notAnArray: true })); + + render( + + + + ); + + expect(screen.getByTestId("total")).toBeInTheDocument(); + }); + + it("allows operations like markAllAsRead even after corrupt recovery", () => { + localStorage.setItem(NOTIFICATION_STORAGE_KEY, "broken-json"); + + render( + + + + ); + + const markBtn = screen.getByRole("button", { name: "Mark All" }); + expect(() => fireEvent.click(markBtn)).not.toThrow(); + }); + }); + + describe("analyticsApi error capture and fallback", () => { + it("captures analytics fetch failure and returns safe fallback data", async () => { + const captureSpy = vi.spyOn(errorReporter, "captureError"); + global.fetch = vi.fn().mockRejectedValue(new Error("Analytics endpoint 503")); + + const data = await fetchAnalytics("24h"); + + expect(data).toBeDefined(); + expect(data.volume).toEqual([]); + expect(data.summary.totalVolume).toBe(0); + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: "Analytics endpoint 503" }), + expect.objectContaining({ + codeOrigin: "analyticsApi.fetchAnalytics", + }) + ); + }); + }); +}); diff --git a/app/frontend/src/app/error.tsx b/app/frontend/src/app/error.tsx new file mode 100644 index 000000000..7d1db2bf9 --- /dev/null +++ b/app/frontend/src/app/error.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { errorReporter } from "@/lib/errorReporter"; + +export default function RouteError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + errorReporter.captureError(error, { + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: "app/error.tsx", + extra: { + digest: error.digest, + source: "route-error-boundary", + }, + }); + }, [error]); + + return ( +
+
+
+ ⚠️ +
+

Something went wrong!

+

+ An unexpected route error occurred. We have captured the details to help resolve this. +

+ + {error.message && ( +
+

+ {error.message} +

+
+ )} + +
+ + + Return to Home + +
+
+
+ ); +} diff --git a/app/frontend/src/app/global-error.tsx b/app/frontend/src/app/global-error.tsx new file mode 100644 index 000000000..dc8cc4258 --- /dev/null +++ b/app/frontend/src/app/global-error.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect } from "react"; +import { errorReporter } from "@/lib/errorReporter"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + errorReporter.captureError(error, { + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: "app/global-error.tsx", + extra: { + digest: error.digest, + source: "global-error-boundary", + }, + }); + }, [error]); + + return ( + + +
+
+ ⚠️ +
+

Application Error

+

+ A critical application error occurred. You can retry or reload the application. +

+
+ + +
+
+ + + ); +} diff --git a/app/frontend/src/app/layout.tsx b/app/frontend/src/app/layout.tsx index d6a2bc5d9..e053ebb83 100644 --- a/app/frontend/src/app/layout.tsx +++ b/app/frontend/src/app/layout.tsx @@ -3,7 +3,7 @@ import { Header } from "@/components/Header"; import { NotificationCenterProvider } from "@/components/NotificationCenterProvider"; import { ErrorReportingShell } from "@/components/ErrorReportingShell"; import { PWAHandler } from "@/components/PWAHandler"; -import { BRANDING, getBrandedTitle } from "@/lib/branding"; +import { BRANDING } from "@/lib/branding"; import "./globals.css"; const siteUrl = diff --git a/app/frontend/src/app/marketplace/page.tsx b/app/frontend/src/app/marketplace/page.tsx index 0fecd49fe..dd5856af8 100644 --- a/app/frontend/src/app/marketplace/page.tsx +++ b/app/frontend/src/app/marketplace/page.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "next/dynamic"; -import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { UsernameCard } from "@/components/UsernameCard"; import { ListingDetailModal } from "@/components/ListingDetailModal"; import type { MarketplaceListing } from "@/hooks/marketplaceApi"; @@ -12,6 +12,7 @@ import Link from "next/link"; import { WatchlistProvider } from "@/contexts/WatchlistContext"; import { MarketplaceApiProvider } from "@/hooks/MarketplaceApiContext"; import { RealtimeApiProvider } from "@/hooks/RealtimeApiContext"; +import { errorReporter } from "@/lib/errorReporter"; const BidModal = dynamic( () => import("@/components/BidModal").then((mod) => mod.BidModal), @@ -80,6 +81,7 @@ function StatsBar({ listings }: { listings: MarketplaceListing[] }) { function MarketplacePageContent() { const [listings, setListings] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [activeCategory, setActiveCategory] = useState("all"); const [sortKey, setSortKey] = useState("ending"); @@ -108,47 +110,86 @@ function MarketplacePageContent() { // Stable connection-status derived from the provider const isConnected = realtimeApi.isConnected; - useEffect(() => { - marketplaceApi.fetchListings().then((data) => { - setListings(data); + const loadListings = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await marketplaceApi.fetchListings(); + setListings(data ?? []); + } catch (err) { + const captured = err instanceof Error ? err : new Error(String(err)); + setError(captured.message || "Failed to load marketplace listings"); + errorReporter.captureError(captured, { + route: "/marketplace", + codeOrigin: "marketplace.loadListings", + extra: { source: "MarketplacePageContent", operation: "fetchListings" }, + }); + } finally { setLoading(false); - }); + } }, [marketplaceApi]); - // Latest listings, readable from effects without being an effect dependency. - const listingsRef = useRef([]); + useEffect(() => { + loadListings(); + }, [loadListings]); + useEffect(() => { if (listings.length > 0) { - listings.forEach((listing) => - realtimeApi.subscribeToListing(listing.id), - ); - return () => { + try { listings.forEach((listing) => - realtimeApi.unsubscribeFromListing(listing.id), + realtimeApi.subscribeToListing(listing.id), + ); + return () => { + listings.forEach((listing) => + realtimeApi.unsubscribeFromListing(listing.id), + ); + }; + } catch (err) { + errorReporter.captureError( + err instanceof Error ? err : new Error(String(err)), + { + route: "/marketplace", + codeOrigin: "marketplace.realtimeSubscription", + extra: { source: "MarketplacePageContent", operation: "subscribeToListing" }, + } ); - }; + } } }, [listings, realtimeApi]); // Handle real-time bid updates. applyBidUpdate discards stale, duplicate, // and out-of-order deliveries so bidCount only moves for genuinely new bids. useEffect(() => { - const unsubscribe = realtimeApi.onBidUpdate((update) => { - setLastUpdate(update.timestamp); - setListings((prev) => - prev.map((listing) => - listing.id === update.listingId - ? { - ...listing, - currentBid: Math.max(listing.currentBid, update.newBid), - bidCount: listing.bidCount + 1, - } - : listing, - ), - ); - }); + try { + const unsubscribe = realtimeApi.onBidUpdate((update) => { + if (!update || !update.listingId || typeof update.newBid !== "number") { + return; + } + setLastUpdate(update.timestamp ?? new Date()); + setListings((prev) => + prev.map((listing) => + listing.id === update.listingId + ? { + ...listing, + currentBid: Math.max(listing.currentBid, update.newBid), + bidCount: (listing.bidCount ?? 0) + 1, + } + : listing, + ), + ); + }); - return unsubscribe; + return unsubscribe; + } catch (err) { + errorReporter.captureError( + err instanceof Error ? err : new Error(String(err)), + { + route: "/marketplace", + codeOrigin: "marketplace.onBidUpdate", + extra: { source: "MarketplacePageContent", operation: "onBidUpdate" }, + } + ); + } }, [realtimeApi]); const handleBidSuccess = useCallback( @@ -267,7 +308,23 @@ function MarketplacePageContent() { {/* ── MAIN CONTENT ─────────────────────────────── */}
- {!loading && } + {error && listings.length > 0 && ( +
+
+ ⚠️ + {error} +
+ +
+ )} + + {!loading && !error && } {/* ── CONTROLS ─────────────────────────────── */}
@@ -363,7 +420,7 @@ function MarketplacePageContent() {
{/* ── RESULTS COUNT ─────────────────────────── */} - {!loading && ( + {!loading && !error && (

{filtered.length} listing{filtered.length !== 1 ? "s" : ""} found {search && ` for "${search}"`} @@ -371,7 +428,28 @@ function MarketplacePageContent() { )} {/* ── GRID ─────────────────────────────────── */} - {loading ? ( + {error && listings.length === 0 ? ( +

+
⚠️
+
+

+ Unable to load marketplace listings +

+

+ {error} +

+
+
+ +
+
+ ) : loading ? (
{Array.from({ length: 6 }).map((_, i) => (
{ const handlePrefetch = () => { - router.prefetch("/dashboard"); - router.prefetch("/marketplace"); + try { + router.prefetch("/dashboard"); + router.prefetch("/marketplace"); + } catch (err) { + console.warn("Route prefetch failed:", err); + } + fetchAnalytics("30d").catch((err: unknown) => - errorReporter.captureError(err instanceof Error ? err : new Error(String(err)), { - route: "/", - extra: { source: "page.tsx", operation: "fetchAnalytics" }, - }) + errorReporter.captureError( + err instanceof Error ? err : new Error(String(err)), + { + route: "/", + codeOrigin: "page.tsx.handlePrefetch", + extra: { source: "page.tsx", operation: "fetchAnalytics" }, + } + ) ); }; const id = window.setTimeout(handlePrefetch, 250); @@ -80,4 +89,4 @@ export default function Home() {
); -} \ No newline at end of file +} diff --git a/app/frontend/src/app/pay/PaymentPageClient.tsx b/app/frontend/src/app/pay/PaymentPageClient.tsx index 225f93432..9637ea203 100644 --- a/app/frontend/src/app/pay/PaymentPageClient.tsx +++ b/app/frontend/src/app/pay/PaymentPageClient.tsx @@ -251,12 +251,9 @@ function LoadingFallback() { } // Simple analytics tracking (replace with your analytics provider) -function trackAnalyticsEvent(event: string, data: Record) { +function trackAnalyticsEvent(..._args: unknown[]) { + void _args; if (typeof window !== "undefined") { // Replace with your analytics provider (e.g., PostHog, Google Analytics, etc.) - // console.log(`[Analytics] ${event}`, data); - - // Example: window.posthog?.capture(event, data); - // Example: window.gtag?.('event', event, data); } } diff --git a/app/frontend/src/app/settings/page.tsx b/app/frontend/src/app/settings/page.tsx index a7687721e..cc6c33a6f 100644 --- a/app/frontend/src/app/settings/page.tsx +++ b/app/frontend/src/app/settings/page.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import { useApi } from "@/hooks/useApi"; import { getProfile, saveProfile } from "@/lib/api"; import { validateProfile, type Profile, type ProfileValidationErrors } from "@/types/profile"; +import { errorReporter } from "@/lib/errorReporter"; export default function Settings() { const { t } = useTranslation(); @@ -25,23 +26,54 @@ export default function Settings() { const [errors, setErrors] = useState({}); const [successMessage, setSuccessMessage] = useState(null); + const [loadError, setLoadError] = useState(null); const [showPreview, setShowPreview] = useState(false); const { error: apiError, loading, callApi } = useApi(); + const loadData = async () => { + try { + setLoadError(null); + const data = await getProfile("john_doe"); + if (data) { + setForm(data); + } + } catch (err) { + console.error("Failed to load profile settings", err); + const captured = err instanceof Error ? err : new Error(String(err)); + errorReporter.captureError(captured, { + route: "/settings", + codeOrigin: "settings.loadProfile", + extra: { source: "settings/page.tsx", operation: "getProfile" }, + }); + setLoadError(captured.message || "Failed to load profile settings"); + } + }; + useEffect(() => { let active = true; - const loadData = async () => { + const fetchInitial = async () => { try { + setLoadError(null); const data = await getProfile("john_doe"); if (active && data) { setForm(data); } } catch (err) { console.error("Failed to load profile settings", err); + const captured = err instanceof Error ? err : new Error(String(err)); + errorReporter.captureError(captured, { + route: "/settings", + codeOrigin: "settings.loadProfile", + extra: { source: "settings/page.tsx", operation: "getProfile" }, + }); + if (active) { + setLoadError(captured.message || "Failed to load profile settings"); + } } }; - loadData(); + + fetchInitial(); return () => { active = false; }; @@ -65,6 +97,12 @@ export default function Settings() { }, 5000); } catch (err) { console.error("Save profile error", err); + const captured = err instanceof Error ? err : new Error(String(err)); + errorReporter.captureError(captured, { + route: "/settings", + codeOrigin: "settings.handleSave", + extra: { source: "settings/page.tsx", operation: "saveProfile" }, + }); } }; @@ -166,6 +204,22 @@ export default function Settings() {
)} + {loadError && ( +
+
+ ⚠️ + {loadError} +
+ +
+ )} + {apiError && (
⚠️ {apiError} @@ -428,11 +482,17 @@ function ProfilePreview({ discordHandle: string; githubHandle: string; }) { + const [imageError, setImageError] = useState(false); + + useEffect(() => { + setImageError(false); + }, [avatarUrl]); + return (
{/* Avatar */}
- {avatarUrl ? ( + {avatarUrl && !imageError ? ( {username} setImageError(true)} + unoptimized /> ) : (
- {username[0]?.toUpperCase()} + {username[0]?.toUpperCase() ?? "U"}
)}
diff --git a/app/frontend/src/components/ErrorBoundary.tsx b/app/frontend/src/components/ErrorBoundary.tsx index fccdac50f..b60bf1fde 100644 --- a/app/frontend/src/components/ErrorBoundary.tsx +++ b/app/frontend/src/components/ErrorBoundary.tsx @@ -1,15 +1,17 @@ "use client"; -import { Component, type ErrorInfo } from "react"; +import React, { Component, type ErrorInfo, type ReactNode } from "react"; import { errorReporter } from "@/lib/errorReporter"; import { RequestContext, type RequestContextValue } from "@/lib/requestContext"; -type ErrorBoundaryProps = { - children: React.ReactNode; +export type ErrorBoundaryProps = { + children: ReactNode; onOpenReportIssue?: (error: Error, componentStack?: string) => void; + fallback?: ReactNode | ((error: Error, retry: () => void) => ReactNode); + onError?: (error: Error, info: ErrorInfo) => void; }; -type ErrorBoundaryState = { +export type ErrorBoundaryState = { hasError: boolean; error?: Error; componentStack?: string; @@ -31,6 +33,14 @@ export class ErrorBoundary extends Component< }; } + static getDerivedStateFromError(error: Error): Partial { + const capturedError = error instanceof Error ? error : new Error(String(error)); + return { + hasError: true, + error: capturedError, + }; + } + componentDidCatch(error: Error, info: ErrorInfo) { const capturedError = error instanceof Error ? error : new Error(String(error)); @@ -40,11 +50,18 @@ export class ErrorBoundary extends Component< componentStack: info.componentStack ?? undefined, }); + this.props.onError?.(capturedError, info); + errorReporter.captureError(capturedError, { requestId: this.context?.requestId, correlationId: this.context?.correlationId, route: typeof window !== "undefined" ? window.location.pathname : undefined, componentStack: info.componentStack ?? undefined, + codeOrigin: "ErrorBoundary", + extra: { + source: "ErrorBoundary", + componentStack: info.componentStack, + }, }); } @@ -67,10 +84,41 @@ export class ErrorBoundary extends Component< }); }; + handleGoHome = () => { + this.handleRetry(); + if (typeof window !== "undefined") { + window.location.href = "/"; + } + }; + + handleReload = () => { + if (typeof window !== "undefined") { + window.location.reload(); + } + }; + render() { if (this.state.hasError) { + if (typeof this.props.fallback === "function") { + return this.props.fallback( + this.state.error ?? new Error("An unexpected error occurred"), + this.handleRetry + ); + } + + if (this.props.fallback) { + return this.props.fallback; + } + return ( -
+
+
+ ⚠️ +

Something went wrong

@@ -93,17 +141,26 @@ export class ErrorBoundary extends Component< + {this.props.onOpenReportIssue && ( + + )}
); diff --git a/app/frontend/src/components/ErrorReportingShell.tsx b/app/frontend/src/components/ErrorReportingShell.tsx index 541863454..012474c70 100644 --- a/app/frontend/src/components/ErrorReportingShell.tsx +++ b/app/frontend/src/components/ErrorReportingShell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ReportIssueModal } from "@/components/ReportIssueModal"; import { @@ -23,6 +23,64 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) { const [activeError, setActiveError] = useState(null); const [activeSummary, setActiveSummary] = useState(""); + useEffect(() => { + const handleWindowError = (event: ErrorEvent) => { + const error = + event.error instanceof Error + ? event.error + : new Error(event.message || "Uncaught window error"); + + errorReporter.captureError(error, { + requestId, + correlationId, + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: event.filename + ? `${event.filename}:${event.lineno}:${event.colno}` + : "window.onerror", + extra: { + source: "window.onerror", + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }, + }); + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason; + const error = + reason instanceof Error + ? reason + : new Error( + typeof reason === "string" + ? reason + : "Unhandled Promise Rejection" + ); + + errorReporter.captureError(error, { + requestId, + correlationId, + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: "unhandledrejection", + extra: { + source: "window.unhandledrejection", + reason: + typeof reason === "object" && reason !== null + ? JSON.stringify(reason) + : String(reason), + }, + }); + }; + + window.addEventListener("error", handleWindowError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); + + return () => { + window.removeEventListener("error", handleWindowError); + window.removeEventListener("unhandledrejection", handleUnhandledRejection); + }; + }, [requestId, correlationId]); + const openReportModal = (error: Error, componentStack?: string) => { setActiveError(error); setActiveSummary(componentStack ?? error.message); @@ -45,6 +103,7 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) { correlationId, route: typeof window !== "undefined" ? window.location.pathname : undefined, componentStack: activeError.stack, + codeOrigin: "ErrorReportingShell.ReportIssueModal", extra: { userMessage, source: "report-issue-modal", diff --git a/app/frontend/src/components/NotificationCenterProvider.tsx b/app/frontend/src/components/NotificationCenterProvider.tsx index 66d992579..9a24e2aa4 100644 --- a/app/frontend/src/components/NotificationCenterProvider.tsx +++ b/app/frontend/src/components/NotificationCenterProvider.tsx @@ -13,6 +13,7 @@ import { type StoredNotification, } from "@/lib/notifications"; import { usePersistentState } from "@/hooks/usePersistentState"; +import { errorReporter } from "@/lib/errorReporter"; type NotificationCenterContextValue = { notifications: StoredNotification[]; @@ -30,8 +31,17 @@ const NotificationCenterContext = function mergeStoredNotifications( storedNotifications: StoredNotification[], ): StoredNotification[] { + if (!Array.isArray(storedNotifications)) { + return sortNotifications(INITIAL_NOTIFICATIONS); + } + + const validStored = storedNotifications.filter( + (item): item is StoredNotification => + Boolean(item && typeof item === "object" && typeof item.id === "string"), + ); + const storedById = new Map( - storedNotifications.map((notification) => [notification.id, notification]), + validStored.map((notification) => [notification.id, notification]), ); return sortNotifications( @@ -68,54 +78,75 @@ export function NotificationCenterProvider({ return mergeStoredNotifications(parsedValue); } catch (e) { console.error("Unable to parse notifications", e); + const captured = e instanceof Error ? e : new Error(String(e)); + errorReporter.captureError(captured, { + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: "NotificationCenterProvider.deserialize", + extra: { + source: "NotificationCenterProvider", + operation: "deserialize", + }, + }); return sortNotifications(INITIAL_NOTIFICATIONS); } }, } ); + const safeNotifications = useMemo( + () => (Array.isArray(notifications) ? notifications : []), + [notifications], + ); + const unreadCount = useMemo( () => - notifications.filter((notification) => notification.readAt === null) - .length, - [notifications], + safeNotifications.filter( + (notification) => notification && notification.readAt === null, + ).length, + [safeNotifications], ); const value = useMemo( () => ({ - notifications, + notifications: safeNotifications, unreadCount, hasHydrated, markAsRead: (id: string) => { - setNotifications((currentNotifications) => - sortNotifications( - currentNotifications.map((notification) => - notification.id === id && notification.readAt === null + setNotifications((currentNotifications) => { + const list = Array.isArray(currentNotifications) + ? currentNotifications + : INITIAL_NOTIFICATIONS; + return sortNotifications( + list.map((notification) => + notification && notification.id === id && notification.readAt === null ? { ...notification, readAt: new Date().toISOString(), } : notification, ), - ), - ); + ); + }); }, markAllAsRead: () => { - setNotifications((currentNotifications) => - sortNotifications( - currentNotifications.map((notification) => - notification.readAt === null + setNotifications((currentNotifications) => { + const list = Array.isArray(currentNotifications) + ? currentNotifications + : INITIAL_NOTIFICATIONS; + return sortNotifications( + list.map((notification) => + notification && notification.readAt === null ? { ...notification, readAt: new Date().toISOString(), } : notification, ), - ), - ); + ); + }); }, }), - [notifications, unreadCount, hasHydrated, setNotifications], + [safeNotifications, unreadCount, hasHydrated, setNotifications], ); return ( diff --git a/app/frontend/src/components/__tests__/ErrorBoundary.test.tsx b/app/frontend/src/components/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 000000000..8f683846a --- /dev/null +++ b/app/frontend/src/components/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ErrorBoundary } from "../ErrorBoundary"; +import { errorReporter } from "@/lib/errorReporter"; + +function ProblemChild({ shouldThrow = true }: { shouldThrow?: boolean }) { + if (shouldThrow) { + throw new Error("Render explosion"); + } + return
Healthy Child Content
; +} + +describe("ErrorBoundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("renders children when no error occurs", () => { + render( + +
Hello Safe World
+
+ ); + + expect(screen.getByText("Hello Safe World")).toBeInTheDocument(); + }); + + it("catches rendering errors and shows fallback UI with error details", () => { + const captureSpy = vi.spyOn(errorReporter, "captureError"); + + render( + + + + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("An error occurred")).toBeInTheDocument(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByText(/Render explosion/)).toBeInTheDocument(); + + expect(captureSpy).toHaveBeenCalledTimes(1); + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: "Render explosion" }), + expect.objectContaining({ + codeOrigin: "ErrorBoundary", + }) + ); + }); + + it("calls onError callback when provided", () => { + const onError = vi.fn(); + + render( + + + + ); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Render explosion" }), + expect.objectContaining({ componentStack: expect.any(String) }) + ); + }); + + it("supports custom ReactNode fallback", () => { + render( + Custom Static Fallback
}> + + + ); + + expect(screen.getByText("Custom Static Fallback")).toBeInTheDocument(); + }); + + it("supports custom render function fallback with retry", () => { + render( + ( +
+

Custom: {err.message}

+ +
+ )} + > + +
+ ); + + expect(screen.getByText("Custom: Render explosion")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Custom Retry" })).toBeInTheDocument(); + }); + + it("recovers and re-renders children when retry button is clicked", () => { + let shouldThrow = true; + + function ConditionalChild() { + if (shouldThrow) { + throw new Error("Temporary crash"); + } + return
Now Working
; + } + + const { rerender } = render( + + + + ); + + expect(screen.getByText("An error occurred")).toBeInTheDocument(); + + // Fix the underlying issue before retrying + shouldThrow = false; + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + rerender( + + + + ); + + expect(screen.getByText("Now Working")).toBeInTheDocument(); + }); + + it("triggers onOpenReportIssue when Report Issue button is clicked", () => { + const onOpenReportIssue = vi.fn(); + + render( + + + + ); + + const reportButton = screen.getByRole("button", { name: "Report Issue" }); + fireEvent.click(reportButton); + + expect(onOpenReportIssue).toHaveBeenCalledTimes(1); + expect(onOpenReportIssue).toHaveBeenCalledWith( + expect.objectContaining({ message: "Render explosion" }), + expect.any(String) + ); + }); +}); diff --git a/app/frontend/src/components/__tests__/ErrorReportingShell.test.tsx b/app/frontend/src/components/__tests__/ErrorReportingShell.test.tsx new file mode 100644 index 000000000..3ff84066d --- /dev/null +++ b/app/frontend/src/components/__tests__/ErrorReportingShell.test.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ErrorReportingShell } from "../ErrorReportingShell"; +import { errorReporter } from "@/lib/errorReporter"; + +function Bomb(): React.ReactNode { + throw new Error("Shell explosion"); +} + +describe("ErrorReportingShell", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("renders children wrapped with request context and error boundary", () => { + render( + +
Content Inside Shell
+
+ ); + + expect(screen.getByText("Content Inside Shell")).toBeInTheDocument(); + }); + + it("catches rendering errors and opens issue reporting modal on click", async () => { + render( + + + + ); + + expect(screen.getByText("An error occurred")).toBeInTheDocument(); + + const reportButton = screen.getByRole("button", { name: "Report Issue" }); + fireEvent.click(reportButton); + + // Modal should now be open + expect(screen.getByText(/Report an issue/i)).toBeInTheDocument(); + }); + + it("captures unhandled window error events", () => { + const captureSpy = vi.spyOn(errorReporter, "captureError"); + + render( + +
Safe Content
+
+ ); + + const errorEvent = new ErrorEvent("error", { + error: new Error("Async background crash"), + message: "Async background crash", + filename: "background.ts", + lineno: 10, + colno: 5, + }); + + window.dispatchEvent(errorEvent); + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: "Async background crash" }), + expect.objectContaining({ + codeOrigin: "background.ts:10:5", + extra: expect.objectContaining({ source: "window.onerror" }), + }) + ); + }); + + it("captures unhandled promise rejection events", () => { + const captureSpy = vi.spyOn(errorReporter, "captureError"); + + render( + +
Safe Content
+
+ ); + + const rejectionEvent = new CustomEvent("unhandledrejection", { + detail: { reason: new Error("Failed fetch in promise") }, + }) as unknown as PromiseRejectionEvent; + Object.defineProperty(rejectionEvent, "reason", { + value: new Error("Failed fetch in promise"), + }); + + window.dispatchEvent(rejectionEvent); + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: "Failed fetch in promise" }), + expect.objectContaining({ + codeOrigin: "unhandledrejection", + extra: expect.objectContaining({ source: "window.unhandledrejection" }), + }) + ); + }); +}); diff --git a/app/frontend/src/hooks/__tests__/usePersistentState.test.tsx b/app/frontend/src/hooks/__tests__/usePersistentState.test.tsx index 880b6dc4f..c712d19d6 100644 --- a/app/frontend/src/hooks/__tests__/usePersistentState.test.tsx +++ b/app/frontend/src/hooks/__tests__/usePersistentState.test.tsx @@ -77,7 +77,7 @@ describe("usePersistentState", () => { }); it("should handle custom serialization and deserialization", () => { - const serialize = (val: any) => `CUSTOM-${val.count}`; + const serialize = (val: { count: number }) => `CUSTOM-${val.count}`; const deserialize = (str: string) => ({ count: parseInt(str.replace("CUSTOM-", ""), 10) }); const { result } = renderHook(() => diff --git a/app/frontend/src/hooks/analyticsApi.ts b/app/frontend/src/hooks/analyticsApi.ts index 8d42e0525..7c210dca2 100644 --- a/app/frontend/src/hooks/analyticsApi.ts +++ b/app/frontend/src/hooks/analyticsApi.ts @@ -1,5 +1,6 @@ import i18n from "i18next"; import { getRustAcademyApiBase } from "@/lib/api"; +import { errorReporter } from "@/lib/errorReporter"; export type DateRange = "24h" | "7d" | "30d" | "all"; @@ -220,6 +221,16 @@ export async function fetchAnalytics(range: DateRange): Promise { analyticsCache[range] = parsed; return parsed; } catch (error) { + const captured = error instanceof Error ? error : new Error(String(error)); + errorReporter.captureError(captured, { + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: "analyticsApi.fetchAnalytics", + extra: { + source: "analyticsApi", + operation: "fetchAnalytics", + range, + }, + }); console.warn("Falling back to empty analytics data:", error); const empty = fallbackEmpty(); analyticsCache[range] = empty; @@ -242,23 +253,39 @@ export async function exportAnalyticsReport( url.searchParams.set("format", format); url.searchParams.set("reportType", reportType); - const res = await fetch(url.toString(), { method: "GET" }); - if (!res.ok) { - throw new Error(`Export request failed with status ${res.status}`); - } + try { + const res = await fetch(url.toString(), { method: "GET" }); + if (!res.ok) { + throw new Error(`Export request failed with status ${res.status}`); + } - const blob = await res.blob(); - const disposition = res.headers.get("Content-Disposition"); - const fallbackName = ` RustAcademy-analytics-report.${format}`; - const fileName = parseFilename(disposition) ?? fallbackName; - const objectUrl = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = objectUrl; - link.download = fileName; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(objectUrl); + const blob = await res.blob(); + const disposition = res.headers.get("Content-Disposition"); + const fallbackName = ` RustAcademy-analytics-report.${format}`; + const fileName = parseFilename(disposition) ?? fallbackName; + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(objectUrl); + } catch (error) { + const captured = error instanceof Error ? error : new Error(String(error)); + errorReporter.captureError(captured, { + route: typeof window !== "undefined" ? window.location.pathname : undefined, + codeOrigin: "analyticsApi.exportAnalyticsReport", + extra: { + source: "analyticsApi", + operation: "exportAnalyticsReport", + range, + format, + reportType, + }, + }); + throw error; + } } function parseFilename(disposition: string | null): string | null { diff --git a/app/frontend/src/lib/errorReporter.ts b/app/frontend/src/lib/errorReporter.ts index 80a838638..9bce863a9 100644 --- a/app/frontend/src/lib/errorReporter.ts +++ b/app/frontend/src/lib/errorReporter.ts @@ -3,30 +3,80 @@ export type ErrorContext = { correlationId?: string; userId?: string; route?: string; + codeOrigin?: string; componentStack?: string; extra?: Record; }; +export type ErrorPayload = { + timestamp: string; + error: { + name?: string; + message: string; + stack?: string; + }; + context: ErrorContext; + appVersion: string; + environment: string; + codeOrigin?: string; + userAgent?: string; +}; + const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; const PHONE_RE = /(\+?[\d\s\-()]{10,})/g; const CARD_RE = /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g; +const STELLAR_SECRET_KEY_RE = /\bS[A-Z0-9]{55}\b/g; +const BEARER_TOKEN_RE = /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi; +const JWT_RE = /\beyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/g; +const API_KEY_RE = /\b(api[_-]?key\s*[:=]\s*)[A-Za-z0-9\-._~+/]+/gi; +const PASSWORD_RE = /\b(password\s*[:=]\s*)[^\s"',}]+/gi; +const SECRET_RE = /\b(secret\s*[:=]\s*)[^\s"',}]+/gi; + +const SENSITIVE_KEY_PATTERN = /^(password|secret|token|apiKey|api_key|api-key|privateKey|private_key|secretKey|secret_key|auth|authorization)$/i; + +export function extractCodeOrigin(stack?: string): string | undefined { + if (!stack) return undefined; + const lines = stack.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("at ") && !trimmed.includes("errorReporter")) { + return trimmed; + } + } + return undefined; +} export function redactPII(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + if (typeof value === "string") { - // Cards before phones: PHONE_RE also matches 16-digit card numbers. + // Redact cards before phones since phone regex may match card digits return value .replace(EMAIL_RE, "[REDACTED_EMAIL]") .replace(CARD_RE, "[REDACTED_CARD]") - .replace(PHONE_RE, "[REDACTED_PHONE]"); + .replace(PHONE_RE, "[REDACTED_PHONE]") + .replace(STELLAR_SECRET_KEY_RE, "[REDACTED_SECRET_KEY]") + .replace(BEARER_TOKEN_RE, "Bearer [REDACTED_TOKEN]") + .replace(JWT_RE, "[REDACTED_JWT]") + .replace(API_KEY_RE, "$1[REDACTED_API_KEY]") + .replace(PASSWORD_RE, "$1[REDACTED_PASSWORD]") + .replace(SECRET_RE, "$1[REDACTED_SECRET]"); } if (Array.isArray(value)) { return value.map(redactPII); } - if (value && typeof value === "object") { + if (typeof value === "object") { return Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, redactPII(child)]) + Object.entries(value).map(([key, child]) => { + if (SENSITIVE_KEY_PATTERN.test(key)) { + return [key, "[REDACTED]"]; + } + return [key, redactPII(child)]; + }) ); } @@ -38,15 +88,35 @@ class ErrorReporter { const enabled = process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED === "true"; const environment = process.env.NEXT_PUBLIC_VERCEL_ENV || process.env.NODE_ENV || "unknown"; const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "unknown"; - const errorPayload = { + + const route = + context?.route ?? + (typeof window !== "undefined" ? window.location.pathname : undefined); + + const codeOrigin = + context?.codeOrigin ?? + (typeof context?.extra?.source === "string" ? context.extra.source : undefined) ?? + (typeof context?.extra?.component === "string" ? context.extra.component : undefined) ?? + extractCodeOrigin(error.stack); + + const fullContext: ErrorContext = { + ...context, + route, + codeOrigin, + }; + + const errorPayload: ErrorPayload = { timestamp: new Date().toISOString(), error: redactPII({ - message: error.message, + name: error.name || "Error", + message: error.message || String(error), stack: error.stack, - }), - context: redactPII(context ?? {}), + }) as { name?: string; message: string; stack?: string }, + context: redactPII(fullContext) as ErrorContext, appVersion, environment, + codeOrigin: redactPII(codeOrigin) as string | undefined, + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, }; if (!enabled || environment === "development") {