using Serilog; using Serilog.Events; namespace ZB.MOM.WW.ScadaBridge.Host; /// /// Builds the Serilog for the Host process. /// /// REQ-HOST-8 / Host-011: the configured minimum level comes from /// ScadaBridge:Logging:MinimumLevel (bound to ) so an /// operator editing that key changes the effective log level. /// /// REQ-HOST-8 / Host-014: the console and file sinks are read from the standard /// Serilog configuration section via ReadFrom.Configuration — the sink /// set, console output template, file path and rolling interval are all /// configuration-driven (defined in appsettings.json), not hard-coded. The /// explicit MinimumLevel.Is below pins the floor from . /// /// Host-020: ScadaBridge:Logging:MinimumLevel is the single source of truth /// for the floor — the explicit MinimumLevel.Is call deliberately runs /// AFTER ReadFrom.Configuration so a Serilog:MinimumLevel entry in /// configuration is overridden. To make that precedence visible (so an operator /// who sets Serilog:MinimumLevel does not wonder why the change had no /// effect), writes a one-shot warning to /// when both keys are present. Pick one path — /// editing Serilog:MinimumLevel alone has no effect. /// public static class LoggerConfigurationFactory { /// Builds a enriched with node-identity properties and a configured minimum level. /// Application configuration supplying the Serilog section and logging options. /// Role label (e.g., central-a) added as a log enrichment property. /// Site identifier added as a log enrichment property. /// Hostname added as a log enrichment property. /// The configured . public static LoggerConfiguration Build( IConfiguration configuration, string nodeRole, string siteId, string nodeHostname) => Build(configuration, nodeRole, siteId, nodeHostname, Console.Error); /// /// Test-visible overload of /// that routes the Host-020 precedence warning through a caller-supplied /// writer so unit tests can capture it. Production calls the four-arg /// overload which uses . /// /// Application configuration supplying the Serilog section and logging options. /// Role label added as a log enrichment property. /// Site identifier added as a log enrichment property. /// Hostname added as a log enrichment property. /// Writer that receives the one-shot Host-020 override-warning when both keys are present. 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); // Host-020: 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) .Enrich.WithProperty("SiteId", siteId) .Enrich.WithProperty("NodeHostname", nodeHostname) .Enrich.WithProperty("NodeRole", nodeRole); } /// /// Parses a Serilog name, falling back to /// for null/blank/unrecognised values. /// /// Host-022: 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. /// 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. 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; } }