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
217 lines
9.0 KiB
C#
217 lines
9.0 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 D6): the decorator installed over the script
|
|
/// context's logger while a run is traced. It must forward everything the node would have logged
|
|
/// anyway, capture Information and above as <c>runtime</c> rows — including levels the node's own
|
|
/// minimum level suppresses, because they are trace data, not node log output — and never capture
|
|
/// Debug/Trace chatter.
|
|
/// </summary>
|
|
public class TraceCapturingLoggerTests
|
|
{
|
|
/// <summary>One row the sink's emitter received.</summary>
|
|
private sealed record Row(string Level, string Source, string Message, Exception? Exception, Guid? StepEventId);
|
|
|
|
/// <summary>An inner logger that records what it was asked to write, at a configurable minimum level.</summary>
|
|
private sealed class RecordingLogger(LogLevel minimum = LogLevel.Trace) : ILogger
|
|
{
|
|
/// <summary>Gets the entries written to this logger.</summary>
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
/// <summary>Gets the scopes begun on this logger.</summary>
|
|
public List<object?> Scopes { get; } = [];
|
|
|
|
/// <summary>Records the scope state and returns no scope handle.</summary>
|
|
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
|
/// <param name="state">The scope state.</param>
|
|
/// <returns>Always <see langword="null"/>.</returns>
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
|
|
{
|
|
Scopes.Add(state);
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Reports whether the level meets the configured minimum.</summary>
|
|
/// <param name="logLevel">The log level being checked.</param>
|
|
/// <returns><see langword="true"/> when the level is at or above the minimum.</returns>
|
|
public bool IsEnabled(LogLevel logLevel) => logLevel >= minimum;
|
|
|
|
/// <summary>Records the formatted message for later assertions.</summary>
|
|
/// <typeparam name="TState">The type of the log state.</typeparam>
|
|
/// <param name="level">The log level.</param>
|
|
/// <param name="eventId">The event id.</param>
|
|
/// <param name="state">The log state.</param>
|
|
/// <param name="exception">The exception being logged, if any.</param>
|
|
/// <param name="formatter">The message formatter.</param>
|
|
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
|
Entries.Add((level, formatter(state, exception)));
|
|
}
|
|
|
|
private static (TraceCapturingLogger Logger, RecordingLogger Inner, List<Row> Rows) Make(LogLevel innerMinimum = LogLevel.Trace)
|
|
{
|
|
var inner = new RecordingLogger(innerMinimum);
|
|
var rows = new List<Row>();
|
|
var sink = new ScriptLogger(
|
|
new RecordingLogger(),
|
|
(level, source, message, exception, stepEventId, _, _) => rows.Add(new Row(level, source, message, exception, stepEventId)),
|
|
maxLines: 200,
|
|
instance: "CvdReactor01",
|
|
script: "OnHeartbeat",
|
|
executionId: Guid.NewGuid());
|
|
return (new TraceCapturingLogger(inner, sink), inner, rows);
|
|
}
|
|
|
|
/// <summary>Verifies that a debug entry is forwarded to the inner logger but not captured.</summary>
|
|
[Fact]
|
|
public void Debug_IsForwarded_ButNotCaptured()
|
|
{
|
|
var (logger, inner, rows) = Make();
|
|
|
|
logger.LogDebug("internal chatter");
|
|
|
|
Assert.Single(inner.Entries);
|
|
Assert.Empty(rows);
|
|
}
|
|
|
|
/// <summary>Verifies that a warning is captured as a runtime row together with its exception.</summary>
|
|
[Fact]
|
|
public void Warning_IsCapturedAsARuntimeRow_WithItsException()
|
|
{
|
|
var (logger, inner, rows) = Make();
|
|
var boom = new TimeoutException("no reply");
|
|
|
|
logger.LogWarning(boom, "tag write timed out after {Ms} ms", 5_000);
|
|
|
|
Assert.Single(inner.Entries);
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal("warn", row.Level);
|
|
Assert.Equal("runtime", row.Source);
|
|
Assert.Equal("tag write timed out after 5000 ms", row.Message);
|
|
Assert.Same(boom, row.Exception);
|
|
}
|
|
|
|
/// <summary>Verifies that error and critical entries are both captured as errors.</summary>
|
|
[Fact]
|
|
public void ErrorAndCritical_AreBothCapturedAsError()
|
|
{
|
|
var (logger, _, rows) = Make();
|
|
|
|
logger.LogError("bad");
|
|
logger.LogCritical("worse");
|
|
|
|
Assert.Equal(["error", "error"], rows.Select(r => r.Level));
|
|
}
|
|
|
|
/// <summary>Verifies that information level is enabled even when the inner logger disables it.</summary>
|
|
[Fact]
|
|
public void InformationIsEnabled_EvenWhenTheInnerLoggerSaysNo()
|
|
{
|
|
// A node whose Serilog minimum is Warning must still contribute Information lines to a trace.
|
|
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Warning);
|
|
|
|
Assert.False(inner.IsEnabled(LogLevel.Information));
|
|
Assert.True(logger.IsEnabled(LogLevel.Information));
|
|
|
|
logger.LogInformation("captured anyway");
|
|
|
|
Assert.Empty(inner.Entries);
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal("info", row.Level);
|
|
Assert.Equal("captured anyway", row.Message);
|
|
}
|
|
|
|
/// <summary>Verifies that below information level the inner logger decides whether the level is enabled.</summary>
|
|
[Fact]
|
|
public void BelowInformation_TheInnerLoggerDecidesEnablement()
|
|
{
|
|
var (logger, _, _) = Make(innerMinimum: LogLevel.Warning);
|
|
|
|
Assert.False(logger.IsEnabled(LogLevel.Debug));
|
|
}
|
|
|
|
/// <summary>Verifies that a disabled inner logger is not written to but the row is still captured.</summary>
|
|
[Fact]
|
|
public void ADisabledInnerLogger_IsNotWrittenTo_ButTheRowIsStillCaptured()
|
|
{
|
|
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical);
|
|
|
|
logger.LogWarning("suppressed on the node");
|
|
|
|
Assert.Empty(inner.Entries);
|
|
Assert.Single(rows);
|
|
}
|
|
|
|
/// <summary>Verifies that captured lines carry the ambient step id.</summary>
|
|
[Fact]
|
|
public void CapturedLines_CarryTheAmbientStepId()
|
|
{
|
|
var (logger, _, rows) = Make();
|
|
var stepId = Guid.NewGuid();
|
|
|
|
using (TraceStepScope.Enter(stepId))
|
|
{
|
|
logger.LogInformation("inside the helper");
|
|
}
|
|
|
|
Assert.Equal(stepId, Assert.Single(rows).StepEventId);
|
|
}
|
|
|
|
/// <summary>Verifies that the constructor rejects a logger that is already decorated.</summary>
|
|
[Fact]
|
|
public void Ctor_RejectsAnAlreadyDecoratedLogger()
|
|
{
|
|
// Wrapping twice would forward each script line through two capture stages and emit two
|
|
// runtime rows per line — and would burn the run's line cap at double rate.
|
|
var (already, _, _) = Make();
|
|
var sink = new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 200, "i", "s", null);
|
|
|
|
var ex = Assert.Throws<ArgumentException>(() => new TraceCapturingLogger(already, sink));
|
|
Assert.Equal("inner", ex.ParamName);
|
|
}
|
|
|
|
/// <summary>Verifies that the constructor requires both collaborators.</summary>
|
|
[Fact]
|
|
public void Ctor_RequiresBothCollaborators()
|
|
{
|
|
var sink = new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 200, "i", "s", null);
|
|
|
|
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(null!, sink));
|
|
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(new RecordingLogger(), null!));
|
|
}
|
|
|
|
/// <summary>Verifies that a throwing formatter is swallowed and drops only that line.</summary>
|
|
[Fact]
|
|
public void AThrowingFormatter_IsSwallowed_AndDropsOnlyThatLine()
|
|
{
|
|
// Most of what this decorator captures is the runtime's own best-effort
|
|
// "audit write failed" warnings, logged from INSIDE a catch block: a formatter exception
|
|
// escaping here would replace the original failure with a formatting one on a path whose
|
|
// contract is that it never throws. The inner logger is disabled for this level so the
|
|
// decorator's OWN formatter call is the only one under test.
|
|
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical);
|
|
|
|
logger.Log(LogLevel.Warning, new EventId(0), "state", null,
|
|
(_, _) => throw new InvalidOperationException("formatter broke"));
|
|
|
|
Assert.Empty(rows);
|
|
Assert.Empty(inner.Entries);
|
|
|
|
// …and the decorator keeps working for the next line.
|
|
logger.LogWarning("still alive");
|
|
Assert.Equal("still alive", Assert.Single(rows).Message);
|
|
}
|
|
|
|
/// <summary>Verifies that beginning a scope delegates to the inner logger.</summary>
|
|
[Fact]
|
|
public void BeginScope_DelegatesToTheInnerLogger()
|
|
{
|
|
var (logger, inner, _) = Make();
|
|
|
|
logger.BeginScope("state");
|
|
|
|
Assert.Equal(["state"], inner.Scopes);
|
|
}
|
|
}
|