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; } }