2d2e9f35c5
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
232 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|