Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogAkkaLoggerTests.cs
T
Joseph Doherty c810bd0f7c feat(logging): bridge Akka logging to Serilog and copy traced runs' node-log lines into the Flow Debugger (ScadaBridge#120, #56)
#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
2026-11-10 06:12:34 -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>LoggerConfigurationFactory</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);
}
}