feat(inbound-api): Log global for method scripts, central ScriptLog rows when traced (D4/D5)
This commit is contained in:
@@ -117,4 +117,19 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("StandbyForwardTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
// --- Flow Debugger step logs (design 2026-09-09 D7) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void NonPositiveMaxLogLinesPerRun_IsRejected(int max)
|
||||
{
|
||||
// A zero/negative cap would audit no line of a traced run at all while still
|
||||
// paying for the closing "dropped" row - the option must bound, not disable.
|
||||
var result = Validate(new InboundApiOptions { MaxLogLinesPerRun = max });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxLogLinesPerRun", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user