fix(site-runtime): Flow Debugger inline trace copy is redacted and byte-capped at the collector (arch-review F1, site half)

Every emit site hands FlowTraceCollector the same AuditEvent it hands the
writer, but redaction and payload capping lived inside the writers, which
return a NEW filtered record — so the inline copy that rides
ScriptCallResult.TraceEvents / RouteToCallResponse.TraceEvents was the RAW
event: no per-target SQL-parameter redaction, no header scrubbing, no 8 KB /
64 KB truncation. Live and Replay of the same execution could therefore show
different payloads, and the 500-event-only cap left the reply free to breach
the 4 MB gRPC receive limit — which would fail a real call, since the same
reply carries the script's return value.

The collector now applies the host IAuditRedactor itself (outside the lock;
the writers keep their own idempotent Apply) and enforces a byte budget
(DefaultMaxBytes = 1 MB over the redacted DetailsJson) alongside the event
cap, flagging Truncated when either trips. It moves to Commons
(Types/Debugging) so the central FlowDebugRunner can share it.

The redactor is a singleton, so ScriptRunLauncher resolves it from the ROOT
provider and keeps constructing the collector before the try — the traced
ScriptRun row is emitted from the catch blocks too, including the
shed-at-dequeue path that throws before the DI scope exists.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:15:38 -04:00
parent cccef7b2f4
commit e79452d2de
4 changed files with 138 additions and 11 deletions
@@ -0,0 +1,120 @@
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Per-run sink for <c>ScriptTrace</c> audit events so a traced routed call can return them
/// inline on <c>ScriptCallResult.TraceEvents</c> (Flow Debugger design 2026-09-05 §5.4).
/// Lock-based: a run emits tens of events, not thousands. Beyond <see cref="DefaultMaxEvents"/>
/// the collector keeps the first N, sets <see cref="Truncated"/>, and the audit rows written
/// through <c>IAuditWriter</c> remain complete — Replay is never truncated, only the inline copy.
/// An untraced run holds <c>null</c> rather than an inert collector; callers null-check.
/// Outbound-calls addendum (plan 2026-09-06 D5): the <c>ApiOutbound</c> / <c>DbOutbound</c> /
/// <c>Notification</c> rows a run emits ride the same sink, so <see cref="Complete"/> closes it
/// when the launcher takes the reply snapshot — a lifecycle row the S&amp;F forwarder writes
/// minutes later, or an un-awaited call finishing after the run returned, must not mutate a
/// list the reply has already been built from. <see cref="Add"/> is a no-op once completed.
/// Arch-review F1 (2026-09-06): the inline copy is <b>REDACTED</b> — the collector applies the
/// host's <see cref="IAuditRedactor"/> itself rather than storing the raw event the emitter also
/// hands the writer, so Live shows exactly what Replay reads back (per-target SQL-parameter
/// overrides, header scrubbing and the 8 KB / 64 KB payload caps all apply). The writers keep
/// their own <c>Apply</c> call — redaction is idempotent, so the stored row is unaffected.
/// The copy is also capped by BYTES as well as by count, because it rides a gRPC reply whose
/// default 4 MB receive limit a few hundred large error rows would otherwise breach — and that
/// same reply carries the script's return value, so an oversized trace would fail a real call.
/// </summary>
public sealed class FlowTraceCollector
{
/// <summary>Default inline cap per run (design §5.4 <c>MaxTraceEventsPerRun</c>).</summary>
public const int DefaultMaxEvents = 500;
/// <summary>
/// Default inline byte budget per run — the summed redacted <see cref="AuditEvent.DetailsJson"/>
/// length, well under the 4 MB gRPC default receive limit the reply must fit in.
/// </summary>
public const int DefaultMaxBytes = 1_048_576;
private readonly List<AuditEvent> _events = new();
private readonly object _gate = new();
private readonly int _max;
private readonly int _maxBytes;
private readonly IAuditRedactor? _redactor;
private int _bytes;
/// <summary>
/// True once an <see cref="Add"/> was dropped because the event cap or the byte budget
/// was reached.
/// </summary>
public bool Truncated { get; private set; }
/// <summary>
/// True once <see cref="Complete"/> ran — the run's reply snapshot has been taken and
/// later <see cref="Add"/> calls are ignored (they never count as <see cref="Truncated"/>).
/// </summary>
public bool Completed { get; private set; }
/// <summary>
/// Creates a collector with the given inline caps (both must be positive) and the redactor
/// the inline copy is filtered through. A null <paramref name="redactor"/> — a host that
/// never called <c>AddAuditLog</c>, or a test — collects raw but is still capped.
/// </summary>
public FlowTraceCollector(
int maxEvents = DefaultMaxEvents,
int maxBytes = DefaultMaxBytes,
IAuditRedactor? redactor = null)
{
if (maxEvents <= 0) throw new ArgumentOutOfRangeException(nameof(maxEvents), "must be positive");
if (maxBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxBytes), "must be positive");
_max = maxEvents;
_maxBytes = maxBytes;
_redactor = redactor;
}
/// <summary>
/// Appends the REDACTED copy of an event, or marks the collector truncated once the event
/// cap or the byte budget is reached. A no-op after <see cref="Complete"/>.
/// </summary>
public void Add(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
// Redaction runs OUTSIDE the lock (arch-review F1): IAuditRedactor is a pure function
// but not necessarily a cheap one (regex scrubbing + payload truncation), and the
// collector is shared by every emit site of a run.
var filtered = _redactor is null ? evt : _redactor.Apply(evt);
var size = filtered.DetailsJson?.Length ?? 0;
lock (_gate)
{
if (Completed) return;
if (_events.Count >= _max || _bytes + size > _maxBytes) { Truncated = true; return; }
_events.Add(filtered);
_bytes += size;
}
}
/// <summary>
/// Closes the collector and returns the final snapshot — the launcher calls this exactly
/// where it builds <c>ScriptCallResult.TraceEvents</c>. Idempotent: a second call returns
/// the same contents.
/// </summary>
public IReadOnlyList<AuditEvent> Complete()
{
lock (_gate)
{
Completed = true;
return _events.ToArray();
}
}
/// <summary>Current event count.</summary>
public int Count { get { lock (_gate) return _events.Count; } }
/// <summary>
/// Copies the collected events in emission order WITHOUT closing the collector — a diagnostic /
/// test-only peek. A reply path must use <see cref="Complete"/>, otherwise an un-awaited call
/// finishing after the reply could mutate the list the reply was built from.
/// </summary>
public IReadOnlyList<AuditEvent> Snapshot()
{
lock (_gate) return _events.ToArray();
}
}