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;
///
/// Builds the Host process's Serilog logger through the family's shared bootstrap,
/// AddZbSerilog (ZB.MOM.WW.Telemetry.Serilog 0.2.0+, ScadaBridge#94), with two
/// ScadaBridge hooks: a minimum-level governor and a final callback.
///
///
///
/// What the shared bootstrap supplies. ReadFrom.Configuration over the standard
/// Serilog section (the console and file sinks, output template, file path and rolling
/// interval all stay configuration-driven in appsettings.json), the SiteId /
/// NodeRole / NodeHostname identity enrichers, the shared TraceContextEnricher
/// and the (inert here, no ILogRedactor is registered) redaction seam. No OTLP log sink is
/// added: the telemetry options passed here never select the OTLP exporter, as before.
///
///
/// Minimum level (Host-011). ScadaBridge:Logging:MinimumLevel is the single source of
/// truth for the floor. is handed to
/// ZbSerilogOptions.MinimumLevelGovernor, which the library applies AFTER
/// ReadFrom.Configuration, so a Serilog:MinimumLevel entry is overridden. It always
/// returns a level (never ), 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
/// Serilog:MinimumLevel learns why the change had no effect. Namespace
/// Serilog:MinimumLevel:Override:* entries still apply.
///
///
/// Final hook. ZbSerilogOptions.ConfigureLogger adds what is ScadaBridge's own: the
/// standby active-node exclusion filter (register row 71), the exact node identity this
/// process was configured with (the library stamps NodeHostname from the machine name;
/// ScadaBridge has always stamped ScadaBridge:Node:NodeHostname), Enrich.FromLogContext
/// (ScadaBridge#56 run identity) and the Flow Debugger ExecutionLogCaptureSink.
///
///
/// Who owns the logger. AddZbSerilog registers the logger in a container and leaves
/// alone. ScadaBridge needs the static logger before any host exists
/// (startup banner and warnings, a pre-host Fatal), for the Akka bridge
/// (SerilogAkkaLogger), and after the host is disposed (the shutdown line and the
/// unexpected-termination Fatal). So
/// runs AddZbSerilog on a small pre-host builder and resolves the logger from it once;
/// Program installs it as and the web hosts keep
/// UseSerilog() 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
/// Log.CloseAndFlushAsync() in Program's finally does.
///
///
public static class ScadaBridgeLogging
{
/// The OTel service.name ScadaBridge registers with AddZbTelemetry.
internal const string ServiceName = "scadabridge";
/// Creates the process logger: the shared bootstrap plus ScadaBridge's hooks.
/// Application configuration supplying the Serilog section and logging options.
/// Role label (e.g., Central) stamped as NodeRole.
/// Site identifier stamped as SiteId.
/// Configured node hostname stamped as NodeHostname.
/// The built logger; the caller owns it (Program installs it as ).
public static Logger CreateLogger(
IConfiguration configuration,
string nodeRole,
string siteId,
string nodeHostname)
=> CreateLogger(configuration, nodeRole, siteId, nodeHostname, Console.Error, additionalConfiguration: null);
///
/// Test-visible overload of :
/// routes the operator warnings through and lets a test append
/// a capturing sink after ScadaBridge's own hook.
///
/// Application configuration supplying the Serilog section and logging options.
/// Role label stamped as NodeRole.
/// Site identifier stamped as SiteId.
/// Configured node hostname stamped as NodeHostname.
/// Receives the one-shot operator warnings.
/// Optional extra configuration applied last (tests add a sink here).
/// The built logger.
internal static Logger CreateLogger(
IConfiguration configuration,
string nodeRole,
string siteId,
string nodeHostname,
TextWriter warningWriter,
Action? 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();
}
///
/// Registers the ScadaBridge logger on through AddZbSerilog
/// with the minimum-level governor and the final ScadaBridge hook.
///
/// The host application builder.
/// Role label stamped as NodeRole.
/// Site identifier stamped as SiteId.
/// Configured node hostname stamped as NodeHostname.
/// Receives the one-shot operator warnings.
/// Optional extra configuration applied last.
/// The same builder.
internal static IHostApplicationBuilder AddScadaBridgeSerilog(
this IHostApplicationBuilder builder,
string nodeRole,
string siteId,
string nodeHostname,
TextWriter warningWriter,
Action? 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);
};
});
}
///
/// The minimum-level governor: reads ScadaBridge:Logging:MinimumLevel (falling back to
/// Information, with a warning for an unparseable value) and warns once when a
/// Serilog:MinimumLevel entry is present, because this value overrides it.
///
/// The application configuration.
/// Receives the one-shot operator warnings.
/// The governed floor; never .
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;
}
///
/// ScadaBridge's own pipeline stages, run by ZbSerilogOptions.ConfigureLogger after the
/// shared bootstrap.
///
/// The configuration being built.
/// Role label stamped as NodeRole.
/// Site identifier stamped as SiteId.
/// Configured node hostname stamped as NodeHostname.
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());
}
/// Stamps the configured node identity, overwriting any earlier value.
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);
}
}
///
/// Logger category that emits the per-probe "Health check {HealthCheckName} with status
/// {HealthStatus} …" line. It is DefaultHealthCheckService, not
/// HealthCheckPublisherHostedService: this host registers no
/// IHealthCheckPublisher, so the checks run only on an inbound HTTP probe of
/// /health/*.
///
internal const string HealthCheckServiceSourceContext =
"Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService";
/// Name both the site and central /health/active probes register under.
internal const string ActiveNodeCheckName = "active-node";
///
/// Suppresses the standby node's correct-by-design active-node Error line
/// (deferred-work register row 71).
///
///
///
/// The problem. DefaultHealthCheckService logs one line per check per probe,
/// picking the level from the verdict — Debug for Healthy, Warning for Degraded,
/// Error for Unhealthy. Both active-node checks
/// (the shared ActiveNodeHealthCheck, role-scoped on a site and unscoped
/// on central) answer Unhealthy on the standby, because 503 is exactly the contract
/// /health/active 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.
///
///
/// Why a filter and not a level override. Serilog's MinimumLevel.Override
/// can only RAISE a per-source floor; it cannot rewrite one Error event as Information.
/// A LogEvent'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 /health/active itself, the
/// cluster view on the readiness probe, and the health dashboard.
///
///
/// Why this cannot hide a genuine failure. The predicate is keyed on the check
/// NAME, not on the category, so a failing localdb, akka-cluster,
/// database, or required-singletons 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.
///
///
/// Matching on rather than on the HealthStatus
/// property avoids depending on how the enum renders into the event; the level→verdict
/// mapping above already makes Error equivalent to Unhealthy.
///
///
/// The candidate log event.
/// when the event should be dropped.
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);
}
/// Tests one structured property for an exact scalar string value.
/// The event whose properties are inspected.
/// The structured property name.
/// The value the property must equal.
/// on an exact ordinal match.
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);
///
/// Parses a Serilog name, falling back to
/// for null/blank/unrecognised values.
///
/// When an operator sets ScadaBridge:Logging:MinimumLevel 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
/// using ; 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.
///
/// Configured level string, possibly null/blank/invalid.
/// The parsed , or when the value is absent or unrecognised.
internal static LogEventLevel ParseLevel(string? level)
=> ParseLevel(level, Console.Error);
///
/// Test-visible overload of that routes the
/// one-shot warning through a caller-supplied writer (
/// in production) so unit tests can capture the warning output.
///
/// Configured level string, possibly null/blank/invalid.
/// Writer that receives a single warning line if the value is non-blank but unparseable.
/// The parsed , or when the value is absent or unrecognised.
internal static LogEventLevel ParseLevel(string? level, TextWriter warningWriter)
{
if (Enum.TryParse(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;
}
}