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; /// /// ScadaBridge#120: Akka ILoggingAdapter lines reach the host's Serilog pipeline, with the /// node enrichers and, for an execution-aware adapter, the ExecutionId of the message the /// actor was serving. /// public class SerilogAkkaLoggerTests : TestKit { private sealed class CaptureSink : ILogEventSink { public ConcurrentQueue Events { get; } = new(); public void Emit(SerilogLogEvent logEvent) => Events.Enqueue(logEvent); } /// An actor that logs through the execution-aware adapter while serving a message. 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; /// /// The system keeps Akka's default logger (TestKit's); each test that needs the bridge spawns a /// over a capturing pipeline that mirrors the host's /// ScadaBridgeLogging (Enrich.FromLogContext plus a node property). /// 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; /// An Akka line reaches Serilog with its level, class, actor path, structured args and node enrichers. [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)); } /// /// 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. /// [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)); } /// The Akka timestamp is kept, not replaced by the time the logger actor wrote the line. [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); } /// The Serilog minimum level applies to Akka lines as it does to every other line. [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)); } /// /// The HOCON the host builds names this logger and keeps akka.loglevel at INFO, so Akka's /// Debug lifecycle chatter stays filtered at the adapter (no volume change from the bridge). /// [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")); } /// /// The type name in akka.loggers resolves and the logger completes Akka's startup /// handshake — a wrong name or a missing LoggerInitialized reply fails system creation. /// [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 Lines { get; } = new(); public void Capture(CapturedLogLine line) => Lines.Enqueue(line); } /// /// 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. /// [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); } }