diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs index d4b41e8c..9400f3e4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs @@ -69,4 +69,17 @@ public class InboundApiOptions /// is still legitimately working and moves on to the next one. Default 60 s. /// public TimeSpan StandbyForwardTimeout { get; set; } = TimeSpan.FromSeconds(60); + + /// Default value of , also used when no options instance is available. + public const int DefaultMaxLogLinesPerRun = 200; + + /// + /// Flow Debugger step logs (design 2026-09-09 D7): how many Log.* lines ONE traced + /// method execution may turn into ScriptTrace/ScriptLog audit rows. Past the cap the + /// lines are dropped from the trace — they still reach the node's ILogger — and the + /// run ends with a single runtime-sourced warning row naming the dropped count, so a + /// truncated trace is never silently short. Untraced executions emit no rows at all and are + /// therefore unaffected by this value. Must be greater than zero. + /// + public int MaxLogLinesPerRun { get; set; } = DefaultMaxLogLinesPerRun; } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs index f9b35c74..1d028f01 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs @@ -36,6 +36,10 @@ public sealed class InboundApiOptionsValidator : OptionsValidatorBase 0, + $"ScadaBridge:InboundApi:MaxLogLinesPerRun must be greater than zero " + + $"(was {options.MaxLogLinesPerRun}); it caps the Log.* lines one traced method execution audits."); + foreach (var peer in options.StandbyForwardPeerEndpoints) { var ok = Uri.TryCreate(peer, UriKind.Absolute, out var uri) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs index c66d0fc4..1a0df00c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs @@ -4,7 +4,9 @@ using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; @@ -363,6 +365,9 @@ public class InboundScriptExecutor // client abort), disposing the scope underneath it would fault the still-running // task with ObjectDisposedException — so the finally defers disposal instead. Task? handlerTask = null; + // Flow Debugger step logs (design 2026-09-09 D7): hoisted so the finally can flush the + // "lines dropped" closing row on EVERY exit path — success, timeout, or a thrown script. + InboundScriptLogger? scriptLogger = null; try { scope = _serviceProvider.CreateScope(); @@ -392,10 +397,14 @@ public class InboundScriptExecutor // come from the same scope as the gateway (both null-tolerant: an unregistered // writer means no row, never a failed script), and the trace sink is the one the // caller attached to the RouteHelper so a Live run collects both row kinds. + // Shared by the Database helper's outbound rows and the script's Log.* lines, so both + // land in the same store and carry the same SourceNode. + var auditWriter = scope?.ServiceProvider.GetService(); + var nodeIdentity = scope?.ServiceProvider.GetService(); var dbHelper = new InboundDatabaseHelper( gateway, cts.Token, timeout, - auditWriter: scope?.ServiceProvider.GetService(), - nodeIdentity: scope?.ServiceProvider.GetService(), + auditWriter: auditWriter, + nodeIdentity: nodeIdentity, executionId: parentExecutionId, parentExecutionId: null, methodName: method.Name, @@ -424,6 +433,31 @@ public class InboundScriptExecutor && auditOptions.PerTargetOverrides.TryGetValue(method.Name, out var perTargetOverride) && perTargetOverride.SkipBodyCapture; + // Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the script's Log global. + // Lines ALWAYS reach this node's logger; only a TRACED execution also builds + // ScriptTrace/ScriptLog rows, which are fanned out to the Live run's inline sink and + // written best-effort to the central store for Replay — exactly as RouteHelper does + // for its RouteCall row. The row cap comes from options when they are available + // (the sandbox/test providers that register none fall back to the documented default). + var maxLogLines = scope?.ServiceProvider + .GetService>()?.CurrentValue.MaxLogLinesPerRun + ?? InboundApiOptions.DefaultMaxLogLinesPerRun; + var logTraceSink = route.TraceSink; + var methodName = method.Name; + scriptLogger = new InboundScriptLogger( + _logger, + emit: trace + ? evt => + { + logTraceSink?.Invoke(evt); + WriteScriptLogBestEffort(auditWriter, evt, methodName); + } + : null, + maxLines: maxLogLines, + methodName: methodName, + executionId: parentExecutionId, + sourceNode: nodeIdentity?.NodeName); + var context = new InboundScriptContext( parameters, route.WithDeadline(cts.Token) @@ -432,7 +466,8 @@ public class InboundScriptExecutor .WithTrace(trace) .WithBodyCapture(!skipBodyCapture), dbHelper, - cts.Token); + cts.Token, + scriptLogger); _scriptHandlers.TryGetValue(method.Name, out var cached); // Revision check: a cached handler compiled from different script text is stale @@ -553,6 +588,17 @@ public class InboundScriptExecutor } finally { + // The script body has finished (or been abandoned): close out its log lines before the + // caller completes its trace collector. Idempotent and a no-op when nothing was dropped. + try + { + scriptLogger?.FlushDropped(); + } + catch (Exception) + { + // A debug observer must never fail the request. + } + if (handlerTask is null || handlerTask.IsCompleted) { scope?.Dispose(); @@ -577,6 +623,33 @@ public class InboundScriptExecutor } } } + + /// + /// Writes one ScriptLog row to the central audit store without ever blocking or failing + /// the script (design 2026-09-09 D5) — the same fire-and-observe pattern + /// RouteHelper.EmitRouteCall uses for its always-on RouteCall row. Never awaited on + /// a script path: an audit write completes on the writer's own flush cadence. + /// + private void WriteScriptLogBestEffort(ICentralAuditWriter? writer, AuditEvent evt, string methodName) + { + if (writer is null) return; + try + { + var t = writer.WriteAsync(evt, CancellationToken.None); + if (!t.IsCompletedSuccessfully) + { + _ = t.ContinueWith( + x => _logger.LogWarning(x.Exception, "Flow trace ScriptLog write failed for method {Method}", methodName), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } + catch (Exception) + { + // A debug observer must never fail the request: an audit failure is not a script failure. + } + } } /// @@ -605,6 +678,14 @@ public class InboundScriptContext /// public CancellationToken CancellationToken { get; } + /// + /// Flow Debugger step logs (design 2026-09-09 D4): the script's leveled logger — + /// Log.Debug/Info/Warn/Error(message, exception?). Always reaches the node's log; a + /// traced execution additionally records each line as a ScriptTrace/ScriptLog audit row + /// the Flow Debugger folds onto the step that produced it. + /// + public InboundScriptLogger Log { get; } + /// /// Initializes a new instance of the InboundScriptContext. /// @@ -612,16 +693,25 @@ public class InboundScriptContext /// The route helper for cross-site routing. /// The scoped, parameterized database helper for the script. /// The cancellation token. + /// + /// The script's Log global. Optional so existing constructions (and the Central UI + /// sandbox host) keep compiling: when omitted the context gets an inert logger that emits no + /// audit rows and writes nowhere. + /// public InboundScriptContext( IReadOnlyDictionary parameters, RouteHelper route, InboundDatabaseHelper database, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + InboundScriptLogger? log = null) { Parameters = new ScriptParameters(parameters); Route = route; Database = database; CancellationToken = cancellationToken; + Log = log ?? new InboundScriptLogger( + NullLogger.Instance, emit: null, InboundApiOptions.DefaultMaxLogLinesPerRun, + methodName: string.Empty, executionId: null); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptLogger.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptLogger.cs new file mode 100644 index 00000000..34967224 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptLogger.cs @@ -0,0 +1,219 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.Audit; +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; + +/// +/// The Log global an inbound API method script writes through (Flow Debugger step logs, +/// design 2026-09-09 D4/D5/D7). Central counterpart of the site runtime's script logger; the two +/// deliberately share a shape but no code (the InboundAPI component never references SiteRuntime). +/// +/// +/// Every call ALWAYS reaches the node's , so an author's message is never +/// lost on an ordinary request. A ScriptTrace/ScriptLog audit row is emitted ONLY +/// when the execution is traced — the executor supplies a non-null emit delegate in that +/// case and null otherwise, so an untraced run pays nothing beyond the log write (D5). +/// +/// +/// +/// The logger knows nothing about ICentralAuditWriter or the Live-run trace sink: the +/// executor composes both into the single emit delegate it passes in. +/// +/// +/// +/// Bounded per run (D7): at most maxLines rows, each message truncated to +/// ; further lines are counted and emits +/// one closing runtime-sourced warn row naming the dropped count at the end of the run. +/// +/// +/// +/// A method script may log from several concurrent tasks, so the counters are guarded. Nothing in +/// this class is allowed to throw into the script: a debug observer must never fail the request. +/// +/// +public sealed class InboundScriptLogger +{ + /// Per-line message cap (design D7); longer messages are truncated with an ellipsis. + public const int MaxMessageChars = 2_000; + + private const string SourceScript = "script"; + private const string SourceRuntime = "runtime"; + + private readonly ILogger _logger; + private readonly Action? _emit; + private readonly int _maxLines; + private readonly string _methodName; + private readonly string? _actor; + private readonly Guid? _executionId; + private readonly string? _sourceNode; + + private readonly object _gate = new(); + private int _emitted; + private int _dropped; + private int _seq; + + /// + /// Initializes the per-execution logger. + /// + /// The node logger every line is forwarded to, traced or not. + /// + /// Receives each built ScriptLog row (the executor fans it out to the Live trace sink and + /// the central audit writer). null for an untraced execution — no rows are built at all. + /// + /// Row cap for this run (ScadaBridge:InboundApi:MaxLogLinesPerRun). + /// The inbound method being executed; the row's Target. + /// The inbound request's execution id, tying the lines to its other rows. + /// Emitting central node (central-a/central-b); null = unstamped. + public InboundScriptLogger( + ILogger logger, + Action? emit, + int maxLines, + string methodName, + Guid? executionId, + string? sourceNode = null) + { + _logger = logger; + _emit = emit; + // A non-positive cap is rejected by the options validator; clamp defensively so a + // hand-constructed logger can never silently audit nothing. + _maxLines = maxLines > 0 ? maxLines : InboundApiOptions.DefaultMaxLogLinesPerRun; + _methodName = methodName; + _actor = string.IsNullOrEmpty(methodName) ? null : "api:" + methodName; + _executionId = executionId; + _sourceNode = sourceNode; + } + + /// Writes a debug-level line. + /// The message; truncated to . + /// Optional exception whose message/stack ride the row. + public void Debug(string message, Exception? exception = null) => + Write(LogLevel.Debug, "debug", message, exception); + + /// Writes an information-level line. + /// The message; truncated to . + /// Optional exception whose message/stack ride the row. + public void Info(string message, Exception? exception = null) => + Write(LogLevel.Information, "info", message, exception); + + /// Writes a warning-level line. + /// The message; truncated to . + /// Optional exception whose message/stack ride the row. + public void Warn(string message, Exception? exception = null) => + Write(LogLevel.Warning, "warn", message, exception); + + /// Writes an error-level line. Does NOT fail the script — it is a message, not a throw. + /// The message; truncated to . + /// Optional exception whose message/stack ride the row. + public void Error(string message, Exception? exception = null) => + Write(LogLevel.Error, "error", message, exception); + + /// + /// Emits the single closing warn/runtime row naming how many lines the per-run + /// cap dropped, then resets the count — so it is idempotent and a second call after a flushed + /// run emits nothing. No-op when nothing was dropped or the run is untraced. Called by + /// once the script body has finished, on every path. + /// + public void FlushDropped() + { + if (_emit is null) return; + + int dropped; + int seq; + lock (_gate) + { + if (_dropped == 0) return; + dropped = _dropped; + _dropped = 0; + seq = ++_seq; + } + + Emit("warn", SourceRuntime, seq, + $"log line cap reached - {dropped} further log line(s) were not captured " + + $"(ScadaBridge:InboundApi:MaxLogLinesPerRun = {_maxLines})", + exception: null); + } + + private void Write(LogLevel level, string levelName, string message, Exception? exception) + { + var text = Truncate(message); + + try + { + _logger.Log(level, exception, + "Script log [{LogLevel}] for inbound method {Method} (execution {ExecutionId}): {Message}", + levelName, _methodName, _executionId, text); + } + catch (Exception) + { + // A debug observer must never fail the request: a logging sink that throws is swallowed. + } + + if (_emit is null) return; // untraced run: the ILogger write above is the whole cost (D5) + + int seq; + lock (_gate) + { + if (_emitted >= _maxLines) + { + // Past the cap the LINE is still logged above; only the audit row is dropped, + // and FlushDropped reports the total at the end of the run. + _dropped++; + return; + } + + _emitted++; + seq = ++_seq; + } + + Emit(levelName, SourceScript, seq, text, exception); + } + + private void Emit(string level, string source, int seq, string message, Exception? exception) + { + AuditEvent evt; + try + { + evt = ScadaBridgeAuditEventFactory.Create( + AuditChannel.ScriptTrace, + AuditKind.ScriptLog, + AuditStatus.Delivered, + actor: _actor, + target: _methodName, + sourceNode: _sourceNode, + executionId: _executionId, + // An inbound request is a root: its lines have no spawning execution, and + // StepEventId stays null so the graph builder attaches them to the execution's + // anchor step (design D2). + parentExecutionId: null, + errorMessage: exception?.Message, + errorDetail: exception?.ToString(), + requestSummary: message, + extra: new ScriptTraceExtra { LogLevel = level, LogSource = source, Seq = seq }.ToJson()); + } + catch (Exception) + { + // A debug observer must never fail the request: an unserializable row is dropped. + return; + } + + try + { + _emit!(evt); + } + catch (Exception) + { + // A debug observer must never fail the request: a misbehaving sink/writer is swallowed. + } + } + + private static string Truncate(string? message) + { + if (string.IsNullOrEmpty(message)) return string.Empty; + return message.Length <= MaxMessageChars + ? message + : string.Concat(message.AsSpan(0, MaxMessageChars), "…"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs index 57e7da3b..9ba46776 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs @@ -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); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs index 068deb93..8ff4a79e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -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(); + 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(), 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(); + var route = NewRoute().WithTraceSink(sink.Add); + var writer = Substitute.For(); + writer.WriteAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + var services = new ServiceCollection(); + services.AddSingleton(writer); + using var provider = services.BuildServiceProvider(); + + var recorded = new List(); + var executor = new InboundScriptExecutor(new RecordingLogger(recorded), provider); + var method = new ApiMethod("quiet", "Log.Warn(\"unwatched\"); return 1;") { Id = 7, TimeoutSeconds = 10 }; + + var result = await executor.ExecuteAsync( + method, new Dictionary(), route, TimeSpan.FromSeconds(10), + parentExecutionId: Guid.NewGuid()); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Empty(ScriptLogRows(sink)); + await writer.DidNotReceive().WriteAsync(Arg.Any(), Arg.Any()); + 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(); + 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(), 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(); + var route = NewRoute().WithTraceSink(sink.Add); + var services = new ServiceCollection(); + services.Configure(o => o.MaxLogLinesPerRun = 2); + using var provider = services.BuildServiceProvider(); + + var executor = new InboundScriptExecutor(NullLogger.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(), 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(), Substitute.For()); + + private static List ScriptLogRows(IEnumerable 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(List messages) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (messages) messages.Add(formatter(state, exception)); + } + } + private sealed class CompileLogCounter { public int CompilationFailures;