# Business Logic Model — U4 Observability Integration **Unit**: U4 Observability Integration **Requirements**: FR-13, FR-14, FR-15, FR-16, FR-19 --- ## 1. Scope of the Logic U4 answers three questions that cannot otherwise be answered without host access: is the application erroring, is it being used, and which build is running. Its logic is mostly about **graceful absence** — every observability service must be optional, because local development and any deployment without them must work unchanged. --- ## 2. Degradation Model Three fully functional configurations rather than one required setup: ```mermaid graph TD boot["Startup"] logging["Structured console logging
always active"] dsn{"Sentry DSN configured ?"} sentryon["Sentry initialised
environment and release tagged"] sentryoff["Sentry skipped
console only"] reachable{"Sentry reachable ?"} delivered["Events delivered"] buffered["Sentry buffers and drops
application never blocked"] boot --> logging logging --> dsn dsn -->|yes| sentryon dsn -->|no| sentryoff sentryon --> reachable reachable -->|yes| delivered reachable -->|no| buffered classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; classDef always fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; classDef degraded fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; class boot entry; class logging,sentryon,delivered always; class dsn,reachable decision; class sentryoff,buffered degraded; ``` Text alternative: structured console logging is always active; Sentry initialises only when a DSN is present, and an unreachable Sentry never blocks the application. **An absent DSN is a supported state, not an error.** Logging is registered before Sentry so that a problem initialising Sentry is itself logged. --- ## 3. Log Levels — Console Versus Sentry Q3 = C asked for `Information` across the board, including the framework. That is right for the **console**, and would be wrong for **Sentry**. Sending every framework `Information` entry to Sentry means an event per request. Sentry's free plan would be exhausted within hours, and the errors that matter would be lost among request noise — the opposite of what monitoring is for. The two destinations therefore get different thresholds: | Destination | Threshold | Rationale | |---|---|---| | **Console** | `Information` for everything, framework included (Q3 = C) | On the Pi the console is captured by the process manager, so volume is cheap and detail is useful | | **Sentry — events** | `Warning` and above | Still "more than exceptions" as Q16 = C requires, since warnings are included, without one event per request | | **Sentry — breadcrumbs** | `Information` | Informational entries travel *attached to* an event as context, so the detail is there when something goes wrong without being an event itself | This satisfies both answers rather than choosing between them: the console gets everything, Sentry gets warnings and errors, and every Sentry event arrives carrying the informational trail that led to it. --- ## 4. Security Event Emission Per Q4 = all of A–F, six event types are emitted for alerting. ```mermaid graph TD subgraph auth["Authentication and authorization"] e1["Failed login"] e2["Authorization denied
on a protected endpoint"] e5["Rate limit triggered
on login endpoints"] end subgraph proto["Master and slave protocol"] e3["Master API key rejected"] e4["Admin bypass rejected
at the availability gate"] end subgraph infra["Infrastructure"] e6["Migration failure at startup"] end sink["Structured log entry
plus Sentry event"] alert["Sentry alert rule
configured in Operations"] e1 --> sink e2 --> sink e3 --> sink e4 --> sink e5 --> sink e6 --> sink sink --> alert classDef authgrp fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000; classDef protogrp fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; classDef infragrp fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; classDef out fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; class e1,e2,e5 authgrp; class e3,e4 protogrp; class e6 infragrp; class sink,alert out; ``` Text alternative: six event types across authentication, the master/slave protocol and infrastructure all flow into structured log entries that also become Sentry events, on which alert rules are configured during the Operations phase. ### What each event means, and what it carries | Event | What it indicates | Context carried | Never carried | |---|---|---|---| | Failed login | Repeated occurrences suggest an attack or a forgotten password | Timestamp, endpoint, correlation ID, whether the account exists | Password, the attempted password, full email | | Authorization denied | Someone reached an endpoint they lack rights for | Endpoint, required policy, role held, correlation ID | Token contents | | Master API key rejected | An attacker, **or** a genuine key-ring problem | Endpoint, calling host, correlation ID | The key, or any part of it | | Admin bypass rejected | Since FR-24, someone attempted the availability gate with an invalid token | Path, reason class (invalid signature, expired, wrong role), correlation ID | The token | | Rate limit triggered | Brute-force pressure on login | Limiter name, endpoint, correlation ID | Client identity beyond what the limiter partitions on | | Migration failure | Not a security event, but you want to know immediately | Exception, attempt count, correlation ID | Connection string, credentials | **The two most diagnostically valuable are also the least obvious.** A rejected master API key is ambiguous by nature — it means either an intruder or that the key ring has become unreadable. Distinguishing them is exactly what the U2 durability work exists to make unnecessary, but if it ever happens, this event is the first sign. And a rejected admin bypass only became a meaningful signal *because* FR-24 started validating properly; before that, a forged token succeeded silently. --- ## 5. Sentry Transport — Tunnel Through the API Per Q1 = A, browser error reports do not go directly to Sentry. ```mermaid sequenceDiagram box rgba(246,224,94,0.4) Browser participant SPA as Admin SPA end box rgba(144,205,244,0.4) Application participant T as Tunnel endpoint end box rgba(251,182,206,0.4) External participant S as Sentry ingest end SPA->>T: POST envelope to same-origin tunnel path T->>T: validate size and content type T->>S: forward envelope to the configured DSN host S-->>T: accepted T-->>SPA: 200 ``` Text alternative: the admin SPA posts its Sentry envelope to a same-origin tunnel endpoint, which forwards it to Sentry's ingest host and returns success to the browser. **Why a tunnel** — two reasons, one of them the actual motivation: 1. **Ad blockers block requests to Sentry domains** with `ERR_BLOCKED_BY_CLIENT`. Without a tunnel, errors are lost precisely for the users who have an ad blocker — a silently biased sample of exactly the group most likely to have browser oddities. 2. It keeps browser traffic same-origin, so U3's CSP needs `connect-src 'self'` and no external Sentry origin. Simpler policy, and one fewer thing to forget. **The reference project tunnels through nginx.** NFR-01 forbids relying on server configuration, so here the application forwards it. **Constraints on the tunnel**, because it is an anonymous endpoint that makes outbound requests on request: - Only forwards to the host derived from the configured DSN — never to a caller-supplied destination - Rejects payloads above a fixed size - Does nothing at all when no DSN is configured - Not on the availability bypass list: if the instance is switched off, error reporting from the admin SPA stopping is acceptable **The backend's own Sentry reporting does not use the tunnel** — server-side code has no ad blocker and no CSP, and reports directly. --- ## 6. Sentry Request Context and Scrubbing Per Q2 = B, `SendDefaultPii` is enabled with a filter. This needs care, because "PII" understates what is actually attached. With `SendDefaultPii` on, Sentry includes request headers — and this application carries a `refreshToken` in a cookie and a master API key in a header. Both are **credentials**, not merely personal data. Sending them to a third party would be worse than the problem the setting solves. The filter therefore removes, before any event leaves the process: | Removed | Why | |---|---| | The entire `Cookie` header | Contains the `refreshToken`. Removing one cookie by rewriting the header is error-prone; removing the header is not | | `Authorization` header | Bearer token | | `X-Master-Api-Key` header | The master/slave shared secret. **Not mentioned when this was chosen, but the same class of secret** | | Request body | Login and password-change bodies contain passwords | What remains and is genuinely useful: method, path, query string, user agent, IP address, authenticated username, and the correlation ID. **Query strings are retained** — but note that invitation tokens travel as `?token=…` on `/api/v1/Invitation/validate`. That is a single-use, time-limited token rather than a standing credential, and the diagnostic value of seeing which endpoint was called outweighs it. Recorded so the decision is visible rather than accidental. --- ## 7. Frontend Configuration Resolution Per FR-13, the API base URL becomes same-origin by default. ```mermaid graph TD read["Read VITE_API_BASE_URL"] empty{"Absent or empty ?"} same["Same-origin: use relative paths"] valid{"Valid absolute URL ?"} explicit["Use the explicit origin"] invalid["Development: warn loudly
Production: use as given"] read --> empty empty -->|yes| same empty -->|no| valid valid -->|yes| explicit valid -->|no| invalid classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; classDef warn fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; class read entry; class empty,valid decision; class same,explicit good; class invalid warn; ``` Text alternative: an absent or empty API base URL means same-origin relative requests; an explicit absolute URL is used as given; a malformed value warns loudly in development rather than being silently accepted. **Why same-origin is the right default here**: in the single-host model the API is served by the same process as `/admin`, so a relative path always works and no CORS configuration is needed. The explicit form remains fully supported because local development runs the SPA on port 5173 against the API on 7221 (or 7222 for the slave) — that setup must keep working exactly as before. **Malformed values are not silently accepted.** Validation is relaxed to permit an empty string, not to permit anything. --- ## 8. Umami Analytics Per Q5 = A: measured in test and production, never locally. | Condition | Behaviour | |---|---| | Local development | Script never loaded, regardless of configuration | | No website ID configured | Nothing rendered | | Website ID configured, test or production | Script loaded with the environment's own website ID | Per-environment website IDs are why two separate frontend builds exist (D-15): the ID is a build-time value, so one bundle cannot carry both. `Do Not Track` is deliberately **not** consulted (Q5 = A rather than B). Umami sets no cookies and collects no personal data, and the admin SPA's audience is a known set of operators — so honouring DNT would reduce data without protecting anyone. Recorded as a conscious choice.