feat(host): build the process logger through the shared AddZbSerilog bootstrap (#94)

Switch ScadaBridge from its own LoggerConfigurationFactory to AddZbSerilog
(ZB.MOM.WW.Telemetry(.Serilog) 0.1.0 -> 0.2.0) using the two 0.2.0 hooks:

- MinimumLevelGovernor: ScadaBridge:Logging:MinimumLevel, applied after
  ReadFrom.Configuration so it outranks Serilog:MinimumLevel (never null, so the
  floor never defers to the Serilog key; Override:* namespace entries still apply;
  the one-shot conflict / unparseable-value warnings are unchanged).
- ConfigureLogger: the standby active-node exclusion filter (register row 71),
  the configured node identity (the shared bootstrap stamps NodeHostname from
  the machine name and skips empty values; an overwriting enricher keeps the
  configured ScadaBridge:Node values verbatim), Enrich.FromLogContext and the
  ExecutionLogCaptureSink.

ScadaBridgeLogging runs AddZbSerilog on a small pre-host builder and Program
installs the result as the static Log.Logger; both web hosts keep UseSerilog()
over it. One logger and one file-sink handle per process, usable before the
host is built (startup banner, warnings, pre-host Fatal), by SerilogAkkaLogger,
and after the host is disposed (shutdown line, unexpected-termination Fatal) —
AddZbSerilog's own DI registration would leave Log.Logger alone and have the
host dispose the logger.

LoggerConfigurationFactory is deleted (nothing else used it). Tests: existing
logging/standby/sink tests now run through the new pipeline, plus
ScadaBridgeLoggingParityTests (level precedence, Override:* still applies,
one-shot warning, configured identity incl. NodeHostname != MachineName and empty
SiteId, exact property set of a plain line, trace_id/span_id). Mutation-checked:
governor deferring, filter removed, identity enricher removed, FromLogContext
removed, capture sink removed each fail tests.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 13:53:26 -05:00
parent dd4f1c2aa3
commit 2d2e9f35c5
15 changed files with 575 additions and 274 deletions
@@ -18,8 +18,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
/// <para><b>Wiring.</b> <see cref="AkkaHostedService.BuildHocon"/> names this type in
/// <c>akka.loggers</c>. It replaces Akka's <c>DefaultLogger</c>, which wrote to stdout only. Lines go
/// to the static <see cref="SerilogLog.Logger"/>, which <c>Program</c> builds through
/// <c>LoggerConfigurationFactory</c> before the ActorSystem is created. ScadaBridge keeps its own
/// Serilog factory by design, so no Akka.Hosting or <c>Akka.Logger.Serilog</c> bridge is used.</para>
/// <c>ScadaBridgeLogging</c> (the shared <c>AddZbSerilog</c> bootstrap) before the ActorSystem is
/// created. ScadaBridge owns that static logger by design, so no Akka.Hosting or
/// <c>Akka.Logger.Serilog</c> bridge is used.</para>
/// <para><b>Levels.</b> Filtering is unchanged. <c>akka.loglevel</c> (INFO) still decides which lines
/// an adapter publishes, so Akka's Debug lifecycle chatter is never built. The Serilog minimum level
/// then applies as it does to every other line. Akka Debug, Info, Warning and Error map to Serilog
@@ -1,220 +0,0 @@
using Serilog;
using Serilog.Events;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Builds the Serilog <see cref="LoggerConfiguration"/> for the Host process.
///
/// The configured minimum level comes from
/// <c>ScadaBridge:Logging:MinimumLevel</c> (bound to <see cref="LoggingOptions"/>) so an
/// operator editing that key changes the effective log level.
///
/// The console and file sinks are read from the standard
/// <c>Serilog</c> configuration section via <c>ReadFrom.Configuration</c> — the sink
/// set, console output template, file path and rolling interval are all
/// configuration-driven (defined in <c>appsettings.json</c>), not hard-coded. The
/// explicit <c>MinimumLevel.Is</c> below pins the floor from <see cref="LoggingOptions"/>.
///
/// <c>ScadaBridge:Logging:MinimumLevel</c> is the single source of truth
/// for the floor — the explicit <c>MinimumLevel.Is</c> call deliberately runs
/// AFTER <c>ReadFrom.Configuration</c> so a <c>Serilog:MinimumLevel</c> entry in
/// configuration is overridden. To make that precedence visible (so an operator
/// who sets <c>Serilog:MinimumLevel</c> does not wonder why the change had no
/// effect), <see cref="Build"/> writes a one-shot warning to
/// <see cref="Console.Error"/> when both keys are present. Pick one path —
/// editing <c>Serilog:MinimumLevel</c> alone has no effect.
/// </summary>
public static class LoggerConfigurationFactory
{
/// <summary>Builds a <see cref="LoggerConfiguration"/> enriched with node-identity properties and a configured minimum level.</summary>
/// <param name="configuration">Application configuration supplying the Serilog section and logging options.</param>
/// <param name="nodeRole">Role label (e.g., <c>central-a</c>) added as a log enrichment property.</param>
/// <param name="siteId">Site identifier added as a log enrichment property.</param>
/// <param name="nodeHostname">Hostname added as a log enrichment property.</param>
/// <returns>The configured <see cref="LoggerConfiguration"/>.</returns>
public static LoggerConfiguration Build(
IConfiguration configuration,
string nodeRole,
string siteId,
string nodeHostname)
=> Build(configuration, nodeRole, siteId, nodeHostname, Console.Error);
/// <summary>
/// Test-visible overload of <see cref="Build(IConfiguration, string, string, string)"/>
/// that routes the precedence warning through a caller-supplied
/// writer so unit tests can capture it. Production calls the four-arg
/// overload which uses <see cref="Console.Error"/>.
/// </summary>
/// <param name="configuration">Application configuration supplying the Serilog section and logging options.</param>
/// <param name="nodeRole">Role label added as a log enrichment property.</param>
/// <param name="siteId">Site identifier added as a log enrichment property.</param>
/// <param name="nodeHostname">Hostname added as a log enrichment property.</param>
/// <param name="warningWriter">Writer that receives the one-shot override-warning when both keys are present.</param>
/// <returns>The fully configured <see cref="LoggerConfiguration"/> ready to create the Serilog logger.</returns>
internal static LoggerConfiguration Build(
IConfiguration configuration,
string nodeRole,
string siteId,
string nodeHostname,
TextWriter warningWriter)
{
var loggingOptions = new LoggingOptions();
configuration.GetSection("ScadaBridge:Logging").Bind(loggingOptions);
var minimumLevel = ParseLevel(loggingOptions.MinimumLevel, warningWriter);
// Warn once if the operator also set a Serilog:MinimumLevel —
// they almost certainly expected it to take effect, but the explicit
// MinimumLevel.Is call below silently overrides it. The warning is
// emitted only when the conflicting key is actually present (a bare
// "Default" value is what ReadFrom.Configuration reads); a missing /
// empty Serilog:MinimumLevel section is silent.
var serilogMinimumLevel = configuration["Serilog:MinimumLevel"]
?? configuration["Serilog:MinimumLevel:Default"];
if (!string.IsNullOrWhiteSpace(serilogMinimumLevel))
{
warningWriter.WriteLine(
$"warning: Serilog:MinimumLevel ('{serilogMinimumLevel}') is being overridden by " +
$"ScadaBridge:Logging:MinimumLevel ('{loggingOptions.MinimumLevel ?? "Information (default)"}'). " +
"ScadaBridge:Logging:MinimumLevel is the documented source of truth for the floor (Host-011); " +
"remove the Serilog:MinimumLevel entry to silence this warning.");
}
return new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.MinimumLevel.Is(minimumLevel)
// Register row 71. Drop ONLY the framework's Error line for the by-design
// standby verdict of the active-node probe. See IsExpectedStandbyActiveNodeProbe
// for why this cannot hide a genuine failure.
.Filter.ByExcluding(IsExpectedStandbyActiveNodeProbe)
.Enrich.WithProperty("SiteId", siteId)
.Enrich.WithProperty("NodeHostname", nodeHostname)
.Enrich.WithProperty("NodeRole", nodeRole)
// ScadaBridge#56: surface the ExecutionId/InstanceName/ScriptName the site script
// launcher pushes onto LogContext, so lines written on a run's own call chain carry
// the run's identity. See SiteRuntime ScriptRunLogScope for the (bounded) scope.
.Enrich.FromLogContext()
.Enrich.With(new TraceContextEnricher())
// ScadaBridge#56: copies each line tagged with a TRACED execution's ExecutionId into that
// run's Flow Debugger step log (ScriptLog rows, source "node"). One volatile read and out
// when nothing is traced. Attached directly (never behind an async wrapper): its
// de-duplication guard is per thread.
.WriteTo.Sink(new ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogCaptureSink());
}
/// <summary>
/// Logger category that emits the per-probe "Health check {HealthCheckName} with status
/// {HealthStatus} …" line. It is <c>DefaultHealthCheckService</c>, not
/// <c>HealthCheckPublisherHostedService</c>: this host registers no
/// <c>IHealthCheckPublisher</c>, so the checks run only on an inbound HTTP probe of
/// <c>/health/*</c>.
/// </summary>
internal const string HealthCheckServiceSourceContext =
"Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService";
/// <summary>Name both the site and central <c>/health/active</c> probes register under.</summary>
internal const string ActiveNodeCheckName = "active-node";
/// <summary>
/// Suppresses the standby node's correct-by-design <c>active-node</c> Error line
/// (deferred-work register row 71).
/// </summary>
/// <remarks>
/// <para>
/// <b>The problem.</b> <c>DefaultHealthCheckService</c> logs one line per check per probe,
/// picking the level from the verdict — Debug for Healthy, Warning for Degraded,
/// <b>Error for Unhealthy</b>. Both active-node checks
/// (the shared <c>ActiveNodeHealthCheck</c>, role-scoped on a site and unscoped
/// on central) answer Unhealthy on the standby, because 503 is exactly the contract
/// <c>/health/active</c> publishes to Traefik and the family dashboard. So a perfectly
/// healthy standby logged ERR on every probe — every 10 s on a site node, every 5 s on a
/// central node behind Traefik's 5 s active-node probe. That floods the standby's log and
/// trains operators to ignore ERR.
/// </para>
/// <para>
/// <b>Why a filter and not a level override.</b> Serilog's <c>MinimumLevel.Override</c>
/// can only RAISE a per-source floor; it cannot rewrite one Error event as Information.
/// A <c>LogEvent</c>'s level is immutable, so an enricher cannot do it either. Excluding
/// the single by-design event is the surgical option, and it leaves the verdict fully
/// observable where it belongs: the HTTP status of <c>/health/active</c> itself, the
/// cluster view on the readiness probe, and the health dashboard.
/// </para>
/// <para>
/// <b>Why this cannot hide a genuine failure.</b> The predicate is keyed on the check
/// NAME, not on the category, so a failing <c>localdb</c>, <c>akka-cluster</c>,
/// <c>database</c>, or <c>required-singletons</c> check still logs at Error untouched.
/// And the active-node check is a pure "am I the oldest Up member" verdict with no error
/// path of its own — the ACTIVE node always answers Healthy, so the only event this can
/// ever match is a standby reporting that it is a standby. Genuine loss of the pair
/// surfaces on the readiness checks, which are deliberately out of scope here.
/// </para>
/// <para>
/// Matching on <see cref="LogEventLevel.Error"/> rather than on the <c>HealthStatus</c>
/// property avoids depending on how the enum renders into the event; the level→verdict
/// mapping above already makes Error equivalent to Unhealthy.
/// </para>
/// </remarks>
/// <param name="logEvent">The candidate log event.</param>
/// <returns><see langword="true"/> when the event should be dropped.</returns>
internal static bool IsExpectedStandbyActiveNodeProbe(LogEvent logEvent)
{
if (logEvent is null || logEvent.Level != LogEventLevel.Error)
{
return false;
}
return MatchesScalar(logEvent, "SourceContext", HealthCheckServiceSourceContext)
&& MatchesScalar(logEvent, "HealthCheckName", ActiveNodeCheckName);
}
/// <summary>Tests one structured property for an exact scalar string value.</summary>
/// <param name="logEvent">The event whose properties are inspected.</param>
/// <param name="propertyName">The structured property name.</param>
/// <param name="expected">The value the property must equal.</param>
/// <returns><see langword="true"/> on an exact ordinal match.</returns>
private static bool MatchesScalar(LogEvent logEvent, string propertyName, string expected) =>
logEvent.Properties.TryGetValue(propertyName, out var value)
&& value is ScalarValue { Value: string actual }
&& string.Equals(actual, expected, StringComparison.Ordinal);
/// <summary>
/// Parses a Serilog <see cref="LogEventLevel"/> name, falling back to
/// <see cref="LogEventLevel.Information"/> for null/blank/unrecognised values.
///
/// When an operator sets <c>ScadaBridge:Logging:MinimumLevel</c> to a
/// value that doesn't parse (e.g. the typo "Informaiton"), the helper must NOT
/// throw — startup has to succeed so the rest of the system can come up — but
/// it MUST make the silent fallback visible. The logger is not yet built at
/// this point, so the warning is written directly to <see cref="Console.Error"/>
/// using <see cref="WriteParseWarning"/>; non-null/non-blank values that fail
/// to parse are reported once, naming the offending value and the fallback.
/// Null/blank values are treated as "unset" and silently default — only
/// explicit-but-invalid values trigger the warning.
/// </summary>
/// <param name="level">Configured level string, possibly null/blank/invalid.</param>
/// <returns>The parsed <see cref="LogEventLevel"/>, or <see cref="LogEventLevel.Information"/> when the value is absent or unrecognised.</returns>
internal static LogEventLevel ParseLevel(string? level)
=> ParseLevel(level, Console.Error);
/// <summary>
/// Test-visible overload of <see cref="ParseLevel(string?)"/> that routes the
/// one-shot warning through a caller-supplied writer (<see cref="Console.Error"/>
/// in production) so unit tests can capture the warning output.
/// </summary>
/// <param name="level">Configured level string, possibly null/blank/invalid.</param>
/// <param name="warningWriter">Writer that receives a single warning line if the value is non-blank but unparseable.</param>
/// <returns>The parsed <see cref="LogEventLevel"/>, or <see cref="LogEventLevel.Information"/> when the value is absent or unrecognised.</returns>
internal static LogEventLevel ParseLevel(string? level, TextWriter warningWriter)
{
if (Enum.TryParse<LogEventLevel>(level, ignoreCase: true, out var parsed))
return parsed;
if (!string.IsNullOrWhiteSpace(level))
warningWriter.WriteLine(
$"warning: ScadaBridge:Logging:MinimumLevel value '{level}' is not a recognised Serilog LogEventLevel; falling back to Information.");
return LogEventLevel.Information;
}
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="LoggingOptions"/> at host startup (arch-review 08 round
/// 2 NF4). <see cref="LoggingOptions.MinimumLevel"/> is parsed into a Serilog
/// <see cref="LogEventLevel"/> by <c>LoggerConfigurationFactory.ParseLevel</c>,
/// <see cref="LogEventLevel"/> by <c>ScadaBridgeLogging.ParseLevel</c>,
/// which silently falls back to <c>Information</c> for an unrecognised value.
/// This validator surfaces that misconfiguration at boot instead: a present,
/// non-blank value must name a real level (Verbose/Debug/Information/Warning/
+9 -9
View File
@@ -141,15 +141,15 @@ var nodeRole = configuration["ScadaBridge:Node:Role"]!;
var nodeHostname = configuration["ScadaBridge:Node:NodeHostname"] ?? "unknown";
var siteId = configuration["ScadaBridge:Node:SiteId"] ?? "central";
// Serilog structured logging.
// Minimum level is driven by ScadaBridge:Logging:MinimumLevel (LoggingOptions).
// Console and file sinks are defined in the `Serilog` configuration
// section (appsettings.json) and applied via ReadFrom.Configuration inside the
// factory — the sink set, output template, file path and rolling interval are all
// configuration-driven, not hard-coded here.
Log.Logger = ZB.MOM.WW.ScadaBridge.Host.LoggerConfigurationFactory
.Build(configuration, nodeRole, siteId, nodeHostname)
.CreateLogger();
// Serilog structured logging, built by the family's shared AddZbSerilog bootstrap
// (ZB.MOM.WW.Telemetry.Serilog, ScadaBridge#94) with ScadaBridge's hooks: minimum level driven by
// ScadaBridge:Logging:MinimumLevel (LoggingOptions) and the standby active-node filter.
// Console and file sinks are defined in the `Serilog` configuration section (appsettings.json)
// and applied via ReadFrom.Configuration — the sink set, output template, file path and rolling
// interval are all configuration-driven, not hard-coded here. The static logger stays the single
// process logger (both web hosts call UseSerilog() over it); see ScadaBridgeLogging for why.
Log.Logger = ZB.MOM.WW.ScadaBridge.Host.ScadaBridgeLogging
.CreateLogger(configuration, nodeRole, siteId, nodeHostname);
// The host's ApplicationStopping token, captured once the host is built. A normal Windows service
// stop (or SIGTERM) cancels it BEFORE RunAsync unwinds, and WindowsServiceLifetime.StopAsync can
@@ -0,0 +1,349 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Builds the Host process's Serilog logger through the family's shared bootstrap,
/// <c>AddZbSerilog</c> (<c>ZB.MOM.WW.Telemetry.Serilog</c> 0.2.0+, ScadaBridge#94), with two
/// ScadaBridge hooks: a minimum-level governor and a final <see cref="LoggerConfiguration"/> callback.
/// </summary>
/// <remarks>
/// <para>
/// <b>What the shared bootstrap supplies.</b> <c>ReadFrom.Configuration</c> over the standard
/// <c>Serilog</c> section (the console and file sinks, output template, file path and rolling
/// interval all stay configuration-driven in <c>appsettings.json</c>), the <c>SiteId</c> /
/// <c>NodeRole</c> / <c>NodeHostname</c> identity enrichers, the shared <c>TraceContextEnricher</c>
/// and the (inert here, no <c>ILogRedactor</c> is registered) redaction seam. No OTLP log sink is
/// added: the telemetry options passed here never select the OTLP exporter, as before.
/// </para>
/// <para>
/// <b>Minimum level (Host-011).</b> <c>ScadaBridge:Logging:MinimumLevel</c> is the single source of
/// truth for the floor. <see cref="GovernMinimumLevel(IConfiguration, TextWriter)"/> is handed to
/// <c>ZbSerilogOptions.MinimumLevelGovernor</c>, which the library applies AFTER
/// <c>ReadFrom.Configuration</c>, so a <c>Serilog:MinimumLevel</c> entry is overridden. It always
/// returns a level (never <see langword="null"/>), so the floor never defers to the Serilog key.
/// When both keys are present it writes a one-shot warning so an operator who edits
/// <c>Serilog:MinimumLevel</c> learns why the change had no effect. Namespace
/// <c>Serilog:MinimumLevel:Override:*</c> entries still apply.
/// </para>
/// <para>
/// <b>Final hook.</b> <c>ZbSerilogOptions.ConfigureLogger</c> adds what is ScadaBridge's own: the
/// standby <c>active-node</c> exclusion filter (register row 71), the exact node identity this
/// process was configured with (the library stamps <c>NodeHostname</c> from the machine name;
/// ScadaBridge has always stamped <c>ScadaBridge:Node:NodeHostname</c>), <c>Enrich.FromLogContext</c>
/// (ScadaBridge#56 run identity) and the Flow Debugger <c>ExecutionLogCaptureSink</c>.
/// </para>
/// <para>
/// <b>Who owns the logger.</b> <c>AddZbSerilog</c> registers the logger in a container and leaves
/// <see cref="Log.Logger"/> alone. ScadaBridge needs the static logger before any host exists
/// (startup banner and warnings, a pre-host <c>Fatal</c>), for the Akka bridge
/// (<c>SerilogAkkaLogger</c>), and after the host is disposed (the shutdown line and the
/// unexpected-termination <c>Fatal</c>). So <see cref="CreateLogger(IConfiguration, string, string, string)"/>
/// runs <c>AddZbSerilog</c> on a small pre-host builder and resolves the logger from it once;
/// <c>Program</c> installs it as <see cref="Log.Logger"/> and the web hosts keep
/// <c>UseSerilog()</c> over the static logger, exactly as before. There is therefore one logger
/// (one file-sink handle) per process, and the host's container never disposes it — the
/// <c>Log.CloseAndFlushAsync()</c> in <c>Program</c>'s <c>finally</c> does.
/// </para>
/// </remarks>
public static class ScadaBridgeLogging
{
/// <summary>The OTel <c>service.name</c> ScadaBridge registers with <c>AddZbTelemetry</c>.</summary>
internal const string ServiceName = "scadabridge";
/// <summary>Creates the process logger: the shared bootstrap plus ScadaBridge's hooks.</summary>
/// <param name="configuration">Application configuration supplying the Serilog section and logging options.</param>
/// <param name="nodeRole">Role label (e.g., <c>Central</c>) stamped as <c>NodeRole</c>.</param>
/// <param name="siteId">Site identifier stamped as <c>SiteId</c>.</param>
/// <param name="nodeHostname">Configured node hostname stamped as <c>NodeHostname</c>.</param>
/// <returns>The built logger; the caller owns it (Program installs it as <see cref="Log.Logger"/>).</returns>
public static Logger CreateLogger(
IConfiguration configuration,
string nodeRole,
string siteId,
string nodeHostname)
=> CreateLogger(configuration, nodeRole, siteId, nodeHostname, Console.Error, additionalConfiguration: null);
/// <summary>
/// Test-visible overload of <see cref="CreateLogger(IConfiguration, string, string, string)"/>:
/// routes the operator warnings through <paramref name="warningWriter"/> and lets a test append
/// a capturing sink after ScadaBridge's own hook.
/// </summary>
/// <param name="configuration">Application configuration supplying the Serilog section and logging options.</param>
/// <param name="nodeRole">Role label stamped as <c>NodeRole</c>.</param>
/// <param name="siteId">Site identifier stamped as <c>SiteId</c>.</param>
/// <param name="nodeHostname">Configured node hostname stamped as <c>NodeHostname</c>.</param>
/// <param name="warningWriter">Receives the one-shot operator warnings.</param>
/// <param name="additionalConfiguration">Optional extra configuration applied last (tests add a sink here).</param>
/// <returns>The built logger.</returns>
internal static Logger CreateLogger(
IConfiguration configuration,
string nodeRole,
string siteId,
string nodeHostname,
TextWriter warningWriter,
Action<LoggerConfiguration>? additionalConfiguration)
{
ArgumentNullException.ThrowIfNull(configuration);
// A pre-host builder whose only job is to run AddZbSerilog: no default sources, so its
// configuration is exactly the caller's (already secret-expanded) configuration.
var builder = Microsoft.Extensions.Hosting.Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());
builder.Configuration.AddConfiguration(configuration);
builder.AddScadaBridgeSerilog(nodeRole, siteId, nodeHostname, warningWriter, additionalConfiguration);
// The provider is deliberately never disposed: disposing it would dispose the logger, whose
// lifetime belongs to Program (Log.CloseAndFlushAsync). It holds nothing else.
#pragma warning disable ASP0000 // deliberate pre-host container
var provider = builder.Services.BuildServiceProvider();
#pragma warning restore ASP0000
return (Logger)provider.GetRequiredService<Serilog.ILogger>();
}
/// <summary>
/// Registers the ScadaBridge logger on <paramref name="builder"/> through <c>AddZbSerilog</c>
/// with the minimum-level governor and the final ScadaBridge hook.
/// </summary>
/// <param name="builder">The host application builder.</param>
/// <param name="nodeRole">Role label stamped as <c>NodeRole</c>.</param>
/// <param name="siteId">Site identifier stamped as <c>SiteId</c>.</param>
/// <param name="nodeHostname">Configured node hostname stamped as <c>NodeHostname</c>.</param>
/// <param name="warningWriter">Receives the one-shot operator warnings.</param>
/// <param name="additionalConfiguration">Optional extra configuration applied last.</param>
/// <returns>The same builder.</returns>
internal static IHostApplicationBuilder AddScadaBridgeSerilog(
this IHostApplicationBuilder builder,
string nodeRole,
string siteId,
string nodeHostname,
TextWriter warningWriter,
Action<LoggerConfiguration>? additionalConfiguration = null)
{
ArgumentNullException.ThrowIfNull(warningWriter);
return builder.AddZbSerilog(
telemetry =>
{
telemetry.ServiceName = ServiceName;
telemetry.SiteId = siteId;
telemetry.NodeRole = nodeRole;
},
serilog =>
{
serilog.MinimumLevelGovernor = configuration => GovernMinimumLevel(configuration, warningWriter);
serilog.ConfigureLogger = (loggerConfiguration, _) =>
{
ConfigureScadaBridgePipeline(loggerConfiguration, nodeRole, siteId, nodeHostname);
additionalConfiguration?.Invoke(loggerConfiguration);
};
});
}
/// <summary>
/// The minimum-level governor: reads <c>ScadaBridge:Logging:MinimumLevel</c> (falling back to
/// Information, with a warning for an unparseable value) and warns once when a
/// <c>Serilog:MinimumLevel</c> entry is present, because this value overrides it.
/// </summary>
/// <param name="configuration">The application configuration.</param>
/// <param name="warningWriter">Receives the one-shot operator warnings.</param>
/// <returns>The governed floor; never <see langword="null"/>.</returns>
internal static LogEventLevel GovernMinimumLevel(IConfiguration configuration, TextWriter warningWriter)
{
var loggingOptions = new LoggingOptions();
configuration.GetSection("ScadaBridge:Logging").Bind(loggingOptions);
var minimumLevel = ParseLevel(loggingOptions.MinimumLevel, warningWriter);
// Warn once if the operator also set a Serilog:MinimumLevel — they almost certainly
// expected it to take effect, but the governed level silently overrides it. Emitted only
// when the conflicting key is actually present (a bare "Default" value is what
// ReadFrom.Configuration reads); a missing / empty Serilog:MinimumLevel section is silent.
var serilogMinimumLevel = configuration["Serilog:MinimumLevel"]
?? configuration["Serilog:MinimumLevel:Default"];
if (!string.IsNullOrWhiteSpace(serilogMinimumLevel))
{
warningWriter.WriteLine(
$"warning: Serilog:MinimumLevel ('{serilogMinimumLevel}') is being overridden by " +
$"ScadaBridge:Logging:MinimumLevel ('{loggingOptions.MinimumLevel ?? "Information (default)"}'). " +
"ScadaBridge:Logging:MinimumLevel is the documented source of truth for the floor (Host-011); " +
"remove the Serilog:MinimumLevel entry to silence this warning.");
}
return minimumLevel;
}
/// <summary>
/// ScadaBridge's own pipeline stages, run by <c>ZbSerilogOptions.ConfigureLogger</c> after the
/// shared bootstrap.
/// </summary>
/// <param name="loggerConfiguration">The configuration being built.</param>
/// <param name="nodeRole">Role label stamped as <c>NodeRole</c>.</param>
/// <param name="siteId">Site identifier stamped as <c>SiteId</c>.</param>
/// <param name="nodeHostname">Configured node hostname stamped as <c>NodeHostname</c>.</param>
internal static void ConfigureScadaBridgePipeline(
LoggerConfiguration loggerConfiguration,
string nodeRole,
string siteId,
string nodeHostname)
{
loggerConfiguration
// Register row 71. Drop ONLY the framework's Error line for the by-design
// standby verdict of the active-node probe. See IsExpectedStandbyActiveNodeProbe
// for why this cannot hide a genuine failure.
.Filter.ByExcluding(IsExpectedStandbyActiveNodeProbe)
// The shared bootstrap stamps NodeHostname from the machine name and skips an empty
// SiteId/NodeRole; ScadaBridge has always stamped the configured values verbatim.
// Enrich.WithProperty only adds an ABSENT property, so this enricher overwrites.
.Enrich.With(new NodeIdentityEnricher(siteId, nodeHostname, nodeRole))
// ScadaBridge#56: surface the ExecutionId/InstanceName/ScriptName the site script
// launcher pushes onto LogContext, so lines written on a run's own call chain carry
// the run's identity. See SiteRuntime ScriptRunLogScope for the (bounded) scope.
.Enrich.FromLogContext()
// ScadaBridge#56: copies each line tagged with a TRACED execution's ExecutionId into that
// run's Flow Debugger step log (ScriptLog rows, source "node"). One volatile read and out
// when nothing is traced. Attached directly (never behind an async wrapper): its
// de-duplication guard is per thread.
.WriteTo.Sink(new ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogCaptureSink());
}
/// <summary>Stamps the configured node identity, overwriting any earlier value.</summary>
private sealed class NodeIdentityEnricher : ILogEventEnricher
{
private readonly LogEventProperty _siteId;
private readonly LogEventProperty _nodeHostname;
private readonly LogEventProperty _nodeRole;
public NodeIdentityEnricher(string siteId, string nodeHostname, string nodeRole)
{
_siteId = new LogEventProperty(ZbLogEnricherNames.SiteId, new ScalarValue(siteId));
_nodeHostname = new LogEventProperty(ZbLogEnricherNames.NodeHostname, new ScalarValue(nodeHostname));
_nodeRole = new LogEventProperty(ZbLogEnricherNames.NodeRole, new ScalarValue(nodeRole));
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
logEvent.AddOrUpdateProperty(_siteId);
logEvent.AddOrUpdateProperty(_nodeHostname);
logEvent.AddOrUpdateProperty(_nodeRole);
}
}
/// <summary>
/// Logger category that emits the per-probe "Health check {HealthCheckName} with status
/// {HealthStatus} …" line. It is <c>DefaultHealthCheckService</c>, not
/// <c>HealthCheckPublisherHostedService</c>: this host registers no
/// <c>IHealthCheckPublisher</c>, so the checks run only on an inbound HTTP probe of
/// <c>/health/*</c>.
/// </summary>
internal const string HealthCheckServiceSourceContext =
"Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService";
/// <summary>Name both the site and central <c>/health/active</c> probes register under.</summary>
internal const string ActiveNodeCheckName = "active-node";
/// <summary>
/// Suppresses the standby node's correct-by-design <c>active-node</c> Error line
/// (deferred-work register row 71).
/// </summary>
/// <remarks>
/// <para>
/// <b>The problem.</b> <c>DefaultHealthCheckService</c> logs one line per check per probe,
/// picking the level from the verdict — Debug for Healthy, Warning for Degraded,
/// <b>Error for Unhealthy</b>. Both active-node checks
/// (the shared <c>ActiveNodeHealthCheck</c>, role-scoped on a site and unscoped
/// on central) answer Unhealthy on the standby, because 503 is exactly the contract
/// <c>/health/active</c> publishes to Traefik and the family dashboard. So a perfectly
/// healthy standby logged ERR on every probe — every 10 s on a site node, every 5 s on a
/// central node behind Traefik's 5 s active-node probe. That floods the standby's log and
/// trains operators to ignore ERR.
/// </para>
/// <para>
/// <b>Why a filter and not a level override.</b> Serilog's <c>MinimumLevel.Override</c>
/// can only RAISE a per-source floor; it cannot rewrite one Error event as Information.
/// A <c>LogEvent</c>'s level is immutable, so an enricher cannot do it either. Excluding
/// the single by-design event is the surgical option, and it leaves the verdict fully
/// observable where it belongs: the HTTP status of <c>/health/active</c> itself, the
/// cluster view on the readiness probe, and the health dashboard.
/// </para>
/// <para>
/// <b>Why this cannot hide a genuine failure.</b> The predicate is keyed on the check
/// NAME, not on the category, so a failing <c>localdb</c>, <c>akka-cluster</c>,
/// <c>database</c>, or <c>required-singletons</c> check still logs at Error untouched.
/// And the active-node check is a pure "am I the oldest Up member" verdict with no error
/// path of its own — the ACTIVE node always answers Healthy, so the only event this can
/// ever match is a standby reporting that it is a standby. Genuine loss of the pair
/// surfaces on the readiness checks, which are deliberately out of scope here.
/// </para>
/// <para>
/// Matching on <see cref="LogEventLevel.Error"/> rather than on the <c>HealthStatus</c>
/// property avoids depending on how the enum renders into the event; the level→verdict
/// mapping above already makes Error equivalent to Unhealthy.
/// </para>
/// </remarks>
/// <param name="logEvent">The candidate log event.</param>
/// <returns><see langword="true"/> when the event should be dropped.</returns>
internal static bool IsExpectedStandbyActiveNodeProbe(LogEvent logEvent)
{
if (logEvent is null || logEvent.Level != LogEventLevel.Error)
{
return false;
}
return MatchesScalar(logEvent, "SourceContext", HealthCheckServiceSourceContext)
&& MatchesScalar(logEvent, "HealthCheckName", ActiveNodeCheckName);
}
/// <summary>Tests one structured property for an exact scalar string value.</summary>
/// <param name="logEvent">The event whose properties are inspected.</param>
/// <param name="propertyName">The structured property name.</param>
/// <param name="expected">The value the property must equal.</param>
/// <returns><see langword="true"/> on an exact ordinal match.</returns>
private static bool MatchesScalar(LogEvent logEvent, string propertyName, string expected) =>
logEvent.Properties.TryGetValue(propertyName, out var value)
&& value is ScalarValue { Value: string actual }
&& string.Equals(actual, expected, StringComparison.Ordinal);
/// <summary>
/// Parses a Serilog <see cref="LogEventLevel"/> name, falling back to
/// <see cref="LogEventLevel.Information"/> for null/blank/unrecognised values.
///
/// When an operator sets <c>ScadaBridge:Logging:MinimumLevel</c> to a
/// value that doesn't parse (e.g. the typo "Informaiton"), the helper must NOT
/// throw — startup has to succeed so the rest of the system can come up — but
/// it MUST make the silent fallback visible. The logger is not yet built at
/// this point, so the warning is written directly to <see cref="Console.Error"/>
/// using <see cref="WriteParseWarning"/>; non-null/non-blank values that fail
/// to parse are reported once, naming the offending value and the fallback.
/// Null/blank values are treated as "unset" and silently default — only
/// explicit-but-invalid values trigger the warning.
/// </summary>
/// <param name="level">Configured level string, possibly null/blank/invalid.</param>
/// <returns>The parsed <see cref="LogEventLevel"/>, or <see cref="LogEventLevel.Information"/> when the value is absent or unrecognised.</returns>
internal static LogEventLevel ParseLevel(string? level)
=> ParseLevel(level, Console.Error);
/// <summary>
/// Test-visible overload of <see cref="ParseLevel(string?)"/> that routes the
/// one-shot warning through a caller-supplied writer (<see cref="Console.Error"/>
/// in production) so unit tests can capture the warning output.
/// </summary>
/// <param name="level">Configured level string, possibly null/blank/invalid.</param>
/// <param name="warningWriter">Writer that receives a single warning line if the value is non-blank but unparseable.</param>
/// <returns>The parsed <see cref="LogEventLevel"/>, or <see cref="LogEventLevel.Information"/> when the value is absent or unrecognised.</returns>
internal static LogEventLevel ParseLevel(string? level, TextWriter warningWriter)
{
if (Enum.TryParse<LogEventLevel>(level, ignoreCase: true, out var parsed))
return parsed;
if (!string.IsNullOrWhiteSpace(level))
warningWriter.WriteLine(
$"warning: ScadaBridge:Logging:MinimumLevel value '{level}' is not a recognised Serilog LogEventLevel; falling back to Information.");
return LogEventLevel.Information;
}
}
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// run (ScadaBridge#56), so every log line written on the run's async flow - the script body's own
/// callees (external-system client, database gateway, store-and-forward enqueue, ...) - carries
/// <c>ExecutionId</c>, <c>InstanceName</c> and <c>ScriptName</c> once the host enables
/// <c>Enrich.FromLogContext()</c> (<c>LoggerConfigurationFactory</c>).
/// <c>Enrich.FromLogContext()</c> (<c>ScadaBridgeLogging</c>).
/// </summary>
/// <remarks>
/// <c>LogContext</c> is an <c>AsyncLocal</c>: it follows the run's own call chain but NOT an Akka