Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/nfr-design/logical-components.md
T
SluijsensandClaude Opus 5 5102f8668b Settles how the headers and observability units get built
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
2026-07-28 10:25:11 +02:00

12 KiB
Raw Blame History

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 EventIdsecurity_event tag so alert rules filter on a tag 6
SecurityEvents Static partial Six source-generated LoggerMessage methods, EventId 50015006 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