c810bd0f7c
#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
369 lines
15 KiB
C#
369 lines
15 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the <c>Log</c> global's two obligations —
|
|
/// ALWAYS forward to the node logger, emit a trace row ONLY when traced — plus the per-run line cap,
|
|
/// the per-message character cap, step attribution through <see cref="TraceStepScope"/>, and the
|
|
/// "never throws into the script" rule.
|
|
///
|
|
/// <para>Deliberately built without a <c>ScriptRuntimeContext</c>: the row emitter is a delegate
|
|
/// precisely so this surface is testable on its own.</para>
|
|
/// </summary>
|
|
public class ScriptLoggerTests
|
|
{
|
|
/// <summary>One entry the logger under test forwarded to the node logger.</summary>
|
|
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
|
|
|
|
/// <summary>A minimal recording <see cref="ILogger"/> — clearer here than a mocking framework.</summary>
|
|
private sealed class RecordingLogger(bool enabled = true) : ILogger
|
|
{
|
|
/// <summary>Gets the entries recorded so far.</summary>
|
|
public List<LogEntry> Entries { get; } = [];
|
|
|
|
/// <summary>Returns no scope because the fake does not track scopes.</summary>
|
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
|
/// <param name="state">The scope or log state.</param>
|
|
/// <returns>A no-op scope.</returns>
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
/// <summary>Reports whether logging is enabled for the given level.</summary>
|
|
/// <param name="logLevel">The log level.</param>
|
|
/// <returns>True if the level is enabled; otherwise false.</returns>
|
|
public bool IsEnabled(LogLevel logLevel) => enabled;
|
|
|
|
/// <summary>Records the log entry for later assertions.</summary>
|
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
|
/// <param name="level">The log level.</param>
|
|
/// <param name="eventId">The event id.</param>
|
|
/// <param name="state">The scope or log state.</param>
|
|
/// <param name="exception">The exception associated with the entry, if any.</param>
|
|
/// <param name="formatter">Formats the state and exception into the message text.</param>
|
|
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
|
Entries.Add(new LogEntry(level, formatter(state, exception), exception));
|
|
}
|
|
|
|
/// <summary>One row the logger under test handed to its emitter.</summary>
|
|
private sealed record Row(string Level, string Source, string Message, Exception? Exception, Guid? StepEventId);
|
|
|
|
private static (ScriptLogger Logger, RecordingLogger Node, List<Row> Rows) MakeTraced(int maxLines = 200)
|
|
{
|
|
var node = new RecordingLogger();
|
|
var rows = new List<Row>();
|
|
var logger = new ScriptLogger(
|
|
node,
|
|
(level, source, message, exception, stepEventId, _, _) => rows.Add(new Row(level, source, message, exception, stepEventId)),
|
|
maxLines,
|
|
instance: "CvdReactor01",
|
|
script: "OnHeartbeat",
|
|
executionId: Guid.NewGuid());
|
|
return (logger, node, rows);
|
|
}
|
|
|
|
/// <summary>Verifies that an untraced run forwards to the node logger and emits no row.</summary>
|
|
[Fact]
|
|
public void UntracedRun_ForwardsToTheNodeLogger_AndEmitsNoRow()
|
|
{
|
|
// D5: an untraced run is an ordinary logging surface — the author's messages are never lost,
|
|
// but nothing is audited.
|
|
var node = new RecordingLogger();
|
|
var logger = new ScriptLogger(node, emit: null, maxLines: 200, "CvdReactor01", "OnHeartbeat", Guid.NewGuid());
|
|
|
|
logger.Info("hello");
|
|
logger.Warn("careful");
|
|
|
|
Assert.Equal(2, node.Entries.Count);
|
|
Assert.Equal(LogLevel.Information, node.Entries[0].Level);
|
|
Assert.Contains("hello", node.Entries[0].Message);
|
|
Assert.Equal(LogLevel.Warning, node.Entries[1].Level);
|
|
}
|
|
|
|
/// <summary>Verifies that a traced run emits one script-sourced row per line.</summary>
|
|
[Fact]
|
|
public void TracedRun_EmitsOneScriptSourcedRowPerLine()
|
|
{
|
|
var (logger, node, rows) = MakeTraced();
|
|
|
|
logger.Info("msg");
|
|
|
|
Assert.Single(node.Entries);
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal(("info", "script", "msg", (Exception?)null, (Guid?)null), (row.Level, row.Source, row.Message, row.Exception, row.StepEventId));
|
|
}
|
|
|
|
/// <summary>Verifies that every log level maps to its wire name.</summary>
|
|
[Fact]
|
|
public void EveryLevel_MapsToItsWireName()
|
|
{
|
|
var (logger, node, rows) = MakeTraced();
|
|
|
|
logger.Debug("d");
|
|
logger.Info("i");
|
|
logger.Warn("w");
|
|
logger.Error("e");
|
|
|
|
Assert.Equal(["debug", "info", "warn", "error"], rows.Select(r => r.Level));
|
|
Assert.Equal(
|
|
[LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error],
|
|
node.Entries.Select(e => e.Level));
|
|
}
|
|
|
|
/// <summary>Verifies that a line inside a step scope is attributed to that step and the scope unwinds afterwards.</summary>
|
|
[Fact]
|
|
public void LineInsideAStepScope_IsAttributedToThatStep_AndTheScopeUnwinds()
|
|
{
|
|
// D6: the ambient scope is how a line written inside a helper body names its step node.
|
|
var (logger, _, rows) = MakeTraced();
|
|
var stepId = Guid.NewGuid();
|
|
|
|
using (TraceStepScope.Enter(stepId))
|
|
{
|
|
Assert.Equal(stepId, TraceStepScope.Current);
|
|
logger.Info("inside");
|
|
}
|
|
|
|
Assert.Null(TraceStepScope.Current);
|
|
logger.Info("outside");
|
|
|
|
Assert.Equal(stepId, rows[0].StepEventId);
|
|
Assert.Null(rows[1].StepEventId);
|
|
}
|
|
|
|
/// <summary>Verifies that nested step scopes unwind to the enclosing step.</summary>
|
|
[Fact]
|
|
public void NestedStepScopes_Unwind_ToTheEnclosingStep()
|
|
{
|
|
// A helper calling another helper: the inner step owns its own lines, and the outer step
|
|
// gets its attribution back the moment the inner scope closes.
|
|
var (logger, _, rows) = MakeTraced();
|
|
var outer = Guid.NewGuid();
|
|
var inner = Guid.NewGuid();
|
|
|
|
using (TraceStepScope.Enter(outer))
|
|
{
|
|
using (TraceStepScope.Enter(inner))
|
|
{
|
|
Assert.Equal(inner, TraceStepScope.Current);
|
|
logger.Info("inner");
|
|
}
|
|
|
|
Assert.Equal(outer, TraceStepScope.Current);
|
|
logger.Info("outer");
|
|
}
|
|
|
|
Assert.Null(TraceStepScope.Current);
|
|
Assert.Equal(inner, rows[0].StepEventId);
|
|
Assert.Equal(outer, rows[1].StepEventId);
|
|
}
|
|
|
|
/// <summary>Verifies that lines past the per-run cap are dropped and flushing names the dropped count exactly once.</summary>
|
|
[Fact]
|
|
public void PastThePerRunCap_LinesAreDropped_AndFlushDroppedNamesTheCount_Once()
|
|
{
|
|
// D7: bounded per run; the closing row bypasses the cap because it is the only signal that
|
|
// anything was lost — and it is idempotent so a double flush cannot duplicate it.
|
|
var (logger, _, rows) = MakeTraced(maxLines: 3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
logger.Info($"line {i}");
|
|
}
|
|
|
|
Assert.Equal(3, rows.Count);
|
|
Assert.Equal(["line 0", "line 1", "line 2"], rows.Select(r => r.Message));
|
|
|
|
logger.FlushDropped();
|
|
|
|
Assert.Equal(4, rows.Count);
|
|
var closing = rows[^1];
|
|
Assert.Equal("warn", closing.Level);
|
|
Assert.Equal("runtime", closing.Source);
|
|
// Worded identically to the central InboundScriptLogger closing row.
|
|
Assert.Contains("2 further log line(s) were not captured", closing.Message);
|
|
Assert.Contains("ScadaBridge:SiteRuntime:MaxLogLinesPerRun = 3", closing.Message);
|
|
Assert.Null(closing.StepEventId);
|
|
|
|
logger.FlushDropped();
|
|
|
|
Assert.Equal(4, rows.Count);
|
|
}
|
|
|
|
/// <summary>Verifies that flushing emits nothing when no line was dropped.</summary>
|
|
[Fact]
|
|
public void FlushDropped_EmitsNothing_WhenNoLineWasDropped()
|
|
{
|
|
var (logger, _, rows) = MakeTraced(maxLines: 3);
|
|
|
|
logger.Info("only one");
|
|
logger.FlushDropped();
|
|
|
|
Assert.Single(rows);
|
|
}
|
|
|
|
/// <summary>Verifies that messages longer than the cap are clipped with an ellipsis.</summary>
|
|
[Fact]
|
|
public void MessagesLongerThanTheCap_AreClippedWithAnEllipsis()
|
|
{
|
|
var (logger, _, rows) = MakeTraced();
|
|
|
|
logger.Info(new string('x', 2_500));
|
|
|
|
var message = Assert.Single(rows).Message;
|
|
Assert.Equal(ScriptLogger.MaxMessageChars + 1, message.Length);
|
|
Assert.EndsWith("…", message);
|
|
}
|
|
|
|
/// <summary>Verifies that a message exactly at the cap passes through unclipped.</summary>
|
|
[Fact]
|
|
public void AMessageExactlyAtTheCap_PassesThroughUnclipped()
|
|
{
|
|
var (logger, _, rows) = MakeTraced();
|
|
var exact = new string('x', ScriptLogger.MaxMessageChars);
|
|
|
|
logger.Info(exact);
|
|
|
|
Assert.Equal(exact, Assert.Single(rows).Message);
|
|
}
|
|
|
|
/// <summary>Verifies that a surrogate pair straddling the cap is dropped whole.</summary>
|
|
[Fact]
|
|
public void ASurrogatePairStraddlingTheCap_IsDroppedWhole()
|
|
{
|
|
// Cutting between the halves would leave a lone surrogate, which breaks JSON serialization
|
|
// of the audit row further down the pipe.
|
|
var (logger, _, rows) = MakeTraced();
|
|
var straddling = new string('x', ScriptLogger.MaxMessageChars - 1) + "🚀" + new string('y', 10);
|
|
|
|
logger.Info(straddling);
|
|
|
|
var message = Assert.Single(rows).Message;
|
|
Assert.Equal(ScriptLogger.MaxMessageChars, message.Length);
|
|
Assert.EndsWith("…", message);
|
|
Assert.DoesNotContain(message, static c => char.IsSurrogate(c));
|
|
}
|
|
|
|
/// <summary>Verifies that a throwing emitter never reaches the script and the node logger still gets the line.</summary>
|
|
[Fact]
|
|
public void AThrowingEmitter_NeverReachesTheScript_AndTheNodeLoggerStillGotTheLine()
|
|
{
|
|
// The whole feature is a debug observer: a broken observer must not fail a production run.
|
|
var node = new RecordingLogger();
|
|
var logger = new ScriptLogger(
|
|
node,
|
|
(_, _, _, _, _, _, _) => throw new InvalidOperationException("collector is broken"),
|
|
maxLines: 200,
|
|
"CvdReactor01",
|
|
"OnHeartbeat",
|
|
Guid.NewGuid());
|
|
|
|
logger.Info("still logged");
|
|
|
|
Assert.Single(node.Entries);
|
|
Assert.Contains("still logged", node.Entries[0].Message);
|
|
}
|
|
|
|
/// <summary>Verifies that a throwing node logger never reaches the script and the row is still emitted.</summary>
|
|
[Fact]
|
|
public void AThrowingNodeLogger_NeverReachesTheScript_AndTheRowIsStillEmitted()
|
|
{
|
|
var rows = new List<Row>();
|
|
var logger = new ScriptLogger(
|
|
new ThrowingLogger(),
|
|
(level, source, message, exception, stepEventId, _, _) => rows.Add(new Row(level, source, message, exception, stepEventId)),
|
|
maxLines: 200,
|
|
"CvdReactor01",
|
|
"OnHeartbeat",
|
|
Guid.NewGuid());
|
|
|
|
logger.Warn("sink is down");
|
|
|
|
Assert.Single(rows);
|
|
}
|
|
|
|
/// <summary>A node logger whose sink fails, standing in for a wedged Serilog sink.</summary>
|
|
private sealed class ThrowingLogger : ILogger
|
|
{
|
|
/// <summary>Returns no scope because the fake does not track scopes.</summary>
|
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
|
/// <param name="state">The scope or log state.</param>
|
|
/// <returns>A no-op scope.</returns>
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
/// <summary>Reports whether logging is enabled for the given level.</summary>
|
|
/// <param name="logLevel">The log level.</param>
|
|
/// <returns>True if the level is enabled; otherwise false.</returns>
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <summary>Throws to simulate a failing log sink.</summary>
|
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
|
/// <param name="level">The log level.</param>
|
|
/// <param name="eventId">The event id.</param>
|
|
/// <param name="state">The scope or log state.</param>
|
|
/// <param name="exception">The exception associated with the entry, if any.</param>
|
|
/// <param name="formatter">Formats the state and exception into the message text.</param>
|
|
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
|
throw new InvalidOperationException("sink is down");
|
|
}
|
|
|
|
/// <summary>Verifies that an attached exception reaches both the node logger and the row.</summary>
|
|
[Fact]
|
|
public void AnAttachedException_ReachesBothTheNodeLoggerAndTheRow()
|
|
{
|
|
var (logger, node, rows) = MakeTraced();
|
|
var boom = new InvalidOperationException("boom");
|
|
|
|
logger.Error("x", boom);
|
|
|
|
Assert.Same(boom, node.Entries[0].Exception);
|
|
Assert.Same(boom, Assert.Single(rows).Exception);
|
|
}
|
|
|
|
/// <summary>Verifies that runtime lines are row-only and carry the runtime source.</summary>
|
|
[Fact]
|
|
public void RuntimeLines_AreRowOnly_AndCarryTheRuntimeSource()
|
|
{
|
|
// The capturing logger has already written the entry to the node logger; re-logging it here
|
|
// would double it (and loop straight back through the decorator).
|
|
var (logger, node, rows) = MakeTraced();
|
|
|
|
logger.Runtime("warn", "tag write timed out", null);
|
|
|
|
Assert.Empty(node.Entries);
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal("warn", row.Level);
|
|
Assert.Equal("runtime", row.Source);
|
|
}
|
|
|
|
/// <summary>Verifies that runtime lines share the per-run cap with script lines.</summary>
|
|
[Fact]
|
|
public void RuntimeLines_ShareThePerRunCapWithScriptLines()
|
|
{
|
|
var (logger, _, rows) = MakeTraced(maxLines: 2);
|
|
|
|
logger.Info("script line");
|
|
logger.Runtime("info", "runtime line", null);
|
|
logger.Runtime("info", "dropped", null);
|
|
|
|
Assert.Equal(2, rows.Count);
|
|
logger.FlushDropped();
|
|
Assert.Contains("1 further log line(s) were not captured", rows[^1].Message);
|
|
}
|
|
|
|
/// <summary>Verifies that the maximum line count must be positive.</summary>
|
|
[Fact]
|
|
public void MaxLinesMustBePositive()
|
|
{
|
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
|
new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 0, "i", "s", null));
|
|
}
|
|
|
|
/// <summary>Verifies that the node logger is required.</summary>
|
|
[Fact]
|
|
public void TheNodeLoggerIsRequired()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new ScriptLogger(null!, emit: null, maxLines: 1, "i", "s", null));
|
|
}
|
|
}
|