feat(site-runtime): FlowTraceCollector

Per-run inline sink for ScriptTrace audit events (Flow Debugger design 2026-09-05 §5.4):
cap 500, Truncated flag, lock-based; the audit rows stay complete beyond the cap.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-05 23:42:39 -04:00
parent ec7b5b2ea3
commit febb268d89
2 changed files with 118 additions and 0 deletions
@@ -0,0 +1,51 @@
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <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.
/// </summary>
public sealed class FlowTraceCollector
{
/// <summary>Default inline cap per run (design §5.4 <c>MaxTraceEventsPerRun</c>).</summary>
public const int DefaultMaxEvents = 500;
private readonly List<AuditEvent> _events = new();
private readonly object _gate = new();
private readonly int _max;
/// <summary>True once an <see cref="Add"/> was dropped because the cap was reached.</summary>
public bool Truncated { get; private set; }
/// <summary>Creates a collector with the given inline cap (must be positive).</summary>
public FlowTraceCollector(int maxEvents = DefaultMaxEvents)
{
if (maxEvents <= 0) throw new ArgumentOutOfRangeException(nameof(maxEvents), "must be positive");
_max = maxEvents;
}
/// <summary>Appends an event, or marks the collector truncated once the cap is reached.</summary>
public void Add(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
lock (_gate)
{
if (_events.Count >= _max) { Truncated = true; return; }
_events.Add(evt);
}
}
/// <summary>Current event count.</summary>
public int Count { get { lock (_gate) return _events.Count; } }
/// <summary>Copies the collected events in emission order.</summary>
public IReadOnlyList<AuditEvent> Snapshot()
{
lock (_gate) return _events.ToArray();
}
}
@@ -0,0 +1,67 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>Flow Debugger design 2026-09-05 §5.4: the per-run inline trace sink.</summary>
public class FlowTraceCollectorTests
{
private static AuditEvent Evt(int i) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "t",
Action = "ScriptTrace.AttributeWrite",
Outcome = AuditOutcome.Success,
Target = i.ToString(),
};
[Fact]
public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated()
{
var c = new FlowTraceCollector(maxEvents: 500);
Assert.False(c.Truncated);
for (var i = 0; i < 501; i++) c.Add(Evt(i));
var snap = c.Snapshot();
Assert.Equal(500, snap.Count);
Assert.True(c.Truncated);
Assert.Equal("0", snap[0].Target);
Assert.Equal("499", snap[499].Target);
}
[Fact]
public void Snapshot_IsACopy_InEmissionOrder()
{
var c = new FlowTraceCollector();
c.Add(Evt(1));
var first = c.Snapshot();
c.Add(Evt(2));
Assert.Single(first);
Assert.Equal(2, c.Count);
Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target));
}
[Fact]
public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap()
{
var c = new FlowTraceCollector(maxEvents: 100);
var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
{
for (var i = 0; i < 50; i++) c.Add(Evt(t * 100 + i));
}));
await Task.WhenAll(tasks);
Assert.Equal(100, c.Snapshot().Count);
Assert.True(c.Truncated);
}
[Fact]
public void DefaultCap_Is500_AndNonPositiveCapIsRejected()
{
Assert.Equal(500, FlowTraceCollector.DefaultMaxEvents);
Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(0));
}
}