Skip to content

Route optional interfaces through OptionalInterfaceForwardingSink for restricted sinks#2234

Merged
nblumhardt merged 2 commits into
serilog:devfrom
ArieGato:fix/2231-restrictedsink-forward-failure-listener
May 11, 2026
Merged

Route optional interfaces through OptionalInterfaceForwardingSink for restricted sinks#2234
nblumhardt merged 2 commits into
serilog:devfrom
ArieGato:fix/2231-restrictedsink-forward-failure-listener

Conversation

@ArieGato

@ArieGato ArieGato commented May 1, 2026

Copy link
Copy Markdown
Contributor

Closes #2231.

Summary

Serilog.Core.Sinks.RestrictedSink — the wrapper applied by LoggerSinkConfiguration.Sink(sink, restrictedToMinimumLevel) whenever restrictedToMinimumLevel is anything other than LevelAlias.Minimum — hid the inner sink's optional interfaces (IDisposable, IAsyncDisposable, ISetLoggingFailureListener). The most visible consequence: the failure listener installed by FailureListenerSink (used internally by WriteTo.FallbackChain(...) and WriteTo.Fallible(...), introduced in #2108) never reached the inner sink.

For synchronous sinks the gap is masked: FailureListenerSink.Emit(...) catches exceptions and notifies the listener directly. For async/batched sinks that surface failures only through ILoggingFailureListener (notably anything built on BatchingSink), the listener stayed unset and failed batches were silently dropped via SelfLog instead of being routed to the next stage of the fallback chain.

Per maintainer feedback, the fix routes optional interfaces through the existing OptionalInterfaceForwardingSink (the canonical mechanism, already used in the chained-sink path) rather than re-implementing forwarding inside each wrapper. Future optional interfaces will only need updates in one place.

Changes

  • RestrictedSink is now a plain ILogEventSink — its Dispose/DisposeAsync/SetFailureListener shims are removed.
  • LoggerSinkConfiguration.Sink(...) wraps the resulting RestrictedSink in OptionalInterfaceForwardingSink whenever the inner sink supports any optional interface, mirroring the existing chained-sink wiring.
  • Integration test RestrictedToMinimumLevelAllowsFailureListenerPropagation — pins the wiring through FallbackChain + restrictedToMinimumLevel.
  • Companion test DefaultLevelAllowsFailureListenerPropagation — pins the un-wrapped baseline.
  • Added small ListenerTrackingSink helper for the integration tests.

Test plan

  • dotnet test test/Serilog.Tests --filter "FullyQualifiedName~FailureListenerTests" — 4/4 passing on net8.0, net9.0, net10.0
  • Full Serilog.Tests suite — 815/815 passing
  • Serilog.ApprovalTests passes (no public-API drift; RestrictedSink is internal)
  • CI on windows-latest covers the net48/net462 legs

Discovered via

Wiring Serilog.Sinks.RabbitMQ against the new WriteTo.FallbackChain / WriteTo.Fallible extensions from #2108. Setting RestrictedToMinimumLevel = Information on the primary sink silently broke the whole fallback story, with no compile-time or runtime indication.

@nblumhardt

Copy link
Copy Markdown
Member

Thanks for checking this out.

Since RestrictedSink is a wrapper, OptionalInterfaceForwardingSink should have this covered, and RestrictedSink's implementations of Dispose/DisposeAsync can be removed.

It's a little awkward, but this works in my copy:

        var sink = logEventSink;
        if (levelSwitch != null)
        {
            if (restrictedToMinimumLevel != LevelAlias.Minimum)
                SelfLog.WriteLine("Sink {0} was configured with both a level switch and minimum level '{1}'; the minimum level will be ignored and the switch level used", sink, restrictedToMinimumLevel);

            sink = new RestrictedSink(logEventSink, levelSwitch);
            if (!OptionalInterfaceForwardingSink.SupportsAll(sink) && OptionalInterfaceForwardingSink.SupportsAny(logEventSink))
            {
                sink = new OptionalInterfaceForwardingSink(sink, logEventSink);
            }
        }
        else if (restrictedToMinimumLevel > LevelAlias.Minimum)
        {
            sink = new RestrictedSink(logEventSink, new(restrictedToMinimumLevel));
            if (!OptionalInterfaceForwardingSink.SupportsAll(sink) && OptionalInterfaceForwardingSink.SupportsAny(logEventSink))
            {
                sink = new OptionalInterfaceForwardingSink(sink, logEventSink);
            }
        }

In the long run, thie idea is that for any new added interfaces, an update to OptionalInterfaceForwardingSink should be sufficient to get it working consistently everywhere.

How's that look to you?

When LoggerSinkConfiguration.Sink wraps a sink with a non-default
RestrictedToMinimumLevel, the resulting RestrictedSink did not
implement ISetLoggingFailureListener, so failure listeners installed
by FailureListenerSink (used by WriteTo.FallbackChain and
WriteTo.Fallible) never reached the inner sink. For async/batched
sinks that surface failures only through the listener, this caused
batches to be silently dropped via SelfLog instead of routed to the
fallback chain.

Mirror the forwarding pattern already used by
OptionalInterfaceForwardingSink so SetFailureListener is propagated
to the inner sink when supported.
@ArieGato
ArieGato force-pushed the fix/2231-restrictedsink-forward-failure-listener branch from 6210f76 to 7db6514 Compare May 2, 2026 05:17
…aceForwardingSink

Strip RestrictedSink to ILogEventSink and route IDisposable, IAsyncDisposable,
and ISetLoggingFailureListener through OptionalInterfaceForwardingSink so future
optional interfaces only need updates in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ArieGato

ArieGato commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

How's that look to you?

That looks great. I copied the code 1:1.

At the moment !OptionalInterfaceForwardingSink.SupportsAll(sink) always resolves to true. RestrictedSink doesn't implement all the interfaces and probably never will. I assume you want this as defense in depth for the future. So in the strange situation where all the interfaces Do get implemented on RestrictedSink, the bypasses will automatically be skipped.

@ArieGato ArieGato changed the title Forward ISetLoggingFailureListener through RestrictedSink Route optional interfaces through OptionalInterfaceForwardingSink for restricted sinks May 2, 2026
@ArieGato

ArieGato commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

@nblumhardt is it okay like this?

@nblumhardt nblumhardt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perfect, thanks!

@nblumhardt
nblumhardt merged commit 2ef6364 into serilog:dev May 11, 2026
1 check passed
ArieGato added a commit to ArieGato/serilog-sinks-rabbitmq that referenced this pull request Jul 10, 2026
…ound

Serilog 4.4.0 (serilog/serilog#2234) forwards optional sink interfaces —
including ISetLoggingFailureListener — through an OptionalInterfaceForwardingSink
for restricted sinks. That closes the gap where setting RestrictedToMinimumLevel
on a sink wrapped by WriteTo.FallbackChain(...) silently dropped failed batches
instead of routing them to the fallback.

With the minimum Serilog now at 4.4.0, the documentation workaround is obsolete:

- README: replace the "do not set RestrictedToMinimumLevel" gotcha with a note
  that it composes correctly on 4.4.0+; add a migration bullet for the raised
  minimum; bump the 9.0.0 compatibility row to Serilog 4.4.x.
- CHANGELOG: add the raised-minimum breaking-change bullet under 9.0.0.
- FallbackChainOutageTests: convert to a [Theory] and add the
  RestrictedToMinimumLevel case, pinning the upstream fix — the fallback still
  receives post-outage events through the RestrictedSink.

Also updates the remaining dev/runtime package versions to latest.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
ArieGato added a commit to ArieGato/serilog-sinks-rabbitmq that referenced this pull request Jul 10, 2026
…log 4.4.0 (#333)

* Replace in-sink failure sink with WriteTo.FallbackChain composition

The in-sink failure sink and EmitEventFailureHandling enum are removed.
Publish failures now always propagate so Serilog's BatchingSink listener
observes them, which is what enables WriteTo.FallbackChain(...) and
WriteTo.Fallible(...) from Serilog core to compose with the RabbitMQ sink.
Diagnostics are written to SelfLog on every failure with no opt-in flag.

Public surface removed:
- EmitEventFailureHandling (enum)
- RabbitMQSinkConfiguration.EmitEventFailure
- WriteTo.RabbitMQ(..., failureSinkConfiguration) overloads
- emitEventFailure / failureSinkConfiguration flat parameters

Migration guidance and a code/JSON example for FallbackChain are added to
README under "Migrating to 9.0.0", including a callout for the Serilog
RestrictedSink listener-forwarding gap that silently breaks FallbackChain
when RestrictedToMinimumLevel is set on the wrapped sink config.

A new integration test (FallbackChainOutageTests) uses Testcontainers.RabbitMq
to drive a real broker outage and assert the fallback file receives the
post-outage events. A LoadTestSample is bundled for manual smoke-testing
the wire-up under load.

* Expose RetryTimeLimit, add IAsyncDisposable, simplify batched-sink registration

- RabbitMQSinkConfiguration.RetryTimeLimit (default 10m, matching Serilog's
  BatchingOptions default) wires through to BatchingSink so callers can tune
  how long a failed batch is retried before it is handed to the registered
  ILoggingFailureListener (the next link in WriteTo.FallbackChain). Exposed
  on the public flat overload as retryTimeLimit; default substitution lives
  in RegisterSink alongside BatchPostingLimit / BufferingTimeLimit.

- RabbitMQSink now implements IAsyncDisposable on net8.0 / net10.0 (gated by
  FEATURE_ASYNCDISPOSABLE, mirroring Serilog core). DisposeAsync awaits the
  client directly without the AsyncHelpers.RunSync bridge; both Dispose paths
  share _disposedValue and are mutually idempotent. netstandard2.0 keeps
  IDisposable only.

- RegisterSink now calls Sink(IBatchedLogEventSink, BatchingOptions,
  LogEventLevel) directly instead of materialising the BatchingSink via
  CreateSink and wrapping it again. Same RestrictedSink -> BatchingSink ->
  RabbitMQSink chain, fewer steps; private-only simplification.

CHANGELOG and README updated. Approved API surface regenerated.

* Bump packages incl. Serilog 4.4.0; retire fixed RestrictedSink workaround

Serilog 4.4.0 (serilog/serilog#2234) forwards optional sink interfaces —
including ISetLoggingFailureListener — through an OptionalInterfaceForwardingSink
for restricted sinks. That closes the gap where setting RestrictedToMinimumLevel
on a sink wrapped by WriteTo.FallbackChain(...) silently dropped failed batches
instead of routing them to the fallback.

With the minimum Serilog now at 4.4.0, the documentation workaround is obsolete:

- README: replace the "do not set RestrictedToMinimumLevel" gotcha with a note
  that it composes correctly on 4.4.0+; add a migration bullet for the raised
  minimum; bump the 9.0.0 compatibility row to Serilog 4.4.x.
- CHANGELOG: add the raised-minimum breaking-change bullet under 9.0.0.
- FallbackChainOutageTests: convert to a [Theory] and add the
  RestrictedToMinimumLevel case, pinning the upstream fix — the fallback still
  receives post-outage events through the RestrictedSink.

Also updates the remaining dev/runtime package versions to latest.

* Fix RetryTimeLimit=Zero being clobbered to the 10-minute default

The documented "TimeSpan.Zero disables retries → immediate fallback" contract was
unreachable. RegisterSink used `RetryTimeLimit == default` as its "unset" sentinel,
and default(TimeSpan) is TimeSpan.Zero — so every Zero (whether omitted or set
explicitly to disable retries) was overwritten with the 10-minute default. Both
public paths were affected, and two tests locked in the wrong behaviour.

Fix distinguishes "unset" from "explicit Zero":

- The flat overload's `retryTimeLimit` parameter is now `TimeSpan?` (default null);
  null resolves to the library default before the config is built, any value —
  including TimeSpan.Zero — is honored.
- Removed the RetryTimeLimit default-substitution block from RegisterSink. The
  property already initializes to DEFAULT_RETRY_TIME_LIMIT at construction, so the
  delegate and direct-config paths keep the 10-minute default while a caller-set
  Zero now survives to BatchingOptions.RetryTimeLimit.

Tests: dropped the RetryTimeLimit=Zero assertion from the batching-defaults test
(Zero is no longer "unset"), added a regression test asserting Zero survives, and
refreshed the stale RegisterSink comments. Public API snapshot regenerated
(retryTimeLimit is now TimeSpan?). CHANGELOG and README updated.

* Clear CodeQL alerts in test/sample code

- cs/dispose-not-called-on-throw (warning, the PR-gating alert): the two
  cross-path dispose idempotency tests called DisposeAsync/Dispose in sequence
  without guaranteeing the second ran if the first threw. Wrapped each in
  try/finally so the sink is always disposed; assertions unchanged.
- cs/path-combine (note): wrapped the fallback file name in Path.GetFileName so
  the second Path.Combine segment is provably relative.

The two cs/catch-of-all-exceptions notes in LoadTestSample are intentional (a
load-test harness must survive a mid-run broker restart) and are left as-is.

* Potential fix for pull request finding 'CodeQL / Call to 'System.IO.Path.Combine' may silently drop its earlier arguments'

* Potential fix for pull request finding 'CodeQL / Missed 'using' opportunity'

* Potential fix for pull request finding 'CodeQL / Generic catch clause'

* Potential fix for pull request finding 'CodeQL / Generic catch clause'

* Remove unused System.IO using in LoadTestSample

The CodeQL catch-narrowing autofixes left `using System.IO;` unused, which
IDE0005 fails under TreatWarningsAsErrors (the buildcheck/format CI step).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This was referenced Jul 20, 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.

RestrictedSink does not forward ISetLoggingFailureListener to inner sink

2 participants