Feature/sentry error logging #5
@@ -30,6 +30,7 @@ Both the console and Sentry are now active (original Question 3 resolved as a co
|
|||||||
### Temporary manual test tool: `SentryTestButton`
|
### Temporary manual test tool: `SentryTestButton`
|
||||||
- `src/components/SentryTestButton.tsx` renders a "Break the world" button, mounted globally via `RootLayout.tsx`, used to manually verify that errors, logs (`Sentry.logger.info`), and metrics (`Sentry.metrics.count`) actually arrive in Sentry end-to-end.
|
- `src/components/SentryTestButton.tsx` renders a "Break the world" button, mounted globally via `RootLayout.tsx`, used to manually verify that errors, logs (`Sentry.logger.info`), and metrics (`Sentry.metrics.count`) actually arrive in Sentry end-to-end.
|
||||||
- **Visibility**: only shown during local development (`pnpm dev`, via Vite's `import.meta.env.DEV`) and in the test environment (via the new build-time `VITE_APP_ENV` variable, set to `test` by `continuous_integration.yaml`'s `Build` step). It is hidden by default (including in any future production build) unless one of those conditions is explicitly true.
|
- **Visibility**: only shown during local development (`pnpm dev`, via Vite's `import.meta.env.DEV`) and in the test environment (via the new build-time `VITE_APP_ENV` variable, set to `test` by `continuous_integration.yaml`'s `Build` step). It is hidden by default (including in any future production build) unless one of those conditions is explicitly true.
|
||||||
|
- **Visual feedback**: clicking the button immediately shows a green confirmation toast ("Test error verzonden naar Sentry ✅", `role="status"`) that auto-hides after 4 seconds, so the user gets clear confirmation that the test action fired — without this, the resulting uncaught error/blank state gave no indication anything happened. The actual log/metric/throw (`handleSentryTestErrorClick`) is fired on the next tick (`setTimeout(..., 0)`) so the toast has a chance to render/paint first.
|
||||||
- `Sentry.logger.*` requires `enableLogs: true` in `Sentry.init()` (`src/main.tsx`) — added specifically to support this test button (and any future structured logging).
|
- `Sentry.logger.*` requires `enableLogs: true` in `Sentry.init()` (`src/main.tsx`) — added specifically to support this test button (and any future structured logging).
|
||||||
- The thrown error is **intentionally uncaught**: React error boundaries do not catch errors thrown from event handlers (only render/lifecycle errors), so this relies on Sentry's own global `window.onerror` handler, exactly like the official Sentry test snippet.
|
- The thrown error is **intentionally uncaught**: React error boundaries do not catch errors thrown from event handlers (only render/lifecycle errors), so this relies on Sentry's own global `window.onerror` handler, exactly like the official Sentry test snippet.
|
||||||
- This is a temporary verification tool, not a permanent feature — remove `SentryTestButton` (and its usage in `RootLayout.tsx`) once Sentry has been confirmed to receive test errors/logs/metrics end-to-end.
|
- This is a temporary verification tool, not a permanent feature — remove `SentryTestButton` (and its usage in `RootLayout.tsx`) once Sentry has been confirmed to receive test errors/logs/metrics end-to-end.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,6 +9,9 @@ import * as Sentry from '@sentry/react';
|
|||||||
*/
|
*/
|
||||||
const isVisible = import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'test';
|
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)
|
* 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
|
* getest kan worden. React vangt fouten uit event handlers namelijk NIET op via
|
||||||
@@ -31,17 +35,44 @@ export function handleSentryTestErrorClick(): void {
|
|||||||
* daadwerkelijk binnenkomen.
|
* daadwerkelijk binnenkomen.
|
||||||
*/
|
*/
|
||||||
export function SentryTestButton() {
|
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) {
|
if (!isVisible) {
|
||||||
return null;
|
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 (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSentryTestErrorClick}
|
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"
|
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
|
Break the world
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
import { SentryTestButton, handleSentryTestErrorClick } from '../SentryTestButton';
|
import { SentryTestButton, handleSentryTestErrorClick } from '../SentryTestButton';
|
||||||
|
|
||||||
@@ -9,9 +9,30 @@ vi.mock('@sentry/react', () => ({
|
|||||||
metrics: { count: 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', () => {
|
describe('SentryTestButton', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is visible in the current (development/test) build', () => {
|
it('is visible in the current (development/test) build', () => {
|
||||||
@@ -27,4 +48,34 @@ describe('SentryTestButton', () => {
|
|||||||
});
|
});
|
||||||
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user