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
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#120: the DCL actors' own Akka <c>_log</c> lines record the execution id of the
|
||||
/// message being served, so the host's Serilog logger actor can tag them although it writes them
|
||||
/// on another thread.
|
||||
/// </summary>
|
||||
public class ExecutionAwareLoggingTests : TestKit
|
||||
{
|
||||
/// <summary>Debug level so the DCL's write-path Debug line is published.</summary>
|
||||
public ExecutionAwareLoggingTests() : base("akka.loglevel = DEBUG")
|
||||
{
|
||||
}
|
||||
|
||||
private sealed record LogUnder(Guid? Id);
|
||||
|
||||
private sealed class Probe : ReceiveActor
|
||||
{
|
||||
private readonly ILoggingAdapter _log = ExecutionAwareLogging.GetLogger(Context);
|
||||
|
||||
public Probe()
|
||||
{
|
||||
Receive<LogUnder>(m =>
|
||||
{
|
||||
var id = m.Id;
|
||||
using (ExecutionLogContext.Push(id))
|
||||
_log.Info("probe {0}", id?.ToString() ?? "none");
|
||||
});
|
||||
Receive<string>(_ => _log.Debug("debug {0}", "x"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A line logged inside an execution's push is published wrapped with that id; its text is unchanged.</summary>
|
||||
[Fact]
|
||||
public void LineInsideAPush_IsTaggedWithTheExecutionId_TextUnchanged()
|
||||
{
|
||||
var events = CreateTestProbe();
|
||||
Sys.EventStream.Subscribe(events.Ref, typeof(Info));
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
Sys.ActorOf(Props.Create(() => new Probe())).Tell(new LogUnder(id));
|
||||
|
||||
var info = events.FishForMessage<Info>(i => i.Message.ToString()!.StartsWith("probe", StringComparison.Ordinal));
|
||||
var tagged = Assert.IsType<ExecutionTaggedLogMessage>(info.Message);
|
||||
Assert.Equal(id, tagged.ExecutionId);
|
||||
Assert.Equal($"probe {id}", info.Message.ToString());
|
||||
Assert.IsAssignableFrom<LogMessage>(tagged.Message);
|
||||
}
|
||||
|
||||
/// <summary>With no execution current, the line is published exactly as <c>Context.GetLogger()</c> would publish it.</summary>
|
||||
[Fact]
|
||||
public void LineOutsideAPush_IsPublishedUnwrapped()
|
||||
{
|
||||
var events = CreateTestProbe();
|
||||
Sys.EventStream.Subscribe(events.Ref, typeof(Info));
|
||||
|
||||
Sys.ActorOf(Props.Create(() => new Probe())).Tell(new LogUnder(null));
|
||||
|
||||
var info = events.FishForMessage<Info>(i => i.Message.ToString()!.StartsWith("probe", StringComparison.Ordinal));
|
||||
Assert.IsAssignableFrom<LogMessage>(info.Message);
|
||||
Assert.Equal("probe none", info.Message.ToString());
|
||||
}
|
||||
|
||||
/// <summary>The adapter honours <c>akka.loglevel</c>: a Debug line is not published once the bus is at Info.</summary>
|
||||
[Fact]
|
||||
public void DisabledLevel_PublishesNothing()
|
||||
{
|
||||
Sys.EventStream.SetLogLevel(Akka.Event.LogLevel.InfoLevel);
|
||||
var events = CreateTestProbe();
|
||||
Sys.EventStream.Subscribe(events.Ref, typeof(Debug));
|
||||
|
||||
Sys.ActorOf(Props.Create(() => new Probe())).Tell("debug");
|
||||
|
||||
events.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end on the DCL: the DataConnectionActor's own write-path Debug line, logged while it
|
||||
/// serves a correlated WriteTagRequest, is tagged with the request's execution id; an
|
||||
/// uncorrelated write's line is not (control).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DataConnectionActor_WritePathLine_IsTagged_UncorrelatedIsNot()
|
||||
{
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
adapter.WriteAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new WriteResult(true, null)));
|
||||
var options = new DataConnectionOptions
|
||||
{
|
||||
ReconnectInterval = TimeSpan.FromMilliseconds(100),
|
||||
WriteTimeout = TimeSpan.FromSeconds(5),
|
||||
SeedReadMaxAttempts = 1,
|
||||
SeedReadRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||
};
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
"akka-tag", adapter, options, Substitute.For<ISiteHealthCollector>(),
|
||||
Substitute.For<IDataConnectionFactory>(), "OpcUa")), "akka-tag");
|
||||
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
await Task.Delay(200);
|
||||
|
||||
var events = CreateTestProbe();
|
||||
Sys.EventStream.Subscribe(events.Ref, typeof(Debug));
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
actor.Tell(new WriteTagRequest("c1", "akka-tag", "tagT", 1, DateTimeOffset.UtcNow, id));
|
||||
Assert.True(ExpectMsg<WriteTagResponse>(TimeSpan.FromSeconds(5)).Success);
|
||||
var tagged = events.FishForMessage<Debug>(d => d.Message.ToString()!.Contains("Writing to tag tagT", StringComparison.Ordinal));
|
||||
Assert.Equal(id, Assert.IsType<ExecutionTaggedLogMessage>(tagged.Message).ExecutionId);
|
||||
|
||||
actor.Tell(new WriteTagRequest("c2", "akka-tag", "tagU", 1, DateTimeOffset.UtcNow));
|
||||
Assert.True(ExpectMsg<WriteTagResponse>(TimeSpan.FromSeconds(5)).Success);
|
||||
var plain = events.FishForMessage<Debug>(d => d.Message.ToString()!.Contains("Writing to tag tagU", StringComparison.Ordinal));
|
||||
Assert.IsNotType<ExecutionTaggedLogMessage>(plain.Message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user