c810bd0f7c
#120: Akka ILoggingAdapter lines used to bypass Serilog and go to stdout only. - BuildHocon sets akka.loggers = [Host.Actors.SerilogAkkaLogger] and keeps akka.loglevel = INFO, so Akka Debug chatter is still filtered at the adapter. The INFO+ lines that used to reach stdout now go through the Serilog pipeline instead, with no extra volume. - Lines reach the file sinks with the node enrichers. SourceContext is the logging class's full name, AkkaSource is the actor path, Akka's own timestamp is kept, and format arguments become structured properties. - The Akka logger actor writes on its own thread, after the logging actor's LogContext is gone. To keep the ExecutionId, the two DCL actors log through ExecutionAwareLogging.GetLogger(Context), which records ExecutionLogContext.Current (a new AsyncLocal mirror of the pushed id) in an ExecutionTaggedLogMessage. The logger actor pushes that id again while it writes the line. #56 remainder (coordinator decision: no replicated schema change): - The host pipeline gains ExecutionLogCaptureSink. - While a run is traced, its step logger registers its ExecutionId: the site ScriptLogger from the launcher (plus each inline CallShared child id), and the central InboundScriptLogger from the executor. - Each tagged line becomes a ScriptLog row with LogSource "node" and the new ScriptTraceExtra.LogCategory. - The rows count against the existing MaxLogLinesPerRun cap, are clipped at 2 000 chars, ride the low-priority collector lane and pass the same redactor. Untraced runs cost one volatile read. - Lines are never copied twice. The step loggers' own forwards run inside ExecutionLogCapture.Suppress(), and the sink suppresses itself while it calls a target. The registration is declared inside the launcher's and executor's try, so it ends before the catch blocks log the outcome, and FlushDropped closes it. - FlowGraphBuilder lists node lines in FlowGraph.NodeLogs and never folds them onto a step. - The Flow Debugger shows them in a Node log card (FlowNodeLogPanel): grouped per execution under its run step, with an All | Warn + filter. Live and Replay both read the same rows, so site scope and role rules apply unchanged. - CLI --show-logs prints them as a trailing block. Tests: Commons ExecutionLogCaptureTests, ExecutionLogContext.Current, FlowGraphBuilder node lines. SiteRuntime NodeLogCaptureTests covers cross-actor capture via the launcher, untraced runs capturing nothing, no double capture of script and runtime lines or of the launcher failure line, the shared cap, collector redaction, shared-script child ids and FlushDropped closing capture. InboundNodeLogCaptureTests covers the central path. DCL ExecutionAwareLoggingTests covers the DataConnectionActor write-path Debug line being tagged and an uncorrelated line not being tagged. Host SerilogAkkaLoggerTests covers mapping, the ExecutionId crossing the logger hop, HOCON, ActorSystem boot and the Akka line reaching a traced run's step log end to end. Host LoggerConfigurationTests checks that the sink is wired. bUnit FlowNodeLogPanelTests and a CLI projection test cover the UI and CLI. Every new assertion was mutation-proven. Docs: step-logs design section 12, Component-SiteRuntime, CentralUI, InboundAPI, Host, CLI README, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
218 lines
8.5 KiB
C#
218 lines
8.5 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Serilog.Events;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog
|
|
/// minimum level. Previously the value was bound into <see cref="LoggingOptions"/>
|
|
/// but never read, so editing it had no effect.
|
|
/// </summary>
|
|
public class LoggerConfigurationTests
|
|
{
|
|
private static IConfiguration BuildConfig(string? minimumLevel)
|
|
{
|
|
var values = new Dictionary<string, string?>();
|
|
if (minimumLevel != null)
|
|
values["ScadaBridge:Logging:MinimumLevel"] = minimumLevel;
|
|
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
}
|
|
|
|
/// <summary>Verifies that a Warning minimum level suppresses Information logs.</summary>
|
|
[Fact]
|
|
public void MinimumLevel_Warning_SuppressesInformationLogs()
|
|
{
|
|
var sink = new InMemorySink();
|
|
var logger = LoggerConfigurationFactory
|
|
.Build(BuildConfig("Warning"), "Central", "central", "node1")
|
|
.WriteTo.Sink(sink)
|
|
.CreateLogger();
|
|
|
|
logger.Information("info message");
|
|
logger.Warning("warning message");
|
|
|
|
Assert.Single(sink.LogEvents);
|
|
Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level);
|
|
}
|
|
|
|
/// <summary>Verifies the logger enriches events from the Serilog LogContext, so a run's pushed ExecutionId reaches the sink (ScadaBridge#56).</summary>
|
|
[Fact]
|
|
public void LogContextProperties_AreEnrichedOntoEvents()
|
|
{
|
|
var sink = new InMemorySink();
|
|
var logger = LoggerConfigurationFactory
|
|
.Build(BuildConfig(null), "Site", "site-a", "node1")
|
|
.WriteTo.Sink(sink)
|
|
.CreateLogger();
|
|
|
|
var id = Guid.NewGuid();
|
|
using (Serilog.Context.LogContext.PushProperty("ExecutionId", id))
|
|
{
|
|
logger.Information("inside");
|
|
}
|
|
logger.Information("outside");
|
|
|
|
Assert.Equal(2, sink.LogEvents.Count);
|
|
Assert.Equal(id.ToString(), sink.LogEvents[0].Properties["ExecutionId"].ToString().Trim('"'));
|
|
Assert.False(sink.LogEvents[1].Properties.ContainsKey("ExecutionId"));
|
|
}
|
|
|
|
/// <summary>Verifies that a Debug minimum level allows Debug logs.</summary>
|
|
[Fact]
|
|
public void MinimumLevel_Debug_AllowsDebugLogs()
|
|
{
|
|
var sink = new InMemorySink();
|
|
var logger = LoggerConfigurationFactory
|
|
.Build(BuildConfig("Debug"), "Site", "site-a", "node1")
|
|
.WriteTo.Sink(sink)
|
|
.CreateLogger();
|
|
|
|
logger.Debug("debug message");
|
|
|
|
Assert.Single(sink.LogEvents);
|
|
Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level);
|
|
}
|
|
|
|
/// <summary>Verifies that an absent minimum level defaults to Information.</summary>
|
|
[Fact]
|
|
public void MinimumLevel_Absent_DefaultsToInformation()
|
|
{
|
|
var sink = new InMemorySink();
|
|
var logger = LoggerConfigurationFactory
|
|
.Build(BuildConfig(null), "Central", "central", "node1")
|
|
.WriteTo.Sink(sink)
|
|
.CreateLogger();
|
|
|
|
logger.Debug("debug message");
|
|
logger.Information("info message");
|
|
|
|
Assert.Single(sink.LogEvents);
|
|
Assert.Equal(LogEventLevel.Information, sink.LogEvents[0].Level);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo
|
|
/// like "Informaiton") must NOT abort startup but MUST emit a one-shot warning
|
|
/// naming the offending value and the fallback so the silent coercion is
|
|
/// visible. Null/blank is treated as "unset" and silently defaults.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseLevel_UnrecognisedValue_FallsBackAndWarns()
|
|
{
|
|
var writer = new StringWriter();
|
|
|
|
var result = LoggerConfigurationFactory.ParseLevel("Informaiton", writer);
|
|
|
|
Assert.Equal(LogEventLevel.Information, result);
|
|
var warning = writer.ToString();
|
|
Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("Informaiton", warning);
|
|
Assert.Contains("Information", warning);
|
|
}
|
|
|
|
/// <summary>Verifies that a null or blank level falls back silently.</summary>
|
|
[Fact]
|
|
public void ParseLevel_NullOrBlank_FallsBackSilently()
|
|
{
|
|
var writer = new StringWriter();
|
|
|
|
var nullResult = LoggerConfigurationFactory.ParseLevel(null, writer);
|
|
var blankResult = LoggerConfigurationFactory.ParseLevel(" ", writer);
|
|
|
|
Assert.Equal(LogEventLevel.Information, nullResult);
|
|
Assert.Equal(LogEventLevel.Information, blankResult);
|
|
Assert.Empty(writer.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that a recognised level value produces no warning.</summary>
|
|
[Fact]
|
|
public void ParseLevel_RecognisedValue_NoWarning()
|
|
{
|
|
var writer = new StringWriter();
|
|
|
|
var result = LoggerConfigurationFactory.ParseLevel("Warning", writer);
|
|
|
|
Assert.Equal(LogEventLevel.Warning, result);
|
|
Assert.Empty(writer.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source
|
|
/// of truth for the Serilog floor, and the explicit <c>MinimumLevel.Is</c>
|
|
/// call deliberately runs after <c>ReadFrom.Configuration(...)</c> so a
|
|
/// <c>Serilog:MinimumLevel</c> entry is overridden. To make that precedence
|
|
/// visible — instead of silently swallowed — <see cref="LoggerConfigurationFactory.Build(IConfiguration,string,string,string,TextWriter)"/>
|
|
/// writes a one-shot warning when both keys are present. The warning must
|
|
/// name both values and point the operator at the documented key. When the
|
|
/// Serilog key is absent the warning is silent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_BothMinimumLevelKeysSet_WarnsAboutOverride()
|
|
{
|
|
var writer = new StringWriter();
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Logging:MinimumLevel"] = "Warning",
|
|
["Serilog:MinimumLevel"] = "Debug",
|
|
})
|
|
.Build();
|
|
|
|
LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer);
|
|
|
|
var warning = writer.ToString();
|
|
Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("Serilog:MinimumLevel", warning);
|
|
Assert.Contains("ScadaBridge:Logging:MinimumLevel", warning);
|
|
Assert.Contains("Debug", warning);
|
|
Assert.Contains("Warning", warning);
|
|
}
|
|
|
|
/// <summary>Verifies that setting only the ScadaBridge minimum level produces no override warning.</summary>
|
|
[Fact]
|
|
public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning()
|
|
{
|
|
var writer = new StringWriter();
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Logging:MinimumLevel"] = "Warning",
|
|
})
|
|
.Build();
|
|
|
|
LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer);
|
|
|
|
// No Serilog override -> no override-warning. (The ScadaBridge value is
|
|
// a recognised level, so ParseLevel is silent too.)
|
|
Assert.Empty(writer.ToString());
|
|
}
|
|
|
|
private sealed class CollectingTarget : ZB.MOM.WW.ScadaBridge.Commons.Observability.IExecutionLogTarget
|
|
{
|
|
public List<ZB.MOM.WW.ScadaBridge.Commons.Observability.CapturedLogLine> Lines { get; } = new();
|
|
public void Capture(ZB.MOM.WW.ScadaBridge.Commons.Observability.CapturedLogLine line) => Lines.Add(line);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#56: the host pipeline carries the per-execution capture sink, so a line tagged with
|
|
/// a registered (traced) execution reaches that execution's step log.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_IncludesTheExecutionCaptureSink()
|
|
{
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build();
|
|
using var logger = LoggerConfigurationFactory.Build(configuration, "Site", "site-a", "node1", new StringWriter())
|
|
.CreateLogger();
|
|
var id = Guid.NewGuid();
|
|
var target = new CollectingTarget();
|
|
|
|
using (ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogCapture.Register(id, target))
|
|
using (ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogContext.Push(id))
|
|
{
|
|
logger.Warning("tagged line");
|
|
}
|
|
|
|
Assert.Equal("tagged line", Assert.Single(target.Lines).Message);
|
|
}
|
|
}
|