Merge branch 'master' into feature/analytics_uptime_dashboard
# Conflicts: # .env.example # aidlc-docs/features/react-frontend/aidlc-state.md # aidlc-docs/features/react-frontend/audit.md # aidlc-docs/features/react-frontend/operations/monitoring/monitoring-plan.md # aidlc-docs/features/react-frontend/operations/monitoring/monitoring-setup.md # aidlc-docs/features/react-frontend/operations/production-readiness-checklist.md # src/components/RootLayout.tsx # src/vite-env.d.ts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
@@ -22,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('Unexpected application error', error, info);
|
||||
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { Nav } from './Nav';
|
||||
import { Footer } from './Footer';
|
||||
import { UmamiAnalytics } from './UmamiAnalytics';
|
||||
import { SentryTestButton } from './SentryTestButton';
|
||||
|
||||
export function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -10,6 +11,7 @@ export function RootLayout({ children }: { children: ReactNode }) {
|
||||
<Nav />
|
||||
{children}
|
||||
<Footer />
|
||||
<SentryTestButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
/**
|
||||
* Alleen zichtbaar tijdens lokale development (`pnpm dev`) en in de testomgeving
|
||||
* (waar de build met `VITE_APP_ENV=test` wordt gebouwd, zie continuous_integration.yaml).
|
||||
* In een productiebuild (VITE_APP_ENV ontbreekt of is 'production') is deze knop
|
||||
* altijd verborgen, ook als iemand vergeet de variabele expliciet te zetten.
|
||||
*/
|
||||
const isVisible = import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'test';
|
||||
|
||||
/** Hoe lang de bevestigingsmelding zichtbaar blijft voordat hij vanzelf verdwijnt. */
|
||||
const TOAST_DURATION_MS = 4000;
|
||||
|
||||
/**
|
||||
* Losstaand van het component zodat dit direct (zonder DOM/React event-dispatch)
|
||||
* getest kan worden. React vangt fouten uit event handlers namelijk NIET op via
|
||||
* een ErrorBoundary (die vangt alleen render-/lifecycle-fouten). De fout hieronder
|
||||
* blijft dus bewust een onafgevangen (uncaught) fout, die door Sentry's eigen
|
||||
* globale `window.onerror`-handler wordt opgepikt — precies zoals bedoeld voor
|
||||
* deze test-knop.
|
||||
*/
|
||||
export function handleSentryTestErrorClick(): void {
|
||||
// Send a log before throwing the error
|
||||
Sentry.logger.info('User triggered test error', {
|
||||
action: 'test_error_button_click',
|
||||
});
|
||||
// Send a test metric before throwing the error
|
||||
Sentry.metrics.count('test_counter', 1);
|
||||
throw new Error('This is your first error!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tijdelijke testknop om te verifiëren dat Sentry errors, logs en metrics
|
||||
* daadwerkelijk binnenkomen.
|
||||
*/
|
||||
export function SentryTestButton() {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
// Verberg de melding automatisch na een paar seconden.
|
||||
useEffect(() => {
|
||||
if (!showToast) return;
|
||||
const hideTimer = setTimeout(() => setShowToast(false), TOAST_DURATION_MS);
|
||||
return () => clearTimeout(hideTimer);
|
||||
}, [showToast]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
setShowToast(true);
|
||||
// De daadwerkelijke fout (met log + metric) pas na deze tick versturen,
|
||||
// zodat de melding hierboven eerst kan renderen/schilderen voordat de
|
||||
// onafgevangen fout verderop wordt gegooid.
|
||||
setTimeout(handleSentryTestErrorClick, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToast && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-20 right-4 z-50 rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-lg"
|
||||
>
|
||||
Test error verzonden naar Sentry ✅
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="fixed bottom-4 right-4 z-50 rounded bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-red-700"
|
||||
>
|
||||
Break the world
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
|
||||
vi.mock('@sentry/react', () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
function ThrowingChild(): never {
|
||||
throw new Error('boom');
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children when there is no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<p>Alles werkt</p>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('Alles werkt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the generic fallback message when a child throws', () => {
|
||||
// Suppress the expected React error boundary console.error noise for this test.
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Er ging iets mis. Probeer de pagina te vernieuwen.')).toBeInTheDocument();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reports the caught error to Sentry', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ componentStack: expect.any(String) }) }),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { SentryTestButton, handleSentryTestErrorClick } from '../SentryTestButton';
|
||||
|
||||
vi.mock('@sentry/react', () => ({
|
||||
captureException: vi.fn(),
|
||||
logger: { info: vi.fn() },
|
||||
metrics: { count: vi.fn() },
|
||||
}));
|
||||
|
||||
/**
|
||||
* De echte fout wordt bewust ongevangen via een `setTimeout` gegooid (zie
|
||||
* SentryTestButton.tsx). Om dat in deze fake-timer-tests te simuleren zonder
|
||||
* dat de test zelf crasht op die intentionele throw, wordt het "doortikken"
|
||||
* van de timers hierin afgevangen.
|
||||
*/
|
||||
function advanceTimersIgnoringIntentionalThrow(ms: number) {
|
||||
try {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
} catch {
|
||||
// verwacht: de intentionele test-fout uit handleSentryTestErrorClick
|
||||
}
|
||||
}
|
||||
|
||||
describe('SentryTestButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('is visible in the current (development/test) build', () => {
|
||||
render(<SentryTestButton />);
|
||||
expect(screen.getByRole('button', { name: 'Break the world' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a log and a metric to Sentry, then throws an intentional test error', () => {
|
||||
expect(() => handleSentryTestErrorClick()).toThrow('This is your first error!');
|
||||
|
||||
expect(Sentry.logger.info).toHaveBeenCalledWith('User triggered test error', {
|
||||
action: 'test_error_button_click',
|
||||
});
|
||||
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
|
||||
});
|
||||
|
||||
it('shows a confirmation toast immediately when clicked, before the test error fires', () => {
|
||||
render(<SentryTestButton />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Break the world' }));
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Test error verzonden naar Sentry');
|
||||
// De log/metric/throw zijn pas gepland (setTimeout), nog niet uitgevoerd.
|
||||
expect(Sentry.logger.info).not.toHaveBeenCalled();
|
||||
|
||||
advanceTimersIgnoringIntentionalThrow(0);
|
||||
|
||||
expect(Sentry.logger.info).toHaveBeenCalledWith('User triggered test error', {
|
||||
action: 'test_error_button_click',
|
||||
});
|
||||
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
|
||||
});
|
||||
|
||||
it('hides the toast again after it has been shown for a while', () => {
|
||||
render(<SentryTestButton />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Break the world' }));
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
// De intentionele fout (0ms-timer) stopt het "doortikken" van timers
|
||||
// binnen dezelfde advanceTimersByTime-call, dus eerst die apart afhandelen
|
||||
// voordat de resterende hide-timer (4000ms) doorgetikt kan worden.
|
||||
advanceTimersIgnoringIntentionalThrow(0);
|
||||
advanceTimersIgnoringIntentionalThrow(4000);
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,34 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import './fonts';
|
||||
import './index.css';
|
||||
import { router } from './router';
|
||||
|
||||
const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
|
||||
if (sentryDsn) {
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
// VITE_APP_ENV ('test' | 'production') distinguishes test vs. production builds — both use
|
||||
// `vite build` without an explicit mode, so MODE alone would tag both as 'production'.
|
||||
// Falls back to MODE for local development (`pnpm dev` -> 'development').
|
||||
environment: import.meta.env.VITE_APP_ENV ?? import.meta.env.MODE,
|
||||
release: __APP_VERSION__,
|
||||
integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)],
|
||||
// Low-traffic marketing site: capture all traces (lower this if traffic grows significantly).
|
||||
tracesSampleRate: 1.0,
|
||||
// Required for Sentry.logger.* calls (e.g. the temporary SentryTestButton) to actually be sent.
|
||||
enableLogs: true,
|
||||
// Ad-blockers/privacy extensions commonly block requests straight to *.ingest.sentry.io
|
||||
// (ERR_BLOCKED_BY_CLIENT), because it looks like third-party tracking. Routing through our
|
||||
// own domain via a "tunnel" avoids that: in built (test/production) environments this is
|
||||
// handled by the reverse-proxy Pi (see nginx/reverse-proxy-nginx.conf.example); locally
|
||||
// (`pnpm dev`) the equivalent proxy route is set up in vite.config.ts's `server.proxy`.
|
||||
tunnel: '/sentry-tunnel',
|
||||
});
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element "#root" not found in index.html');
|
||||
|
||||
Vendored
+7
@@ -5,8 +5,15 @@ interface ImportMetaEnv {
|
||||
readonly VITE_UMAMI_SCRIPT_URL?: string;
|
||||
/** Umami website ID, created manually in the Umami dashboard for this site. */
|
||||
readonly VITE_UMAMI_WEBSITE_ID?: string;
|
||||
/** Sentry DSN (Data Source Name) for client-side error reporting. Not a secret — safe to expose in the client bundle. */
|
||||
readonly VITE_SENTRY_DSN?: string;
|
||||
/** Build-time deployment environment tag ('test' | 'production' | undefined). Used to hide dev/test-only UI (e.g. SentryTestButton) from production builds. */
|
||||
readonly VITE_APP_ENV?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/** App version at build time (from package.json), injected via vite.config.ts `define`. Used as the Sentry `release` tag. */
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
Reference in New Issue
Block a user