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
@@ -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<object?>? 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<ICentralAuditWriter>();
var nodeIdentity = scope?.ServiceProvider.GetService<INodeIdentityProvider>();
var dbHelper = new InboundDatabaseHelper(
gateway, cts.Token, timeout,
auditWriter: scope?.ServiceProvider.GetService<ICentralAuditWriter>(),
nodeIdentity: scope?.ServiceProvider.GetService<INodeIdentityProvider>(),
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<IOptionsMonitor<InboundApiOptions>>()?.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
}
}
}
/// <summary>
/// Writes one <c>ScriptLog</c> row to the central audit store without ever blocking or failing
/// the script (design 2026-09-09 D5) — the same fire-and-observe pattern
/// <c>RouteHelper.EmitRouteCall</c> uses for its always-on <c>RouteCall</c> row. Never awaited on
/// a script path: an audit write completes on the writer's own flush cadence.
/// </summary>
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.
}
}
}
/// <summary>
@@ -605,6 +678,14 @@ public class InboundScriptContext
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D4): the script's leveled logger —
/// <c>Log.Debug/Info/Warn/Error(message, exception?)</c>. Always reaches the node's log; a
/// traced execution additionally records each line as a <c>ScriptTrace/ScriptLog</c> audit row
/// the Flow Debugger folds onto the step that produced it.
/// </summary>
public InboundScriptLogger Log { get; }
/// <summary>
/// Initializes a new instance of the InboundScriptContext.
/// </summary>
@@ -612,16 +693,25 @@ public class InboundScriptContext
/// <param name="route">The route helper for cross-site routing.</param>
/// <param name="database">The scoped, parameterized database helper for the script.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="log">
/// The script's <c>Log</c> 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.
/// </param>
public InboundScriptContext(
IReadOnlyDictionary<string, object?> 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);
}
}