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:
Joseph Doherty
2026-11-10 06:12:34 -05:00
parent 463aced7a1
commit c810bd0f7c
43 changed files with 2261 additions and 46 deletions
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using Akka.Actor;
using Akka.Event;
@@ -32,7 +33,8 @@ public class DataConnectionActor : UntypedActor, IWithTimers
{
public enum ActiveEndpoint { Primary, Backup }
private readonly ILoggingAdapter _log = Context.GetLogger();
// ScadaBridge#120: execution-aware, so a line logged while serving a correlated message keeps its ExecutionId.
private readonly ILoggingAdapter _log = ExecutionAwareLogging.GetLogger(Context);
private readonly string _connectionName;
private IDataConnection _adapter;
private readonly DataConnectionOptions _options;
@@ -337,7 +339,8 @@ public class DataConnectionActor : UntypedActor, IWithTimers
/// <remarks>
/// Enrichment reaches lines written through a Serilog-backed <c>ILogger</c> - here, the protocol
/// adapter's own logging on the write path, whose <c>WriteAsync</c> starts inside this scope. This
/// actor's own Akka <c>_log</c> lines go through Akka's logger, not Serilog, and are not enriched.
/// actor's own Akka <c>_log</c> lines are tagged too (Gitea #120): the execution-aware adapter
/// records the id when the line is logged, and the host's Serilog logger actor re-pushes it.
/// </remarks>
/// <param name="receive">The behaviour that handles the message.</param>
/// <param name="message">The message.</param>
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using Akka.Actor;
using Akka.Event;
@@ -21,7 +22,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
/// </summary>
public class DataConnectionManagerActor : ReceiveActor
{
private readonly ILoggingAdapter _log = Context.GetLogger();
// ScadaBridge#120: execution-aware, so a line logged while serving a correlated message keeps its ExecutionId.
private readonly ILoggingAdapter _log = ExecutionAwareLogging.GetLogger(Context);
private readonly IDataConnectionFactory _factory;
private readonly DataConnectionOptions _options;
private readonly ISiteHealthCollector _healthCollector;
@@ -242,7 +244,8 @@ public class DataConnectionManagerActor : ReceiveActor
/// <remarks>
/// Enrichment reaches lines written through a Serilog-backed <c>ILogger</c> - here, the protocol
/// adapter's own logging on the write path, whose <c>WriteAsync</c> starts inside this scope. This
/// actor's own Akka <c>_log</c> lines go through Akka's logger, not Serilog, and are not enriched.
/// actor's own Akka <c>_log</c> lines are tagged too (Gitea #120): the execution-aware adapter
/// records the id when the line is logged, and the host's Serilog logger actor re-pushes it.
/// </remarks>
/// <param name="receive">The behaviour that handles the message.</param>
/// <param name="message">The message.</param>
@@ -0,0 +1,95 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Logging;
/// <summary>
/// Creates Akka logging adapters whose lines keep the <c>ExecutionId</c> of the message being
/// served (ScadaBridge#120).
/// </summary>
/// <remarks>
/// <para>An actor that re-pushes an execution id per message in <c>AroundReceive</c>
/// (<see cref="ExecutionLogContext.PushFor"/>) tags every Serilog-backed <c>ILogger</c> line it
/// writes. An Akka <c>ILoggingAdapter</c> line is different: Akka's logger actor writes it later, on
/// another thread, where the push is no longer in effect. The adapter created here records
/// <see cref="ExecutionLogContext.Current"/> when the line is logged, by wrapping the message in an
/// <see cref="ExecutionTaggedLogMessage"/>. The host's Serilog logger actor pushes the id again while
/// it writes the line.</para>
/// <para>Use it in place of <c>Context.GetLogger()</c>:
/// <code>private readonly ILoggingAdapter _log = ExecutionAwareLogging.GetLogger(Context);</code>
/// Level filtering is unchanged (<c>akka.loglevel</c>). A disabled level allocates nothing, and a
/// line logged while no execution is current is published exactly as <c>Context.GetLogger()</c>
/// would publish it.</para>
/// </remarks>
public static class ExecutionAwareLogging
{
/// <summary>Creates an execution-aware adapter for the given actor context.</summary>
/// <param name="context">The actor context (<c>Context</c> inside an actor).</param>
/// <returns>An adapter with the same log source, class and formatter as <c>Context.GetLogger()</c>.</returns>
public static ILoggingAdapter GetLogger(IActorContext context)
{
ArgumentNullException.ThrowIfNull(context);
var source = LogSource.Create(context, context.System);
return new ExecutionAwareBusLogging(
context.System.EventStream, source.Source, source.Type, context.System.Settings.LogFormatter);
}
/// <summary>
/// The same publishing behaviour as Akka's (sealed) <see cref="BusLogging"/>, except that each
/// message is wrapped in an <see cref="ExecutionTaggedLogMessage"/> while an execution id is
/// current.
/// </summary>
internal sealed class ExecutionAwareBusLogging : LoggingAdapterBase
{
private readonly LoggingBus _bus;
private readonly string _logSource;
private readonly Type _logClass;
/// <summary>Creates the adapter over the given logging bus.</summary>
/// <param name="bus">The system's event stream.</param>
/// <param name="logSource">The log source string (the actor path).</param>
/// <param name="logClass">The logging class (the actor type).</param>
/// <param name="formatter">The system's log message formatter.</param>
public ExecutionAwareBusLogging(LoggingBus bus, string logSource, Type logClass, ILogMessageFormatter formatter)
: base(formatter)
{
_bus = bus;
_logSource = logSource;
_logClass = logClass;
}
/// <inheritdoc />
public override bool IsDebugEnabled => _bus.LogLevel <= LogLevel.DebugLevel;
/// <inheritdoc />
public override bool IsInfoEnabled => _bus.LogLevel <= LogLevel.InfoLevel;
/// <inheritdoc />
public override bool IsWarningEnabled => _bus.LogLevel <= LogLevel.WarningLevel;
/// <inheritdoc />
public override bool IsErrorEnabled => _bus.LogLevel <= LogLevel.ErrorLevel;
/// <summary>Publishes the line, tagged with the current execution id when there is one.</summary>
/// <param name="logLevel">The line's level.</param>
/// <param name="message">The log message.</param>
/// <param name="cause">The attached exception, if any.</param>
protected override void NotifyLog(LogLevel logLevel, object message, Exception? cause = null)
{
if (ExecutionLogContext.Current is { } id)
{
message = new ExecutionTaggedLogMessage(message, id);
}
LogEvent logEvent = logLevel switch
{
LogLevel.DebugLevel => new Debug(cause, _logSource, _logClass, message),
LogLevel.InfoLevel => new Info(cause, _logSource, _logClass, message),
LogLevel.WarningLevel => new Warning(cause, _logSource, _logClass, message),
_ => new Error(cause, _logSource, _logClass, message),
};
_bus.Publish(logEvent);
}
}
}