Skip to content

feat(app): structured logs at domain state transitions#228

Merged
mforce merged 2 commits into
mainfrom
feat/216-handler-logs
Jul 26, 2026
Merged

feat(app): structured logs at domain state transitions#228
mforce merged 2 commits into
mainfrom
feat/216-handler-logs

Conversation

@mforce

@mforce mforce commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #216. Third telemetry slice, on top of #226 + #227.

What

  • 14 money-path handlers narrate their state transitions: daily entry record/submit/adjust/void, FIFO allocation (confirm sale), void sale, cancel order, record/void payment, feed usage, water usage, purchase, inventory adjustment, bird movement.
    • Information on success with stable structured ids (DailyEntryId, FlockId, SalesOrderId, PaymentId, FeedUsageId, …) — property names consistent across handlers so one log query works across features.
    • Warning on every failed Result through a single ResultLogging extension: {Operation} failed: {ErrorCode} — {ErrorDescription}ErrorCode is the stable query key.
  • AccountId on every in-request event (tech spec §10): the tenant middleware opens a logging scope beside the feat(api): activate the telemetry pipeline — OTLP export, request logs, TraceId correlation #226 completion-event enrichment, so handler lines inherit the account with zero per-call noise. Scope disposed with the request.
  • No secrets/PII in properties — ids, quantities, dates, error codes only.

Root-cause find: the third static-logger bug

Handler events vanished — but only in multi-host test runs, and only for logger categories created after another host shut down. The Serilog MEL bridge binds categories through the process-global Log.Logger; any co-hosted Serilog app's dispose flips that to SilentLogger (via Log.CloseAndFlush), permanently silencing every category created afterwards (probe showed IsEnabled = false across all levels). Fixed with preserveStaticLogger: true — this host's pipeline never binds through nor stomps the static. Same family as #226's options.Logger and DiagnosticContext pinnings; production is single-host so the latent risk was shutdown-window only, but the test suite made it deterministic.

Tests

4 integration tests drive real HTTP flows and assert through the DI sink tap: submit carries ids + account scope; confirm carries the order id; insufficient-stock confirm warns with EggLot.InsufficientStock; payment carries order + payment ids. Asserts correlate by entity id, so the shared sink can't cross-contaminate.

Suite: 734 green, integration run twice, build clean. New package: Microsoft.Extensions.Logging.Abstractions (Application layer), lock files synced.

- 14 money-path handlers now narrate their transitions: daily entry
  record/submit/adjust/void, FIFO allocation (confirm), sale void, order
  cancel, payment record/void, feed/water usage, purchase, inventory
  adjustment, bird movement. Information on success with stable ids
  (DailyEntryId, FlockId, SalesOrderId, PaymentId, ...), Warning with the
  failure reason on every failed Result via one ResultLogging extension
  ({Operation} failed: {ErrorCode} — {ErrorDescription}).
- AccountId on every in-request event (spec §10): the tenant middleware
  opens a logging scope beside the existing completion-event enrichment,
  so handler lines inherit the account without per-call noise.
- Serilog MEL bridge pinned with preserveStaticLogger: the bridge bound
  logger categories through the process-global Log.Logger, so any
  co-hosted Serilog app's shutdown (Log.CloseAndFlush -> SilentLogger)
  permanently silenced every category created afterwards. Third bug of
  this static-fallback family (after options.Logger and DiagnosticContext
  in #226); found because handler events vanished only in multi-host test
  runs.
- New package: Microsoft.Extensions.Logging.Abstractions in Application;
  lock files synced.

4 integration tests drive real HTTP flows through the sink tap: submit
carries ids + account scope, confirm carries the order id, insufficient
stock warns with ErrorCode, payment carries order + payment ids. Suite
734 green, integration run twice.
// ...and on every event logged INSIDE the request (#216): handler
// transition logs inherit it via FromLogContext. Disposed with the
// request so the AsyncLocal scope can't bleed past it.
return logger.BeginScope(new Dictionary<string, object> { ["AccountId"] = accountId });
- AdjustDailyEntry's UnknownGrade exit was the single failed-Result path
  across all 14 handlers that bypassed LogFailure (codex + agent; verified
  by a statement-level scan of every touched handler).
- DailyEntryLockSweep now narrates each lock per entry AFTER the save
  (DailyEntryId, FlockId, EntryDate, AccountId) — the 'lock' transition is
  a #216 state transition too, and logging before commit would narrate
  locks that never happened. Its failure path adopts the canonical
  DailyEntryId/ErrorCode/ErrorDescription shape (was EntryId/Error).
- Insufficient-stock test correlates through a GUID-suffixed grade name
  instead of fixed wording; middleware comment no longer claims
  FromLogContext serves MEL scopes.

Rejected with evidence (details on the PR): pi's outcome-NRE quartet
(every transaction-lambda exit sets outcome before returning false;
independently traced by the agent reviewer), pi's Set+BeginScope
redundancy (completion event bypasses MEL scopes), cavecrew's
ErrorDescription-PII (flock/grade names are operator-authored farm data;
grep shows no customer fields in any Error.Description) and date-name
drift (each date property names its domain concept; ids are the join
keys).

Suite 734 green, integration run twice.
@mforce

mforce commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Review round (repo policy: codex + 2 Claude agents + pi)

Accepted → fixed in f97898b:

Finding Source Fix
AdjustDailyEntry.UnknownGrade was the ONE failed-Result exit (of ~40 across 14 handlers) bypassing LogFailure codex, agent — confirmed by statement-level scan .LogFailure appended
The lock transition (named in AC 1) only batch-logged, no per-entry id; failure props EntryId/Error broke the canonical shape codex HIGH + agent DailyEntryLockSweep logs each lock per entry AFTER SaveChanges (DailyEntryId, FlockId, EntryDate, AccountId — background job, no request scope) and adopts ErrorCode/ErrorDescription on failure
Middleware comment credited FromLogContext for MEL scope flow agent minor comment corrected (Serilog provider's MEL scope handling)
Warning test coupled to fixed grade-name wording agent minor GUID-suffixed marker

Rejected with evidence:

  • pi's four "important" NRE claims (outcome! deref on failure path, 4 handlers) — every return false path inside those transaction lambdas sets outcome first; exceptions bypass the tail line entirely. Independently traced exit-by-exit by the agent reviewer. Pattern also pre-existing and unchanged.
  • pi minor (IDiagnosticContext.Set + BeginScope redundant) — the completion event is written through the pinned raw Serilog logger, which never sees MEL scopes. Set feeds the completion; BeginScope feeds handler events. Both required (tests assert AccountId on both).
  • cavecrew's ErrorDescription-PII — flock/grade/item names are operator-authored farm data, not personal data; grep shows no customer name/email/phone in any Error.Description (NotFound carries ids only); user free-text reasons go to the audit trail, never into Error. ErrorCode remains the stable dashboard key, ErrorDescription is for the human reading the page.
  • cavecrew's date-property drift (EntryDate/UsageDate/ReceivedDate/AllocationDate/MovementDate) — deliberate: each names its domain concept and mirrors its command field; cross-handler joins run on the uniform ids + AccountId.

Reviewer verification highlights (agent): every exit in all 14 handlers traced — no double-logging; zero static Log.* usage anywhere, so preserveStaticLogger: true loses nothing; layering clean (only Logging.Abstractions enters Application); no Reason/Note free text in any structured property.

Suite after fixes: 734 green, integration run twice, build clean.

foreach (var entry in due.Where(e => e.Status == DailyEntryStatus.Locked))
logger.LogInformation(
"Daily entry {DailyEntryId} locked for flock {FlockId} on {EntryDate} (account {AccountId})",
entry.Id, entry.FlockId, entry.Date, accountId);
@mforce
mforce merged commit a3e5e48 into main Jul 26, 2026
6 of 7 checks passed
@mforce
mforce deleted the feat/216-handler-logs branch July 26, 2026 06:20
@mforce mforce mentioned this pull request Jul 26, 2026
57 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Observability: structured logs at domain state transitions

2 participants