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
@@ -187,9 +187,27 @@ internal static class FlowDebugTableProjection
}
}
// ScadaBridge#56: other actors' lines for the traced executions are not on any step; with
// --show-logs they print as one trailing block, each line naming its logger.
if (showLogs && graph.NodeLogs is { Count: > 0 } nodeLogs)
{
rows.Add(new JsonObject { ["note"] = JsonValue.Create($"node log ({nodeLogs.Count} line{(nodeLogs.Count == 1 ? "" : "s")}):") });
foreach (var line in nodeLogs)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(FormatNodeLogLine(line)) });
}
return rows;
}
/// <summary>Formats one node-log line as <c> HH:mm:ss.fff LEVEL [Category] message</c>, the category without its namespace.</summary>
private static string FormatNodeLogLine(FlowLogLine line)
{
var time = line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5);
var category = line.Category is { Length: > 0 } c ? c[(c.LastIndexOf('.') + 1)..] : "node";
return $" {time} {level} [{category}] {line.Message}";
}
/// <summary>Formats one <see cref="FlowLogLine"/> as <c> HH:mm:ss.fff LEVEL source message</c> (UTC, level upper-cased and padded to 5 chars).</summary>
private static string FormatLogLine(FlowLogLine line)
{
+8 -5
View File
@@ -2727,7 +2727,10 @@ Pass `--show-logs` (on `run-method`, `run-script`, `run-value-change` and `repla
each step's log lines immediately under its row, as additional indented `note` rows —
` HH:mm:ss.fff LEVEL source message` (UTC, level upper-cased) — and, for a failed step whose
error carries an exception detail, the first three lines of it (` exception: <first line>`,
then two more lines indented four spaces). Off by default; the `logs` count column is always
then two more lines indented four spaces). Node-log lines (other actors' lines for the traced
executions, Gitea #56) are on no step, so the `logs` column does not count them. With
`--show-logs` they print as one trailing block, `node log (n lines):` followed by
` HH:mm:ss.fff LEVEL [Logger] message` rows. Off by default; the `logs` count column is always
shown regardless of this flag.
#### `flow-debug run-method`
@@ -2744,7 +2747,7 @@ scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>
| `--method-id` | yes | Inbound API method ID (`api-method list`) |
| `--params` | no | Flat JSON object of parameters, validated against the method's parameter schema. Omit for a parameterless method. |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the method's own `TimeoutSeconds + 25` for a long-running method, or the client gives up before the server's own run grace does. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-method`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a method legitimately runs longer. |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row, plus a trailing node-log block (table output only; default off) |
Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running
the script.
@@ -2770,7 +2773,7 @@ scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name
| `--value` | no | Value to write to the trigger attribute of a ValueChange/Conditional script, in canonical string form |
| `--wait-seconds` | no | Write kinds: how long to wait for the triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the script's own `ExecutionTimeoutSeconds + 25` for a long-running script. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-script`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a script legitimately runs longer. |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row, plus a trailing node-log block (table output only; default off) |
A missing `--value` on a write-kind script, or `--params` that fail schema validation on a
call-kind script, are reported as `VALIDATION_FAILED` without running the script. An unknown
@@ -2793,7 +2796,7 @@ scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attrib
| `--attribute` | yes | Canonical attribute name to write |
| `--value` | yes | Value to write, in canonical string form |
| `--wait-seconds` | no | How long to wait for a triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row, plus a trailing node-log block (table output only; default off) |
The client waits `--wait-seconds` + 25 (no separate option — the client's HTTP wait always
derives from `--wait-seconds`). The write itself is real — the attribute changes — and is the root step of the flow.
@@ -2812,7 +2815,7 @@ scadabridge --url <url> flow-debug replay --execution-id <guid> [--show-logs]
| Option | Required | Description |
|--------|----------|-------------|
| `--execution-id` | yes | Any execution ID in the chain — the root is resolved server-side |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row, plus a trailing node-log block (table output only; default off) |
Only flows whose site legs were traced (a Live run, or a method with `--trace-all-calls`
set) have site steps to replay; an untraced call shows its central `RouteCall` step plus the
@@ -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>
@@ -0,0 +1,216 @@
using System.Collections.Concurrent;
using Serilog.Core;
using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Commons.Observability;
/// <summary>One node-log line copied for a traced execution (ScadaBridge#56).</summary>
/// <param name="OccurredAtUtc">When the line was logged.</param>
/// <param name="Level"><c>debug</c> | <c>info</c> | <c>warn</c> | <c>error</c>.</param>
/// <param name="Category">The logger category (<c>SourceContext</c>), when the line had one.</param>
/// <param name="Message">The rendered message.</param>
/// <param name="Exception">The exception attached to the line, if any.</param>
public sealed record CapturedLogLine(
DateTime OccurredAtUtc, string Level, string? Category, string Message, Exception? Exception);
/// <summary>Receives the node-log lines of one traced execution (ScadaBridge#56).</summary>
public interface IExecutionLogTarget
{
/// <summary>Records one line. Called on the logging thread; must not throw and must not block.</summary>
/// <param name="line">The captured line.</param>
void Capture(CapturedLogLine line);
}
/// <summary>
/// The process-wide set of executions whose node-log lines are being copied into their Flow
/// Debugger trace (ScadaBridge#56).
/// </summary>
/// <remarks>
/// <para><b>What it is for.</b> Every node-log line written while a correlated message is served
/// carries the run's <c>ExecutionId</c> (<see cref="ExecutionLogContext"/>). While a run is traced,
/// its step logger registers here under that id, and <see cref="ExecutionLogCaptureSink"/> hands it
/// each such line, wherever it was logged: the Instance Actor, a DCL actor (through the Akka bridge,
/// #120), a protocol adapter, an HTTP client. The step logger turns each line into a
/// <c>ScriptLog</c> row with source <c>node</c>, under the run's existing caps and redaction.</para>
/// <para><b>Cost when nothing is traced.</b> The sink reads one volatile counter and returns. No
/// property lookup, no rendering, no allocation.</para>
/// <para><b>No double capture.</b> The step loggers already turn their own lines into rows (the
/// script's <c>Log.*</c> calls and the runtime lines a traced context captures). They write those
/// lines to the node logger inside <see cref="Suppress"/>, so the sink skips them. The sink also
/// suppresses itself while it calls a target, so a line the target logs while emitting its row
/// cannot loop back.</para>
/// <para><b>Lifetime.</b> A registration is disposed when the run's step log closes (the site
/// launcher's and inbound executor's run end). A line logged after that is not captured: it still
/// reaches the node log, just not the trace.</para>
/// </remarks>
public static class ExecutionLogCapture
{
private static readonly ConcurrentDictionary<Guid, IExecutionLogTarget> Targets = new();
private static int _count;
[ThreadStatic]
private static int _suppressDepth;
/// <summary>True when at least one execution is registered. One volatile read.</summary>
public static bool HasTargets => Volatile.Read(ref _count) > 0;
/// <summary>True while the current thread is inside <see cref="Suppress"/>.</summary>
public static bool IsSuppressed => _suppressDepth > 0;
/// <summary>Registers <paramref name="target"/> for the lines tagged with <paramref name="executionId"/>.</summary>
/// <param name="executionId">The traced execution.</param>
/// <param name="target">Where its lines go.</param>
/// <returns>
/// A disposable that removes the registration (idempotent). When the id is already registered
/// the first registration is kept and the returned disposable does nothing.
/// </returns>
public static IDisposable Register(Guid executionId, IExecutionLogTarget target)
{
ArgumentNullException.ThrowIfNull(target);
if (!Targets.TryAdd(executionId, target))
{
return NoOp.Instance;
}
Interlocked.Increment(ref _count);
return new Registration(executionId, target);
}
/// <summary>Finds the target registered for an execution.</summary>
/// <param name="executionId">The execution id.</param>
/// <param name="target">The registered target, when found.</param>
/// <returns><see langword="true"/> when the execution is registered.</returns>
public static bool TryGetTarget(Guid executionId, out IExecutionLogTarget target)
=> Targets.TryGetValue(executionId, out target!);
/// <summary>
/// Stops the sink from capturing lines logged on this thread until the scope is disposed. Used
/// around writes whose line is already a trace row, so it is not copied a second time.
/// </summary>
/// <returns>A disposable that ends the suppression; dispose it on the same thread.</returns>
public static SuppressScope Suppress()
{
_suppressDepth++;
return new SuppressScope(true);
}
/// <summary>Ends one <see cref="Suppress"/> on the thread that started it.</summary>
public readonly struct SuppressScope : IDisposable
{
private readonly bool _active;
internal SuppressScope(bool active) => _active = active;
/// <summary>Ends the suppression.</summary>
public void Dispose()
{
if (_active && _suppressDepth > 0)
{
_suppressDepth--;
}
}
}
/// <summary>Maps a Serilog level to the step-log level names.</summary>
/// <param name="level">The Serilog level.</param>
/// <returns><c>debug</c>, <c>info</c>, <c>warn</c> or <c>error</c>.</returns>
public static string LevelName(LogEventLevel level) => level switch
{
LogEventLevel.Verbose or LogEventLevel.Debug => "debug",
LogEventLevel.Information => "info",
LogEventLevel.Warning => "warn",
_ => "error",
};
private sealed class Registration(Guid executionId, IExecutionLogTarget target) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (Targets.TryRemove(new KeyValuePair<Guid, IExecutionLogTarget>(executionId, target)))
{
Interlocked.Decrement(ref _count);
}
}
}
private sealed class NoOp : IDisposable
{
public static readonly NoOp Instance = new();
public void Dispose()
{
}
}
}
/// <summary>
/// The Serilog sink behind <see cref="ExecutionLogCapture"/> (ScadaBridge#56): copies each line
/// tagged with a registered execution's <c>ExecutionId</c> to that execution's target.
/// </summary>
/// <remarks>
/// <para>It sees what the node logs: the configured minimum level and every per-source override
/// apply before it, so a node at Information never hands it a Debug line. It must be attached
/// directly, never behind an async wrapper, because <see cref="ExecutionLogCapture.Suppress"/> is
/// per thread.</para>
/// </remarks>
public sealed class ExecutionLogCaptureSink : ILogEventSink
{
/// <summary>Copies one line to its execution's target, when that execution is being traced.</summary>
/// <param name="logEvent">The Serilog event.</param>
public void Emit(LogEvent logEvent)
{
if (!ExecutionLogCapture.HasTargets || ExecutionLogCapture.IsSuppressed || logEvent is null)
{
return;
}
if (!logEvent.Properties.TryGetValue(ExecutionLogContext.ExecutionIdProperty, out var value)
|| !TryReadGuid(value, out var executionId)
|| !ExecutionLogCapture.TryGetTarget(executionId, out var target))
{
return;
}
using (ExecutionLogCapture.Suppress())
{
try
{
var category = logEvent.Properties.TryGetValue(Serilog.Core.Constants.SourceContextPropertyName, out var sc)
&& sc is ScalarValue { Value: string s } ? s : null;
target.Capture(new CapturedLogLine(
logEvent.Timestamp.UtcDateTime,
ExecutionLogCapture.LevelName(logEvent.Level),
category,
logEvent.RenderMessage(),
logEvent.Exception));
}
catch
{
// Deliberate swallow: a debug observer must never fail the write that logged the line.
}
}
}
private static bool TryReadGuid(LogEventPropertyValue value, out Guid id)
{
switch (value)
{
case ScalarValue { Value: Guid g }:
id = g;
return true;
case ScalarValue { Value: string s } when Guid.TryParse(s, out var parsed):
id = parsed;
return true;
default:
id = Guid.Empty;
return false;
}
}
}
@@ -32,15 +32,55 @@ public static class ExecutionLogContext
/// <summary>The log-event property carrying the execution id.</summary>
public const string ExecutionIdProperty = "ExecutionId";
/// <summary>
/// The innermost execution id pushed on this async flow, or null. Mirrors the
/// <see cref="ExecutionIdProperty"/> on the Serilog <c>LogContext</c>, which cannot be read back.
/// </summary>
/// <remarks>
/// ScadaBridge#120: Akka's <c>ILoggingAdapter</c> hands each line to a logger actor on another
/// thread, where this flow's <c>LogContext</c> is gone. An adapter that captures this value when
/// the line is logged lets the logger actor re-push it when it writes the line to Serilog.
/// </remarks>
public static Guid? Current => CurrentId.Value;
private static readonly AsyncLocal<Guid?> CurrentId = new();
/// <summary>Pushes <paramref name="executionId"/> as <see cref="ExecutionIdProperty"/>.</summary>
/// <param name="executionId">The execution id; null pushes nothing.</param>
/// <returns>A disposable that pops the property, or null when nothing was pushed.</returns>
public static IDisposable? Push(Guid? executionId)
=> executionId is { } id ? LogContext.PushProperty(ExecutionIdProperty, id) : null;
{
if (executionId is not { } id)
{
return null;
}
var previous = CurrentId.Value;
var property = LogContext.PushProperty(ExecutionIdProperty, id);
CurrentId.Value = id;
return new Scope(property, previous);
}
/// <summary>Pushes the execution a message serves, when it names one.</summary>
/// <param name="message">Any actor message.</param>
/// <returns>A disposable that pops the property, or null when the message names no execution.</returns>
public static IDisposable? PushFor(object? message)
=> message is IExecutionCorrelated correlated ? Push(correlated.CorrelatedExecutionId) : null;
/// <summary>Pops the pushed property and restores the previous <see cref="Current"/>.</summary>
private sealed class Scope(IDisposable property, Guid? previous) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
CurrentId.Value = previous;
property.Dispose();
}
}
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Observability;
/// <summary>
/// An Akka log message together with the execution id that was current when it was logged
/// (ScadaBridge#120).
/// </summary>
/// <remarks>
/// <para>Akka's logging adapter publishes each line to a logger actor, which writes it on its own
/// thread. By then the logging actor's <c>LogContext</c>, including the <c>ExecutionId</c> that
/// <see cref="ExecutionLogContext.PushFor"/> pushed for the message being served, is no longer
/// available. An execution-aware adapter wraps the message in this type when
/// <see cref="ExecutionLogContext.Current"/> is set. The host's Serilog logger actor unwraps it and
/// pushes the id again while it writes the line.</para>
/// <para><see cref="ToString"/> returns the inner message's text. A logger that does not know this
/// type, such as Akka's stdout logger during startup or the TestKit's event filters, still sees the
/// same text it would have seen without the wrapper.</para>
/// <para>This type has no Akka dependency. It travels only inside one process, on the local event
/// stream, and is never serialized.</para>
/// </remarks>
/// <param name="Message">The original log message (an Akka <c>LogMessage</c>, or any object).</param>
/// <param name="ExecutionId">The execution the line was logged for.</param>
public sealed record ExecutionTaggedLogMessage(object? Message, Guid ExecutionId)
{
/// <summary>The inner message's text, unchanged by the wrapper.</summary>
/// <returns>The formatted inner message, or an empty string when it is null.</returns>
public override string ToString() => Message?.ToString() ?? string.Empty;
}
@@ -16,13 +16,19 @@ public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoun
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete — Replay shows everything).</param>
/// <param name="LogsTruncated">True when the inline trace collector dropped low-priority log rows to keep room for the step rows — the audit rows remain complete, so Replay shows every line (design 2026-09-09 D7).</param>
/// <param name="UnattachedLogLines">Count of log lines the builder could not place on any step (design 2026-09-09 D2).</param>
/// <param name="NodeLogs">
/// Node-log lines copied for the traced executions (source <c>node</c>, ScadaBridge#56), in time
/// order. They are listed per execution, never folded onto a step: they come from other actors and
/// threads, so no step can honestly claim them. Null or empty when there are none.
/// </param>
public sealed record FlowGraph(
IReadOnlyList<FlowStep> Steps,
IReadOnlyList<FlowEdge> Edges,
bool Predicted,
bool Truncated,
bool LogsTruncated = false,
int UnattachedLogLines = 0)
int UnattachedLogLines = 0,
IReadOnlyList<FlowLogLine>? NodeLogs = null)
{
/// <summary>An empty, non-predicted graph.</summary>
public static FlowGraph Empty { get; } = new(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), false, false);
@@ -51,6 +51,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// dropped and counted in <see cref="FlowGraph.UnattachedLogLines"/>. Within a step, lines are ordered
/// by <c>OccurredAtUtc</c> then <c>Seq</c>, with a null <c>Seq</c> sorting last, so same-millisecond
/// lines keep their emission order.</item>
/// <item><b>Node-log lines are listed, not folded</b> (ScadaBridge#56). A <c>ScriptLog</c> row with source
/// <c>node</c> — another actor's line the per-execution sink copied — goes to
/// <see cref="FlowGraph.NodeLogs"/> in time order and never onto a step or into the unattached count.</item>
/// </list>
/// </remarks>
public static class FlowGraphBuilder
@@ -82,9 +85,13 @@ public static class FlowGraphBuilder
events = stepEvents.Select(e => e.Event).ToList();
// ScadaBridge#56: node-log lines (other actors' lines copied for a traced execution) are
// listed per execution, not folded onto a step, so they never count as unattached either.
var nodeLogs = TakeNodeLines(lines);
if (events.Count == 0)
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated,
LogsTruncated: logsTruncated, UnattachedLogLines: lines.Count);
LogsTruncated: logsTruncated, UnattachedLogLines: lines.Count, NodeLogs: nodeLogs);
// Arch-review F5: a step's position in the input is the only cross-node ordering we can trust —
// central rows carry central's clock, site rows the site's, and a 1-2 s skew used to sort a whole
@@ -268,7 +275,19 @@ public static class FlowGraphBuilder
AttachLogLines(steps, lines, out var unattached);
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated,
LogsTruncated: logsTruncated, UnattachedLogLines: unattached);
LogsTruncated: logsTruncated, UnattachedLogLines: unattached, NodeLogs: nodeLogs);
}
/// <summary>
/// Removes the node-log lines (source <c>node</c>, ScadaBridge#56) from <paramref name="lines"/>
/// and returns them ordered by time, then per-run <c>Seq</c> (null last). Null when there are none.
/// </summary>
private static IReadOnlyList<FlowLogLine>? TakeNodeLines(List<FlowLogLine> lines)
{
var node = lines.Where(l => l.IsNodeLine).ToList();
if (node.Count == 0) return null;
lines.RemoveAll(l => l.IsNodeLine);
return node.OrderBy(l => l.OccurredAtUtc).ThenBy(l => l.Seq ?? int.MaxValue).ToList();
}
/// <summary>
@@ -84,12 +84,13 @@ public sealed record FlowLifecycleRow(
/// <param name="ExecutionId">Execution the line was written in.</param>
/// <param name="OccurredAtUtc">When the line was written.</param>
/// <param name="Level"><c>debug</c> | <c>info</c> | <c>warn</c> | <c>error</c>.</param>
/// <param name="Source"><c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced).</param>
/// <param name="Source"><c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced) | <c>node</c> (another node-log line tagged with the execution, ScadaBridge#56).</param>
/// <param name="Message">The logged message.</param>
/// <param name="ExceptionMessage">Message of an attached exception, when the line carried one.</param>
/// <param name="ExceptionDetail">Full <c>ToString()</c> of that exception.</param>
/// <param name="StepEventId">Step the line belongs to; null = the execution's anchor step (design D2).</param>
/// <param name="Seq">Per-run emission counter, breaking same-millisecond ties in display order.</param>
/// <param name="Category">On a <c>node</c> line (ScadaBridge#56): the logger category that wrote it.</param>
public sealed record FlowLogLine(
Guid EventId,
Guid? ExecutionId,
@@ -100,7 +101,15 @@ public sealed record FlowLogLine(
string? ExceptionMessage,
string? ExceptionDetail,
Guid? StepEventId,
int? Seq);
int? Seq,
string? Category = null)
{
/// <summary>The <see cref="Source"/> of a node-log line copied by the per-execution sink (ScadaBridge#56).</summary>
public const string NodeSource = "node";
/// <summary>True for a node-log line, which the graph lists per execution rather than on a step.</summary>
public bool IsNodeLine => string.Equals(Source, NodeSource, StringComparison.Ordinal);
}
/// <summary>
/// One node of the flow graph — a central-side projection of a <c>ScriptTrace</c> /
@@ -102,7 +102,8 @@ public static class FlowStepProjector
ExceptionMessage: d.ErrorMessage,
ExceptionDetail: d.ErrorDetail,
StepEventId: x?.StepEventId,
Seq: x?.Seq);
Seq: x?.Seq,
Category: x?.LogCategory);
}
/// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary>
@@ -52,9 +52,12 @@ public sealed record ScriptTraceExtra
/// <summary>On a <c>ScriptLog</c> row: <c>debug</c> | <c>info</c> | <c>warn</c> | <c>error</c>.</summary>
public string? LogLevel { get; init; }
/// <summary>On a <c>ScriptLog</c> row: <c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced).</summary>
/// <summary>On a <c>ScriptLog</c> row: <c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced) | <c>node</c> (any other node-log line tagged with the execution's id, ScadaBridge#56).</summary>
public string? LogSource { get; init; }
/// <summary>On a <c>node</c> <c>ScriptLog</c> row: the logger category (<c>SourceContext</c>) that wrote the line (ScadaBridge#56).</summary>
public string? LogCategory { get; init; }
/// <summary>On a <c>ScriptLog</c> row: the pre-minted <c>EventId</c> of the step the line belongs to; null = the execution's anchor step (design D2).</summary>
public Guid? StepEventId { get; init; }
@@ -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);
}
}
}
@@ -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>
@@ -573,6 +573,11 @@ public class InboundScriptExecutor
executionId: parentExecutionId,
sourceNode: nodeIdentity?.NodeName);
// ScadaBridge#56: on a traced request, copy other central node-log lines tagged with its
// ExecutionId into its ScriptLog rows (source "node"). Declared inside the try so it is
// disposed before any catch block logs the request's outcome (already the inbound row).
using var nodeLines = scriptLogger.CaptureNodeLines();
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token)
@@ -3,6 +3,7 @@ using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
@@ -33,14 +34,22 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// A method script may log from several concurrent tasks, so the counters are guarded. Nothing in
/// this class is allowed to throw into the script: a debug observer must never fail the request.
/// </para>
///
/// <para>
/// Node lines (ScadaBridge#56): while <see cref="CaptureNodeLines"/> is open on a traced request,
/// every other central node-log line tagged with the request's <c>ExecutionId</c> becomes a row with
/// source <c>node</c>, under the same line cap. This logger's own node forward is suppressed, so a
/// <c>Log.*</c> line is never copied twice.
/// </para>
/// </summary>
public sealed class InboundScriptLogger
public sealed class InboundScriptLogger : IExecutionLogTarget
{
/// <summary>Per-line message cap (design D7); longer messages are truncated with an ellipsis.</summary>
public const int MaxMessageChars = 2_000;
private const string SourceScript = "script";
private const string SourceRuntime = "runtime";
private const string SourceNode = "node";
private readonly ILogger _logger;
private readonly Action<AuditEvent>? _emit;
@@ -54,6 +63,8 @@ public sealed class InboundScriptLogger
private int _emitted;
private int _dropped;
private int _seq;
private IDisposable? _capture;
private bool _captureClosed;
/// <summary>
/// Initializes the per-execution logger.
@@ -120,6 +131,9 @@ public sealed class InboundScriptLogger
{
if (_emit is null) return;
// The request is over: stop copying node lines first, so none lands after the closing row.
CloseCapture();
int dropped;
int seq;
lock (_gate)
@@ -142,9 +156,13 @@ public sealed class InboundScriptLogger
try
{
_logger.Log(level, exception,
"Script log [{LogLevel}] for inbound method {Method} (execution {ExecutionId}): {Message}",
levelName, _methodName, _executionId, text);
// Suppressed for the node-line sink (#56): this line becomes a "script" row below.
using (ExecutionLogCapture.Suppress())
{
_logger.Log(level, exception,
"Script log [{LogLevel}] for inbound method {Method} (execution {ExecutionId}): {Message}",
levelName, _methodName, _executionId, text);
}
}
catch (Exception)
{
@@ -171,7 +189,72 @@ public sealed class InboundScriptLogger
Emit(levelName, SourceScript, seq, text, exception);
}
private void Emit(string level, string source, int seq, string message, Exception? exception)
/// <summary>
/// ScadaBridge#56: starts copying the central node-log lines tagged with this request's
/// <c>ExecutionId</c> into its rows (source <c>node</c>). A no-op on an untraced request or one
/// with no execution id.
/// </summary>
/// <para>Internal on purpose: it is host plumbing, and a public member of the <c>Log</c> global
/// would be callable from script code.</para>
/// <returns>A disposable that stops the copy; <see cref="FlushDropped"/> also stops it.</returns>
internal IDisposable CaptureNodeLines()
{
if (_emit is null || _executionId is not { } id)
{
return NoCapture.Instance;
}
lock (_gate)
{
if (_captureClosed || _capture is not null)
{
return NoCapture.Instance;
}
_capture = ExecutionLogCapture.Register(id, this);
return _capture;
}
}
/// <summary>ScadaBridge#56: one tagged node-log line, from the per-execution sink.</summary>
/// <param name="line">The captured line.</param>
void IExecutionLogTarget.Capture(CapturedLogLine line)
{
if (_emit is null) return;
int seq;
lock (_gate)
{
if (_captureClosed) return;
if (_emitted >= _maxLines)
{
_dropped++;
return;
}
_emitted++;
seq = ++_seq;
}
Emit(line.Level, SourceNode, seq, Truncate(line.Message), line.Exception, line.Category, line.OccurredAtUtc);
}
private void CloseCapture()
{
IDisposable? capture;
lock (_gate)
{
_captureClosed = true;
capture = _capture;
_capture = null;
}
capture?.Dispose();
}
private void Emit(
string level, string source, int seq, string message, Exception? exception,
string? category = null, DateTime? occurredAtUtc = null)
{
AuditEvent evt;
try
@@ -180,6 +263,7 @@ public sealed class InboundScriptLogger
AuditChannel.ScriptTrace,
AuditKind.ScriptLog,
AuditStatus.Delivered,
occurredAtUtc: occurredAtUtc,
actor: _actor,
target: _methodName,
sourceNode: _sourceNode,
@@ -191,7 +275,7 @@ public sealed class InboundScriptLogger
errorMessage: exception?.Message,
errorDetail: exception?.ToString(),
requestSummary: message,
extra: new ScriptTraceExtra { LogLevel = level, LogSource = source, Seq = seq }.ToJson());
extra: new ScriptTraceExtra { LogLevel = level, LogSource = source, LogCategory = category, Seq = seq }.ToJson());
}
catch (Exception)
{
@@ -225,4 +309,13 @@ public sealed class InboundScriptLogger
var keep = char.IsHighSurrogate(message[MaxMessageChars - 1]) ? MaxMessageChars - 1 : MaxMessageChars;
return string.Concat(message.AsSpan(0, keep), "…");
}
private sealed class NoCapture : IDisposable
{
public static readonly NoCapture Instance = new();
public void Dispose()
{
}
}
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -11,7 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <param name="message">The already-clipped message text.</param>
/// <param name="exception">The exception attached to the line, if any.</param>
/// <param name="stepEventId">The step row the line belongs to, or <see langword="null"/> to fall back to the run's anchor row.</param>
public delegate void ScriptLogEmitter(string level, string source, string message, Exception? exception, Guid? stepEventId);
/// <param name="category">On a <c>node</c> line (ScadaBridge#56): the logger category that wrote it; otherwise null.</param>
/// <param name="occurredAtUtc">When the line was logged; null means now.</param>
public delegate void ScriptLogEmitter(
string level, string source, string message, Exception? exception, Guid? stepEventId,
string? category, DateTime? occurredAtUtc);
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the <c>Log</c> global site scripts call.
@@ -29,8 +34,14 @@ public delegate void ScriptLogEmitter(string level, string source, string messag
///
/// <para><b>Never throws into the script.</b> A logging or emitting failure is swallowed: this is a
/// debug observer, and a broken observer must not fail a production script run.</para>
///
/// <para><b>Node lines (ScadaBridge#56).</b> While <see cref="CaptureNodeLines"/> is open, the
/// node-log sink (<see cref="ExecutionLogCaptureSink"/>) hands this logger every other line tagged
/// with the run's <c>ExecutionId</c> — the Instance Actor's, a DCL actor's, a protocol adapter's —
/// and each becomes a row with source <c>node</c>, under the same line cap. The logger's own node
/// forward is suppressed, so a <c>Log.*</c> line is never copied twice.</para>
/// </summary>
public sealed class ScriptLogger
public sealed class ScriptLogger : IExecutionLogTarget
{
/// <summary>Per-message character cap (design D7); longer messages are truncated and suffixed with an ellipsis.</summary>
public const int MaxMessageChars = 2_000;
@@ -45,6 +56,12 @@ public sealed class ScriptLogger
private int _emitted;
private int _dropped;
// Node-line registrations (ScadaBridge#56): the run's own id plus any inline shared-script child
// ids. Closed by FlushDropped (every run-end path) or by disposing the returned scopes.
private readonly List<IDisposable> _captures = new();
private readonly object _captureGate = new();
private volatile bool _capturesClosed;
/// <summary>Creates the <c>Log</c> global for one script run.</summary>
/// <param name="logger">The node logger every line forwards to, traced or not.</param>
/// <param name="emit">Row emitter; <see langword="null"/> when the run is not traced (no rows are produced).</param>
@@ -103,7 +120,11 @@ public sealed class ScriptLogger
message = Clip(message);
try
{
_logger.Log(level, exception, "[script] {Instance}/{Script} {ExecutionId}: {Message}", _instance, _script, _executionId, message);
// Suppressed for the node-line sink: this line becomes a "script" row below.
using (ExecutionLogCapture.Suppress())
{
_logger.Log(level, exception, "[script] {Instance}/{Script} {ExecutionId}: {Message}", _instance, _script, _executionId, message);
}
}
catch
{
@@ -113,8 +134,70 @@ public sealed class ScriptLogger
WriteRow(name, "script", message, exception);
}
/// <summary>
/// ScadaBridge#56: starts copying the node-log lines tagged with <paramref name="executionId"/>
/// into this run's rows (source <c>node</c>). The site launcher opens it for the run's own id; an
/// inline shared script opens it for its child id, which shares this logger. A no-op on an
/// untraced run, so untraced runs never touch the registry.
/// </summary>
/// <param name="executionId">The execution whose tagged lines to copy.</param>
/// <para>Internal on purpose: it is host plumbing, and a public member of the <c>Log</c> global
/// would be callable from script code.</para>
/// <returns>A disposable that stops the copy; <see cref="FlushDropped"/> also stops every copy.</returns>
internal IDisposable CaptureNodeLines(Guid executionId)
{
if (_emit is null)
{
return NoCapture.Instance;
}
lock (_captureGate)
{
if (_capturesClosed)
{
return NoCapture.Instance;
}
var registration = ExecutionLogCapture.Register(executionId, this);
_captures.Add(registration);
return registration;
}
}
/// <summary>ScadaBridge#56: one tagged node-log line, from <see cref="ExecutionLogCaptureSink"/>.</summary>
/// <param name="line">The captured line.</param>
void IExecutionLogTarget.Capture(CapturedLogLine line)
{
// A line the sink looked up just before the run closed must not land after the closing row.
if (_capturesClosed)
{
return;
}
WriteRow(line.Level, "node", line.Message, line.Exception, line.Category, line.OccurredAtUtc);
}
/// <summary>Stops every node-line copy this logger opened. Idempotent.</summary>
private void CloseCaptures()
{
IDisposable[] open;
lock (_captureGate)
{
_capturesClosed = true;
open = _captures.ToArray();
_captures.Clear();
}
foreach (var registration in open)
{
registration.Dispose();
}
}
/// <summary>Emits one trace row, subject to the per-run line cap.</summary>
private void WriteRow(string level, string source, string message, Exception? exception)
private void WriteRow(
string level, string source, string message, Exception? exception,
string? category = null, DateTime? occurredAtUtc = null)
{
if (_emit is null)
{
@@ -129,7 +212,7 @@ public sealed class ScriptLogger
try
{
_emit(level, source, Clip(message), exception, TraceStepScope.Current);
_emit(level, source, Clip(message), exception, TraceStepScope.Current, category, occurredAtUtc);
}
catch
{
@@ -149,6 +232,9 @@ public sealed class ScriptLogger
return;
}
// The run is over: stop copying node lines first, so none lands after the closing row.
CloseCaptures();
var n = Interlocked.Exchange(ref _dropped, 0);
if (n == 0)
{
@@ -161,7 +247,7 @@ public sealed class ScriptLogger
// so the two surfaces read the same in the step-log panel (only the option key differs).
_emit("warn", "runtime",
$"log line cap reached - {n} further log line(s) were not captured " +
$"(ScadaBridge:SiteRuntime:MaxLogLinesPerRun = {_maxLines})", null, null);
$"(ScadaBridge:SiteRuntime:MaxLogLinesPerRun = {_maxLines})", null, null, null, null);
}
catch
{
@@ -193,4 +279,13 @@ public sealed class ScriptLogger
var keep = char.IsHighSurrogate(m[MaxMessageChars - 1]) ? MaxMessageChars - 1 : MaxMessageChars;
return m[..keep] + "…";
}
private sealed class NoCapture : IDisposable
{
public static readonly NoCapture Instance = new();
public void Dispose()
{
}
}
}
@@ -416,6 +416,13 @@ internal static class ScriptRunLauncher
// ScadaBridge#56: stamp the run identity on the LogContext for the rest of the run.
logScope = ScriptRunLogScope.Push(context.ExecutionId, instanceName, scriptName);
// ScadaBridge#56: while a TRACED run executes, other node-log lines tagged with its id (the
// Instance Actor's, the DCL's, an adapter's) are copied into its ScriptLog rows as source
// "node". Declared inside the try on purpose: it is disposed before any catch block runs,
// so the launcher's own timeout/failure lines (already the ScriptRun row) are never
// copied. FlushDropped closes it on the success path. A no-op when untraced.
using var nodeLines = context.Log.CaptureNodeLines(context.ExecutionId);
var globals = new ScriptGlobals
{
Instance = context,
@@ -34,7 +34,8 @@ internal static class ScriptRunLogScope
/// <returns>A disposable that removes the three properties again.</returns>
internal static IDisposable Push(Guid executionId, string instanceName, string scriptName)
{
var a = LogContext.PushProperty(ExecutionIdProperty, executionId);
// Through ExecutionLogContext so ExecutionLogContext.Current tracks the run too (#120).
var a = ExecutionLogContext.Push(executionId)!;
var b = LogContext.PushProperty(InstanceNameProperty, instanceName);
var c = LogContext.PushProperty(ScriptNameProperty, scriptName);
return new Scope(a, b, c);
@@ -603,11 +603,15 @@ public class ScriptRuntimeContext
/// feed the warning straight back into this method.</para>
/// </summary>
/// <param name="level">Level name as it appears on the wire: <c>debug|info|warn|error</c>.</param>
/// <param name="source">Line origin: <c>script</c> (a <c>Log.*</c> call) or <c>runtime</c>.</param>
/// <param name="source">Line origin: <c>script</c> (a <c>Log.*</c> call), <c>runtime</c> or <c>node</c> (ScadaBridge#56).</param>
/// <param name="message">The already-clipped message text.</param>
/// <param name="exception">The exception attached to the line, if any.</param>
/// <param name="stepEventId">The owning step's <c>EventId</c>; null falls back to the run's anchor row (D2).</param>
private void EmitLog(string level, string source, string message, Exception? exception, Guid? stepEventId)
/// <param name="category">On a <c>node</c> line (ScadaBridge#56): the logger category; otherwise null.</param>
/// <param name="occurredAtUtc">When the line was logged; null means now.</param>
private void EmitLog(
string level, string source, string message, Exception? exception, Guid? stepEventId,
string? category, DateTime? occurredAtUtc)
{
if (!_isTraced) return;
AuditEvent evt;
@@ -615,7 +619,7 @@ public class ScriptRuntimeContext
{
evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: DateTime.UtcNow,
occurredAtUtc: occurredAtUtc is { } at ? DateTime.SpecifyKind(at, DateTimeKind.Utc) : DateTime.UtcNow,
actor: _sourceScript, target: _logTarget, sourceNode: _sourceNode,
executionId: _executionId, parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -626,6 +630,7 @@ public class ScriptRuntimeContext
{
LogLevel = level,
LogSource = source,
LogCategory = category,
StepEventId = stepEventId,
Seq = Interlocked.Increment(ref _logSeq),
}.ToJson());
@@ -1468,6 +1473,11 @@ public class ScriptRuntimeContext
// ExecutionId, so its audit rows chain under the calling run.
var childContext = _context.CreateChildContextForSharedScript(nextDepth);
// ScadaBridge#56: the shared script's requests are stamped with ITS execution id, so the
// node lines they cause carry that id. Copy them into the shared Log (the caller's rows),
// exactly where the shared script's own Log.* lines go. No-op when untraced.
using var childNodeLines = childContext.Log.CaptureNodeLines(childContext.ExecutionId);
return await _library.ExecuteAsync(
scriptName, childContext, ScriptArgs.Normalize(parameters), cancellationToken).ConfigureAwait(false);
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -68,7 +69,12 @@ public sealed class TraceCapturingLogger : ILogger
// upward for trace capture, so the node's own minimum level must be re-checked here.
if (_inner.IsEnabled(level))
{
_inner.Log(level, eventId, state, exception, formatter);
// Suppressed for the node-line sink (ScadaBridge#56): this decorator already turns the
// entry into a "runtime" row below (or deliberately drops it, below Information).
using (ExecutionLogCapture.Suppress())
{
_inner.Log(level, eventId, state, exception, formatter);
}
}
if (level < LogLevel.Information || level == LogLevel.None)