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:
@@ -1,97 +0,0 @@
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_ClosesTheCollector_LaterAddsAreIgnored_AndNeverTruncate()
|
||||
{
|
||||
var c = new FlowTraceCollector(maxEvents: 2);
|
||||
var first = NewEvent();
|
||||
c.Add(first);
|
||||
Assert.False(c.Completed);
|
||||
|
||||
var snapshot = c.Complete();
|
||||
|
||||
Assert.True(c.Completed);
|
||||
Assert.Same(first, Assert.Single(snapshot));
|
||||
c.Add(NewEvent());
|
||||
c.Add(NewEvent());
|
||||
c.Add(NewEvent());
|
||||
Assert.Equal(1, c.Count);
|
||||
Assert.False(c.Truncated);
|
||||
Assert.Equal(snapshot, c.Complete()); // idempotent
|
||||
}
|
||||
|
||||
private static AuditEvent NewEvent() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "t",
|
||||
Action = "ScriptTrace.ScriptRun",
|
||||
Outcome = AuditOutcome.Success,
|
||||
DetailsJson = "{}",
|
||||
};
|
||||
}
|
||||
+33
@@ -209,6 +209,39 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.False(ctx.IsTraced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review F1: the collector applies the host redactor, so the inline copy the reply
|
||||
/// carries is the FILTERED record — while the writer still receives the raw event and
|
||||
/// redacts it itself (redaction is idempotent, so the stored row is identical).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SetAttribute_WhenTraced_InlineCopyIsRedacted_WriterStillSeesTheRawEvent()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var writer = new CapturingAuditWriter();
|
||||
var collector = new FlowTraceCollector(redactor: new UpperRedactor());
|
||||
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
|
||||
|
||||
var call = ctx.SetAttribute("Setpoint", "42");
|
||||
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
|
||||
await call;
|
||||
|
||||
var written = Assert.Single(writer.Events);
|
||||
var collected = Assert.Single(collector.Complete());
|
||||
Assert.NotSame(written, collected);
|
||||
Assert.Contains("\"channel\":\"ScriptTrace\"", written.DetailsJson);
|
||||
Assert.NotEqual(written.DetailsJson, collected.DetailsJson);
|
||||
Assert.Equal(written.DetailsJson!.ToUpperInvariant(), collected.DetailsJson);
|
||||
}
|
||||
|
||||
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
|
||||
private sealed class UpperRedactor : IAuditRedactor
|
||||
{
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user