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:
@@ -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<RouteHelper>()</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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user