feat(inbound-api): RouteHelper writes an always-on RouteCall audit row, propagates Trace, exposes an inline trace sink

Flow Debugger (design 2026-09-05 §5.1/§5.2/§7.1): RouteTarget.Call emits one
ScriptTrace.RouteCall central-direct-write row per routed call (ExecutionId = the inbound
parent, Extra.childExecutionId from the reply, best-effort, never throws into the script)
whether or not the run is traced. WithTrace(bool) stamps Trace on RouteToCallRequest and
RouteToSetAttributesRequest; WithTraceSink hands the row + the reply's inline site events to a
callback so a Live run renders without touching the store. DI supplies ICentralAuditWriter /
INodeIdentityProvider as optional ctor args; new RouteHelper(locator, router) still compiles.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-05 23:59:08 -04:00
parent 7e61552d82
commit 3f582a278c
2 changed files with 317 additions and 12 deletions
@@ -1,7 +1,13 @@
using System.Diagnostics;
using System.Text.Json;
using Akka.Actor;
using ZB.MOM.WW.Audit;
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;
@@ -47,16 +53,27 @@ public class RouteHelper
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
private readonly ICentralAuditWriter? _auditWriter;
private readonly INodeIdentityProvider? _nodeIdentity;
private readonly bool _trace;
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new <see cref="RouteHelper"/> with no deadline token and no parent execution id.
/// DI (<c>AddScoped&lt;RouteHelper&gt;()</c>) resolves the two optional audit collaborators when the
/// host registers them; a bare <c>new RouteHelper(locator, router)</c> writes no audit rows.
/// </summary>
/// <param name="instanceLocator">Service to resolve the site id for a given instance code.</param>
/// <param name="instanceRouter">Service to route cross-site calls to the resolved site.</param>
/// <param name="auditWriter">Flow Debugger (design 2026-09-05 §5.1): central-direct-write audit writer for the always-on <c>RouteCall</c> row; null = no row.</param>
/// <param name="nodeIdentity">Stamps <c>SourceNode</c> (central-a/central-b) on the <c>RouteCall</c> row; null = NULL.</param>
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null)
IInstanceRouter instanceRouter,
ICentralAuditWriter? auditWriter = null,
INodeIdentityProvider? nodeIdentity = null)
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null,
auditWriter, nodeIdentity, trace: false, traceSink: null)
{
}
@@ -65,13 +82,21 @@ public class RouteHelper
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId)
Guid? parentExecutionId,
ICentralAuditWriter? auditWriter,
INodeIdentityProvider? nodeIdentity,
bool trace,
Action<AuditEvent>? traceSink)
{
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
_auditWriter = auditWriter;
_nodeIdentity = nodeIdentity;
_trace = trace;
_traceSink = traceSink;
}
/// <summary>
@@ -84,7 +109,8 @@ public class RouteHelper
/// <param name="deadlineToken">The executing method's timeout cancellation token to inherit for routed calls.</param>
/// <returns>A new <see cref="RouteHelper"/> inheriting the given deadline token.</returns>
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId);
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink);
/// <summary>
/// Returns a <see cref="RouteHelper"/> carrying the raw request-abort
@@ -97,7 +123,8 @@ public class RouteHelper
/// <param name="requestAbortedToken">The raw client-disconnect token, independent of the method timeout.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the given request-abort token.</returns>
public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId);
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink);
/// <summary>
/// Audit Log (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
@@ -111,7 +138,31 @@ public class RouteHelper
/// <param name="parentExecutionId">The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the given parent execution id.</returns>
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId);
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink);
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.2): returns a <see cref="RouteHelper"/> whose routed
/// calls and writes carry <c>Trace = <paramref name="trace"/></c>, so the site traces the
/// spawned run. The always-on <c>RouteCall</c> row is written regardless of this flag.
/// </summary>
/// <param name="trace">True to trace the routed site runs.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the flag.</returns>
public RouteHelper WithTrace(bool trace) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, trace, _traceSink);
/// <summary>
/// Flow Debugger (design 2026-09-05 §7.1): returns a <see cref="RouteHelper"/> that also
/// hands every <c>RouteCall</c> row AND every inline site trace event on the routed reply
/// to <paramref name="traceSink"/>, so a Live run can render without a round-trip to the
/// audit store. Null clears the sink.
/// </summary>
/// <param name="traceSink">Receives the audit events in emission order; null = none.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the sink.</returns>
public RouteHelper WithTraceSink(Action<AuditEvent>? traceSink) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, traceSink);
/// <summary>
/// Creates a route target for the specified instance.
@@ -121,7 +172,8 @@ public class RouteHelper
public RouteTarget To(string instanceCode)
{
return new RouteTarget(
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId);
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink);
}
}
@@ -136,6 +188,10 @@ public class RouteTarget
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
private readonly ICentralAuditWriter? _auditWriter;
private readonly INodeIdentityProvider? _nodeIdentity;
private readonly bool _trace;
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Initializes a new <see cref="RouteTarget"/> for the specified instance.
@@ -152,7 +208,11 @@ public class RouteTarget
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId)
Guid? parentExecutionId,
ICentralAuditWriter? auditWriter = null,
INodeIdentityProvider? nodeIdentity = null,
bool trace = false,
Action<AuditEvent>? traceSink = null)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
@@ -160,6 +220,10 @@ public class RouteTarget
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
_auditWriter = auditWriter;
_nodeIdentity = nodeIdentity;
_trace = trace;
_traceSink = traceSink;
}
/// <summary>
@@ -188,11 +252,31 @@ public class RouteTarget
// (the inbound API request's ExecutionId) so the routed site script
// records this call's parent. CorrelationId above is a separate concern
// — the per-operation lifecycle id, freshly minted per routed call.
var normalized = ScriptArgs.Normalize(parameters);
var request = new RouteToCallRequest(
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
DateTimeOffset.UtcNow, _parentExecutionId);
correlationId, _instanceCode, scriptName, normalized,
DateTimeOffset.UtcNow, _parentExecutionId,
// Flow Debugger (design 2026-09-05 §5.2): the trace flag rides beside the parent id.
Trace: _trace);
var response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token));
// Flow Debugger (design §5.1): the ALWAYS-ON RouteCall row — one per routed call,
// written whether or not the run is traced (a writer must be present).
var startedUtc = DateTime.UtcNow;
var sw = Stopwatch.StartNew();
RouteToCallResponse response;
try
{
response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token));
}
catch (Exception ex)
{
EmitRouteCall(scriptName, normalized, startedUtc, sw.ElapsedMilliseconds, success: false,
returnValue: null, error: ex.Message, childExecutionId: null, traceEvents: null);
throw;
}
EmitRouteCall(scriptName, normalized, startedUtc, sw.ElapsedMilliseconds, response.Success,
response.ReturnValue, response.ErrorMessage, response.ChildExecutionId, response.TraceEvents);
if (!response.Success)
{
@@ -203,6 +287,78 @@ public class RouteTarget
return response.ReturnValue;
}
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.1/§7.1): builds the <c>ScriptTrace.RouteCall</c> row,
/// writes it best-effort through the central audit writer (never throws into the script),
/// and hands it — followed by the site's inline trace events — to the trace sink.
/// </summary>
private void EmitRouteCall(
string scriptName, IReadOnlyDictionary<string, object?>? args, DateTime startedUtc, long elapsedMs,
bool success, object? returnValue, string? error, Guid? childExecutionId,
IReadOnlyList<AuditEvent>? traceEvents)
{
if (_auditWriter is null && _traceSink is null) return;
AuditEvent evt;
try
{
evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.RouteCall,
success ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
actor: "inbound-api",
target: $"{_instanceCode}.{scriptName}",
sourceNode: _nodeIdentity?.NodeName,
executionId: _parentExecutionId,
parentExecutionId: null,
durationMs: (int)Math.Min(elapsedMs, int.MaxValue),
errorMessage: error,
requestSummary: TraceJson(args),
responseSummary: TraceJson(returnValue),
extra: childExecutionId is null ? null : new ScriptTraceExtra { ChildExecutionId = childExecutionId }.ToJson());
}
catch (Exception)
{
return;
}
if (_auditWriter is not null)
{
try
{
var t = _auditWriter.WriteAsync(evt, CancellationToken.None);
if (!t.IsCompletedSuccessfully)
{
_ = t.ContinueWith(static _ => { }, TaskContinuationOptions.OnlyOnFaulted);
}
}
catch (Exception)
{
// Best-effort: an audit failure never fails the routed call.
}
}
if (_traceSink is null) return;
try
{
_traceSink(evt);
if (traceEvents is not null)
{
foreach (var e in traceEvents) _traceSink(e);
}
}
catch (Exception)
{
// A misbehaving sink must not fail the script either.
}
}
private static string? TraceJson(object? value)
{
if (value is null) return null;
try { return JsonSerializer.Serialize(value, ScriptTraceExtra.JsonOptions); }
catch (Exception) { return null; }
}
/// <summary>
/// Gets a single attribute value from the remote instance.
/// </summary>
@@ -353,7 +509,9 @@ public class RouteTarget
// across Call / GetAttributes / SetAttributes.
var request = new RouteToSetAttributesRequest(
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow,
_parentExecutionId);
_parentExecutionId,
// Flow Debugger (design §5.3): a traced routed write arms the site's echo stamp.
Trace: _trace);
var response = await RouteOrUnreachable(_instanceRouter.RouteToSetAttributesAsync(siteId, request, token));