feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry

Site: site emitters of SiteCallOperational (ExternalSystemClient, the script-API
cached call path in ScriptRuntimeContext, CachedCallLifecycleBridge) inject
INodeIdentityProvider and stamp SourceNode = NodeName at construction.

OperationTrackingStore call site in CachedCallTelemetryForwarder now stamps
SourceNode too.

Central: SiteCallAuditRepository.UpsertAsync INSERT includes SourceNode in the
column list; conditional monotonic UPDATE uses
COALESCE(@SourceNode, SourceNode) so later packets cannot blank a previously-
stamped value. After this commit every SiteCalls row carries node-a/node-b in
SourceNode (subject to monotonic preservation).
This commit is contained in:
Joseph Doherty
2026-05-23 17:41:22 -04:00
parent d1fcab490c
commit 06ed0acead
10 changed files with 539 additions and 34 deletions

View File

@@ -570,4 +570,69 @@ public class ExternalSystemCachedCallEmissionTests
var only = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditKind.CachedSubmit, only.Audit.Kind);
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow()
{
// SourceNode-stamping (Task 14): when the helper is constructed with
// a non-null sourceNode, every SiteCallOperational it produces
// (CachedSubmit on enqueue + the immediate-completion Attempted/
// CachedResolve pair when WasBuffered=false) carries that node name.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// Immediate completion — helper produces all three rows itself.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
client.Object,
InstanceName,
NullLogger.Instance,
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: null,
sourceNode: "node-a");
await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode));
}
[Fact]
public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull()
{
// Default CreateHelper does NOT pass sourceNode — the legacy / test
// host path. Every operational row carries null SourceNode, leaving
// central's SiteCalls.SourceNode NULL.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
var only = Assert.Single(forwarder.Telemetry);
Assert.Null(only.Operational.SourceNode);
}
}