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
313 lines
14 KiB
C#
313 lines
14 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
|
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.Types.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#56: while a run is traced, node-log lines that OTHER actors write for it (tagged with
|
|
/// its <c>ExecutionId</c>) are copied into its <c>ScriptLog</c> rows as source <c>node</c> — under the
|
|
/// run's line cap, through the same collector (and so the same redaction) as every other row — and
|
|
/// an untraced run copies nothing.
|
|
/// </summary>
|
|
public class NodeLogCaptureTests : TestKit, IDisposable
|
|
{
|
|
private readonly ScriptExecutionScheduler _scheduler = new(4);
|
|
private readonly SharedScriptLibrary _sharedLibrary = new(
|
|
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
|
|
NullLogger<SharedScriptLibrary>.Instance);
|
|
|
|
/// <summary>The node's Serilog pipeline as the host builds it: LogContext enrichment plus the capture sink.</summary>
|
|
private readonly Serilog.ILogger _node = new Serilog.LoggerConfiguration()
|
|
.MinimumLevel.Information()
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Sink(new ExecutionLogCaptureSink())
|
|
.CreateLogger();
|
|
|
|
/// <inheritdoc />
|
|
void IDisposable.Dispose()
|
|
{
|
|
Shutdown();
|
|
_scheduler.Dispose();
|
|
}
|
|
|
|
/// <summary>A Microsoft logger over a Serilog logger (the host's <c>UseSerilog</c> shape), with a category.</summary>
|
|
private sealed class SerilogMel(Serilog.ILogger inner, string category) : ILogger
|
|
{
|
|
private readonly Serilog.ILogger _inner = inner.ForContext("SourceContext", category);
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (!IsEnabled(logLevel)) return;
|
|
var level = logLevel switch
|
|
{
|
|
LogLevel.Information => Serilog.Events.LogEventLevel.Information,
|
|
LogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
|
|
_ => Serilog.Events.LogEventLevel.Error,
|
|
};
|
|
_inner.Write(level, exception, "{Message:l}", formatter(state, exception));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stands in for the Instance Actor: re-pushes the message's execution id (as the real one does in
|
|
/// AroundReceive), logs a line on ITS thread, and acknowledges the write.
|
|
/// </summary>
|
|
private sealed class LoggingInstanceActor : ReceiveActor
|
|
{
|
|
public LoggingInstanceActor(Serilog.ILogger node)
|
|
{
|
|
var log = node.ForContext("SourceContext", "ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.InstanceActor");
|
|
Receive<SetStaticAttributeCommand>(cmd =>
|
|
{
|
|
using (ExecutionLogContext.PushFor(cmd))
|
|
{
|
|
log.Warning("instance actor applied {Attribute} token=hunter2", cmd.AttributeName);
|
|
}
|
|
|
|
Sender.Tell(new SetStaticAttributeResponse(
|
|
cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName, true, null, DateTimeOffset.UtcNow));
|
|
});
|
|
}
|
|
}
|
|
|
|
private static Script<object?> Compile(string code)
|
|
{
|
|
var options = ScriptOptions.Default
|
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(ScriptGlobals).Assembly)
|
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
|
var script = CSharpScript.Create<object?>(code, options, typeof(ScriptGlobals));
|
|
script.Compile();
|
|
return script;
|
|
}
|
|
|
|
private ScriptCallResult Run(string body, bool trace, int maxLogLines = 200)
|
|
{
|
|
var caller = CreateTestProbe();
|
|
var instance = Sys.ActorOf(Props.Create(() => new LoggingInstanceActor(_node)));
|
|
ScriptRunLauncher.LaunchScript(
|
|
scriptName: "Runner", instanceName: "Inst1", compiledScript: Compile(body),
|
|
parameters: null, callDepth: 0, instanceActor: instance,
|
|
sharedScriptLibrary: _sharedLibrary,
|
|
options: new SiteRuntimeOptions
|
|
{
|
|
MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = 30, StuckScriptGraceMs = 30000,
|
|
MaxLogLinesPerRun = maxLogLines,
|
|
},
|
|
replyTo: caller.Ref, correlationId: "corr-1", completionTarget: CreateTestProbe().Ref,
|
|
siteCommunicationActor: null,
|
|
logger: new SerilogMel(_node, "ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.ScriptActor"),
|
|
scope: ScriptScope.Root, runId: 1, healthCollector: null, serviceProvider: null,
|
|
parentExecutionId: null, executionTimeoutSeconds: null, scheduler: _scheduler,
|
|
trace: trace, triggerExtra: null);
|
|
return caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(20));
|
|
}
|
|
|
|
private static List<(AuditEvent Event, ScriptTraceExtra Extra)> LogRows(ScriptCallResult result)
|
|
=> result.TraceEvents!
|
|
.Where(e => e.AsRow().Kind == AuditKind.ScriptLog)
|
|
.Select(e => (e, ScriptTraceExtra.TryParse(e.AsRow().Extra)!))
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Cross-actor: the Instance Actor's line, written on its own thread while serving the traced
|
|
/// run's write, comes back inline as a <c>node</c> row of that run, with its category and level.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TracedRun_OtherActorsLine_IsCapturedAsANodeRow()
|
|
{
|
|
var result = Run("await Instance.SetAttribute(\"Setpoint\", \"42\"); return null;", trace: true);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
var node = Assert.Single(LogRows(result), r => r.Extra.LogSource == "node");
|
|
Assert.Equal("warn", node.Extra.LogLevel);
|
|
Assert.Equal("ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.InstanceActor", node.Extra.LogCategory);
|
|
Assert.Contains("instance actor applied \"Setpoint\"", node.Event.AsRow().RequestSummary);
|
|
Assert.Equal(result.ExecutionId, node.Event.AsRow().ExecutionId);
|
|
// The registration ends with the run: a later line for the same id is not captured.
|
|
Assert.False(ExecutionLogCapture.TryGetTarget(result.ExecutionId!.Value, out _));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An inline shared script's write is stamped with the SHARED script's own execution id; its
|
|
/// node line still lands in the caller's rows (where the shared script's Log.* lines go).
|
|
/// </summary>
|
|
[Fact]
|
|
public void SharedScriptsWrite_NodeLine_LandsInTheCallersRows()
|
|
{
|
|
Assert.True(_sharedLibrary.CompileAndRegister(
|
|
"WriteHelper", "await Instance.SetAttribute(\"FromShared\", \"1\"); return null;"));
|
|
|
|
var result = Run("await Instance.Scripts.CallShared(\"WriteHelper\"); return null;", trace: true);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
var node = Assert.Single(LogRows(result), r => r.Extra.LogSource == "node");
|
|
Assert.Contains("FromShared", node.Event.AsRow().RequestSummary);
|
|
Assert.Equal(result.ExecutionId, node.Event.AsRow().ExecutionId);
|
|
}
|
|
|
|
/// <summary>An untraced run never registers, so the same line is not captured (and the reply has no rows).</summary>
|
|
[Fact]
|
|
public void UntracedRun_CapturesNothing()
|
|
{
|
|
var result = Run("await Instance.SetAttribute(\"Setpoint\", \"42\"); return null;", trace: false);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.True(result.TraceEvents is null || result.TraceEvents.Count == 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// No double capture: the script's own <c>Log.Info</c> goes to the node logger (tagged with the run's
|
|
/// id) and is a <c>script</c> row exactly once — never a second <c>node</c> row. The launcher's
|
|
/// own failure line is the <c>ScriptRun</c> row, so it is not copied either.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScriptLogLine_AndLauncherFailureLine_AreNotCopiedAsNodeRows()
|
|
{
|
|
var result = Run("Log.Info(\"hello\"); throw new InvalidOperationException(\"boom\");", trace: true);
|
|
|
|
Assert.False(result.Success);
|
|
var rows = LogRows(result);
|
|
var script = Assert.Single(rows);
|
|
Assert.Equal("script", script.Extra.LogSource);
|
|
Assert.Equal("hello", script.Event.AsRow().RequestSummary);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Node rows share the run's line cap with the script's own lines: past it they are dropped and
|
|
/// the closing runtime row names the count.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NodeRows_ShareTheRunsLineCap()
|
|
{
|
|
var result = Run(
|
|
"Log.Info(\"one\"); for (var i = 0; i < 3; i++) await Instance.SetAttribute(\"A\" + i, \"1\"); return null;",
|
|
trace: true, maxLogLines: 2);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
var rows = LogRows(result);
|
|
Assert.Equal(3, rows.Count); // "one" + 1 node row + the closing row
|
|
Assert.Single(rows, r => r.Extra.LogSource == "node");
|
|
var closing = Assert.Single(rows, r => r.Extra.LogSource == "runtime");
|
|
Assert.Contains("2 further log line(s)", closing.Event.AsRow().RequestSummary);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Node rows ride the same inline collector as every other row, so its redactor applies: what Live
|
|
/// shows is what Replay stores.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task NodeRows_PassTheCollectorsRedactor()
|
|
{
|
|
var collector = new FlowTraceCollector(redactor: new TokenRedactor());
|
|
var exec = Guid.NewGuid();
|
|
var ctx = new ScriptRuntimeContext(
|
|
Sys.ActorOf(Props.Create(() => new LoggingInstanceActor(_node))), _sharedLibrary,
|
|
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
|
|
instanceName: "Inst1", logger: NullLogger.Instance, siteId: "site-a",
|
|
sourceScript: "ScriptActor:Runner", executionId: exec, isTraced: true, traceCollector: collector);
|
|
|
|
using (ctx.Log.CaptureNodeLines(exec))
|
|
{
|
|
await ctx.SetAttribute("Setpoint", "42");
|
|
}
|
|
|
|
var node = Assert.Single(collector.Complete(), e => e.AsRow().Kind == AuditKind.ScriptLog);
|
|
Assert.DoesNotContain("hunter2", node.DetailsJson);
|
|
Assert.Contains("token=***", node.AsRow().RequestSummary);
|
|
}
|
|
|
|
/// <summary>Scrubs <c>hunter2</c> from the details, standing in for the host's body-regex redaction.</summary>
|
|
private sealed class TokenRedactor : IAuditRedactor
|
|
{
|
|
public AuditEvent Apply(AuditEvent rawEvent)
|
|
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.Replace("hunter2", "***", StringComparison.Ordinal) };
|
|
}
|
|
|
|
private (ScriptLogger Logger, List<(string Level, string Source, string Message, string? Category)> Rows) Traced(Guid id)
|
|
{
|
|
var rows = new List<(string, string, string, string?)>();
|
|
var logger = new ScriptLogger(
|
|
new SerilogMel(_node, "ScriptActor"),
|
|
(level, source, message, _, _, category, _) => { lock (rows) rows.Add((level, source, message, category)); },
|
|
maxLines: 200, "Inst1", "Runner", id);
|
|
return (logger, rows);
|
|
}
|
|
|
|
/// <summary>An untraced logger never touches the registry.</summary>
|
|
[Fact]
|
|
public void UntracedLogger_CaptureNodeLines_DoesNotRegister()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var untraced = new ScriptLogger(NullLogger.Instance, emit: null, maxLines: 200, "Inst1", "Runner", id);
|
|
|
|
using (untraced.CaptureNodeLines(id))
|
|
{
|
|
Assert.False(ExecutionLogCapture.TryGetTarget(id, out _));
|
|
}
|
|
}
|
|
|
|
/// <summary>FlushDropped (every run-end path) closes the copy, and a capture opened after it is refused.</summary>
|
|
[Fact]
|
|
public void FlushDropped_ClosesTheCopy()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var (logger, rows) = Traced(id);
|
|
var capture = logger.CaptureNodeLines(id);
|
|
Assert.True(ExecutionLogCapture.TryGetTarget(id, out _));
|
|
|
|
logger.FlushDropped();
|
|
|
|
Assert.False(ExecutionLogCapture.TryGetTarget(id, out _));
|
|
using (logger.CaptureNodeLines(id))
|
|
using (ExecutionLogContext.Push(id))
|
|
{
|
|
_node.Warning("after the run");
|
|
}
|
|
|
|
Assert.Empty(rows);
|
|
capture.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The runtime decorator's forward is suppressed: a runtime warning logged while traced is one
|
|
/// <c>runtime</c> row, never a second <c>node</c> row — while a genuine other-actor line still is one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RuntimeLine_IsNotCopiedAgain_ButAnotherLoggersLineIs()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var (logger, rows) = Traced(id);
|
|
var runtime = new TraceCapturingLogger(new SerilogMel(_node, "ScriptActor"), logger);
|
|
|
|
using (logger.CaptureNodeLines(id))
|
|
using (ExecutionLogContext.Push(id))
|
|
{
|
|
runtime.LogWarning("runtime warning");
|
|
_node.ForContext("SourceContext", "Acme.Other").Warning("other actor");
|
|
}
|
|
|
|
Assert.Equal(
|
|
[("warn", "runtime", "runtime warning", (string?)null), ("warn", "node", "other actor", "Acme.Other")],
|
|
rows);
|
|
}
|
|
}
|