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
@@ -244,6 +244,12 @@ public class AkkaHostedService : IHostedService
/// <see cref="ClusterOptions.DownIfAlone"/> rather than hard-coded, so the bound
/// configuration value is actually consumed.
///
/// Logging (ScadaBridge#120): <c>akka.loggers</c> names <see cref="SerilogAkkaLogger"/>, so every
/// Akka <c>ILoggingAdapter</c> line is written to the host's Serilog pipeline (file sinks, node
/// enrichers, <c>ExecutionId</c>) instead of Akka's stdout-only <c>DefaultLogger</c>.
/// <c>akka.loglevel</c> stays <c>INFO</c>, so Akka's Debug chatter is filtered at the adapter as
/// before.
///
/// A <c>downing-provider-class</c> is always installed explicitly: Akka defaults
/// to <c>NoDowning</c>, under which the downing configuration is inert and
/// singletons never migrate on a hard crash or partition. Naming the provider is
@@ -324,6 +330,8 @@ public class AkkaHostedService : IHostedService
}}
}}
akka {{
loggers = [{QuoteHocon(SerilogAkkaLogger.HoconTypeName)}]
loglevel = INFO
extensions = [
""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools""
]
@@ -0,0 +1,149 @@
using Akka.Actor;
using Akka.Dispatch;
using Akka.Event;
using Serilog.Events;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using AkkaLogEvent = Akka.Event.LogEvent;
using AkkaLogLevel = Akka.Event.LogLevel;
using SerilogLog = Serilog.Log;
namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
/// <summary>
/// The ActorSystem's logger (ScadaBridge#120): writes every Akka <c>ILoggingAdapter</c> line into
/// the host's Serilog pipeline, so those lines reach the file sinks and carry the node enrichers,
/// the same as lines written through <c>Microsoft.Extensions.Logging</c>.
/// </summary>
/// <remarks>
/// <para><b>Wiring.</b> <see cref="AkkaHostedService.BuildHocon"/> names this type in
/// <c>akka.loggers</c>. It replaces Akka's <c>DefaultLogger</c>, which wrote to stdout only. Lines go
/// to the static <see cref="SerilogLog.Logger"/>, which <c>Program</c> builds through
/// <c>LoggerConfigurationFactory</c> before the ActorSystem is created. ScadaBridge keeps its own
/// Serilog factory by design, so no Akka.Hosting or <c>Akka.Logger.Serilog</c> bridge is used.</para>
/// <para><b>Levels.</b> Filtering is unchanged. <c>akka.loglevel</c> (INFO) still decides which lines
/// an adapter publishes, so Akka's Debug lifecycle chatter is never built. The Serilog minimum level
/// then applies as it does to every other line. Akka Debug, Info, Warning and Error map to Serilog
/// Debug, Information, Warning and Error.</para>
/// <para><b>Shape.</b> <c>SourceContext</c> is the logging class's full name (for example
/// <c>ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.DataConnectionActor</c>), so a
/// <c>MinimumLevel.Override</c> keyed on a namespace applies to Akka lines too. <c>AkkaSource</c>
/// carries the actor path. The message template is the adapter's own format string, so its
/// arguments become structured properties. The event keeps the timestamp Akka recorded when the
/// line was logged, not the time this actor wrote it.</para>
/// <para><b>ExecutionId.</b> When the line was logged through an execution-aware adapter while an
/// execution was current, the message arrives as an <see cref="ExecutionTaggedLogMessage"/>. This
/// actor pushes that id onto the <c>LogContext</c> while it writes the line, so
/// <c>Enrich.FromLogContext()</c> adds <c>ExecutionId</c> as it would on the logging thread.</para>
/// </remarks>
public sealed class SerilogAkkaLogger : ActorBase, IRequiresMessageQueue<ILoggerMessageQueueSemantics>
{
/// <summary>The assembly-qualified type name <c>akka.loggers</c> uses to load this logger.</summary>
public static readonly string HoconTypeName =
$"{typeof(SerilogAkkaLogger).FullName}, {typeof(SerilogAkkaLogger).Assembly.GetName().Name}";
/// <summary>The property carrying the Akka log source (the actor path).</summary>
public const string AkkaSourceProperty = "AkkaSource";
private readonly Serilog.ILogger? _root;
/// <summary>Creates the logger <c>akka.loggers</c> loads; it writes to <see cref="SerilogLog.Logger"/>.</summary>
public SerilogAkkaLogger()
{
}
/// <summary>
/// Writes to <paramref name="root"/> instead of the static logger (tests). Public because Akka
/// creates actors through reflection, which does not find a non-public constructor.
/// </summary>
/// <param name="root">The Serilog logger to write to.</param>
public SerilogAkkaLogger(Serilog.ILogger root)
{
_root = root;
}
/// <summary>Handles the logger handshake and every published log event.</summary>
/// <param name="message">The message.</param>
/// <returns>Whether the message was handled.</returns>
protected override bool Receive(object message)
{
switch (message)
{
case InitializeLogger:
Sender.Tell(new LoggerInitialized());
return true;
case AkkaLogEvent logEvent:
// Read the static logger per event, not once: Program may replace it after startup.
Write(logEvent, _root ?? SerilogLog.Logger);
return true;
default:
return false;
}
}
/// <summary>Writes one Akka log event to a Serilog logger.</summary>
/// <param name="logEvent">The Akka log event.</param>
/// <param name="root">The Serilog logger to write to.</param>
internal static void Write(AkkaLogEvent logEvent, Serilog.ILogger root)
{
try
{
var level = ToSerilogLevel(logEvent.LogLevel());
var logger = root;
if (!logger.IsEnabled(level))
{
return;
}
var (inner, executionId) = logEvent.Message is ExecutionTaggedLogMessage tagged
? (tagged.Message, (Guid?)tagged.ExecutionId)
: (logEvent.Message, null);
string template;
object?[] args;
if (inner is LogMessage logMessage)
{
template = logMessage.Format;
args = logMessage.Parameters().ToArray();
}
else
{
template = "{AkkaMessage:l}";
args = [inner?.ToString() ?? string.Empty];
}
logger = logger
.ForContext(Serilog.Core.Constants.SourceContextPropertyName,
logEvent.LogClass?.FullName ?? logEvent.LogSource)
.ForContext(AkkaSourceProperty, logEvent.LogSource);
if (!logger.BindMessageTemplate(template, args, out var parsed, out var properties))
{
return;
}
var timestamp = new DateTimeOffset(DateTime.SpecifyKind(logEvent.Timestamp, DateTimeKind.Utc));
var serilogEvent = new Serilog.Events.LogEvent(timestamp, level, logEvent.Cause, parsed, properties);
using (ExecutionLogContext.Push(executionId))
{
logger.Write(serilogEvent);
}
}
catch
{
// Deliberate swallow: a logger that throws would restart this actor and lose the lines
// behind it. Serilog itself never throws from Write; this guards the message unwrap.
}
}
/// <summary>Maps an Akka log level to the matching Serilog level.</summary>
/// <param name="level">The Akka level.</param>
/// <returns>The Serilog level.</returns>
internal static LogEventLevel ToSerilogLevel(AkkaLogLevel level) => level switch
{
AkkaLogLevel.DebugLevel => LogEventLevel.Debug,
AkkaLogLevel.InfoLevel => LogEventLevel.Information,
AkkaLogLevel.WarningLevel => LogEventLevel.Warning,
_ => LogEventLevel.Error,
};
}
@@ -96,7 +96,12 @@ public static class LoggerConfigurationFactory
// launcher pushes onto LogContext, so lines written on a run's own call chain carry
// the run's identity. See SiteRuntime ScriptRunLogScope for the (bounded) scope.
.Enrich.FromLogContext()
.Enrich.With(new TraceContextEnricher());
.Enrich.With(new TraceContextEnricher())
// ScadaBridge#56: copies each line tagged with a TRACED execution's ExecutionId into that
// run's Flow Debugger step log (ScriptLog rows, source "node"). One volatile read and out
// when nothing is traced. Attached directly (never behind an async wrapper): its
// de-duplication guard is per thread.
.WriteTo.Sink(new ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogCaptureSink());
}
/// <summary>