Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
<PackageVersion Include="JetBrains.Annotations" Version="2026.2.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.7.115" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="NuGet.Packaging" Version="6.14.3" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
Expand Down
3 changes: 3 additions & 0 deletions docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
15 changes: 15 additions & 0 deletions src/Fallout.Build/Execution/BuildManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyModel;
using Microsoft.Extensions.Logging;
using Fallout.Common.Tooling;
using Fallout.Common.Utilities;
using Fallout.Common.Utilities.Collections;
Expand Down Expand Up @@ -44,9 +46,20 @@ public static int Execute<T>(Expression<Func<T, Target>>[] 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<ILoggerFactory>());

build.ExecutableTargets = ExecutableTargetFactory.CreateAll(build, defaultTargetExpressions);
build.ExecuteExtension<IOnBuildCreated>(x => x.OnBuildCreated(build.ExecutableTargets));
Expand Down Expand Up @@ -89,6 +102,8 @@ public static int Execute<T>(Expression<Func<T, Target>>[] 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.
}
Expand Down
3 changes: 3 additions & 0 deletions src/Fallout.Build/Fallout.Build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

New transitive dependency on the full MS.DI container for every Fallout.Build consumer. Fine by me — #428 calls for it explicitly — but it needs a row in docs/dependencies.md along with Serilog.Extensions.Logging and Microsoft.Extensions.Logging.Abstractions.

Worth a note on the DI row that the container currently serves one internal composition root, so the footprint is deliberate rather than accidental. The Logging section is the natural home for the two Serilog/MEL entries.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 9dfdf33. Serilog.Extensions.Logging and Microsoft.Extensions.Logging.Abstractions went into the Logging section, as you suggested.

Microsoft.Extensions.DependencyInjection went into the Microsoft / .NET BCL table, with the footprint note in the Used by column:

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) is what will use it more widely.

<PackageReference Include="Microsoft.Extensions.DependencyModel" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
<PackageReference Include="Serilog.Sinks.Console" />
Expand Down
59 changes: 59 additions & 0 deletions src/Fallout.Build/Logging.DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;

namespace Fallout.Common.Execution;

/// <summary>
/// Composition root for the logging seam. Serilog stays the provider. This only puts
/// <see cref="ILogger"/> in front of it, so framework code can stop referencing Serilog directly.
/// </summary>
/// <remarks>
/// Internal on purpose: the abstraction is the framework's own foundation, not public surface yet.
/// The root <c>AssemblyInfo.cs</c> grants <c>InternalsVisibleTo</c> to <c>Fallout.Cli</c> and the
/// spec assemblies, which is everything that needs to wire a container today.
/// </remarks>
internal static class LoggingServiceCollectionExtensions
{
/// <summary>
/// Registers the <see cref="ILogger"/> abstraction over the Serilog pipeline.
/// </summary>
/// <remarks>
/// Registration only. This method never touches the pipeline, so any container can call it, any
/// number of times. Installing the pipeline is <see cref="Logging.Configure"/>, which the caller
/// runs explicitly just before it builds the provider.
///
/// The two are kept apart because <see cref="Logging.Configure"/> is not idempotent. It
/// reassigns Serilog's <c>Log.Logger</c> 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 <c>services.AddLogging(...)</c>. That installs Microsoft.Extensions.Logging's
/// own filter pipeline, whose default minimum is <see cref="LogLevel.Information"/>. It would be
/// a second authority on levels, dropping trace and debug records before Serilog ever saw them.
/// Registering the Serilog factory directly leaves <see cref="Logging.LevelSwitch"/> as the only
/// thing that decides what gets logged.
/// </remarks>
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<T> 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<ILoggerFactory>().CreateLogger(Logging.DefaultCategoryName));

return services;
}
}
67 changes: 67 additions & 0 deletions src/Fallout.Build/Logging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

/// <summary>Category for framework log records written without a category of their own.</summary>
internal const string DefaultCategoryName = "Fallout";

private static ILoggerFactory loggerFactory;

internal static bool SupportsAnsiOutput => Environment.GetEnvironmentVariable("TERM") is { } term && term.StartsWithOrdinalIgnoreCase("xterm");
internal static IHostTheme DefaultTheme { get; } = SupportsAnsiOutput
? AnsiConsoleHostTheme.Default256AnsiColorTheme
Expand All @@ -36,6 +48,61 @@ public static LogLevel Level
set => LevelSwitch.MinimumLevel = value.ToLogEventLevel();
}

/// <summary>
/// Logger factory for the current build run, backed by the Serilog pipeline that
/// <see cref="Configure"/> installs. <c>BuildManager</c> feeds this from its composition root
/// (see <c>AddFalloutLogging</c>). Outside a run there is no container — the CLI commands call
/// <see cref="Configure"/> directly — so this falls back to a factory over the ambient Serilog
/// pipeline, and the seam is usable either way.
/// </summary>
internal static ILoggerFactory Factory => loggerFactory ??= CreateSerilogLoggerFactory();

/// <summary>
/// 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
/// <see cref="CreateSerilogLoggerFactory"/>.
/// </summary>
internal static ILogger Logger => Factory.CreateLogger(DefaultCategoryName);

/// <summary>
/// Points <see cref="Factory"/> at <paramref name="factory"/> until the returned bracket is
/// disposed. Ownership stays with the caller: disposing the bracket restores the previous
/// factory, it does not dispose <paramref name="factory"/>.
/// </summary>
internal static IDisposable UseLoggerFactory(ILoggerFactory factory)
{
return DelegateDisposable.SetAndRestore(() => loggerFactory, factory.NotNull());
}

/// <summary>
/// Bridges <see cref="ILogger"/> onto Serilog.
/// </summary>
/// <remarks>
/// Passing no logger leaves the factory itself unbound, so each logger it hands out reads the
/// ambient <see cref="Log.Logger"/> as it is created. That matters because the pipeline is not
/// stable for the lifetime of the process: <see cref="Configure"/> installs it late and
/// replaces it on re-entry, and <c>Host.WriteErrorsAndWarnings</c> 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 <c>SourceContext</c> at construction. Three consequences the callers
/// depend on. <see cref="Configure"/> runs before the container is built, so a resolved logger
/// can never bind a pipeline that is already gone. The container registrations for
/// <c>ILogger&lt;T&gt;</c> and <see cref="ILogger"/> are transient, so each
/// resolution binds the pipeline that is current right now. And <see cref="Logger"/> 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
/// <see cref="Logger"/> at the point of writing.
///
/// Serilog owns the pipeline's lifetime (<c>Log.CloseAndFlush</c>), hence <c>dispose: false</c>.
/// </remarks>
internal static ILoggerFactory CreateSerilogLoggerFactory()
{
return new SerilogLoggerFactory(logger: null, dispose: false);
}

public static void Configure(IFalloutBuild build = null)
{
if (build != null)
Expand Down
Loading