diff --git a/apps/backend-services/src/logging/client-error.controller.spec.ts b/apps/backend-services/src/logging/client-error.controller.spec.ts new file mode 100644 index 000000000..809bbbec8 --- /dev/null +++ b/apps/backend-services/src/logging/client-error.controller.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AppLoggerService } from "./app-logger.service"; +import { ClientErrorController } from "./client-error.controller"; +import { ClientErrorDto } from "./dto/client-error.dto"; + +describe("ClientErrorController", () => { + let controller: ClientErrorController; + let logger: AppLoggerService; + + const mockLogger = { + error: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClientErrorController], + providers: [ + { + provide: AppLoggerService, + useValue: mockLogger, + }, + ], + }).compile(); + + controller = module.get(ClientErrorController); + logger = module.get(AppLoggerService); + }); + + describe("reportClientError", () => { + it("should log the error message and return received: true", () => { + const dto: ClientErrorDto = { message: "Something broke" }; + + const result = controller.reportClientError(dto); + + expect(result).toEqual({ received: true }); + expect(logger.error).toHaveBeenCalledWith( + "Client-side error reported", + expect.objectContaining({ errorMessage: "Something broke" }), + ); + }); + + it("should include all optional fields in log context when provided", () => { + const dto: ClientErrorDto = { + message: "Render error", + componentStack: " at MyComponent\n at App", + errorStack: "Error: Render error\n at MyComponent (index.js:10:5)", + url: "https://example.com/queue", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }; + + controller.reportClientError(dto); + + expect(logger.error).toHaveBeenCalledWith( + "Client-side error reported", + expect.objectContaining({ + errorMessage: "Render error", + componentStack: dto.componentStack, + errorStack: dto.errorStack, + url: dto.url, + userAgent: dto.userAgent, + }), + ); + }); + + it("should omit optional fields from log context when not provided", () => { + const dto: ClientErrorDto = { message: "Error without extras" }; + + controller.reportClientError(dto); + + expect(logger.error).toHaveBeenCalledWith( + "Client-side error reported", + expect.not.objectContaining({ + componentStack: expect.anything(), + errorStack: expect.anything(), + url: expect.anything(), + userAgent: expect.anything(), + }), + ); + }); + }); +}); diff --git a/apps/backend-services/src/logging/client-error.controller.ts b/apps/backend-services/src/logging/client-error.controller.ts new file mode 100644 index 000000000..347fdc195 --- /dev/null +++ b/apps/backend-services/src/logging/client-error.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common"; +import { + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { Identity } from "@/auth/identity.decorator"; +import { AppLoggerService } from "./app-logger.service"; +import { ClientErrorDto, ClientErrorResponseDto } from "./dto/client-error.dto"; + +/** + * Receives client-side errors reported by the frontend ErrorBoundary and + * writes them to the structured application log so they appear in Loki. + */ +@ApiTags("Logging") +@Controller("api/client-errors") +export class ClientErrorController { + constructor(private readonly logger: AppLoggerService) {} + + /** + * Report a client-side error caught by the frontend ErrorBoundary. + */ + @Post() + @HttpCode(HttpStatus.OK) + @Identity() + @ApiOperation({ + summary: "Report a client-side error", + description: + "Accepts an error caught by the React ErrorBoundary and logs it server-side so it is captured by the structured logging pipeline.", + }) + @ApiOkResponse({ + description: "Error received and logged", + type: ClientErrorResponseDto, + }) + @ApiUnauthorizedResponse({ description: "Not authenticated" }) + reportClientError(@Body() dto: ClientErrorDto): ClientErrorResponseDto { + this.logger.error("Client-side error reported", { + errorMessage: dto.message, + ...(dto.componentStack && { componentStack: dto.componentStack }), + ...(dto.errorStack && { errorStack: dto.errorStack }), + ...(dto.url && { url: dto.url }), + ...(dto.userAgent && { userAgent: dto.userAgent }), + }); + + return { received: true }; + } +} diff --git a/apps/backend-services/src/logging/dto/client-error.dto.ts b/apps/backend-services/src/logging/dto/client-error.dto.ts new file mode 100644 index 000000000..a77f0f568 --- /dev/null +++ b/apps/backend-services/src/logging/dto/client-error.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; + +/** + * DTO representing a client-side error reported by the frontend ErrorBoundary. + */ +export class ClientErrorDto { + @ApiProperty({ description: "Error message from the thrown Error object" }) + @IsString() + message!: string; + + @ApiPropertyOptional({ + description: "React component stack trace from ErrorInfo", + }) + @IsOptional() + @IsString() + componentStack?: string; + + @ApiPropertyOptional({ + description: "JavaScript error stack trace from the Error object", + }) + @IsOptional() + @IsString() + errorStack?: string; + + @ApiPropertyOptional({ + description: "Browser URL where the error occurred (window.location.href)", + }) + @IsOptional() + @IsString() + url?: string; + + @ApiPropertyOptional({ + description: "Browser user agent string (navigator.userAgent)", + }) + @IsOptional() + @IsString() + userAgent?: string; +} + +/** + * Response DTO returned after a client error is accepted. + */ +export class ClientErrorResponseDto { + @ApiProperty({ description: "Indicates the error was received successfully" }) + received!: boolean; +} diff --git a/apps/backend-services/src/logging/logging.module.ts b/apps/backend-services/src/logging/logging.module.ts index 226dbe355..db3b02c9a 100644 --- a/apps/backend-services/src/logging/logging.module.ts +++ b/apps/backend-services/src/logging/logging.module.ts @@ -2,12 +2,14 @@ import { Global, Module, type NestModule } from "@nestjs/common"; import { APP_INTERCEPTOR } from "@nestjs/core"; import { MetricsModule } from "@/metrics/metrics.module"; import { AppLoggerService } from "./app-logger.service"; +import { ClientErrorController } from "./client-error.controller"; import { LoggingMiddleware } from "./logging.middleware"; import { RequestLoggingInterceptor } from "./request-logging.interceptor"; @Global() @Module({ imports: [MetricsModule], + controllers: [ClientErrorController], providers: [ AppLoggerService, LoggingMiddleware, diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 9d720ee2d..fe1f292a8 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { MembershipPageGuard, NoGroupGuard } from "./auth/NoGroupGuard"; import { useAuth } from "./auth/useAuth"; import { Stack, Text, Title } from "./ui"; import "./App.css"; -import { Login } from "./components"; +import { Login, RouterErrorPage } from "./components"; import { ReviewQueuePage } from "./features/annotation/hitl/pages/ReviewQueuePage"; import { ReviewWorkspacePage } from "./features/annotation/hitl/pages/ReviewWorkspacePage"; import { LabelingWorkspacePage } from "./features/annotation/template-models/pages/LabelingWorkspacePage"; @@ -49,6 +49,7 @@ const router = createBrowserRouter([ ), + errorElement: , children: [ { index: true, element: }, { path: "documents", element: }, diff --git a/apps/frontend/src/components/ErrorBoundary.spec.tsx b/apps/frontend/src/components/ErrorBoundary.spec.tsx new file mode 100644 index 000000000..758c4701e --- /dev/null +++ b/apps/frontend/src/components/ErrorBoundary.spec.tsx @@ -0,0 +1,165 @@ +import { MantineProvider } from "@mantine/core"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiService } from "../data/services/api.service"; +import { ErrorBoundary } from "./ErrorBoundary"; + +vi.mock("../data/services/api.service", () => ({ + apiService: { + post: vi.fn().mockResolvedValue({ success: true, data: null }), + }, +})); + +const mockPost = vi.mocked(apiService.post); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +/** + * Creates an isolated ThrowingChild component with its own closure-scoped + * `shouldThrow` flag. Each test gets a fresh instance so there is no + * shared mutable state between tests, making order-independent execution safe. + */ +const createThrowingChild = () => { + let shouldThrow = false; + const ThrowingChild = () => { + if (shouldThrow) throw new Error("Test render error"); + return
Safe content
; + }; + return { + ThrowingChild, + setThrow: (value: boolean) => { + shouldThrow = value; + }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Suppress React's console.error output for intentional error boundary tests + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +describe("ErrorBoundary", () => { + it("renders children when no error is thrown", () => { + const { ThrowingChild } = createThrowingChild(); + + render( + + + + + , + ); + + expect(screen.getByText("Safe content")).toBeInTheDocument(); + }); + + it("shows the fallback UI when a child throws", () => { + const { ThrowingChild, setThrow } = createThrowingChild(); + setThrow(true); + + render( + + + + + , + ); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Try again" }), + ).toBeInTheDocument(); + }); + + it("reports the error to the backend on catch", () => { + const { ThrowingChild, setThrow } = createThrowingChild(); + setThrow(true); + + render( + + + + + , + ); + + expect(mockPost).toHaveBeenCalledWith( + "client-errors", + expect.objectContaining({ message: "Test render error" }), + ); + }); + + it("resets and shows children again when Try again is clicked and error is resolved", () => { + const { ThrowingChild, setThrow } = createThrowingChild(); + setThrow(true); + + render( + + + + + , + ); + + setThrow(false); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(screen.getByText("Safe content")).toBeInTheDocument(); + }); + + it("shows Go to home page button on the final retry attempt", () => { + const { ThrowingChild, setThrow } = createThrowingChild(); + setThrow(true); + + render( + + + + + , + ); + + // Click through until the last attempt (MAX_RETRIES - 1 = 2 retries, + // error persists so the boundary re-catches each time) + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect( + screen.getByRole("button", { name: "Go to home page" }), + ).toBeInTheDocument(); + }); + + it("redirects to home when Go to home page is clicked", () => { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + + const { ThrowingChild, setThrow } = createThrowingChild(); + setThrow(true); + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + fireEvent.click(screen.getByRole("button", { name: "Go to home page" })); + + expect(window.location.href).toBe("/"); + + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + }); + }); +}); diff --git a/apps/frontend/src/components/ErrorBoundary.tsx b/apps/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..a51b86f3f --- /dev/null +++ b/apps/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,97 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { apiService } from "../data/services/api.service"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + retryCount: number; +} + +const MAX_RETRIES = 3; + +/** + * React error boundary that catches unhandled errors thrown by providers above + * the router (AuthProvider, GroupProvider, QueryClientProvider) and by App + * itself. It does NOT catch errors inside route components — those are handled + * by RouterErrorPage via React Router's errorElement mechanism. + * + * Reports caught errors to the backend logging endpoint and displays a + * user-friendly fallback UI. Redirects to the home page after exceeding the + * retry limit. + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, retryCount: 0 }; + } + + static getDerivedStateFromError(): Partial { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + void apiService.post("client-errors", { + message: error.message, + componentStack: info.componentStack ?? undefined, + errorStack: error.stack ?? undefined, + url: window.location.href, + userAgent: navigator.userAgent, + }); + } + + handleReset = (): void => { + const nextCount = this.state.retryCount + 1; + if (nextCount >= MAX_RETRIES) { + window.location.href = "/"; + return; + } + this.setState({ hasError: false, retryCount: nextCount }); + }; + + render(): ReactNode { + if (this.state.hasError) { + const attemptsLeft = MAX_RETRIES - this.state.retryCount; + return ( +
+
+

Something went wrong

+

+ An unexpected error occurred. Please try again or contact support + if the problem persists. +

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/frontend/src/components/HelloWorld.tsx b/apps/frontend/src/components/HelloWorld.tsx deleted file mode 100644 index a38cbd520..000000000 --- a/apps/frontend/src/components/HelloWorld.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; -import { Stack, Text, Title } from "../ui"; - -interface HelloWorldProps { - name?: string; -} - -export const HelloWorld: React.FC = ({ name = "World" }) => { - return ( - - Hello, {name}! - - Welcome to the AI OCR Frontend application. - - - ); -}; diff --git a/apps/frontend/src/components/RouterErrorPage.spec.tsx b/apps/frontend/src/components/RouterErrorPage.spec.tsx new file mode 100644 index 000000000..5f81ed86a --- /dev/null +++ b/apps/frontend/src/components/RouterErrorPage.spec.tsx @@ -0,0 +1,87 @@ +import { MantineProvider } from "@mantine/core"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiService } from "../data/services/api.service"; +import { RouterErrorPage } from "./RouterErrorPage"; + +vi.mock("../data/services/api.service", () => ({ + apiService: { + post: vi.fn().mockResolvedValue({ success: true, data: null }), + }, +})); + +const mockNavigate = vi.fn(); + +vi.mock("react-router-dom", () => ({ + useRouteError: vi.fn(), + useNavigate: () => mockNavigate, +})); + +import { useRouteError } from "react-router-dom"; + +const mockUseRouteError = vi.mocked(useRouteError); +const mockPost = vi.mocked(apiService.post); + +const renderPage = () => + render( + + + , + ); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("RouterErrorPage", () => { + it("renders the error fallback UI", () => { + mockUseRouteError.mockReturnValue(new Error("Route crashed")); + + renderPage(); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Go to home page" }), + ).toBeInTheDocument(); + }); + + it("reports an Error instance to the backend", async () => { + const error = new Error("Route crashed"); + mockUseRouteError.mockReturnValue(error); + + renderPage(); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + "client-errors", + expect.objectContaining({ + message: "Route crashed", + errorStack: error.stack, + }), + ); + }); + }); + + it("reports a non-Error thrown value to the backend", async () => { + mockUseRouteError.mockReturnValue("404 Not Found"); + + renderPage(); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + "client-errors", + expect.objectContaining({ message: "404 Not Found" }), + ); + }); + }); + + it("navigates to / when Go to home page is clicked", () => { + mockUseRouteError.mockReturnValue(new Error("Route crashed")); + + renderPage(); + + fireEvent.click(screen.getByRole("button", { name: "Go to home page" })); + + expect(mockNavigate).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/frontend/src/components/RouterErrorPage.tsx b/apps/frontend/src/components/RouterErrorPage.tsx new file mode 100644 index 000000000..47485d73d --- /dev/null +++ b/apps/frontend/src/components/RouterErrorPage.tsx @@ -0,0 +1,48 @@ +import { Button, Center, Stack, Text, Title } from "@mantine/core"; +import { useEffect } from "react"; +import { useNavigate, useRouteError } from "react-router-dom"; +import { apiService } from "../data/services/api.service"; + +/** + * React Router error page rendered via the `errorElement` prop on route + * definitions. Catches errors thrown during route component rendering — + * i.e. anything inside RootLayout and its children. It does NOT catch errors + * in providers above the router (AuthProvider, GroupProvider, + * QueryClientProvider); those are handled by the React ErrorBoundary in + * main.tsx. + * + * Reports the error to the backend logging endpoint and navigates the user + * back to the home page. + */ +export const RouterErrorPage = () => { + const error = useRouteError(); + const navigate = useNavigate(); + + const message = error instanceof Error ? error.message : String(error); + const errorStack = + error instanceof Error ? (error.stack ?? undefined) : undefined; + + useEffect(() => { + void apiService.post("client-errors", { + message, + errorStack, + url: window.location.href, + userAgent: navigator.userAgent, + }); + }, [message, errorStack]); + + return ( +
+ + Something went wrong + + An unexpected error occurred. Please try again or contact support if + the problem persists. + + + +
+ ); +}; diff --git a/apps/frontend/src/components/index.ts b/apps/frontend/src/components/index.ts index 1978a7291..e6e218fb9 100644 --- a/apps/frontend/src/components/index.ts +++ b/apps/frontend/src/components/index.ts @@ -1,5 +1,6 @@ // Component exports export { DocumentsList } from "./DocumentsList"; -export { HelloWorld } from "./HelloWorld"; +export { ErrorBoundary } from "./ErrorBoundary"; export { Login } from "./Login"; +export { RouterErrorPage } from "./RouterErrorPage"; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 4a78b3d2c..e53253e19 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -21,18 +21,21 @@ import "@mantine/notifications/styles.css"; import "./ui/bcds-mantine-fallbacks.css"; import "./ui/bcds-upload-panel.css"; import App from "./App"; +import { ErrorBoundary } from "./components"; createRoot(document.getElementById("root")!).render( - - - - - - - - - - + + + + + + + + + + + + , );