feat(telemetry): AddZbSerilog minimum-level governance hook; bump to 0.2.0 (scadaproj#12)

Adds an additive AddZbSerilog(builder, configure, configureSerilog) overload
taking ZbSerilogOptions:
- MinimumLevelGovernor: Func<IConfiguration, LogEventLevel?> applied with
  MinimumLevel.Is AFTER ReadFrom.Configuration, so an app-owned key (e.g.
  ScadaBridge:Logging:MinimumLevel) outranks Serilog:MinimumLevel; null defers
  to config; Serilog:MinimumLevel:Override:* entries still apply.
- ConfigureLogger: runs last, for app filters/enrichers/sinks (ScadaBridge's
  standby active-node exclusion).

The two-argument overload is unchanged. 8 new tests (4 fail with the hook
neutralized). Telemetry + Telemetry.Serilog 0.1.0 -> 0.2.0, not published.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:39:06 -05:00
parent a05ce8c86d
commit 1ab5ca026b
7 changed files with 288 additions and 12 deletions
@@ -57,7 +57,35 @@ public static class ZbSerilogExtensions
/// <param name="configure">Populates the <see cref="ZbTelemetryOptions"/>.</param>
public static IHostApplicationBuilder AddZbSerilog(
this IHostApplicationBuilder builder,
Action<ZbTelemetryOptions> configure)
Action<ZbTelemetryOptions> configure) =>
AddZbSerilogCore(builder, configure, new ZbSerilogOptions());
/// <summary>
/// Overload of <see cref="AddZbSerilog(IHostApplicationBuilder, Action{ZbTelemetryOptions})"/>
/// that additionally accepts caller-supplied hooks (<see cref="ZbSerilogOptions"/>): a
/// minimum-level governor that has the last word on the default floor (it runs AFTER
/// <c>ReadFrom.Configuration</c>, so it overrides <c>Serilog:MinimumLevel</c>), and a final
/// <see cref="LoggerConfiguration"/> callback for app-specific filters/enrichers/sinks.
/// With every hook left null this is identical to the two-argument overload.
/// </summary>
/// <param name="builder">The host application builder.</param>
/// <param name="configure">Populates the <see cref="ZbTelemetryOptions"/>.</param>
/// <param name="configureSerilog">Populates the <see cref="ZbSerilogOptions"/> hooks.</param>
public static IHostApplicationBuilder AddZbSerilog(
this IHostApplicationBuilder builder,
Action<ZbTelemetryOptions> configure,
Action<ZbSerilogOptions> configureSerilog)
{
ArgumentNullException.ThrowIfNull(configureSerilog);
var serilogOptions = new ZbSerilogOptions();
configureSerilog(serilogOptions);
return AddZbSerilogCore(builder, configure, serilogOptions);
}
private static IHostApplicationBuilder AddZbSerilogCore(
IHostApplicationBuilder builder,
Action<ZbTelemetryOptions> configure,
ZbSerilogOptions serilogOptions)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
@@ -79,7 +107,17 @@ public static class ZbSerilogExtensions
.MinimumLevel.Is(LogEventLevel.Information)
.ReadFrom.Configuration(builder.Configuration);
// The governor runs AFTER ReadFrom.Configuration on purpose: that ordering is what
// lets an app-owned key outrank Serilog:MinimumLevel. Namespace overrides read from
// configuration are untouched — MinimumLevel.Is only sets the default floor.
if (serilogOptions.MinimumLevelGovernor?.Invoke(builder.Configuration) is { } governed)
{
loggerConfiguration.MinimumLevel.Is(governed);
}
ZbSerilogConfig.Apply(loggerConfiguration, options, serviceProvider);
serilogOptions.ConfigureLogger?.Invoke(loggerConfiguration, serviceProvider);
},
preserveStaticLogger: true);
@@ -0,0 +1,47 @@
using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Events;
namespace ZB.MOM.WW.Telemetry.Serilog;
/// <summary>
/// Optional, caller-supplied hooks for <see cref="ZbSerilogExtensions.AddZbSerilog(Microsoft.Extensions.Hosting.IHostApplicationBuilder, System.Action{ZbTelemetryOptions}, System.Action{ZbSerilogOptions})"/>.
/// Every member defaults to <see langword="null"/>, and with all of them null the bootstrap is
/// byte-for-byte the two-argument <c>AddZbSerilog</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> The default bootstrap pins a code floor of
/// <see cref="LogEventLevel.Information"/> and then lets <c>ReadFrom.Configuration</c> override it,
/// so <c>Serilog:MinimumLevel</c> is the source of truth. An app whose documented contract puts the
/// floor somewhere else (ScadaBridge: <c>ScadaBridge:Logging:MinimumLevel</c> wins over
/// <c>Serilog:MinimumLevel</c>) cannot express that with the default ordering — it needs to have
/// the LAST word on the floor. <see cref="MinimumLevelGovernor"/> is that last word.
/// </para>
/// </remarks>
public sealed class ZbSerilogOptions
{
/// <summary>
/// Governs the default minimum level. Invoked with the host's <see cref="IConfiguration"/>
/// each time the logger is built; a non-null result is applied with
/// <c>MinimumLevel.Is(level)</c> <b>after</b> <c>ReadFrom.Configuration</c>, so it overrides
/// <c>Serilog:MinimumLevel</c> / <c>Serilog:MinimumLevel:Default</c>. Returning
/// <see langword="null"/> defers to the default behaviour (code floor Information, config wins).
/// </summary>
/// <remarks>
/// Only the DEFAULT level is governed: per-namespace <c>Serilog:MinimumLevel:Override:*</c>
/// entries read from configuration still apply on top of it, exactly as they do when the app
/// builds its own <see cref="LoggerConfiguration"/> and calls <c>MinimumLevel.Is</c> after
/// <c>ReadFrom.Configuration</c>. The governor is the place for any operator-facing warning
/// (unparseable value, conflicting <c>Serilog:MinimumLevel</c> key): it runs before the logger
/// exists, so it should write to its own sink (e.g. <see cref="System.Console.Error"/>).
/// </remarks>
public Func<IConfiguration, LogEventLevel?>? MinimumLevelGovernor { get; set; }
/// <summary>
/// Final, app-specific adjustments to the <see cref="LoggerConfiguration"/> — filters,
/// extra enrichers, extra sinks — run after the governed level and the shared
/// enricher/redaction/export set. Receives the application <see cref="IServiceProvider"/>.
/// </summary>
public Action<LoggerConfiguration, IServiceProvider>? ConfigureLogger { get; set; }
}