Establish logging and observability (Vikunja task #6) #12

Merged
rob merged 2 commits from feat/logging-observability into main 2026-08-03 07:16:07 +00:00
Owner

Implements Vikunja task #6. All three acceptance criteria met and demonstrated by running the real server, not described.

Evidence

Request logged with method, path, status, duration and the caller's correlation id — from a curl supplying traceparent:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      => SpanId:5238f6de1a2c2a37, TraceId:0af7651916cd43dd8448eb211c80319c, ParentId:b7ad6b7169203331
      Method: GET   Path: /places/42   StatusCode: 404   Duration: 1.9751

Same binary in Production emits JSON, selected purely by Logging:Console:FormatterName — zero code. Unhandled exceptions log a full stack trace at Error under the same trace id. An environment variable overriding the level was proven to suppress the request log, exactly as ADR-0014's precedence requires.

Decisions

Built-in Microsoft.Extensions.Logging, not Serilog. Structured records, JSON console, per-category levels and scopes are framework features now. Serilog's real advantage is its sink ecosystem, and at ADR-0007's scale there is one sink — stdout. It would also add a second configuration system, so "why is this not logged?" would have two places to look. The trigger to revisit is in ADR-0015: the day logs need searching rather than reading.

W3C Trace Context, no bespoke header. IncludeScopes puts TraceId on every record — request logs, warnings, exception traces — not just the request log. A parallel X-Correlation-Id would need threading by hand and would then disagree with the trace id already present.

Console only. No file sink (needs a path, rotation, retention, disk headroom, and hides logs behind filesystem access) and no OTLP — there is no collector, and configuring an endpoint that does not exist is a claim, not a capability.

WebUI→API correlation is NOT implemented, and this was measured

A cross-origin call from Blazor WASM arrived at the API with no traceparent and no preflight; the API opened a fresh trace (ParentId:0000000000000000). Nothing in WASM starts an Activity, traceparent is not CORS-safelisted so it needs a CORS policy that is unbuilt, and ADR-0008's single typed HTTP client — the natural home for the handler — does not exist yet.

Shipping it would have meant shipping a mechanism that could not be demonstrated working. Stated plainly in ADR-0015 and the README rather than glossed.

Two silent no-ops found by running it

1. The WebUI log level was doing nothing. WebAssemblyHostBuilder.CreateDefault loads appsettings.json into configuration and then does not wire it to the logger factory. Confirmed by control experiment: before adding builder.Logging.AddConfiguration(...), the same Default: Warning file was fetched and an info record still appeared. Fixed.

2. Microsoft.AspNetCore: Warning silently swallows the request log, because the middleware writes at Information. appsettings.json therefore names Microsoft.AspNetCore.HttpLogging explicitly, with a comment — and a test fails if that line is removed.

A real defect shipping today, chosen over scope creep

The request log reports an unhandled exception as StatusCode: 200. HttpLoggingMiddleware reads the status as the response unwinds, and with no exception handler registered nothing has written a 500 by then.

Fixing it properly means taking the first acceptance criterion of task 58 ("Implement global exception handling and Problem Details responses"). Instead: UseHttpLogging is registered outermost, which is the ordering that makes it correct the moment that handler lands, and the constraint is documented in both Program.cs and ADR-0015 so it is not reversed by someone tidying the pipeline.

Flagging prominently because it is a genuine inaccuracy in the logs right now.

Tests — and the infrastructure they needed

Three tests in PlaceMark.Api.Tests. This does add test infrastructure that did not exist: Microsoft.AspNetCore.Mvc.Testing and an InternalsVisibleTo so WebApplicationFactory can name the generated internal Program without making it public. Flagged deliberately.

Justified because this ticket's entire failure mode is "configured but silently ineffective" — and the tests were mutation-checked: deleting the HttpLogging level line fails two of three; setting Microsoft.AspNetCore to None fails all three. Not vacuous.

Not tested: that an unhandled exception logs its stack trace. That record comes from Kestrel, and WebApplicationFactory's TestServer is not Kestrel — it rethrows and logs nothing, so the test would pass vacuously or need real Kestrel plus an IStartupFilter. The behaviour is framework-provided; the only thing our configuration could break is filtering it away, which the third test pins in three lines. The behaviour itself was verified against the real server.

Corrections to my brief

The stated dependency does exist. I told the agent no ticket matched "Scaffold ASP.NET Minimal API project structure". It is task 57, #1 in the API Core epic — not done. So this was built ahead of its dependency. It cost nothing in practice (there is no endpoint to log, so demonstrations run against 404s), but task #17-1's /health endpoint will be the first thing this logging actually describes.

For other tickets

  • Task 58's third criterion — "exception details logged server-side with correlation ID" — is already satisfied by this change. Worth noting there so it is not re-implemented.
  • CA1848 is an error here, so logger.LogInformation("…") will not compile. Log statements must use the [LoggerMessage] source generator. Nothing here writes one, so it bites the first person who does. Recorded in ADR-0015 and CLAUDE.md.

Verification

dotnet build and -c Release both 0 warnings / 0 errors; dotnet test 3 passed; dotnet format --verify-no-changes exit 0. WebUI behaviour confirmed in a real headless browser, comparing dev-server and published output.

Implements Vikunja task #6. All three acceptance criteria met and **demonstrated by running the real server**, not described. ## Evidence Request logged with method, path, status, duration and the **caller's** correlation id — from a `curl` supplying `traceparent`: ``` info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9] => SpanId:5238f6de1a2c2a37, TraceId:0af7651916cd43dd8448eb211c80319c, ParentId:b7ad6b7169203331 Method: GET Path: /places/42 StatusCode: 404 Duration: 1.9751 ``` Same binary in Production emits JSON, selected purely by `Logging:Console:FormatterName` — zero code. Unhandled exceptions log a full stack trace at `Error` **under the same trace id**. An environment variable overriding the level was proven to suppress the request log, exactly as ADR-0014's precedence requires. ## Decisions **Built-in `Microsoft.Extensions.Logging`, not Serilog.** Structured records, JSON console, per-category levels and scopes are framework features now. Serilog's real advantage is its sink ecosystem, and at ADR-0007's scale there is one sink — stdout. It would also add a second configuration system, so "why is this not logged?" would have two places to look. The trigger to revisit is in ADR-0015: the day logs need *searching* rather than reading. **W3C Trace Context, no bespoke header.** `IncludeScopes` puts `TraceId` on *every* record — request logs, warnings, exception traces — not just the request log. A parallel `X-Correlation-Id` would need threading by hand and would then disagree with the trace id already present. **Console only.** No file sink (needs a path, rotation, retention, disk headroom, and hides logs behind filesystem access) and **no OTLP** — there is no collector, and configuring an endpoint that does not exist is a claim, not a capability. ## WebUI→API correlation is NOT implemented, and this was measured A cross-origin call from Blazor WASM arrived at the API with **no `traceparent` and no preflight**; the API opened a fresh trace (`ParentId:0000000000000000`). Nothing in WASM starts an `Activity`, `traceparent` is not CORS-safelisted so it needs a CORS policy that is unbuilt, and ADR-0008's single typed HTTP client — the natural home for the handler — does not exist yet. Shipping it would have meant shipping a mechanism that could not be demonstrated working. Stated plainly in ADR-0015 and the README rather than glossed. ## Two silent no-ops found by running it **1. The WebUI log level was doing nothing.** `WebAssemblyHostBuilder.CreateDefault` loads `appsettings.json` into configuration and then **does not wire it to the logger factory**. Confirmed by control experiment: before adding `builder.Logging.AddConfiguration(...)`, the same `Default: Warning` file was fetched and an `info` record still appeared. Fixed. **2. `Microsoft.AspNetCore: Warning` silently swallows the request log**, because the middleware writes at `Information`. `appsettings.json` therefore names `Microsoft.AspNetCore.HttpLogging` explicitly, with a comment — and a test fails if that line is removed. ## A real defect shipping today, chosen over scope creep **The request log reports an unhandled exception as `StatusCode: 200`.** `HttpLoggingMiddleware` reads the status as the response unwinds, and with no exception handler registered nothing has written a 500 by then. Fixing it properly means taking the first acceptance criterion of task 58 ("Implement global exception handling and Problem Details responses"). Instead: `UseHttpLogging` is registered **outermost**, which is the ordering that makes it correct the moment that handler lands, and the constraint is documented in both `Program.cs` and ADR-0015 so it is not reversed by someone tidying the pipeline. Flagging prominently because it is a genuine inaccuracy in the logs right now. ## Tests — and the infrastructure they needed Three tests in `PlaceMark.Api.Tests`. **This does add test infrastructure that did not exist**: `Microsoft.AspNetCore.Mvc.Testing` and an `InternalsVisibleTo` so `WebApplicationFactory` can name the generated internal `Program` without making it public. Flagged deliberately. Justified because this ticket's entire failure mode is "configured but silently ineffective" — and the tests were **mutation-checked**: deleting the `HttpLogging` level line fails two of three; setting `Microsoft.AspNetCore` to `None` fails all three. Not vacuous. **Not tested: that an unhandled exception logs its stack trace.** That record comes from Kestrel, and `WebApplicationFactory`'s `TestServer` is not Kestrel — it rethrows and logs nothing, so the test would pass vacuously or need real Kestrel plus an `IStartupFilter`. The behaviour is framework-provided; the only thing our configuration could break is filtering it away, which the third test pins in three lines. The behaviour itself was verified against the real server. ## Corrections to my brief **The stated dependency does exist.** I told the agent no ticket matched "Scaffold ASP.NET Minimal API project structure". It is **task 57, `#1` in the API Core epic** — not done. So this was built ahead of its dependency. It cost nothing in practice (there is no endpoint to log, so demonstrations run against 404s), but task #17-1's `/health` endpoint will be the first thing this logging actually describes. ## For other tickets - **Task 58's third criterion — "exception details logged server-side with correlation ID" — is already satisfied by this change.** Worth noting there so it is not re-implemented. - **`CA1848` is an error here**, so `logger.LogInformation("…")` will not compile. Log statements must use the `[LoggerMessage]` source generator. Nothing here writes one, so it bites the first person who does. Recorded in ADR-0015 and `CLAUDE.md`. ## Verification `dotnet build` and `-c Release` both 0 warnings / 0 errors; `dotnet test` 3 passed; `dotnet format --verify-no-changes` exit 0. WebUI behaviour confirmed in a real headless browser, comparing dev-server and published output.
Establish logging and observability
All checks were successful
CI / build (pull_request) Successful in 35s
134a83a416
Uses the built-in Microsoft.Extensions.Logging rather than Serilog.
Structured records, JSON console, per-category levels and scopes are all
framework features now; Serilog's advantage is its sink ecosystem, and at
ADR-0007's scale there is one sink, stdout. It would also add a second
configuration system, so 'why is this not logged' would have two places
to look.

Correlation is W3C Trace Context, with no bespoke header. ASP.NET Core
adopts an inbound traceparent and IncludeScopes puts TraceId on every
record, not only the request log. Verified by curl: a caller-supplied
traceparent appears as the trace and parent span id on the request log
and on the exception record beside it.

WebUI to API correlation is NOT implemented, and this was measured rather
than assumed: a cross-origin call from Blazor WebAssembly arrives with no
traceparent and no preflight, and the API opens a fresh trace. Nothing in
WebAssembly starts an Activity, traceparent is not CORS-safelisted, and
ADR-0008's single typed HTTP client does not exist yet. Shipping it now
would mean shipping a mechanism that could not be demonstrated working.

The WebUI log level was a silent no-op before this change.
WebAssemblyHostBuilder.CreateDefault loads appsettings into
configuration and does not wire it to the logger factory, so the file was
being fetched and ignored. Confirmed by control experiment before and
after adding AddConfiguration.

Registers UseHttpLogging outermost deliberately. With no exception
handler yet, the request log reports an unhandled exception as 200 —
HttpLoggingMiddleware reads the status as the response unwinds and
nothing has written a 500 by then. Fixing that belongs to the global
exception handling ticket; this ordering makes it correct the moment that
lands, and the constraint is recorded so it is not reversed by tidying.

Adds three tests and the WebApplicationFactory infrastructure they need.
Mutation-checked rather than assumed: deleting the HttpLogging level line
fails two of them, setting Microsoft.AspNetCore to None fails all three.
rob left a comment

Verdict: mergeable

Independent review. I re-ran every claim in the description rather than reading it; where I disagree with a number I say so below.

What I verified by running it

Check Result
dotnet build 0 warnings, 0 errors
dotnet build -c Release 0 warnings, 0 errors
dotnet test 3 passed, exit 0
dotnet format --verify-no-changes exit 0

Request log with the caller's trace id — reproduced. Development, ASPNETCORE_URLS=http://localhost:5301, curl -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' /places/42:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      => SpanId:adaf3a1b1c8aeb92, TraceId:0af7651916cd43dd8448eb211c80319c, ParentId:b7ad6b7169203331 => ...
      Method: GET   PathBase:    Path: /places/42   StatusCode: 404   Duration: 0.0317

The trace id is the caller's, not a fresh one, and the parent span is the caller's. An unrelated request in the same process got its own fresh id, so this is adoption rather than coincidence.

JSON formatter in Production — confirmed, and confirmed to be config-only. Same binary, ASPNETCORE_ENVIRONMENT=Production, no rebuild: one JSON object per line. Importantly the fields are genuinely structured, not a rendered blob — "State":{"Method":"GET","PathBase":"","Path":"/places/42","StatusCode":404,"Duration":2.2793}. That is what makes the ticket's "structured logging" real rather than nominal, and it is worth more than the formatter switch itself.

IncludeScopes puts TraceId on every record, including exception records — confirmed, not taken on trust. The HttpsRedirectionMiddleware warning, the request log and the Kestrel exception record all carried the same TraceId scope in the same run. This is the load-bearing claim behind rejecting X-Correlation-Id, and it holds.

Unhandled exception with stack trace — confirmed in Production, under the caller's trace id. I temporarily added a throwing endpoint (and removed it; the tree is clean). The record is LogLevel: Error, Category: Microsoft.AspNetCore.Server.Kestrel, with a populated Exception field carrying the full stack trace, and TraceId:3333... matching the traceparent I sent. That also independently validates the category string RequestLoggingTests pins — the third test is asserting on the real category, not a guess.

Env-var level override — confirmed. Logging__LogLevel__Microsoft.AspNetCore.HttpLogging=None suppressed the request log while leaving the rest of the pipeline logging, i.e. the environment variable beat the settings file exactly as ADR-0014's precedence requires.

WebUI settings are served and contain nothing confidential — confirmed. dotnet publish puts both appsettings.json and appsettings.Development.json under wwwroot, and the dev server returns both with 200. Contents are Logging:LogLevel only, and the "public by definition, no key of any kind" comment is present in both files. ADR-0015's consequence about the Development file being publicly readable is accurate rather than theoretical.

CA1848 really is a hard error — confirmed. app.Logger.LogInformation("probe") fails the build with error CA1848. The next person genuinely cannot write a log statement without [LoggerMessage].

The mutation check

It holds in substance, but one of the two numbers in the description does not reproduce.

  • Deleting the Microsoft.AspNetCore.HttpLogging line: 2 of 3 fail — as claimed.
  • Setting Microsoft.AspNetCore to None, leaving the HttpLogging line at Information: 2 of 3 fail, not 3 of 3. The failures are Send_CallerSuppliesTraceParent_LogsUnderTheCallersTraceId and IsEnabled_UnhandledExceptionCategoryAtError_ReturnsTrue; Send_AnyRequest_LogsMethodPathStatusCodeAndDuration still passes, because the more specific rule wins. (All 3 fail only if you set both lines to None.)

The conclusion — the tests are not vacuous — survives, and the second result is arguably more interesting than the claimed one: it shows the trace-id scope depends on Microsoft.AspNetCore.Hosting being enabled, because nothing starts an Activity otherwise. That is a non-obvious coupling worth having pinned. Please correct the number in the description, since the credibility of the section rests on it. appsettings.json was restored and the 3 tests pass again.

The StatusCode: 200 defect — reproduced, and the ordering claim is sound, with one caveat

Reproduced in both environments. Production:

"Category":"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware","Message":"...StatusCode: 200..."
"LogLevel":"Error","Category":"Microsoft.AspNetCore.Server.Kestrel"   ← the truth, same TraceId

On whether "outermost is the ordering that fixes it once a handler lands": not wishful — I confirmed the nesting structurally. The exception stack trace shows the real pipeline:

DeveloperExceptionPageMiddlewareImpl.Invoke      ← framework, outermost
  HttpLoggingMiddleware.InvokeInternal
    HttpsRedirectionMiddleware.Invoke

Because app.UseHttpLogging() is the first user-registered middleware, any UseExceptionHandler that task 58 adds to Program.cs lands inside it, writes the 500 before the response unwinds past HttpLoggingMiddleware, and the field becomes correct with no further change. The reasoning is right.

Caveat worth recording: that same stack trace shows DeveloperExceptionPageMiddleware is auto-registered by the framework outside everything the user adds, so it can never be brought inside UseHttpLogging. That is harmless provided task 58 registers its own handler (the inner one wins, and the dev page never sees the exception) — but if task 58 decides Development can just rely on the developer exception page, the 200 will silently survive there. One sentence in the Program.cs comment would stop that.

Is shipping the inaccuracy acceptable? Yes, and more comfortably than the description suggests. The API currently maps zero endpoints — I had to add one to make the defect reachable at all. So today there is no code path that can produce a wrong 200, and by the time there is (/health, task 63), either task 58 has landed or the Kestrel Error record sitting beside it under the same trace id tells the truth. Taking a slice of task 58 to pre-empt a defect that nothing can currently trigger would be worse. Non-blocking — but please add a comment on task 58 noting that fixing it also fixes this, because the PR body records the reverse dependency (58's third criterion already satisfied) and not this one.

The correlation criterion — my view, plainly

Reading the ticket precisely: the three numbered acceptance criteria are all met, and I verified each by running the server. "Correlation ID propagation between WebUI and API calls" is in the description's scope sentence, not the criteria list — and that scope is unmet, not merely deferred. I would say so on the ticket rather than let "all acceptance criteria met" imply the whole ticket is done.

Declining to build it was nonetheless the right call, and the reasoning is measured rather than convenient: the header is not CORS-safelisted, task 61 (CORS) is not started, and ADR-0008's single typed client — the only correct home for a DelegatingHandler — does not exist. A partial mechanism would have been ~15 lines that could not be exercised end to end in this repository, i.e. exactly the "configured but silently ineffective" failure mode this ticket exists to prevent.

The gap I would actually fix: the deferred work has no owner. Tasks 58 and 61 exist, but neither says "make the WebUI emit traceparent", and ADR-0015 only says the enablers are in the backlog. Please put the remainder on the typed-HTTP-client ticket (or add a note to task 61), otherwise this quietly falls off the plan.

Judgements on the decisions

Built-in logging over Serilog — sound, and I would not expect it to be regretted at ADR-0007's scale. The framework demonstrably delivers structured records, JSON on stdout, per-category levels, scopes and config-driven levels; Serilog's real edge is sinks, and there is one sink. The "second configuration system" argument is the strongest one and it is correct. The revisit trigger — the day logs need searching rather than reading — is concrete enough to act on. The honest counterpoint, which the ADR half-concedes: Serilog.AspNetCore's UseSerilogRequestLogging produces a cleaner record and gets the status code right across an exception filter for free, so the 200 defect is partly a cost of this choice. It is still the right call, but that is the price, not an incidental.

W3C Trace Context with no bespoke header — correct, and verified rather than assumed. Rejecting X-Correlation-Id on the grounds that it would run in parallel with a trace id that is already on every record is the right reason, and the IncludeScopes behaviour that makes it true is real.

Test infrastructure — proportionate, not scope creep. CLAUDE.md already names WebApplicationFactory as the integration-test approach, so this is planned infrastructure arriving early rather than new. InternalsVisibleTo is the better of the two mechanisms: the documented alternative, public partial class Program {}, widens the public API surface of a production assembly purely for tests, whereas this widens internal surface to exactly one named assembly. Right choice, and the comment explaining it in the .csproj is the reason nobody will "tidy" it away.

CA1848 documentation — adequate but thin. It is in CLAUDE.md and ADR-0015's consequences, neither of which is where someone staring at a red build looks first. The analyser message links to the docs, so nobody is truly blocked. Suggestion (non-blocking): six lines of [LoggerMessage] example in the README's new Logging section would turn "you can't do that" into "do this instead". At this scale the rule itself is arguably over-strict for a hobby app, but that is ADR-0013's decision and not this PR's to relitigate.

ADR-0015 matches template.md exactly — Status/Date/Source, then Context / Decision / Alternatives considered / Consequences, with the optional supersession lines correctly omitted. The index row is correct and no existing ADR file was touched (only docs/adr/README.md). The Alternatives section is unusually good: it argues for Serilog properly before rejecting it, which is what makes the record useful later.

British English is clean throughout the diff. Test naming follows <MethodName>_<Scenario>_<ExpectedResult> in all three cases.

Non-blocking notes

  1. Correct the mutation number in the description: Microsoft.AspNetCore: None alone fails 2 of 3, not all 3.
  2. Give the deferred WebUI correlation an owner on a ticket, and record the carve-out on task 40 rather than closing it as fully done.
  3. Note on task 58 that the global exception handler must be registered inside UseHttpLogging, and that doing so fixes the 200.
  4. Mention the Development caveat in the Program.cs comment: DeveloperExceptionPageMiddleware sits outside all user middleware, so the fix depends on task 58 registering its own handler rather than leaning on the dev page.
  5. [LoggerMessage] example in the README Logging section.
  6. PathBase also appears in the record — HttpLoggingFields.RequestPath implies it. Harmless, the README sample shows it honestly, but "method, path, status code and duration, and nothing else" in ADR-0015 is very slightly overstated.
  7. Nothing guards the WebUI AddConfiguration line. The PR's own framing is that this ticket's failure mode is silent ineffectiveness — and the one silent no-op it found on the client side is now unprotected in an empty PlaceMark.WebUI.Tests. I accept that WebAssemblyHostBuilder is impractical to unit-test and would not hold the PR for it, but it deserves a comment on the line saying "removing this is a silent no-op" so a future tidy-up does not undo it.
  8. Index collision warning: ADR-0016 is being added concurrently on another branch and will append to the same table row position (line 25/26 of docs/adr/README.md). Whichever merges second will conflict there — trivial to resolve, but expect it.

Nothing above blocks. The change builds clean, the tests fail under mutation, all three acceptance criteria are demonstrable against a running server, and the one defect it ships is currently unreachable and documented in three places.

Verdict: mergeable Independent review. I re-ran every claim in the description rather than reading it; where I disagree with a number I say so below. ## What I verified by running it | Check | Result | |---|---| | `dotnet build` | 0 warnings, 0 errors | | `dotnet build -c Release` | 0 warnings, 0 errors | | `dotnet test` | 3 passed, exit 0 | | `dotnet format --verify-no-changes` | exit 0 | **Request log with the caller's trace id — reproduced.** Development, `ASPNETCORE_URLS=http://localhost:5301`, `curl -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' /places/42`: ``` info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9] => SpanId:adaf3a1b1c8aeb92, TraceId:0af7651916cd43dd8448eb211c80319c, ParentId:b7ad6b7169203331 => ... Method: GET PathBase: Path: /places/42 StatusCode: 404 Duration: 0.0317 ``` The trace id is the caller's, not a fresh one, and the parent span is the caller's. An unrelated request in the same process got its own fresh id, so this is adoption rather than coincidence. **JSON formatter in Production — confirmed, and confirmed to be config-only.** Same binary, `ASPNETCORE_ENVIRONMENT=Production`, no rebuild: one JSON object per line. Importantly the fields are genuinely *structured*, not a rendered blob — `"State":{"Method":"GET","PathBase":"","Path":"/places/42","StatusCode":404,"Duration":2.2793}`. That is what makes the ticket's "structured logging" real rather than nominal, and it is worth more than the formatter switch itself. **`IncludeScopes` puts `TraceId` on every record, including exception records — confirmed, not taken on trust.** The `HttpsRedirectionMiddleware` warning, the request log and the Kestrel exception record all carried the same `TraceId` scope in the same run. This is the load-bearing claim behind rejecting `X-Correlation-Id`, and it holds. **Unhandled exception with stack trace — confirmed in Production, under the caller's trace id.** I temporarily added a throwing endpoint (and removed it; the tree is clean). The record is `LogLevel: Error`, `Category: Microsoft.AspNetCore.Server.Kestrel`, with a populated `Exception` field carrying the full stack trace, and `TraceId:3333...` matching the `traceparent` I sent. That also independently validates the category string `RequestLoggingTests` pins — the third test is asserting on the real category, not a guess. **Env-var level override — confirmed.** `Logging__LogLevel__Microsoft.AspNetCore.HttpLogging=None` suppressed the request log while leaving the rest of the pipeline logging, i.e. the environment variable beat the settings file exactly as ADR-0014's precedence requires. **WebUI settings are served and contain nothing confidential — confirmed.** `dotnet publish` puts both `appsettings.json` and `appsettings.Development.json` under `wwwroot`, and the dev server returns both with `200`. Contents are `Logging:LogLevel` only, and the "public by definition, no key of any kind" comment is present in both files. ADR-0015's consequence about the Development file being publicly readable is accurate rather than theoretical. **`CA1848` really is a hard error — confirmed.** `app.Logger.LogInformation("probe")` fails the build with `error CA1848`. The next person genuinely cannot write a log statement without `[LoggerMessage]`. ## The mutation check It holds in substance, but **one of the two numbers in the description does not reproduce.** - Deleting the `Microsoft.AspNetCore.HttpLogging` line: **2 of 3 fail** — as claimed. ✅ - Setting `Microsoft.AspNetCore` to `None`, leaving the `HttpLogging` line at `Information`: **2 of 3 fail, not 3 of 3.** The failures are `Send_CallerSuppliesTraceParent_LogsUnderTheCallersTraceId` and `IsEnabled_UnhandledExceptionCategoryAtError_ReturnsTrue`; `Send_AnyRequest_LogsMethodPathStatusCodeAndDuration` still passes, because the more specific rule wins. (All 3 fail only if you set *both* lines to `None`.) The conclusion — the tests are not vacuous — survives, and the second result is arguably more interesting than the claimed one: it shows the trace-id scope depends on `Microsoft.AspNetCore.Hosting` being enabled, because nothing starts an `Activity` otherwise. That is a non-obvious coupling worth having pinned. Please correct the number in the description, since the credibility of the section rests on it. `appsettings.json` was restored and the 3 tests pass again. ## The `StatusCode: 200` defect — reproduced, and the ordering claim is sound, with one caveat Reproduced in both environments. Production: ``` "Category":"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware","Message":"...StatusCode: 200..." "LogLevel":"Error","Category":"Microsoft.AspNetCore.Server.Kestrel" ← the truth, same TraceId ``` On whether "outermost is the ordering that fixes it once a handler lands": **not wishful — I confirmed the nesting structurally.** The exception stack trace shows the real pipeline: ``` DeveloperExceptionPageMiddlewareImpl.Invoke ← framework, outermost HttpLoggingMiddleware.InvokeInternal HttpsRedirectionMiddleware.Invoke ``` Because `app.UseHttpLogging()` is the first user-registered middleware, any `UseExceptionHandler` that task 58 adds to `Program.cs` lands *inside* it, writes the 500 before the response unwinds past `HttpLoggingMiddleware`, and the field becomes correct with no further change. The reasoning is right. **Caveat worth recording:** that same stack trace shows `DeveloperExceptionPageMiddleware` is auto-registered by the framework *outside everything the user adds*, so it can never be brought inside `UseHttpLogging`. That is harmless provided task 58 registers its own handler (the inner one wins, and the dev page never sees the exception) — but if task 58 decides Development can just rely on the developer exception page, the `200` will silently survive there. One sentence in the `Program.cs` comment would stop that. **Is shipping the inaccuracy acceptable?** Yes, and more comfortably than the description suggests. The API currently maps **zero endpoints** — I had to add one to make the defect reachable at all. So today there is no code path that can produce a wrong `200`, and by the time there is (`/health`, task 63), either task 58 has landed or the Kestrel `Error` record sitting beside it under the same trace id tells the truth. Taking a slice of task 58 to pre-empt a defect that nothing can currently trigger would be worse. Non-blocking — but please add a comment on task 58 noting that fixing it also fixes this, because the PR body records the *reverse* dependency (58's third criterion already satisfied) and not this one. ## The correlation criterion — my view, plainly Reading the ticket precisely: the three numbered acceptance criteria are **all met**, and I verified each by running the server. "Correlation ID propagation between WebUI and API calls" is in the description's scope sentence, not the criteria list — and **that scope is unmet, not merely deferred.** I would say so on the ticket rather than let "all acceptance criteria met" imply the whole ticket is done. Declining to build it was nonetheless the right call, and the reasoning is measured rather than convenient: the header is not CORS-safelisted, task 61 (CORS) is not started, and ADR-0008's single typed client — the only correct home for a `DelegatingHandler` — does not exist. A partial mechanism would have been ~15 lines that could not be exercised end to end in this repository, i.e. exactly the "configured but silently ineffective" failure mode this ticket exists to prevent. **The gap I would actually fix:** the deferred work has no owner. Tasks 58 and 61 exist, but neither says "make the WebUI emit `traceparent`", and ADR-0015 only says the *enablers* are in the backlog. Please put the remainder on the typed-HTTP-client ticket (or add a note to task 61), otherwise this quietly falls off the plan. ## Judgements on the decisions **Built-in logging over Serilog — sound, and I would not expect it to be regretted at ADR-0007's scale.** The framework demonstrably delivers structured records, JSON on stdout, per-category levels, scopes and config-driven levels; Serilog's real edge is sinks, and there is one sink. The "second configuration system" argument is the strongest one and it is correct. The revisit trigger — *the day logs need searching rather than reading* — is concrete enough to act on. The honest counterpoint, which the ADR half-concedes: `Serilog.AspNetCore`'s `UseSerilogRequestLogging` produces a cleaner record *and* gets the status code right across an exception filter for free, so the `200` defect is partly a cost of this choice. It is still the right call, but that is the price, not an incidental. **W3C Trace Context with no bespoke header — correct, and verified rather than assumed.** Rejecting `X-Correlation-Id` on the grounds that it would run in parallel with a trace id that is already on every record is the right reason, and the `IncludeScopes` behaviour that makes it true is real. **Test infrastructure — proportionate, not scope creep.** `CLAUDE.md` already names `WebApplicationFactory` as the integration-test approach, so this is planned infrastructure arriving early rather than new. `InternalsVisibleTo` is the better of the two mechanisms: the documented alternative, `public partial class Program {}`, widens the *public* API surface of a production assembly purely for tests, whereas this widens internal surface to exactly one named assembly. Right choice, and the comment explaining it in the `.csproj` is the reason nobody will "tidy" it away. **`CA1848` documentation — adequate but thin.** It is in `CLAUDE.md` and ADR-0015's consequences, neither of which is where someone staring at a red build looks first. The analyser message links to the docs, so nobody is truly blocked. Suggestion (non-blocking): six lines of `[LoggerMessage]` example in the README's new Logging section would turn "you can't do that" into "do this instead". At this scale the rule itself is arguably over-strict for a hobby app, but that is ADR-0013's decision and not this PR's to relitigate. **ADR-0015** matches `template.md` exactly — `Status`/`Date`/`Source`, then Context / Decision / Alternatives considered / Consequences, with the optional supersession lines correctly omitted. The index row is correct and no existing ADR file was touched (only `docs/adr/README.md`). The Alternatives section is unusually good: it argues *for* Serilog properly before rejecting it, which is what makes the record useful later. British English is clean throughout the diff. Test naming follows `<MethodName>_<Scenario>_<ExpectedResult>` in all three cases. ## Non-blocking notes 1. **Correct the mutation number** in the description: `Microsoft.AspNetCore: None` alone fails 2 of 3, not all 3. 2. **Give the deferred WebUI correlation an owner** on a ticket, and record the carve-out on task 40 rather than closing it as fully done. 3. **Note on task 58** that the global exception handler must be registered *inside* `UseHttpLogging`, and that doing so fixes the `200`. 4. **Mention the Development caveat** in the `Program.cs` comment: `DeveloperExceptionPageMiddleware` sits outside all user middleware, so the fix depends on task 58 registering its own handler rather than leaning on the dev page. 5. **`[LoggerMessage]` example** in the README Logging section. 6. **`PathBase` also appears** in the record — `HttpLoggingFields.RequestPath` implies it. Harmless, the README sample shows it honestly, but "method, path, status code and duration, and *nothing else*" in ADR-0015 is very slightly overstated. 7. **Nothing guards the WebUI `AddConfiguration` line.** The PR's own framing is that this ticket's failure mode is silent ineffectiveness — and the one silent no-op it found on the client side is now unprotected in an empty `PlaceMark.WebUI.Tests`. I accept that `WebAssemblyHostBuilder` is impractical to unit-test and would not hold the PR for it, but it deserves a comment on the line saying "removing this is a silent no-op" so a future tidy-up does not undo it. 8. **Index collision warning:** ADR-0016 is being added concurrently on another branch and will append to the same table row position (line 25/26 of `docs/adr/README.md`). Whichever merges second will conflict there — trivial to resolve, but expect it. Nothing above blocks. The change builds clean, the tests fail under mutation, all three acceptance criteria are demonstrable against a running server, and the one defect it ships is currently unreachable and documented in three places.
Action review feedback on logging
All checks were successful
CI / build (pull_request) Successful in 44s
a82c596592
Guards the WebUI configuration binding, which was the one place this
ticket had proven a silent no-op and left it unprotected. Deleting the
line restores the bug exactly: the file is fetched, parsed and ignored,
with no error. The test is a source-text assertion rather than a
behavioural one, because WebAssemblyHostBuilder.CreateDefault needs the
JavaScript runtime and cannot be built in a test process at all — that
weakness is stated in the test's own documentation and in the ADR rather
than implied, along with what to replace it with once the client has a
harness for its own reasons.

Adds a compiled [LoggerMessage] example. CA1848 is a hard error here, so
logger.LogInformation does not build, and there was no worked example for
the first person to hit it. The snippet was compiled under this project's
analyser settings before being written down.

Records that the development exception page is registered outside all
user middleware and cannot be brought inside, so a handler added under
the customary IsDevelopment guard would leave the request log reporting
200 locally — which is where anyone is most likely to be reading these
logs and least likely to doubt them.

Corrects the mutation claim: Microsoft.AspNetCore None fails two of three
tests, not three. Chasing the real number isolated the mechanism.
Disabling only Microsoft.AspNetCore.Hosting leaves the request log intact
and silently removes the trace id, because ASP.NET Core starts a request
Activity only when something is listening and that logger counts. A level
that reads as a verbosity preference is load-bearing for correlation. Now
named in the ADR and guarded by a test that fails alone when it is set.

Puts the Serilog concession first among the alternatives, ahead of the
sink-ecosystem argument: UseSerilogRequestLogging would not have had the
status code defect, which is an argument about correctness rather than
convenience and is the strongest case against this decision.
rob left a comment

Verdict: mergeable

Re-review at a82c596, superseding my 134a83a token. I re-ran everything against the new head — extracted via git archive into a scratch directory and built there, so the worktree stayed at the old commit and nothing was checked out or mutated.

Gate checks at a82c596

Check Result
dotnet build 0 warnings, 0 errors
dotnet build -c Release 0 warnings, 0 errors
dotnet test 4 passed (3 API + 1 WebUI), exit 0
dotnet format --verify-no-changes exit 0

PlaceMark.WebUI.Tests now reports a passing test rather than "No test is available" — confirmed. Domain.Tests and Infrastructure.Tests still report it, which is pre-existing and out of scope.

The Microsoft.AspNetCore.Hosting isolation — verified, and it is the best thing in this PR

Exactly as claimed:

$ Logging__LogLevel__Microsoft.AspNetCore.Hosting=None dotnet test tests/PlaceMark.Api.Tests
  Failed  Send_CallerSuppliesTraceParent_LogsUnderTheCallersTraceId
Failed! - Failed: 1, Passed: 2, Total: 3

One failure, precisely the correlation test; the other two pass. I also confirmed the underlying behaviour on a real server rather than only through the test. Same binary, same traceparent, only that variable changed:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      => ConnectionId:0HNNH5QJRQD2H
      Method: GET   Path: /places/42   StatusCode: 404   Duration: 2.2511

The request log is completely intact — every field the ticket asks for is still there — and the scope has collapsed to ConnectionId alone. TraceId, SpanId, ParentId, RequestPath and RequestId are all gone, despite a valid inbound traceparent. Nothing in the output hints that correlation has been switched off.

This is a genuinely nasty trap: a category name that reads as a pure verbosity dial silently disables the acceptance criterion the ticket cares most about, and it does so while leaving the record it would have annotated looking perfectly healthy. Naming it in ADR-0015 as observed, in the README under "two things that will cost you an afternoon", and pinning it with a test that fails in exactly that configuration is the right three-part response. Good result.

Finding 7 — the source-text test: worth having, not theatre

I mutation-checked it three ways, not two.

Mutation Result
Delete the binding line Fails
Rename GetSection("Logging")"Logging2" Fails
Comment the line out (// builder.Logging.AddConfiguration(...)) Passes ⚠️

Both claimed mutations hold. The third is mine, and it is the inherent hole in any source-text assertion: a commented-out statement is still text. I raise it for completeness rather than as an objection — commenting out a line that carries "Do not delete this line" and three sentences of explanation is a much less plausible accident than deleting it during a tidy-up, which is the case actually guarded. If you want it closed cheaply, asserting the match is not preceded by // after whitespace-stripping is a one-line change; equally defensible to leave, since the XML docs already say the test proves presence in source and not behaviour.

My judgement: worth having, and it clears the theatre bar for a specific reason. A test is theatre when it cannot fail for the reason it claims to exist. This one can, and does, under the exact mutation it was written for. The constraint driving it is real — I confirmed WebAssemblyHostBuilder.CreateDefault genuinely cannot be built in a test process, so the honest choice was a bUnit harness for one statement, this, or nothing — and the docs state the limitation in the test itself rather than in a commit message nobody will read. What tips it from "weak but acceptable" to "right" is that the bug it guards has no symptom: delete the line and everything still looks configured. A weak test against a silent failure beats no test against a silent failure. The instruction to replace it once the client has a harness for its own reasons is the part that stops it calcifying.

One incidental confirmation: [CallerFilePath] resolves correctly. I compiled and ran the suite from a completely different absolute path from the author's, and it passed — so the path is genuinely baked relative to the source file rather than hardcoded, and the documented "assumes the tests run on the machine that built them" caveat is accurate.

Finding 5 — the [LoggerMessage] example compiles verbatim

I did doubt it, so I checked. Pasted the README snippet unaltered into PlaceMark.Api, added a call site, built under TreatWarningsAsErrors + AnalysisMode=Recommended: 0 warnings, 0 errors, at both the declaration and the call. The prose around it is right on the points that actually bite — both partial, method static and void, generator writes the body — and framing the named placeholders as "queryable in the JSON output rather than buried in a sentence" connects the syntax to the reason, which is what stops the next person writing Message = "Place added" and interpolating. Removed the probe file afterwards.

Finding 4 — the caveat is recorded in the form someone will hit it

The Program.cs comment and ADR-0015 both now name the failure mode rather than the mechanism: an exception handler behind the customary if (!app.Environment.IsDevelopment()) guard leaves the 200 in place locally. That is exactly right, and putting "where anyone is most likely to be reading these logs and least likely to doubt them" next to it is what makes it land — the discrepancy is worst precisely where it is least visible. The stack trace is quoted outermost-first, matching what I observed.

The related correction — that Kestrel logs the unhandled exception in a deployed environment but the developer exception page catches it first in Development, logging at fail — matches both of my runs exactly. Catching that unprompted is the kind of precision that makes the rest of the record trustworthy.

Serilog concession

Placed first among the alternatives and sharpened past what I wrote: UseSerilogRequestLogging wraps the pipeline in its own try/catch, so it records the 500 and the exception in one record without depending on another middleware having converted the exception first. Correct, and the right thing to lead with, since it is the one argument about correctness rather than convenience. The rebuttal is bounded and honest rather than dismissive — wrong only for unhandled failures, an Error record beside it under the same trace id, closed in deployed environments by the global exception handling ticket. Whoever revisits this now has both sides without rediscovering either.

Everything else re-confirmed

British English clean across the new commit. No existing accepted ADR touched — only ADR-0015 itself, which is this PR's own and unmerged, and the index row from the first commit is unchanged. The #writing-a-log-statement anchor referenced from ADR-0015 matches the new README heading, and the relative path from docs/adr/ resolves.

Standing non-blocking items

All four you are handling outside the branch (PR body number, deferred correlation ownership, task 58 ordering note, ADR-0016 index conflict) remain as I described them; none blocks. My finding 6 (PathBase also appears, so "and nothing else" is very slightly overstated) is still open and still cosmetic. New and equally optional: the commented-out-line hole in the source assertion above.

Nothing blocks. Four tests, all meaningful under mutation, all three acceptance criteria demonstrable against a running server, the one shipped defect unreachable today and now documented with its Development-specific caveat, and the correlation trap I asked about turned into the best-documented thing in the change.

Verdict: mergeable Re-review at `a82c596`, superseding my `134a83a` token. I re-ran everything against the new head — extracted via `git archive` into a scratch directory and built there, so the worktree stayed at the old commit and nothing was checked out or mutated. ## Gate checks at `a82c596` | Check | Result | |---|---| | `dotnet build` | 0 warnings, 0 errors | | `dotnet build -c Release` | 0 warnings, 0 errors | | `dotnet test` | **4 passed** (3 API + 1 WebUI), exit 0 | | `dotnet format --verify-no-changes` | exit 0 | `PlaceMark.WebUI.Tests` now reports a passing test rather than "No test is available" — confirmed. `Domain.Tests` and `Infrastructure.Tests` still report it, which is pre-existing and out of scope. ## The `Microsoft.AspNetCore.Hosting` isolation — verified, and it is the best thing in this PR Exactly as claimed: ``` $ Logging__LogLevel__Microsoft.AspNetCore.Hosting=None dotnet test tests/PlaceMark.Api.Tests Failed Send_CallerSuppliesTraceParent_LogsUnderTheCallersTraceId Failed! - Failed: 1, Passed: 2, Total: 3 ``` One failure, precisely the correlation test; the other two pass. I also confirmed the underlying behaviour on a real server rather than only through the test. Same binary, same `traceparent`, only that variable changed: ``` info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9] => ConnectionId:0HNNH5QJRQD2H Method: GET Path: /places/42 StatusCode: 404 Duration: 2.2511 ``` The request log is completely intact — every field the ticket asks for is still there — and the scope has collapsed to `ConnectionId` alone. `TraceId`, `SpanId`, `ParentId`, `RequestPath` and `RequestId` are all gone, despite a valid inbound `traceparent`. Nothing in the output hints that correlation has been switched off. This is a genuinely nasty trap: a category name that reads as a pure verbosity dial silently disables the acceptance criterion the ticket cares most about, and it does so while leaving the record it would have annotated looking perfectly healthy. Naming it in ADR-0015 as observed, in the README under "two things that will cost you an afternoon", and pinning it with a test that fails in exactly that configuration is the right three-part response. Good result. ## Finding 7 — the source-text test: worth having, not theatre I mutation-checked it three ways, not two. | Mutation | Result | |---|---| | Delete the binding line | **Fails** ✅ | | Rename `GetSection("Logging")` → `"Logging2"` | **Fails** ✅ | | Comment the line out (`// builder.Logging.AddConfiguration(...)`) | **Passes** ⚠️ | Both claimed mutations hold. The third is mine, and it is the inherent hole in any source-text assertion: a commented-out statement is still text. I raise it for completeness rather than as an objection — commenting out a line that carries "Do not delete this line" and three sentences of explanation is a much less plausible accident than deleting it during a tidy-up, which is the case actually guarded. If you want it closed cheaply, asserting the match is not preceded by `//` after whitespace-stripping is a one-line change; equally defensible to leave, since the XML docs already say the test proves presence in source and not behaviour. **My judgement: worth having, and it clears the theatre bar for a specific reason.** A test is theatre when it cannot fail for the reason it claims to exist. This one can, and does, under the exact mutation it was written for. The constraint driving it is real — I confirmed `WebAssemblyHostBuilder.CreateDefault` genuinely cannot be built in a test process, so the honest choice was a bUnit harness for one statement, this, or nothing — and the docs state the limitation in the test itself rather than in a commit message nobody will read. What tips it from "weak but acceptable" to "right" is that the bug it guards has **no symptom**: delete the line and everything still looks configured. A weak test against a silent failure beats no test against a silent failure. The instruction to replace it once the client has a harness for its own reasons is the part that stops it calcifying. One incidental confirmation: `[CallerFilePath]` resolves correctly. I compiled and ran the suite from a completely different absolute path from the author's, and it passed — so the path is genuinely baked relative to the source file rather than hardcoded, and the documented "assumes the tests run on the machine that built them" caveat is accurate. ## Finding 5 — the `[LoggerMessage]` example compiles verbatim I did doubt it, so I checked. Pasted the README snippet unaltered into `PlaceMark.Api`, added a call site, built under `TreatWarningsAsErrors` + `AnalysisMode=Recommended`: **0 warnings, 0 errors**, at both the declaration and the call. The prose around it is right on the points that actually bite — both `partial`, method `static` and `void`, generator writes the body — and framing the named placeholders as "queryable in the JSON output rather than buried in a sentence" connects the syntax to the reason, which is what stops the next person writing `Message = "Place added"` and interpolating. Removed the probe file afterwards. ## Finding 4 — the caveat is recorded in the form someone will hit it The `Program.cs` comment and ADR-0015 both now name the failure mode rather than the mechanism: an exception handler behind the customary `if (!app.Environment.IsDevelopment())` guard leaves the `200` in place locally. That is exactly right, and putting "where anyone is most likely to be reading these logs and least likely to doubt them" next to it is what makes it land — the discrepancy is worst precisely where it is least visible. The stack trace is quoted outermost-first, matching what I observed. The related correction — that Kestrel logs the unhandled exception in a deployed environment but the developer exception page catches it first in Development, logging at `fail` — matches both of my runs exactly. Catching that unprompted is the kind of precision that makes the rest of the record trustworthy. ## Serilog concession Placed first among the alternatives and sharpened past what I wrote: `UseSerilogRequestLogging` wraps the pipeline in its own `try`/`catch`, so it records the 500 and the exception in one record without depending on another middleware having converted the exception first. Correct, and the right thing to lead with, since it is the one argument about correctness rather than convenience. The rebuttal is bounded and honest rather than dismissive — wrong only for unhandled failures, an `Error` record beside it under the same trace id, closed in deployed environments by the global exception handling ticket. Whoever revisits this now has both sides without rediscovering either. ## Everything else re-confirmed British English clean across the new commit. No existing accepted ADR touched — only ADR-0015 itself, which is this PR's own and unmerged, and the index row from the first commit is unchanged. The `#writing-a-log-statement` anchor referenced from ADR-0015 matches the new README heading, and the relative path from `docs/adr/` resolves. ## Standing non-blocking items All four you are handling outside the branch (PR body number, deferred correlation ownership, task 58 ordering note, ADR-0016 index conflict) remain as I described them; none blocks. My finding 6 (`PathBase` also appears, so "and nothing else" is very slightly overstated) is still open and still cosmetic. New and equally optional: the commented-out-line hole in the source assertion above. Nothing blocks. Four tests, all meaningful under mutation, all three acceptance criteria demonstrable against a running server, the one shipped defect unreachable today and now documented with its Development-specific caveat, and the correlation trap I asked about turned into the best-documented thing in the change.
rob merged commit 5fb03f068f into main 2026-08-03 07:16:07 +00:00
rob deleted branch feat/logging-observability 2026-08-03 07:16:08 +00:00
rob referenced this pull request from a commit 2026-08-03 07:16:09 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!12
No description provided.