Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Security: VAPID key support via environment variables
- Part of PWA Phase 3 (Epic #64, Sub-Issue #166)

- **Offline Analytics & Telemetry** (#167, Part of #144 PWA Phase 3)
- Web Vitals integration for performance monitoring:
- CLS (Cumulative Layout Shift) - Visual stability tracking
- INP (Interaction to Next Paint) - Responsiveness measurement (Web Vitals v4)
- LCP (Largest Contentful Paint) - Loading performance
- FCP (First Contentful Paint) - Perceived load speed
- TTFB (Time to First Byte) - Server response time
- Automatic metric collection via `initWebVitals()` in main.tsx
- Metrics tracked to analytics IndexedDB for offline queuing
- Error tracking with React Error Boundary:
- `AnalyticsErrorBoundary` component - Automatic error capture
- Graceful fallback UI with refresh option
- Development-mode error details display
- Analytics integration for error reporting
- Dependencies: `web-vitals@5.1.0` for Core Web Vitals metrics
- 13 comprehensive tests for new features (100% coverage)
- Privacy-first design: No PII, no file paths, anonymous session IDs
- Part of PWA Phase 3 (Epic #144, Sub-Issue #167)

- **IndexedDB File Queue for Offline Uploads** (#142)
- Replaced sessionStorage with IndexedDB for persistent file storage
- Files now survive browser close and offline conditions
Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"motion": "^12.23.24",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6"
"react-router-dom": "^7.9.6",
"web-vitals": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
Expand Down
159 changes: 159 additions & 0 deletions src/components/AnalyticsErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: 2025 SecPal
// SPDX-License-Identifier: AGPL-3.0-or-later

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { AnalyticsErrorBoundary } from "./AnalyticsErrorBoundary";
import { analytics } from "../lib/analytics";

// Mock analytics
vi.mock("../lib/analytics", () => ({
analytics: {
trackError: vi.fn(),
},
}));

// Component that throws an error
const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error("Test error");
}
return <div>No error</div>;
};

describe("AnalyticsErrorBoundary", () => {
beforeEach(() => {
vi.clearAllMocks();
// Suppress console.error in tests
vi.spyOn(console, "error").mockImplementation(() => {});
});

describe("normal rendering", () => {
it("should render children when no error occurs", () => {
render(
<AnalyticsErrorBoundary>
<div>Test content</div>
</AnalyticsErrorBoundary>
);

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

describe("error handling", () => {
it("should catch errors and show fallback UI", () => {
render(
<AnalyticsErrorBoundary>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(
screen.getByText(/we encountered an unexpected error/i)
).toBeInTheDocument();
});

it("should track error in analytics", () => {
render(
<AnalyticsErrorBoundary>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(analytics!.trackError).toHaveBeenCalledWith(
expect.objectContaining({
message: "Test error",
}),
expect.objectContaining({
errorBoundary: true,
}),
false // includeStack parameter
);
});

it("should call onError callback when provided", () => {
const onError = vi.fn();

render(
<AnalyticsErrorBoundary onError={onError}>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: "Test error" }),
expect.objectContaining({ componentStack: expect.any(String) })
);
});

it("should show custom fallback when provided", () => {
const customFallback = <div>Custom error message</div>;

render(
<AnalyticsErrorBoundary fallback={customFallback}>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(screen.getByText("Custom error message")).toBeInTheDocument();
expect(
screen.queryByText("Something went wrong")
).not.toBeInTheDocument();
});

it("should show refresh button", () => {
render(
<AnalyticsErrorBoundary>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(
screen.getByRole("button", { name: /refresh page/i })
).toBeInTheDocument();
});
});

describe("error recovery", () => {
it("should recover when error is fixed", () => {
const { rerender } = render(
<AnalyticsErrorBoundary>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(screen.getByText("Something went wrong")).toBeInTheDocument();

// Rerender with no error
rerender(
<AnalyticsErrorBoundary>
<ThrowError shouldThrow={false} />
</AnalyticsErrorBoundary>
);

// Error boundary keeps showing error UI until component is remounted
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
});

describe("analytics integration", () => {
it("should not crash if analytics is not available", () => {
// Temporarily remove analytics
const originalAnalytics = analytics;
(globalThis as unknown as { analytics: unknown }).analytics = null;

render(
<AnalyticsErrorBoundary>
<ThrowError shouldThrow={true} />
</AnalyticsErrorBoundary>
);

expect(screen.getByText("Something went wrong")).toBeInTheDocument();

// Restore analytics
(globalThis as unknown as { analytics: unknown }).analytics =
originalAnalytics;
});
});
});
120 changes: 120 additions & 0 deletions src/components/AnalyticsErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: 2025 SecPal
// SPDX-License-Identifier: AGPL-3.0-or-later

import { Component, type ReactNode, type ErrorInfo } from "react";
import { analytics } from "../lib/analytics";

interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}

interface State {
hasError: boolean;
error: Error | null;
}

/**
* Error boundary that tracks errors via analytics
* Catches React rendering errors and reports them for monitoring
*
* Usage:
* ```tsx
* <AnalyticsErrorBoundary>
* <YourComponent />
* </AnalyticsErrorBoundary>
* ```
*/
export class AnalyticsErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Track error in analytics (privacy-first: no file paths/stack traces)
if (analytics) {
analytics.trackError(
error,
{
errorBoundary: true,
},
false // Explicitly disable stack traces (privacy)
);
}

// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo);
}

// Log to console in development
if (import.meta.env.DEV) {
console.error("Error caught by boundary:", error, errorInfo);
}
}

render(): ReactNode {
if (this.state.hasError) {
// Show custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}

// Default error UI
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
<div className="flex items-center gap-3 mb-4">
<svg
className="h-6 w-6 text-red-500"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Something went wrong
</h2>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
We encountered an unexpected error. Please try refreshing the
page.
</p>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-xs">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 mb-2">
Error Details (Development Only)
</summary>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded overflow-x-auto text-red-600 dark:text-red-400">
{this.state.error.message}
{"\n\n"}
{this.state.error.stack}
</pre>
</details>
)}
<button
onClick={() => window.location.reload()}
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Refresh Page
</button>
</div>
</div>
);
}

return this.props.children;
}
}
Loading