# Logical Components — U4 Observability Integration
Backend types live in `SlpModularCms.Core/Hosting/Observability/`, with the exception of `SecurityEvents`, which sits at `SlpModularCms.Core/Observability/` because it is referenced from four assemblies and is not host wiring. Everything is in `Core`, therefore everything is reachable from `SlpModularCms.Core.Tests`.
Frontend additions are two components, one library module and three modified files under `frontend/src/`.
---
## Backend Component Wiring
```mermaid
graph TD
subgraph Config["Configuration"]
Section["Observability section"]
Options["ObservabilityOptions"]
LogCfg["Logging:LogLevel
raised to Information"]
end
subgraph Logging["Logging — always active"]
Activity["ActivityTrackingOptions
TraceId, SpanId, ParentId"]
Console["JsonConsole or SimpleConsole
IncludeScopes = true"]
end
subgraph SentryWiring["Sentry — only when a DSN is present"]
UseExt["UseCmsSentry"]
Scrubber["ISentryEventScrubber"]
Processor["SecurityEventProcessor
ISentryEventProcessor"]
Sdk["Sentry SDK
MinimumEventLevel = Warning
MinimumBreadcrumbLevel = Information"]
end
subgraph Events["Alertable Events"]
SecEvents["SecurityEvents
LoggerMessage, EventId 5001-5006"]
Reason["BypassRejectionReason"]
Sites["Emission sites:
Identity, Master, Availability, Core"]
end
subgraph Tunnel["Sentry Tunnel"]
Target["SentryTunnelTarget
singleton, parsed at startup"]
Endpoint["MapSentryTunnel
/sentry-tunnel"]
Limiter["sentry-tunnel rate limiter"]
Client["Named HttpClient
5s timeout, no retry"]
end
Ingest["Sentry ingest"]
Section --> Options
Options --> UseExt
Options --> Target
LogCfg --> Console
Activity --> Console
Activity --> Sdk
UseExt --> Sdk
Scrubber --> Sdk
Processor --> Sdk
Sites --> SecEvents
SecEvents --> Reason
SecEvents --> Console
SecEvents --> Sdk
Endpoint --> Limiter
Endpoint --> Target
Endpoint --> Client
Client --> Ingest
Sdk --> Ingest
classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef always fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef optional fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef guard fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
classDef ext fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class Section,Options,LogCfg cfg;
class Activity,Console,SecEvents,Reason,Sites always;
class UseExt,Sdk,Target,Endpoint,Client,Processor optional;
class Scrubber,Limiter guard;
class Ingest ext;
```
Text alternative: activity tracking and console logging are always active and independent of Sentry; the Sentry SDK is registered only when a DSN is present and always behind the scrubber and the security-event tag processor; the six security events are ordinary log entries that reach both destinations; the tunnel endpoint forwards browser envelopes to a destination parsed once at startup.
---
## Frontend Component Wiring
```mermaid
graph TD
main["main.tsx"]
sentryts["lib/sentry.ts
initSentry — NEW"]
config["lib/config.ts
MODIFIED"]
apiclient["lib/api-client.ts
MODIFIED — empty base"]
vite["vite.config.ts
MODIFIED — define + dev proxy"]
boundary["SentryErrorBoundary
NEW"]
umami["UmamiAnalytics
NEW"]
inner["InnerApp"]
tunnelpath["POST /sentry-tunnel"]
main --> sentryts
main --> boundary
main --> umami
boundary --> inner
config --> sentryts
config --> apiclient
config --> umami
vite --> sentryts
vite --> tunnelpath
sentryts --> tunnelpath
classDef newc fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef modc fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef exist fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef ext fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class sentryts,boundary,umami newc;
class config,apiclient,vite modc;
class main,inner exist;
class tunnelpath ext;
```
Text alternative: a new sentry library module is initialised first from main.tsx and posts to the same-origin tunnel; the modified config module feeds Sentry, the API client and the Umami component; vite supplies the release version and, in development, proxies the tunnel path to the local API.
---
## Backend Component Responsibilities
| Component | Lifetime | Responsibility | Pattern |
|---|---|---|---|
| `ObservabilityOptions` | Options singleton | `SentryDsn`, `Environment`, `TracesSampleRate`, `TunnelMaxPayloadBytes` | 10 |
| `LoggingExtensions.AddCmsLogging` | Static | Activity tracking, `IncludeScopes`, console provider per environment | 1, 2 |
| `SentryExtensions.UseCmsSentry` | Static | No-op without a DSN; otherwise thresholds, scrubber, processor | 3, 8 |
| `ISentryEventScrubber` / `SentryEventScrubber` | Singleton | Removes four credential headers and the body from events **and** transactions | 4 |
| `SecurityEventProcessor` | Singleton | Maps `EventId` → `security_event` tag so alert rules filter on a tag | 6 |
| `SecurityEvents` | Static partial | Six source-generated `LoggerMessage` methods, `EventId` 5001–5006 | 6 |
| `BypassRejectionReason` | Enum | Why an admin bypass was refused — never the token | 6 |
| `SentryTunnelTarget` | Singleton | Envelope endpoint parsed from the DSN once, at startup | 7 |
| `SentryTunnelExtensions.MapSentryTunnel` | Static | The `/sentry-tunnel` endpoint: size cap, forward, `202` on failure | 7 |
### Modified existing components
| File | Change | Why |
|---|---|---|
| `Program.cs` (both hosts) | `AddCmsLogging`, `UseCmsSentry`, `MapSentryTunnel` | Host wiring |
| `appsettings.json` (both hosts) | `Observability` section; `Logging` raised to `Information`; EF command logging pinned at `Warning` | Pattern 3 — otherwise breadcrumbs arrive empty and EF logs parameter values |
| `ServiceCollectionExtensions.AddCmsRateLimiting` | `OnRejected` callback; new `sentry-tunnel` limiter | Pattern 6, Pattern 7 — `429`s currently leave no trace at all |
| `DatabaseMigrationExtensions` (U2) | `FlushAsync` before rethrow | Pattern 5 — otherwise the `Critical` event dies with the process |
| `AdminTokenValidator` (U1) | Overload returning `BypassRejectionReason` | Pattern 6 |
| `AvailabilityMiddleware` | Emit `AdminBypassRejected` | Pattern 6 |
Two of these touch files U1 and U2 already committed. Both are additive and both are named in the U4 commit message.
---
## Frontend Component Responsibilities
| Component | Kind | Responsibility |
|---|---|---|
| `lib/sentry.ts` | Module | `initSentry()`: skip without a DSN, tunnel path, no router integration |
| `components/SentryErrorBoundary.tsx` | Class component | Catch render errors, report, show a recoverable fallback with no exception text |
| `components/UmamiAnalytics.tsx` | Component | Inject the script once, never in development, no cleanup |
| `lib/config.ts` | Modified | Empty-or-URL schema, four new fields |
| `lib/api-client.ts` | Modified | Correct URL construction with an empty base |
| `main.tsx` | Modified | `initSentry()` first; boundary inside `AuthProvider`; `UmamiAnalytics` alongside `InnerApp` |
| `vite.config.ts` | Modified | `__APP_VERSION__` define; dev proxy for `/sentry-tunnel` → local API |
| `vite-env.d.ts` | Modified | Declare the five new `VITE_` variables and `__APP_VERSION__` |
---
## Packages Added
| Project | Package | Version | Note |
|---|---|---|---|
| `SlpModularCms.Core` | `Sentry.AspNetCore` | `6.8.0` | Latest on nuget.org as of 2026-07-28. **`net10.0` compatibility to be confirmed at code generation** — if the package resolves only to a `net9.0` asset the build still succeeds, but a warning must not be ignored silently |
| `frontend` | `@sentry/react` | `^10.68.0` | Same major as the reference project |
`Sentry.AspNetCore` in `Core` rather than in the two host projects, because `UseCmsSentry`, the scrubber, the processor and the tunnel all live in `Core` and both hosts consume them. It brings `Sentry` and `Sentry.Extensions.Logging` transitively.
**FR-22 interaction**: this adds a dependency subtree to `Core` while U5 is due to pin two packages against known advisories. The new tree must be checked in the same pass, not assumed clean because it is new.
---
## DI Registration Order
```
builder.Logging:
1. AddCmsLogging(builder.Environment) ← ClearProviders, ActivityTracking, IncludeScopes
builder.WebHost:
2. UseCmsSentry(builder.Configuration) ← no-op without a DSN
builder.Services:
3. AddOptions().Bind(...).ValidateOnStart()
4. AddSingleton()
5. AddSingleton()
6. AddSingleton() ← throws at startup on an unparseable DSN
7. AddHttpClient("sentry-tunnel")
8. AddCmsRateLimiting(...) ← existing call, now also registers "sentry-tunnel"
app:
9. MapSentryTunnel() ← after MapControllers, before MapCmsSpaFallbacks
```
**Steps 1 and 2 are order-critical** (BR-U4-04): a Sentry initialisation problem must be logged by an already-registered provider.
**Step 9's position matters**: `MapCmsSpaFallbacks` maps `{*path:nonfile}`. A catch-all registered before the tunnel would not actually shadow it — route precedence favours the literal segment — but relying on precedence for a security-relevant endpoint is a poor trade against writing the two lines in the obvious order.
---
## NFR Coverage Traceability
| NFR / Rule | Pattern | Component |
|---|---|---|
| SECURITY-03 — correlation ID on every entry | 1 | `ActivityTrackingOptions` + `IncludeScopes` |
| SECURITY-03 — no secrets or PII in logs | 3, 4 | EF command logging pinned; `SecurityEvents` templates carry no credentials |
| SECURITY-11 — no request forgery | 7 | `SentryTunnelTarget` — destination never from the caller |
| SECURITY-14 — alertable security events | 6 | `SecurityEvents` + `SecurityEventProcessor` tag |
| SECURITY-15 — failures never propagate | 8 | `UseCmsSentry` no-op path; tunnel returns `202` on forwarding failure |
| NFR-01 — no server configuration | 7 | The tunnel is in-process, not in nginx |
| NFR-07 — no secrets committed | 10 | DSN empty in `appsettings`, supplied as `Observability__SentryDsn` |
| NFR-08 — graceful degradation | 8 | Three fully functional configurations |
| BR-U4-01…05 | 1, 2, 3 | Logging always active, before Sentry, `Information` console |
| BR-U4-06, BR-U4-07 | 3 | `MinimumEventLevel` / `MinimumBreadcrumbLevel` |
| BR-U4-08…12 | 8 | DSN gate, environment and release tags |
| BR-U4-13, BR-U4-14 | 4 | `SentryEventScrubber`, in-process, two layers |
| BR-U4-15…21 | 7 | Tunnel endpoint and target |
| BR-U4-22…25 | 9 | `config.ts` union schema, `api-client.ts` URL construction |
| BR-U4-26…29 | 9 | `initSentry` DSN gate, `UmamiAnalytics` guards |
---
## Carried Forward
| Item | To | Why |
|---|---|---|
| Sentry alert rules for `security_event:*` | **Operations — Monitoring Setup** (FR-19) | The tag exists in code; the rules are configured in Sentry |
| `Observability__SentryDsn` as an environment variable per environment | **U6 Deploy Workflow** | Must not be committed |
| `VITE_SENTRY_DSN`, `VITE_APP_ENV`, `VITE_UMAMI_*` per environment build | **U5 CI Workflow** | Build-time values; two artifacts (D-15) |
| New dependency subtree checked against advisories | **U5** (FR-22) | `Sentry.AspNetCore` is new and must not be assumed clean |
| `Sentry.AspNetCore` `net10.0` asset confirmation | **Code Generation** | A `net9.0` fallback builds but should be a conscious acceptance |
| Trace ID propagation master → slave verified end to end | **Build and Test** | Needs both hosts running and a real master/slave call — the whole justification for Pattern 1 |
| `apiBaseUrl === ''` request construction verified | **Build and Test** + unit test | Only fails in the production configuration, which nobody runs locally |