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
@@ -0,0 +1,151 @@
@using System.Globalization
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
@*
Flow Debugger — node log (ScadaBridge#56). The lines OTHER actors wrote while serving a traced
execution (the Instance Actor, the DCL and its protocol adapters, central callees of a method),
copied by the node's per-execution sink into ScriptLog rows with source "node". They come from other
threads and actors, so no step can honestly claim them: they are listed per execution instead of
folded onto a node. Live and Replay read the same rows, so the panel is identical in both modes, and
what it shows is already scoped by whatever scoped the run's rows (site scope, role policy).
*@
@if (Lines.Count > 0)
{
<div class="card fd-node-logs" data-test="flow-nodelog-panel">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
<h6 class="card-title text-muted mb-0">Node log</h6>
<span class="text-muted small" data-test="flow-nodelog-count">@CountText</span>
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="Node log level filter">
<button type="button" class="btn @(_warnOnly ? "btn-outline-secondary" : "btn-secondary")"
data-test="flow-nodelog-filter-all" @onclick="() => _warnOnly = false">All</button>
<button type="button" class="btn @(_warnOnly ? "btn-secondary" : "btn-outline-secondary")"
data-test="flow-nodelog-filter-warn" @onclick="() => _warnOnly = true">Warn +</button>
</div>
</div>
<div class="form-text mb-2">
Lines other actors logged for these executions (Instance Actor, data connections, adapters) —
what the node's log level kept, under the run's log-line cap.
</div>
@foreach (var group in Groups)
{
<div class="mb-2" data-test="flow-nodelog-execution" data-execution="@group.ExecutionId">
<div class="small mb-1">
@if (group.Anchor is { } anchor && OnSelectStep.HasDelegate)
{
<button type="button" class="btn btn-link btn-sm p-0 align-baseline"
data-test="flow-nodelog-step" @onclick="() => OnSelectStep.InvokeAsync(anchor)">@anchor.Title</button>
}
else if (group.Anchor is { } named)
{
<span>@named.Title</span>
}
else
{
<span class="text-muted">execution</span>
}
<span class="font-monospace text-muted ms-1">@(group.ExecutionId?.ToString() ?? "—")</span>
</div>
@if (group.Visible.Count == 0)
{
<div class="text-muted small" data-test="flow-nodelog-none-shown">No warnings or errors.</div>
}
else
{
<table class="table table-sm small mb-0" data-test="flow-nodelog-lines">
<thead><tr><th>Time (UTC)</th><th>Level</th><th>Logger</th><th>Message</th></tr></thead>
<tbody>
@foreach (var line in group.Visible)
{
<tr data-level="@line.Level">
<td class="font-monospace text-nowrap">@line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)</td>
<td><span class="fsd-lvl fsd-lvl-@line.Level.ToLowerInvariant()">@line.Level</span></td>
<td class="text-muted text-nowrap" title="@line.Category">@ShortCategory(line.Category)</td>
<td class="text-break">
@line.Message
@if (!string.IsNullOrEmpty(line.ExceptionMessage))
{
<div class="fsd-line-exc font-monospace small">
@line.ExceptionMessage
@if (!string.IsNullOrEmpty(line.ExceptionDetail))
{
<details>
<summary>detail</summary>
<pre class="fsd-exc-detail mb-0">@line.ExceptionDetail</pre>
</details>
}
</div>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
}
</div>
</div>
}
@code {
/// <summary>The graph whose <see cref="FlowGraph.NodeLogs"/> are listed; nothing renders without node lines.</summary>
[Parameter] public FlowGraph? Graph { get; set; }
/// <summary>Selects an execution's anchor step when its title is clicked.</summary>
[Parameter] public EventCallback<FlowStep> OnSelectStep { get; set; }
private bool _warnOnly;
private IReadOnlyList<FlowLogLine> Lines => Graph?.NodeLogs ?? [];
private sealed record Group(Guid? ExecutionId, FlowStep? Anchor, IReadOnlyList<FlowLogLine> Visible);
/// <summary>
/// One group per execution, in the order its first line appeared; the anchor is that execution's
/// script run / inbound request / route call step, so the title names the run the lines belong to.
/// </summary>
private IEnumerable<Group> Groups
=> Lines.GroupBy(l => l.ExecutionId).Select(g => new Group(
g.Key,
Anchor(g.Key),
g.Where(l => !_warnOnly || !IsLow(l.Level)).ToList()));
private string CountText
{
get
{
var errors = Lines.Count(l => string.Equals(l.Level, "error", StringComparison.OrdinalIgnoreCase));
var warnings = Lines.Count(l => string.Equals(l.Level, "warn", StringComparison.OrdinalIgnoreCase));
var other = Lines.Count - errors - warnings;
return $"{Lines.Count} line{(Lines.Count == 1 ? "" : "s")} · {errors} error{(errors == 1 ? "" : "s")} · " +
$"{warnings} warning{(warnings == 1 ? "" : "s")} · {other} info/debug";
}
}
private FlowStep? Anchor(Guid? executionId)
=> executionId is null || Graph is null
? null
: Graph.Steps
.Where(s => s.ExecutionId == executionId)
.OrderBy(s => s.Kind switch
{
FlowStepKind.ScriptRun => 0,
FlowStepKind.InboundRequest => 1,
FlowStepKind.RouteCall => 2,
_ => 3,
})
.FirstOrDefault();
private static bool IsLow(string level)
=> string.Equals(level, "debug", StringComparison.OrdinalIgnoreCase)
|| string.Equals(level, "info", StringComparison.OrdinalIgnoreCase);
/// <summary>The logger category without its namespace (the full name is the cell's tooltip).</summary>
private static string ShortCategory(string? category)
{
if (string.IsNullOrEmpty(category)) return "—";
var dot = category.LastIndexOf('.');
return dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category;
}
}
@@ -352,5 +352,10 @@
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" OnSelectStep="OnSelectStep" />
</div>
@* ── Node log (ScadaBridge#56): other actors' lines for the traced executions ── *@
<div class="col-12">
<FlowNodeLogPanel Graph="_graph" OnSelectStep="OnSelectStep" />
</div>
</div>
</div>