NFR Design for U3 and U4. Two decisions the earlier stages had deliberately left open, plus four risks the functional design did not name. OPEN-01 closed: the correlation ID is the W3C trace ID from the ambient Activity, with TraceIdentifier as the fallback. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the client. REF-U3-01 raised: BR-U3-22's Umami-origin startup warning cannot work. The backend never sees VITE_UMAMI_WEBSITE_ID, so the check would either always warn or never warn. Withdrawn from U3 and replaced by a blocking U5 CI gate that compares the frontend build variable against that environment's CSP origins, where both values are visible. Four additions beyond the functional design: - Set-Cookie added to the scrub list; the login response issues the refreshToken there, so scrubbing only the request cookie protects nothing - SetBeforeSendTransaction alongside SetBeforeSend; transactions carry request data too - OnRejected on the rate limiter; today a 429 leaves no trace anywhere - FlushAsync before the migration-failure rethrow, or the one Critical event in the system dies with the process Two traps recorded with tests attached: Sentry groups log events by message template, so interpolated messages make FR-19's rate-based alert rules unimplementable while appearing to work; and DefaultHttpContext.Response .OnStarting is a no-op, so the obvious middleware test asserts nothing. Three values chosen rather than escalated, each one line to change and all three listed for review at the end of U4's pattern document: JSON console outside Development, TracesSampleRate 0.1, tunnel cap 200 KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
12 KiB
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
graph TD
subgraph Config["Configuration"]
Section["Observability section"]
Options["ObservabilityOptions"]
LogCfg["Logging:LogLevel<br/>raised to Information"]
end
subgraph Logging["Logging — always active"]
Activity["ActivityTrackingOptions<br/>TraceId, SpanId, ParentId"]
Console["JsonConsole or SimpleConsole<br/>IncludeScopes = true"]
end
subgraph SentryWiring["Sentry — only when a DSN is present"]
UseExt["UseCmsSentry"]
Scrubber["ISentryEventScrubber"]
Processor["SecurityEventProcessor<br/>ISentryEventProcessor"]
Sdk["Sentry SDK<br/>MinimumEventLevel = Warning<br/>MinimumBreadcrumbLevel = Information"]
end
subgraph Events["Alertable Events"]
SecEvents["SecurityEvents<br/>LoggerMessage, EventId 5001-5006"]
Reason["BypassRejectionReason"]
Sites["Emission sites:<br/>Identity, Master, Availability, Core"]
end
subgraph Tunnel["Sentry Tunnel"]
Target["SentryTunnelTarget<br/>singleton, parsed at startup"]
Endpoint["MapSentryTunnel<br/>/sentry-tunnel"]
Limiter["sentry-tunnel rate limiter"]
Client["Named HttpClient<br/>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
graph TD
main["main.tsx"]
sentryts["lib/sentry.ts<br/>initSentry — NEW"]
config["lib/config.ts<br/>MODIFIED"]
apiclient["lib/api-client.ts<br/>MODIFIED — empty base"]
vite["vite.config.ts<br/>MODIFIED — define + dev proxy"]
boundary["SentryErrorBoundary<br/>NEW"]
umami["UmamiAnalytics<br/>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 — 429s 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<ObservabilityOptions>().Bind(...).ValidateOnStart()
4. AddSingleton<ISentryEventScrubber, SentryEventScrubber>()
5. AddSingleton<SecurityEventProcessor>()
6. AddSingleton<SentryTunnelTarget>() ← 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 |