# Frontend Components — U4 Observability Integration
Changes to the admin SPA in `frontend/`. Two new components, two modified files.
---
## Component Hierarchy
```mermaid
graph TD
main["main.tsx
entry point"]
sentryinit["initSentry
called before render"]
config["lib/config.ts
getAppConfig"]
apiclient["lib/api-client.ts
ApiClient"]
query["QueryClientProvider"]
authprov["AuthProvider"]
errbound["SentryErrorBoundary
NEW"]
inner["InnerApp"]
umami["UmamiAnalytics
NEW"]
router["RouterProvider"]
toaster["Toaster"]
main --> sentryinit
main --> config
sentryinit --> config
config --> apiclient
main --> query
query --> authprov
authprov --> errbound
errbound --> inner
errbound --> umami
inner --> router
authprov --> toaster
classDef root fill:#4CAF50,stroke:#2e7d32,color:#000;
classDef guard fill:#FF9800,stroke:#e65100,color:#000;
classDef page fill:#2196F3,stroke:#0d47a1,color:#000;
classDef hook fill:#9C27B0,stroke:#4a148c,color:#000;
classDef newcomp fill:#9ae6b4,stroke:#2f855a,color:#000;
class main root;
class errbound,authprov guard;
class inner,router,toaster page;
class config,apiclient,sentryinit hook;
class umami newcomp;
```
Text alternative: Sentry initialises before rendering, a new error boundary wraps the application inside the auth provider, and a new Umami component sits alongside the app tree; the existing config module now also feeds Sentry initialisation.
---
## New Component — `SentryErrorBoundary`
**Location**: `frontend/src/components/SentryErrorBoundary.tsx`
| Aspect | Detail |
|---|---|
| Purpose | Catch render-time React errors that would otherwise blank the screen, report them, and show a recoverable fallback |
| Props | `children: ReactNode` |
| State | Held by Sentry's own boundary implementation |
| Placement | **Inside** `AuthProvider`, **outside** `InnerApp` |
| Behaviour without a DSN | Still catches and still shows the fallback; simply reports nothing |
| `data-testid` | `error-boundary-fallback`, `error-boundary-retry-button` |
**Why inside `AuthProvider` rather than outermost**: the fallback needs to be reachable for a logged-in user, and an error inside a page should not tear down the session context — otherwise recovering from a render error would also log the user out.
### Fallback content rules
| Must | Must not |
|---|---|
| State that something went wrong | Show the exception message |
| Offer a retry that remounts the subtree | Show a stack trace |
| Offer a link to the dashboard | Show a Sentry event ID as the primary content |
Exception text frequently contains internal detail; showing it to an operator is both unhelpful and a small information leak (SECURITY-09).
---
## New Component — `UmamiAnalytics`
**Location**: `frontend/src/components/UmamiAnalytics.tsx`
| Aspect | Detail |
|---|---|
| Purpose | Inject the Umami tracking script when configured |
| Props | None — reads configuration directly |
| Renders | Nothing visible |
| `data-testid` | Not applicable — no interactive element |
### Behaviour
| Condition | Result |
|---|---|
| `import.meta.env.DEV` | Script **never** injected (BR-U4-27) |
| Script URL or website ID absent | Nothing injected (BR-U4-28) |
| Both present, not local | Script injected once with the website ID |
| Component re-renders | Script injected **once** — guarded against duplicates |
| `Do Not Track` set | Ignored; script still injected (BR-U4-29) |
**Why a component rather than a tag in `index.html`**: the website ID is a build-time variable, and `index.html` cannot read `import.meta.env`. A component also makes the "never in development" and "once only" rules testable.
---
## Modified — `frontend/src/lib/config.ts`
| Change | Detail |
|---|---|
| `apiBaseUrl` | An absent or empty `VITE_API_BASE_URL` now resolves to `''`, meaning same-origin |
| Zod schema | Accepts an empty string **or** a valid absolute URL — nothing else (BR-U4-24) |
| New fields | `sentryDsn`, `appEnv`, `umamiScriptUrl`, `umamiWebsiteId` |
| Existing behaviour | An explicit absolute URL still works unchanged, so local development against `:7221` and `:7222` is unaffected |
**The validation is relaxed by exactly one case, not removed.** A value like `htp://localhost:7221` must still be caught, or the SPA silently issues requests to a nonexistent origin — a failure that looks like the API being down.
---
## Modified — `frontend/src/main.tsx`
| Change | Order |
|---|---|
| Call `initSentry()` | **First**, before the query client and before render — so an error during startup is still captured |
| Wrap the tree in `SentryErrorBoundary` | Inside `AuthProvider` |
| Render `UmamiAnalytics` | Alongside `InnerApp` |
| Existing `document.title` and MSW logic | Unchanged |
---
## Sentry Initialisation
**Location**: `frontend/src/lib/sentry.ts` (new)
| Aspect | Detail |
|---|---|
| Skipped when | No DSN configured (BR-U4-26) |
| `environment` | From `VITE_APP_ENV` |
| `release` | From the package version, matching the existing `__APP_VERSION__` pattern in the reference project |
| `tunnel` | Same-origin tunnel path — **not** Sentry's ingest URL |
| `sendDefaultPii` | `false` on the frontend |
**Why `sendDefaultPii` is false here even though the backend enables it with scrubbing**: the backend can scrub in-process before transmission because it controls the send. In the browser there is no equivalent guarantee, and the frontend has nothing to add that the backend cannot already report. There is no reason to accept the risk.
**Why the tunnel matters most on the frontend**: ad blockers block requests to Sentry domains, so without the tunnel the admin SPA loses errors precisely for users who have one.
---
## User Interaction Flows
### Render error recovery
```mermaid
graph TD
render["Page renders"]
err["Component throws"]
catch["SentryErrorBoundary catches"]
report{"DSN configured ?"}
send["Report via the tunnel"]
skip["No report"]
fallback["Show fallback:
message, retry, dashboard link"]
retry["User clicks retry"]
remount["Subtree remounts;
session preserved"]
render --> err
err --> catch
catch --> report
report -->|yes| send
report -->|no| skip
send --> fallback
skip --> fallback
fallback --> retry
retry --> remount
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef step fill:#2196F3,stroke:#0d47a1,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
class render,err entry;
class report decision;
class catch,send,skip,fallback step;
class retry,remount good;
```
Text alternative: a component error is caught by the boundary, reported through the tunnel when a DSN is configured, and shown as a recoverable fallback whose retry remounts the subtree while preserving the session.
---
## Form Validation Rules
U4 adds no form. Existing validation via `react-hook-form` and Zod is unchanged.
One indirect effect worth noting: existing form submission errors surface as `ProblemDetailsError` from `ApiClient`. Those are **handled** errors, already shown to the user, and must **not** become Sentry events — otherwise every validation failure a user makes becomes an alert. Only unhandled errors and `NetworkError` are reported.
---
## API Integration Points
| Component | Endpoint | Notes |
|---|---|---|
| `ApiClient` | `/api/v1/**` | Now same-origin by default (BR-U4-22) |
| Sentry client | Same-origin tunnel path | New. Not an `/api/v1` route, so no version prefix |
| `UmamiAnalytics` | Umami script origin | External; must be permitted by U3's CSP `script-src` |
---
## Testing Approach
| Component | Assertions |
|---|---|
| `config.ts` | Empty value resolves to same-origin; explicit URL preserved; malformed value rejected |
| `UmamiAnalytics` | Nothing injected in development; nothing without a website ID; injected once when configured; not injected twice on re-render |
| `SentryErrorBoundary` | Fallback shown on a child throw; fallback contains no exception text; retry remounts; works without a DSN |
| Sentry initialisation | Skipped without a DSN; tunnel option set rather than a direct ingest URL |
All use the existing Vitest, Testing Library and MSW setup. Note that `pnpm run lint` is still failing for pre-existing reasons until U5 — so lint should be run on the **changed files** during this unit, to avoid new violations hiding among the five existing ones.