feat(inbound-api): Log global for method scripts, central ScriptLog rows when traced (D4/D5)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user