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;