Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/LoggerConfigurationFactory.cs
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
2026-05-28 09:37:45 -04:00

127 lines
6.9 KiB
C#

using Serilog;
using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Builds the Serilog <see cref="LoggerConfiguration"/> for the Host process.
///
/// REQ-HOST-8 / Host-011: 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.
///
/// REQ-HOST-8 / Host-014: 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"/>.
///
/// Host-020: <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 Host-020 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 Host-020 override-warning when both keys are present.</param>
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);
}
/// <summary>
/// Parses a Serilog <see cref="LogEventLevel"/> name, falling back to
/// <see cref="LogEventLevel.Information"/> for null/blank/unrecognised values.
///
/// Host-022: 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>
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>
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;
}
}