fix(site-runtime): trace sink is guarded — a throwing observer can neither fail a call nor skip the audit write

Review of Task 3: the sink invoke sat before WriteAsync/ForwardAsync inside the
best-effort try, so a throwing sink would have silently dropped the durable
row. GuardTraceSink wraps the delegate in every helper ctor (and Collect
guards EmitTrace); pinned by a throwing-sink test. ScriptRunLauncher no longer
allocates a collector for a traced run with no reply target (Nobody) — the
list would never be read. FlowTraceCollector.Complete pinned at unit level;
Snapshot documented as a non-closing peek.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:57:09 -04:00
parent 69feb40e65
commit ec9011429c
5 changed files with 92 additions and 8 deletions
@@ -64,4 +64,34 @@ public class FlowTraceCollectorTests
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 = "{}",
};
}
@@ -507,4 +507,20 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
writer.Events.Select(e => Row(e).Kind));
Assert.Equal(AuditKind.CachedSubmit, Row(Assert.Single(forwarder.Telemetry).Audit).Kind);
}
[Fact]
public async Task ThrowingTraceSink_NeverFailsTheCall_AndTheWriterStillGetsTheRow()
{
var writer = new CapturingAuditWriter();
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
SyncClient(new ExternalCallResult(true, "{}", null)).Object, InstanceName, NullLogger.Instance, Guid.NewGuid(),
auditWriter: writer, siteId: "site-a", sourceScript: "S",
traceSink: _ => throw new InvalidOperationException("observer broke"));
var result = await helper.Call("ERP", "GetOrder");
Assert.True(result.Success);
var written = Assert.Single(writer.Events);
Assert.Equal(AuditKind.ApiCall, Row(written).Kind);
}
}