Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>(ClientErrorController);
logger = module.get<AppLoggerService>(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(),
}),
);
});
});
});
48 changes: 48 additions & 0 deletions apps/backend-services/src/logging/client-error.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
47 changes: 47 additions & 0 deletions apps/backend-services/src/logging/dto/client-error.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions apps/backend-services/src/logging/logging.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { MembershipPageGuard, NoGroupGuard } from "./auth/NoGroupGuard";
import { useAuth } from "./auth/useAuth";
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";
Expand Down Expand Up @@ -49,6 +49,7 @@ const router = createBrowserRouter([
<RootLayout />
</NoGroupGuard>
),
errorElement: <RouterErrorPage />,
children: [
{ index: true, element: <UploadPage /> },
{ path: "queue", element: <QueuePage /> },
Expand Down
165 changes: 165 additions & 0 deletions apps/frontend/src/components/ErrorBoundary.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<MantineProvider>{children}</MantineProvider>
);

/**
* 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 <div>Safe content</div>;
};
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(
<Wrapper>
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
</Wrapper>,
);

expect(screen.getByText("Safe content")).toBeInTheDocument();
});

it("shows the fallback UI when a child throws", () => {
const { ThrowingChild, setThrow } = createThrowingChild();
setThrow(true);

render(
<Wrapper>
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
</Wrapper>,
);

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(
<Wrapper>
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
</Wrapper>,
);

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(
<Wrapper>
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
</Wrapper>,
);

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(
<Wrapper>
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
</Wrapper>,
);

// 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(
<Wrapper>
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
</Wrapper>,
);

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,
});
});
});
Loading
Loading