From 5aa3218b3c417791df06640a9ea190c47209def8 Mon Sep 17 00:00:00 2001
From: Philippe Matray
Date: Wed, 9 Sep 2026 12:38:00 +1200
Subject: [PATCH] Put an ILogger seam over the Serilog pipeline
Framework code reaches logging through Serilog's static Log, which pins the
project to one logger implementation and leaks Serilog types outward. Introduce
Microsoft.Extensions.Logging.ILogger as the abstraction in front of it, keeping
Serilog as the provider and the pipeline itself untouched.
AddFalloutLogging registers the abstraction over the pipeline and nothing else,
so any container can call it any number of times. Configuring the pipeline stays
separate because Logging.Configure is not idempotent: it reassigns Serilog's
Log.Logger on every call, and with no build it installs a pipeline with no file
sinks, no host sink and no filter, wiping out whatever a previous caller had set
up. BuildManager.Execute runs Configure explicitly, just before it builds the
provider.
The registration deliberately avoids services.AddLogging, which would install
MEL's own filter pipeline with an Information default -- a second level authority
that would drop trace and debug records before Serilog saw them, displacing
Logging.LevelSwitch.
ILogger and ILogger are transient, not singleton. Logger binds its inner
logger in its constructor, so a singleton would pin every consumer to whichever
pipeline was current at the first resolution -- the same failure Logging.Logger
stays uncached to avoid. ILoggerFactory stays a singleton because it is left
unbound and pins nothing. Transient does not rescue a component that holds a
logger across a pipeline swap, so that remaining constraint is documented at the
registration and on CreateSerilogLoggerFactory.
BuildManager.Execute now owns a per-run composition root and feeds the resolved
factory to a static facade on Logging, so the ~85 Log.* call sites and the static
build engine are unchanged. The provider is declared outside the try so it
survives into Finish(), but built inside it so a configuration failure still
returns the same exit code as before.
The seam is internal: it is framework foundation, not public surface yet. Nothing
in the public API changes and no output changes.
docs/dependencies.md gains rows for Serilog.Extensions.Logging,
Microsoft.Extensions.Logging.Abstractions and
Microsoft.Extensions.DependencyInjection. The DI row notes that Fallout.Build is
consumer-facing, so every consumer now pulls the container transitively.
First of the additive PRs in #428.
Co-Authored-By: Claude Opus 5 (1M context)
---
Directory.Packages.props | 2 +
docs/dependencies.md | 3 +
src/Fallout.Build/Execution/BuildManager.cs | 15 +
src/Fallout.Build/Fallout.Build.csproj | 3 +
.../Logging.DependencyInjection.cs | 62 ++++
src/Fallout.Build/Logging.cs | 67 ++++
.../Fallout.Build.Specs/LoggerBridgeSpecs.cs | 333 ++++++++++++++++++
7 files changed, 485 insertions(+)
create mode 100644 src/Fallout.Build/Logging.DependencyInjection.cs
create mode 100644 tests/Fallout.Build.Specs/LoggerBridgeSpecs.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ec07825a4..1ea120cd1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,11 +16,13 @@
+
+
diff --git a/docs/dependencies.md b/docs/dependencies.md
index 1db8ffdfb..7b0079f15 100644
--- a/docs/dependencies.md
+++ b/docs/dependencies.md
@@ -15,6 +15,7 @@ Central package versions are pinned in `Directory.Packages.props`; this page lin
| `Microsoft.Build` (+ `.Framework`, `.Tasks.Core`, `.Utilities.Core`) | MSBuild engine — read/evaluate `.csproj`/`.props` files | `Fallout.ProjectModel`, `Fallout.MSBuildTasks` |
| `Microsoft.Build.Locator` | Locate an installed MSBuild at runtime | `Fallout.ProjectModel` |
| `Microsoft.CodeAnalysis.*` (CSharp, Workspaces, MSBuild, Analyzers) | Roslyn — C# parsing/compilation/analysis | `Fallout.SourceGenerators`, `Fallout.Cli` (Cake rewriter) |
+| `Microsoft.Extensions.DependencyInjection` | DI container for the per-run composition root that wires the logging seam ([#428](https://github.com/Fallout-build/Fallout/issues/428)) | `Fallout.Build`. Note the footprint: `Fallout.Build` is consumer-facing, so every consumer now pulls the container transitively. Deliberate, not accidental. It currently serves one internal composition root, and the plugin foundation ([milestone #6](https://github.com/Fallout-build/Fallout/milestone/6)) is what will use it more widely. |
| `Microsoft.Extensions.DependencyModel` | Parse `.deps.json` runtime metadata | `Fallout.Build` |
| `Microsoft.SourceLink.GitHub` | Source-link symbols into published nupkgs so debuggers can step into Fallout | All packable libs |
| `Nerdbank.GitVersioning` | Build-time semver derived from git history | All packable libs |
@@ -27,6 +28,8 @@ Central package versions are pinned in `Directory.Packages.props`; this page lin
|---|---|
| `Serilog` + `Sinks.Console` + `Sinks.File` | The logging framework. All `Log.Information/Warning/Error` calls route through this. |
| `Serilog.Formatting.Compact` (+ `.Reader`) | Structured JSON log format for machine-readable logs |
+| `Serilog.Extensions.Logging` | Serilog provider for `Microsoft.Extensions.Logging`. Backs the `ILogger` seam in `Fallout.Build`, so framework code logs against the abstraction while Serilog stays the provider. |
+| `Microsoft.Extensions.Logging.Abstractions` | The `ILogger` / `ILoggerFactory` abstraction itself. Abstractions only, no implementation and no filter pipeline. |
## Azure
diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs
index 256922305..e7dce3e34 100644
--- a/src/Fallout.Build/Execution/BuildManager.cs
+++ b/src/Fallout.Build/Execution/BuildManager.cs
@@ -6,7 +6,9 @@
using Fallout.Common.Tooling;
using Fallout.Common.Utilities;
using Fallout.Common.Utilities.Collections;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyModel;
+using Microsoft.Extensions.Logging;
using Serilog;
#pragma warning disable CA2255
@@ -44,9 +46,20 @@ public static int Execute(Expression>[] defaultTargetExpressi
using var context = BuildContext.Activate();
var build = new T();
+ // The composition root for the run. Declared out here so it survives into `finally` —
+ // Finish() still writes the outcome summary — but built inside the `try`, so a failure while
+ // configuring logging is reported the same way it was before there was a container.
+ ServiceProvider services = null;
+ IDisposable loggerFactoryScope = null;
+
try
{
+ // Configure installs the pipeline; AddFalloutLogging only registers the abstraction over
+ // it. Order matters here rather than inside the registration: a logger binds the ambient
+ // pipeline when it is created, so the pipeline has to exist before anything resolves one.
Logging.Configure(build);
+ services = new ServiceCollection().AddFalloutLogging().BuildServiceProvider();
+ loggerFactoryScope = Logging.UseLoggerFactory(services.GetRequiredService());
build.ExecutableTargets = ExecutableTargetFactory.CreateAll(build, defaultTargetExpressions);
build.ExecuteExtension(x => x.OnBuildCreated(build.ExecutableTargets));
@@ -91,6 +104,8 @@ public static int Execute(Expression>[] defaultTargetExpressi
{
Finish();
Log.CloseAndFlush();
+ loggerFactoryScope?.Dispose();
+ services?.Dispose();
// Per-run teardown (handler unsubscription + state reset) is owned by the BuildContext,
// run when `context` is disposed at method exit.
}
diff --git a/src/Fallout.Build/Fallout.Build.csproj b/src/Fallout.Build/Fallout.Build.csproj
index ea2b968c3..3a5f87428 100644
--- a/src/Fallout.Build/Fallout.Build.csproj
+++ b/src/Fallout.Build/Fallout.Build.csproj
@@ -18,7 +18,10 @@
+
+
+
diff --git a/src/Fallout.Build/Logging.DependencyInjection.cs b/src/Fallout.Build/Logging.DependencyInjection.cs
new file mode 100644
index 000000000..fb08a30ad
--- /dev/null
+++ b/src/Fallout.Build/Logging.DependencyInjection.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Linq;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace Fallout.Common.Execution;
+
+///
+/// Composition root for the logging seam. Serilog stays the provider. This only puts
+/// in front of it, so framework code can stop referencing Serilog directly.
+///
+///
+/// Internal on purpose: the abstraction is the framework's own foundation, not public surface yet.
+/// The root AssemblyInfo.cs grants InternalsVisibleTo to Fallout.Cli and the
+/// spec assemblies, which is everything that needs to wire a container today.
+///
+internal static class LoggingServiceCollectionExtensions
+{
+ ///
+ /// Registers the abstraction over the Serilog pipeline.
+ ///
+ ///
+ /// Registration only. This method never touches the pipeline, so any container can call it, any
+ /// number of times. Installing the pipeline is , which the caller
+ /// runs explicitly just before it builds the provider.
+ ///
+ /// The two are kept apart because is not idempotent. It
+ /// reassigns Serilog's Log.Logger on every call. Called with no build, it installs a
+ /// pipeline with no file sinks, no host sink and no filter. A second container calling this
+ /// method would then wipe out the pipeline the first one had set up.
+ ///
+ /// Deliberately not services.AddLogging(...). That installs Microsoft.Extensions.Logging's
+ /// own filter pipeline, whose default minimum is
+ /// — qualified because the
+ /// unqualified name binds to Fallout's own from the enclosing namespace.
+ /// It would be a second authority on levels, dropping trace and debug records before Serilog
+ /// ever saw them.
+ /// Registering the Serilog factory directly leaves as the only
+ /// thing that decides what gets logged.
+ ///
+ public static IServiceCollection AddFalloutLogging(this IServiceCollection services)
+ {
+ // Safe as a singleton because the factory is left unbound: it reads the ambient pipeline
+ // every time it creates a logger. See Logging.CreateSerilogLoggerFactory.
+ services.TryAddSingleton(_ => Logging.CreateSerilogLoggerFactory());
+
+ // Transient, not singleton. Logger resolves its inner logger in its constructor, and the
+ // non-generic lambda would run once per container. As singletons, both would pin every
+ // consumer to whichever pipeline was current at the first resolution. Log.Logger does not
+ // stay put during a run: Configure installs it late, and Host.WriteErrorsAndWarnings swaps
+ // it again to render the end-of-build summary.
+ //
+ // Transient means each resolution binds to the pipeline that is current right now. It does
+ // not rescue a component that resolves a logger once and holds it across a swap. Any
+ // component that outlives a swap must read Logging.Logger at the point of writing instead.
+ services.TryAddTransient(typeof(ILogger<>), typeof(Logger<>));
+ services.TryAddTransient(sp => sp.GetRequiredService().CreateLogger(Logging.DefaultCategoryName));
+
+ return services;
+ }
+}
diff --git a/src/Fallout.Build/Logging.cs b/src/Fallout.Build/Logging.cs
index 714265f2b..6f3722a2f 100644
--- a/src/Fallout.Build/Logging.cs
+++ b/src/Fallout.Build/Logging.cs
@@ -10,15 +10,27 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
+using Serilog.Extensions.Logging;
using Serilog.Formatting.Compact;
using Serilog.Sinks.SystemConsole.Themes;
+// Both Serilog and Microsoft.Extensions.Logging declare an ILogger. This file is the seam between
+// them, so the unqualified name is bound to the abstraction the framework codes against; Serilog's
+// own pipeline is reached through the static Log class below.
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+using ILoggerFactory = Microsoft.Extensions.Logging.ILoggerFactory;
+
namespace Fallout.Common.Execution;
public static class Logging
{
public static readonly LoggingLevelSwitch LevelSwitch = new();
+ /// Category for framework log records written without a category of their own.
+ internal const string DefaultCategoryName = "Fallout";
+
+ private static ILoggerFactory loggerFactory;
+
internal static bool SupportsAnsiOutput =>
Environment.GetEnvironmentVariable("TERM") is { } term && term.StartsWithOrdinalIgnoreCase("xterm");
@@ -40,6 +52,61 @@ public static LogLevel Level
set => LevelSwitch.MinimumLevel = value.ToLogEventLevel();
}
+ ///
+ /// Logger factory for the current build run, backed by the Serilog pipeline that
+ /// installs. BuildManager feeds this from its composition root
+ /// (see AddFalloutLogging). Outside a run there is no container — the CLI commands call
+ /// directly — so this falls back to a factory over the ambient Serilog
+ /// pipeline, and the seam is usable either way.
+ ///
+ internal static ILoggerFactory Factory => loggerFactory ??= CreateSerilogLoggerFactory();
+
+ ///
+ /// Logger for framework code that has no category of its own. Deliberately not cached — each
+ /// access creates a logger against the pipeline that is current right now, which is what keeps
+ /// the façade correct across the reassignments described on
+ /// .
+ ///
+ internal static ILogger Logger => Factory.CreateLogger(DefaultCategoryName);
+
+ ///
+ /// Points at until the returned bracket is
+ /// disposed. Ownership stays with the caller: disposing the bracket restores the previous
+ /// factory, it does not dispose .
+ ///
+ internal static IDisposable UseLoggerFactory(ILoggerFactory factory)
+ {
+ return DelegateDisposable.SetAndRestore(() => loggerFactory, factory.NotNull());
+ }
+
+ ///
+ /// Bridges onto Serilog.
+ ///
+ ///
+ /// Passing no logger leaves the factory itself unbound, so each logger it hands out reads the
+ /// ambient as it is created. That matters because the pipeline is not
+ /// stable for the lifetime of the process: installs it late and
+ /// replaces it on re-entry, and Host.WriteErrorsAndWarnings swaps it again to render the
+ /// end-of-build summary. Pinning a logger into the factory would strand every consumer on
+ /// whichever pipeline happened to exist first.
+ ///
+ /// Binding still happens per logger rather than per write, because the category name is
+ /// attached as Serilog's SourceContext at construction. Three consequences the callers
+ /// depend on. runs before the container is built, so a resolved logger
+ /// can never bind a pipeline that is already gone. The container registrations for
+ /// ILogger<T> and are transient, so each
+ /// resolution binds the pipeline that is current right now. And is not
+ /// cached, for the same reason. A component that resolves a logger once and holds it across a
+ /// reassignment still writes into the old pipeline, so anything living that long must read
+ /// at the point of writing.
+ ///
+ /// Serilog owns the pipeline's lifetime (Log.CloseAndFlush), hence dispose: false.
+ ///
+ internal static ILoggerFactory CreateSerilogLoggerFactory()
+ {
+ return new SerilogLoggerFactory(logger: null, dispose: false);
+ }
+
public static void Configure(IFalloutBuild build = null)
{
if (build != null)
diff --git a/tests/Fallout.Build.Specs/LoggerBridgeSpecs.cs b/tests/Fallout.Build.Specs/LoggerBridgeSpecs.cs
new file mode 100644
index 000000000..5f92ab7d7
--- /dev/null
+++ b/tests/Fallout.Build.Specs/LoggerBridgeSpecs.cs
@@ -0,0 +1,333 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Fallout.Common.Execution;
+using Fallout.Common.Utilities;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Serilog;
+using Serilog.Core;
+using Serilog.Events;
+using Xunit;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
+
+namespace Fallout.Common.Specs;
+
+///
+/// Covers the seam over Serilog added for #428. Serilog stays the provider;
+/// the bridge only has to be faithful — same severities, same message templates, same exceptions,
+/// and no second level authority of its own.
+///
+///
+/// Exercising the seam means writing through the ambient , which is
+/// process-wide: spec classes outside this collection run in parallel and log as they go, so every
+/// message here carries and the collected events are filtered down to it.
+/// Without that, a stray warning from another class lands in the sink and fails an assertion.
+///
+[Collection(ProcessGlobalStateCollection.Name)]
+public class LoggerBridgeSpecs
+{
+ /// Distinguishes this class's log records from those of concurrently running specs.
+ private const string Marker = "loggerbridgespec";
+
+ [Theory]
+ [InlineData(MsLogLevel.Trace, LogEventLevel.Verbose)]
+ [InlineData(MsLogLevel.Debug, LogEventLevel.Debug)]
+ [InlineData(MsLogLevel.Information, LogEventLevel.Information)]
+ [InlineData(MsLogLevel.Warning, LogEventLevel.Warning)]
+ [InlineData(MsLogLevel.Error, LogEventLevel.Error)]
+ [InlineData(MsLogLevel.Critical, LogEventLevel.Fatal)]
+ public void Each_logger_level_maps_to_its_serilog_level(MsLogLevel level, LogEventLevel expected)
+ {
+ var events = Capture(logger => logger.Log(level, Marker + " a line"));
+
+ events.Should().ContainSingle().Which.Level.Should().Be(expected);
+ }
+
+ [Fact]
+ public void The_bridge_does_not_filter_below_information()
+ {
+ // Regression guard for the AddFalloutLogging registration. Wiring the seam through
+ // services.AddLogging(...) would install Microsoft.Extensions.Logging's own filter pipeline,
+ // whose default minimum is Information — trace and debug records would vanish before Serilog
+ // ever saw them, and the level switch would no longer be the only thing that decides.
+ using var pipeline = PreserveAmbientPipeline();
+ using var services = new ServiceCollection().AddFalloutLogging().BuildServiceProvider();
+
+ var events = CaptureAmbient(() =>
+ {
+ var logger = services.GetRequiredService>();
+ logger.LogTrace(Marker + " trace line");
+ logger.LogDebug(Marker + " debug line");
+ });
+
+ events.Select(x => x.Level).Should().Equal(LogEventLevel.Verbose, LogEventLevel.Debug);
+ }
+
+ [Fact]
+ public void The_level_switch_still_gates_the_bridge()
+ {
+ var original = FalloutBuild.Verbosity;
+ try
+ {
+ FalloutBuild.Verbosity = Verbosity.Minimal;
+
+ var events = Capture(
+ logger =>
+ {
+ logger.LogInformation(Marker + " below the gate");
+ logger.LogWarning(Marker + " above the gate");
+ },
+ configuration => configuration.MinimumLevel.ControlledBy(Logging.LevelSwitch));
+
+ events.Should().ContainSingle().Which.Level.Should().Be(LogEventLevel.Warning);
+ }
+ finally
+ {
+ FalloutBuild.Verbosity = original;
+ }
+ }
+
+ [Fact]
+ public void Message_templates_survive_the_bridge()
+ {
+ const string Template = Marker + " restored {PackageCount} packages";
+
+ var events = Capture(logger => logger.LogInformation(Template, 12));
+
+ var logEvent = events.Should().ContainSingle().Subject;
+ // The template must stay a template — a pre-rendered string would defeat the structured
+ // sinks (the compact-JSON interceptor formatter, the file sinks) downstream.
+ logEvent.MessageTemplate.Text.Should().Be(Template);
+ logEvent.Properties.Should().ContainKey("PackageCount")
+ .WhoseValue.Should().BeOfType()
+ .Which.Value.Should().Be(12);
+ }
+
+ [Fact]
+ public void Exceptions_reach_the_log_event()
+ {
+ var exception = new InvalidOperationException("boom");
+
+ var events = Capture(logger => logger.LogError(exception, Marker + " the target failed"));
+
+ events.Should().ContainSingle().Which.Exception.Should().BeSameAs(exception);
+ }
+
+ [Fact]
+ public void The_factory_is_not_pinned_to_one_pipeline()
+ {
+ // Log.Logger is reassigned during a run — Configure installs it late, and
+ // Host.WriteErrorsAndWarnings swaps it again for the end-of-build summary. The factory
+ // itself must stay unbound so a logger it creates afterwards lands in the current pipeline.
+ var factory = Logging.CreateSerilogLoggerFactory();
+ var first = new CollectingSink();
+ var second = new CollectingSink();
+
+ using (PreserveAmbientPipeline())
+ {
+ Log.Logger = CreateLogger(first);
+ factory.CreateLogger(Logging.DefaultCategoryName).LogWarning(Marker + " before the swap");
+
+ Log.Logger = CreateLogger(second);
+ factory.CreateLogger(Logging.DefaultCategoryName).LogWarning(Marker + " after the swap");
+ }
+
+ first.Marked.Should().ContainSingle();
+ second.Marked.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void The_facade_logger_tracks_the_current_pipeline()
+ {
+ // Binding happens once per logger, not once per write, because the category is attached as
+ // SourceContext at construction. Logging.Logger is therefore deliberately uncached — that is
+ // what keeps the façade pointed at whichever pipeline is installed right now.
+ var sink = new CollectingSink();
+
+ using (PreserveAmbientPipeline())
+ {
+ var before = Logging.Logger;
+
+ Log.Logger = CreateLogger(sink);
+ Logging.Logger.LogWarning(Marker + " after the swap");
+
+ Logging.Logger.Should().NotBeSameAs(before);
+ }
+
+ sink.Marked.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void The_facade_works_without_a_composition_root()
+ {
+ // The CLI commands call Logging.Configure() directly, with no container in sight, so the
+ // façade has to fall back to the ambient pipeline rather than throw.
+ var events = CaptureAmbient(() => Logging.Logger.LogInformation(Marker + " no container here"));
+
+ events.Should().ContainSingle().Which.Level.Should().Be(LogEventLevel.Information);
+ }
+
+ [Fact]
+ public void The_container_resolves_the_logging_abstractions()
+ {
+ using var pipeline = PreserveAmbientPipeline();
+ using var services = new ServiceCollection().AddFalloutLogging().BuildServiceProvider();
+
+ services.GetRequiredService().Should().NotBeNull();
+ services.GetRequiredService>().Should().NotBeNull();
+ services.GetRequiredService().Should().NotBeNull();
+ }
+
+ [Fact]
+ public void A_container_logger_binds_the_pipeline_current_at_resolution()
+ {
+ // The container-side pair of The_facade_logger_tracks_the_current_pipeline. Logger binds
+ // its inner logger in its constructor, so the registration has to be transient. As a
+ // singleton it would strand every consumer on whichever pipeline was current at the first
+ // resolution, which is the failure Logging.Logger stays uncached to avoid.
+ var sink = new CollectingSink();
+
+ using var pipeline = PreserveAmbientPipeline();
+ using var services = new ServiceCollection().AddFalloutLogging().BuildServiceProvider();
+
+ services.GetRequiredService>();
+
+ Log.Logger = CreateLogger(sink);
+ services.GetRequiredService>().LogWarning(Marker + " after the swap");
+
+ sink.Marked.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void The_container_hands_out_a_new_logger_per_resolution()
+ {
+ using var pipeline = PreserveAmbientPipeline();
+ using var services = new ServiceCollection().AddFalloutLogging().BuildServiceProvider();
+
+ services.GetRequiredService>()
+ .Should().NotBeSameAs(services.GetRequiredService>());
+ services.GetRequiredService()
+ .Should().NotBeSameAs(services.GetRequiredService());
+ }
+
+ [Fact]
+ public void Registering_the_seam_leaves_the_pipeline_alone()
+ {
+ // AddFalloutLogging registers, it does not configure. Logging.Configure is not idempotent:
+ // with no build it installs a pipeline with no file sinks, no host sink and no filter. A
+ // second container calling AddFalloutLogging must not wipe out the pipeline the first one
+ // is using.
+ using var pipeline = PreserveAmbientPipeline();
+ Log.Logger = CreateLogger(new CollectingSink());
+ var installed = Log.Logger;
+
+ using var services = new ServiceCollection().AddFalloutLogging().BuildServiceProvider();
+
+ Log.Logger.Should().BeSameAs(installed);
+ }
+
+ [Fact]
+ public void Using_a_logger_factory_restores_the_previous_one()
+ {
+ var previous = Logging.Factory;
+ var replacement = new StubLoggerFactory();
+
+ using (Logging.UseLoggerFactory(replacement))
+ {
+ Logging.Factory.Should().BeSameAs(replacement);
+ }
+
+ Logging.Factory.Should().BeSameAs(previous);
+ }
+
+ [Fact]
+ public void Using_a_null_logger_factory_is_rejected()
+ {
+ var act = () => Logging.UseLoggerFactory(factory: null);
+
+ act.Should().Throw();
+ }
+
+ ///
+ /// Writes through a bridged logger against a pipeline that collects this class's records, and
+ /// returns them. overrides the minimum-level rule.
+ ///
+ private static LogEvent[] Capture(
+ Action write,
+ Func configure = null)
+ {
+ return CaptureAmbient(
+ () => write.Invoke(Logging.CreateSerilogLoggerFactory().CreateLogger(Logging.DefaultCategoryName)),
+ configure);
+ }
+
+ /// Runs against a collecting .
+ private static LogEvent[] CaptureAmbient(
+ Action write,
+ Func configure = null)
+ {
+ var sink = new CollectingSink();
+ using (PreserveAmbientPipeline())
+ {
+ Log.Logger = CreateLogger(sink, configure);
+ write.Invoke();
+ }
+
+ return sink.Marked.ToArray();
+ }
+
+ /// Restores the ambient Serilog pipeline when the returned bracket is disposed.
+ private static IDisposable PreserveAmbientPipeline()
+ {
+ var original = Log.Logger;
+ return DelegateDisposable.CreateBracket(cleanup: () => Log.Logger = original);
+ }
+
+ private static Serilog.Core.Logger CreateLogger(
+ ILogEventSink sink,
+ Func configure = null)
+ {
+ var configuration = new LoggerConfiguration();
+ configuration = configure?.Invoke(configuration) ?? configuration.MinimumLevel.Verbose();
+ return configuration.WriteTo.Sink(sink).CreateLogger();
+ }
+
+ private class CollectingSink : ILogEventSink
+ {
+ private readonly List events = new();
+
+ /// The records this class wrote, with concurrent specs' traffic filtered out.
+ public IReadOnlyList Marked
+ {
+ get
+ {
+ lock (events)
+ return events.Where(x => x.MessageTemplate.Text.Contains(Marker)).ToList();
+ }
+ }
+
+ public void Emit(LogEvent logEvent)
+ {
+ lock (events)
+ events.Add(logEvent);
+ }
+ }
+
+ private class StubLoggerFactory : ILoggerFactory
+ {
+ // A no-op rather than a throw: this stub owns the process-wide Logging.Factory while it
+ // is installed, and #428 moves framework code onto Logging.Logger. A throwing stub would
+ // turn any unrelated log write during that window into an intermittent failure.
+ public ILogger CreateLogger(string categoryName) => NullLogger.Instance;
+
+ public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException();
+
+ public void Dispose()
+ {
+ }
+ }
+}