Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogAkkaLoggerTests.cs
T
Joseph Doherty 2d2e9f35c5 feat(host): build the process logger through the shared AddZbSerilog bootstrap (#94)
Switch ScadaBridge from its own LoggerConfigurationFactory to AddZbSerilog
(ZB.MOM.WW.Telemetry(.Serilog) 0.1.0 -> 0.2.0) using the two 0.2.0 hooks:

- MinimumLevelGovernor: ScadaBridge:Logging:MinimumLevel, applied after
  ReadFrom.Configuration so it outranks Serilog:MinimumLevel (never null, so the
  floor never defers to the Serilog key; Override:* namespace entries still apply;
  the one-shot conflict / unparseable-value warnings are unchanged).
- ConfigureLogger: the standby active-node exclusion filter (register row 71),
  the configured node identity (the shared bootstrap stamps NodeHostname from
  the machine name and skips empty values; an overwriting enricher keeps the
  configured ScadaBridge:Node values verbatim), Enrich.FromLogContext and the
  ExecutionLogCaptureSink.

ScadaBridgeLogging runs AddZbSerilog on a small pre-host builder and Program
installs the result as the static Log.Logger; both web hosts keep UseSerilog()
over it. One logger and one file-sink handle per process, usable before the
host is built (startup banner, warnings, pre-host Fatal), by SerilogAkkaLogger,
and after the host is disposed (shutdown line, unexpected-termination Fatal) —
AddZbSerilog's own DI registration would leave Log.Logger alone and have the
host dispose the logger.

LoggerConfigurationFactory is deleted (nothing else used it). Tests: existing
logging/standby/sink tests now run through the new pipeline, plus
ScadaBridgeLoggingParityTests (level precedence, Override:* still applies,
one-shot warning, configured identity incl. NodeHostname != MachineName and empty
SiteId, exact property set of a plain line, trace_id/span_id). Mutation-checked:
governor deferring, filter removed, identity enricher removed, FromLogContext
removed, capture sink removed each fail tests.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 14:43:19 -05:00

232 lines
9.8 KiB
C#

using System.Collections.Concurrent;
using Akka.Actor;
using Akka.Configuration;
using Akka.Event;
using Akka.TestKit.Xunit2;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Logging;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
using AkkaLogEvent = Akka.Event.LogEvent;
using SerilogLogEvent = Serilog.Events.LogEvent;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// ScadaBridge#120: Akka <c>ILoggingAdapter</c> lines reach the host's Serilog pipeline, with the
/// node enrichers and, for an execution-aware adapter, the <c>ExecutionId</c> of the message the
/// actor was serving.
/// </summary>
public class SerilogAkkaLoggerTests : TestKit
{
private sealed class CaptureSink : ILogEventSink
{
public ConcurrentQueue<SerilogLogEvent> Events { get; } = new();
public void Emit(SerilogLogEvent logEvent) => Events.Enqueue(logEvent);
}
/// <summary>An actor that logs through the execution-aware adapter while serving a message.</summary>
private sealed class LoggingActor : ReceiveActor
{
private readonly ILoggingAdapter _log = ExecutionAwareLogging.GetLogger(Context);
public LoggingActor()
{
Receive<(Guid? Id, string Text)>(m =>
{
using (ExecutionLogContext.Push(m.Id))
_log.Warning("actor line {0}", m.Text);
Sender.Tell("done");
});
}
}
private readonly CaptureSink _sink = new();
private readonly Serilog.ILogger _serilog;
/// <summary>
/// The system keeps Akka's default logger (TestKit's); each test that needs the bridge spawns a
/// <see cref="SerilogAkkaLogger"/> over a capturing pipeline that mirrors the host's
/// <c>ScadaBridgeLogging</c> (<c>Enrich.FromLogContext</c> plus a node property).
/// </summary>
public SerilogAkkaLoggerTests() : base("akka.loglevel = INFO")
{
_serilog = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.WithProperty("NodeRole", "site-a")
.Enrich.FromLogContext()
.WriteTo.Sink(_sink)
.CreateLogger();
}
private void StartBridge()
{
var bridge = Sys.ActorOf(Props.Create(() => new SerilogAkkaLogger(_serilog)));
Sys.EventStream.Subscribe(bridge, typeof(AkkaLogEvent));
}
private SerilogLogEvent AwaitLine(string fragment)
{
SerilogLogEvent? found = null;
AwaitCondition(() =>
{
found = _sink.Events.FirstOrDefault(e => e.RenderMessage().Contains(fragment, StringComparison.Ordinal));
return found is not null;
}, TimeSpan.FromSeconds(5));
return found!;
}
private static string? Scalar(SerilogLogEvent e, string name)
=> e.Properties.TryGetValue(name, out var v) && v is ScalarValue { Value: { } x } ? x.ToString() : null;
/// <summary>An Akka line reaches Serilog with its level, class, actor path, structured args and node enrichers.</summary>
[Fact]
public void AkkaLine_ReachesSerilog_WithSourceContextAndEnrichers()
{
StartBridge();
var actor = Sys.ActorOf(Props.Create(() => new LoggingActor()), "plain-logger");
actor.Tell(((Guid?)null, "alpha"));
ExpectMsg("done");
var line = AwaitLine("actor line");
Assert.Equal(LogEventLevel.Warning, line.Level);
Assert.Equal(typeof(LoggingActor).FullName, Scalar(line, "SourceContext"));
Assert.Contains("plain-logger", Scalar(line, SerilogAkkaLogger.AkkaSourceProperty));
Assert.Equal("site-a", Scalar(line, "NodeRole"));
Assert.Equal("actor line \"alpha\"", line.RenderMessage());
// The adapter's argument is a structured property, not baked into the text.
Assert.Equal("alpha", Scalar(line, "0"));
Assert.False(line.Properties.ContainsKey(ExecutionLogContext.ExecutionIdProperty));
}
/// <summary>
/// The line logged while an execution was current carries that id, although Serilog writes it on
/// the logger actor's thread, where the logging actor's LogContext is not in effect.
/// </summary>
[Fact]
public void ExecutionAwareLine_CarriesTheExecutionId_AcrossTheLoggerHop()
{
StartBridge();
var actor = Sys.ActorOf(Props.Create(() => new LoggingActor()));
var id = Guid.NewGuid();
actor.Tell(((Guid?)id, "beta"));
ExpectMsg("done");
Assert.Equal(id.ToString(), Scalar(AwaitLine("beta"), ExecutionLogContext.ExecutionIdProperty));
}
/// <summary>The Akka timestamp is kept, not replaced by the time the logger actor wrote the line.</summary>
[Fact]
public void Write_KeepsTheAkkaTimestamp_AndMapsEveryLevel()
{
var serilog = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(_sink).CreateLogger();
var then = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
foreach (var (evt, expected) in new (AkkaLogEvent, LogEventLevel)[]
{
(new Debug("src", typeof(LoggingActor), "d-line"), LogEventLevel.Debug),
(new Info("src", typeof(LoggingActor), "i-line"), LogEventLevel.Information),
(new Warning("src", typeof(LoggingActor), "w-line"), LogEventLevel.Warning),
(new Error(new InvalidOperationException("boom"), "src", typeof(LoggingActor), "e-line"), LogEventLevel.Error),
})
{
typeof(AkkaLogEvent).GetProperty(nameof(AkkaLogEvent.Timestamp))!.SetValue(evt, then);
SerilogAkkaLogger.Write(evt, serilog);
var line = _sink.Events.Single(e => e.RenderMessage().Contains(evt.Message.ToString()!, StringComparison.Ordinal));
Assert.Equal(expected, line.Level);
Assert.Equal(new DateTimeOffset(then), line.Timestamp);
}
Assert.Equal("boom", _sink.Events.Single(e => e.Level == LogEventLevel.Error).Exception!.Message);
}
/// <summary>The Serilog minimum level applies to Akka lines as it does to every other line.</summary>
[Fact]
public void Write_BelowTheSerilogMinimum_IsDropped()
{
// _serilog's minimum is Information.
SerilogAkkaLogger.Write(new Debug("src", typeof(LoggingActor), "debug-only"), _serilog);
SerilogAkkaLogger.Write(new Info("src", typeof(LoggingActor), "info-kept"), _serilog);
Assert.DoesNotContain(_sink.Events, e => e.RenderMessage().Contains("debug-only", StringComparison.Ordinal));
Assert.Contains(_sink.Events, e => e.RenderMessage().Contains("info-kept", StringComparison.Ordinal));
}
/// <summary>
/// The HOCON the host builds names this logger and keeps <c>akka.loglevel</c> at INFO, so Akka's
/// Debug lifecycle chatter stays filtered at the adapter (no volume change from the bridge).
/// </summary>
[Fact]
public void BuildHocon_NamesTheSerilogLogger_AndKeepsInfoLevel()
{
var hocon = AkkaHostedService.BuildHocon(
new NodeOptions { Role = "Site", NodeHostname = "n", RemotingPort = 8082 },
new ClusterOptions { SeedNodes = ["akka.tcp://scadabridge@n:8082"], SplitBrainResolverStrategy = "auto-down" },
["Site"], TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
var config = ConfigurationFactory.ParseString(hocon);
Assert.Equal([SerilogAkkaLogger.HoconTypeName], config.GetStringList("akka.loggers"));
Assert.Equal("INFO", config.GetString("akka.loglevel"));
}
/// <summary>
/// The type name in <c>akka.loggers</c> resolves and the logger completes Akka's startup
/// handshake — a wrong name or a missing <c>LoggerInitialized</c> reply fails system creation.
/// </summary>
[Fact]
public async Task ActorSystem_StartsWithTheSerilogLoggerConfigured()
{
var config = ConfigurationFactory.ParseString(
$"akka.loggers = [\"{SerilogAkkaLogger.HoconTypeName}\"]\nakka.logger-startup-timeout = 5s");
var system = ActorSystem.Create("serilog-logger-boot", config);
try
{
system.Log.Info("boot line"); // must not throw; goes to the static Serilog logger
Assert.False(system.WhenTerminated.IsCompleted);
}
finally
{
await system.Terminate();
}
}
private sealed class CollectingTarget : IExecutionLogTarget
{
public ConcurrentQueue<CapturedLogLine> Lines { get; } = new();
public void Capture(CapturedLogLine line) => Lines.Enqueue(line);
}
/// <summary>
/// ScadaBridge#56 + #120 end to end: an Akka actor's own line, logged while it serves a traced
/// execution, crosses the logger-actor hop and still reaches that execution's step log, with the
/// actor's class as its category.
/// </summary>
[Fact]
public void AkkaLine_ForATracedExecution_ReachesItsStepLog()
{
var serilog = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Sink(new ExecutionLogCaptureSink())
.CreateLogger();
var bridge = Sys.ActorOf(Props.Create(() => new SerilogAkkaLogger(serilog)));
Sys.EventStream.Subscribe(bridge, typeof(AkkaLogEvent));
var id = Guid.NewGuid();
var target = new CollectingTarget();
using var registration = ExecutionLogCapture.Register(id, target);
Sys.ActorOf(Props.Create(() => new LoggingActor())).Tell(((Guid?)id, "gamma"));
ExpectMsg("done");
AwaitCondition(() => !target.Lines.IsEmpty, TimeSpan.FromSeconds(5));
var line = Assert.Single(target.Lines);
Assert.Equal("warn", line.Level);
Assert.Equal(typeof(LoggingActor).FullName, line.Category);
Assert.Equal("actor line \"gamma\"", line.Message);
}
}