Skip to content

fix(application): log swallowed handler exceptions before wrapping in Result.Failure - #122

Merged
Pomdapis merged 1 commit into
mainfrom
fix/dispatcher-log-swallowed-exceptions
Jun 11, 2026
Merged

fix(application): log swallowed handler exceptions before wrapping in Result.Failure#122
Pomdapis merged 1 commit into
mainfrom
fix/dispatcher-log-swallowed-exceptions

Conversation

@Pomdapis

Copy link
Copy Markdown
Contributor

P0-02 — Dispatcher swallows handler exceptions silently

The Compendium command/query dispatchers convert any exception thrown inside a handler into
Result.Failure(Error.Failure("*.ExecutionFailed", ex.Message)) without logging the exception or
stack trace
. In production this makes debugging blind: the only trace is on OTel spans
(exception.type / exception.message activity tags), which are not always sampled or retained.

Refs:

  • Nexus bug inventory P0-02
  • Memory note project_compendium_dispatcher_silent
  • Nexus PR ci: switch to self-hosted ARC runners (runs-on: dotnet) #86 added an HTTP-layer ProblemDetailsLoggingFilter that only partially mitigated this (REST surface only — MCP, SDK, background/process-manager dispatch paths still went dark).

What changed

Three swallow points fixed (CommandDispatcher.DispatchAsync<TCommand>,
CommandDispatcher.DispatchAsync<TCommand,TResult>, QueryDispatcher.DispatchAsync<TQuery,TResult>):

  1. Structured error logging before wrapping. Each catch block now calls
    _logger.LogError(ex, "...", commandOrQueryType, ...) — logging the full exception (with stack
    trace) at Error level before building the failure Result.
  2. No public API break. Both dispatchers already receive IServiceProvider only. Rather than
    change the public single-argument constructor (which downstream consumers call directly — see the
    existing unit tests), the logger is resolved from the service provider with a
    NullLogger<T> fallback when no logging is configured. Binary- and source-compatible.
  3. Non-breaking error enrichment. Error.Failure(...) already supports an optional metadata
    dictionary, so the failure error now carries { exceptionType } — letting consumers discriminate
    the underlying cause without string-parsing Error.Message.
  4. Result contract preserved. Still returns Result.Failure, never rethrows.

The pre-existing LoggingBehavior<TRequest,TResponse> only logs when registered as a pipeline
behavior and rethrows; the dispatcher catch sits outside the behavior pipeline and is the final,
unconditional swallow point — which is exactly what this fixes.

Tests

Added to CommandDispatcherTests / QueryDispatcherTests (+ a tiny CapturingLogger<T> helper to
avoid a new Microsoft.Extensions.Diagnostics.Testing dependency):

  • handler throws → Result.Failure returned AND logger received the exception at Error level
    AND exceptionType metadata present (command no-result, command-with-result, and query paths)
  • no ILogger registered → falls back to NullLogger, still returns failure, does not throw
  • success path → no Error-level log emitted

dotnet test Compendium.Application.Tests232 passed. Architecture tests → 37 passed.

Notes

  • The legacy [Obsolete] SagaOrchestrator (slated for removal in v1.0) has similar catch-and-wrap
    blocks but is deprecated dead-path; intentionally out of scope for this focused P0 fix. The live
    choreography path (ChoreographyRouter) already aggregates handler errors with messages.

… Result.Failure

P0-02: CommandDispatcher and QueryDispatcher converted any exception thrown
in a handler into Result.Failure(Error.Failure("*.ExecutionFailed", ex.Message))
without logging the exception or stack trace. Production debugging was blind —
the only trace was on OTel spans (exception.type/exception.message tags), which
are not always sampled or retained.

Changes:
- Both dispatchers now resolve ILogger<T> from the existing IServiceProvider
  (NullLogger<T> fallback) so the public single-arg constructor stays binary-
  and source-compatible for downstream consumers.
- Before wrapping the exception into Result.Failure, log it at Error level with
  the full exception (stack trace) and the command/query type name.
- Enrich the failure Error with non-breaking metadata { exceptionType } so
  consumers can discriminate the underlying cause without parsing the message.
- Result-pattern contract preserved: still returns Result.Failure, never rethrows.

Tests: handler-throws paths now assert Result.Failure is returned AND the logger
received the exception at Error level AND the exceptionType metadata is present,
plus a NullLogger fallback test and success-path no-error-log assertions.

Ref: Nexus bug inventory P0-02; memory project_compendium_dispatcher_silent
Copilot AI review requested due to automatic review settings June 11, 2026 08:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses P0-02 by ensuring exceptions thrown by CQRS command/query handlers are no longer silently swallowed: the dispatchers now log the exception (including stack trace) before converting it into a Result.Failure, and they enrich the failure Error with exceptionType metadata without breaking public APIs.

Changes:

  • Add ILogger<T> resolution (with NullLogger<T> fallback) to CommandDispatcher and QueryDispatcher without changing their public constructors.
  • Log swallowed handler exceptions at Error level before returning Result.Failure(...), and attach { exceptionType } metadata to the failure error.
  • Add unit tests (and a lightweight CapturingLogger<T>) to verify logging + metadata enrichment + null-logger fallback behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/Application/Compendium.Application/CQRS/CommandDispatcher.cs Resolve logger via IServiceProvider, log swallowed handler exceptions, and add exceptionType metadata on execution-failure results.
src/Application/Compendium.Application/CQRS/QueryDispatcher.cs Same as command dispatcher: structured error log before wrapping, plus exceptionType metadata enrichment.
tests/Unit/Compendium.Application.Tests/CQRS/CapturingLogger.cs Adds minimal in-memory ILogger<T> implementation for asserting logged entries without extra dependencies.
tests/Unit/Compendium.Application.Tests/CQRS/CommandDispatcherTests.cs Adds coverage for logging-on-throw, null-logger fallback, and “no error logs on success” for command dispatch paths.
tests/Unit/Compendium.Application.Tests/CQRS/QueryDispatcherTests.cs Adds coverage for logging-on-throw, null-logger fallback, and “no error logs on success” for query dispatch path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Pomdapis
Pomdapis merged commit acdd73e into main Jun 11, 2026
6 checks passed
@Pomdapis
Pomdapis deleted the fix/dispatcher-log-swallowed-exceptions branch June 11, 2026 08:48
This was referenced Jun 15, 2026
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.

2 participants