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);
}
}