feat(inbound-api): Log global for method scripts, central ScriptLog rows when traced (D4/D5)

This commit is contained in:
Joseph Doherty
2026-09-09 11:39:25 -04:00
parent 25b127b3e3
commit 2eaaed559f
6 changed files with 483 additions and 4 deletions
@@ -11,6 +11,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -902,6 +904,142 @@ public class InboundScriptExecutorTests
Assert.Equal(0, _executor.AbandonedExecutionCount); // ...and DECREMENTED
}
// --- Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the Log global ---
[Fact]
public async Task Log_TracedRun_EmitsOneScriptLogRowOnTheTraceSink()
{
// D4/D5: a method script's Log.Info(...) binds as a top-level global and, because the
// execution is traced, produces exactly one ScriptTrace/ScriptLog row carrying the
// inbound request's own ExecutionId. stepEventId stays null on a central line — the
// graph builder attaches it to the execution's anchor step (design D2).
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var method = new ApiMethod("logs", "Log.Info(\"central\"); return 1;") { Id = 7, TimeoutSeconds = 10 };
var executionId = Guid.NewGuid();
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: executionId, trace: true);
Assert.True(result.Success, result.ErrorMessage);
var row = Assert.Single(ScriptLogRows(sink));
var details = AuditDetailsCodec.Deserialize(row.DetailsJson);
Assert.Equal(nameof(AuditKind.ScriptLog), details.Kind);
Assert.Equal(nameof(AuditChannel.ScriptTrace), details.Channel);
Assert.Equal(executionId, details.ExecutionId);
Assert.Equal("logs", row.Target);
Assert.Equal("central", details.RequestSummary);
var extra = ScriptTraceExtra.TryParse(details.Extra);
Assert.NotNull(extra);
Assert.Equal("info", extra!.LogLevel);
Assert.Equal("script", extra.LogSource);
Assert.Null(extra.StepEventId);
}
[Fact]
public async Task Log_UntracedRun_WritesOnlyToTheNodeLogger()
{
// D5: untraced runs pay nothing — no trace-sink row, no central audit write — but the
// author's message still reaches the node's ILogger so it is never silently lost.
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var writer = Substitute.For<ICentralAuditWriter>();
writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var services = new ServiceCollection();
services.AddSingleton(writer);
using var provider = services.BuildServiceProvider();
var recorded = new List<string>();
var executor = new InboundScriptExecutor(new RecordingLogger<InboundScriptExecutor>(recorded), provider);
var method = new ApiMethod("quiet", "Log.Warn(\"unwatched\"); return 1;") { Id = 7, TimeoutSeconds = 10 };
var result = await executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid());
Assert.True(result.Success, result.ErrorMessage);
Assert.Empty(ScriptLogRows(sink));
await writer.DidNotReceive().WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
Assert.Contains(recorded, m => m.Contains("unwatched", StringComparison.Ordinal));
}
[Fact]
public async Task Log_TracedWithException_CarriesMessageAndDetail()
{
// §4: the attached exception rides errorMessage/errorDetail, exactly as the failure
// rows do, so the Log tab can render the type + stack.
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var method = new ApiMethod(
"boom", "Log.Warn(\"x\", new InvalidOperationException(\"e\")); return 1;")
{ Id = 7, TimeoutSeconds = 10 };
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid(), trace: true);
Assert.True(result.Success, result.ErrorMessage);
var details = AuditDetailsCodec.Deserialize(Assert.Single(ScriptLogRows(sink)).DetailsJson);
Assert.Equal("x", details.RequestSummary);
Assert.Equal("e", details.ErrorMessage);
Assert.StartsWith("System.InvalidOperationException", details.ErrorDetail);
Assert.Equal("warn", ScriptTraceExtra.TryParse(details.Extra)!.LogLevel);
}
[Fact]
public async Task Log_PastMaxLogLinesPerRun_DropsAndEmitsOneClosingRuntimeWarning()
{
// D7: the per-run cap bounds the ROWS (the ILogger still sees every line); the run
// ends with one runtime-sourced warn row naming how many lines were dropped.
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var services = new ServiceCollection();
services.Configure<InboundApiOptions>(o => o.MaxLogLinesPerRun = 2);
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, provider);
var method = new ApiMethod(
"chatty", "Log.Info(\"a\"); Log.Info(\"b\"); Log.Info(\"c\"); Log.Info(\"d\"); return 1;")
{ Id = 7, TimeoutSeconds = 10 };
var result = await executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid(), trace: true);
Assert.True(result.Success, result.ErrorMessage);
var rows = ScriptLogRows(sink)
.Select(e => AuditDetailsCodec.Deserialize(e.DetailsJson))
.Select(d => (Details: d, Extra: ScriptTraceExtra.TryParse(d.Extra)!))
.ToList();
Assert.Equal(3, rows.Count);
Assert.Equal(new[] { "a", "b" }, rows.Where(r => r.Extra.LogSource == "script").Select(r => r.Details.RequestSummary));
var closing = Assert.Single(rows, r => r.Extra.LogSource == "runtime");
Assert.Equal("warn", closing.Extra.LogLevel);
Assert.Contains("2", closing.Details.RequestSummary);
}
private RouteHelper NewRoute() =>
new(Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
private static List<AuditEvent> ScriptLogRows(IEnumerable<AuditEvent> events) =>
events.Where(e => AuditDetailsCodec.Deserialize(e.DetailsJson).Kind == nameof(AuditKind.ScriptLog)).ToList();
// Minimal ILogger that keeps every formatted message so a test can prove the node
// logger saw a line even when nothing was audited.
private sealed class RecordingLogger<T>(List<string> messages) : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (messages) messages.Add(formatter(state, exception));
}
}
private sealed class CompileLogCounter
{
public int CompilationFailures;