diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index e787dd6..250dcc1 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -420,7 +420,7 @@ public class ScriptRuntimeContext { var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks) * 1000d / Stopwatch.Frequency); - EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown); + EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters); } } @@ -458,7 +458,7 @@ public class ScriptRuntimeContext // Submitted row even if the immediate-delivery attempt happens to // resolve before this method returns. await EmitCachedSubmitTelemetryAsync( - systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken) + systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken) .ConfigureAwait(false); // Hand off to the existing cached-call path. The TrackedOperationId @@ -503,7 +503,7 @@ public class ScriptRuntimeContext if (result is { WasBuffered: false }) { await EmitImmediateTerminalTelemetryAsync( - systemName, methodName, target, trackedId, result, cancellationToken) + systemName, methodName, target, trackedId, result, parameters, cancellationToken) .ConfigureAwait(false); } @@ -521,6 +521,7 @@ public class ScriptRuntimeContext string target, TrackedOperationId trackedId, DateTime occurredAtUtc, + IReadOnlyDictionary? parameters, CancellationToken cancellationToken) { if (_cachedForwarder == null) @@ -544,6 +545,8 @@ public class ScriptRuntimeContext SourceScript = _sourceScript, Target = target, Status = AuditStatus.Submitted, + // Submit precedes the call — request args only, no response yet. + RequestSummary = SerializeRequest(parameters), ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( @@ -599,6 +602,7 @@ public class ScriptRuntimeContext string target, TrackedOperationId trackedId, ExternalCallResult result, + IReadOnlyDictionary? parameters, CancellationToken cancellationToken) { if (_cachedForwarder == null) @@ -653,6 +657,8 @@ public class ScriptRuntimeContext Status = AuditStatus.Attempted, HttpStatus = httpStatus, ErrorMessage = result.Success ? null : result.ErrorMessage, + RequestSummary = SerializeRequest(parameters), + ResponseSummary = result.ResponseJson, ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( @@ -712,6 +718,8 @@ public class ScriptRuntimeContext Status = auditTerminalStatus, HttpStatus = httpStatus, ErrorMessage = result.Success ? null : result.ErrorMessage, + RequestSummary = SerializeRequest(parameters), + ResponseSummary = result.ResponseJson, ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( @@ -762,7 +770,8 @@ public class ScriptRuntimeContext DateTime occurredAtUtc, int durationMs, ExternalCallResult? result, - Exception? thrown) + Exception? thrown, + IReadOnlyDictionary? parameters) { if (_auditWriter == null) { @@ -772,7 +781,8 @@ public class ScriptRuntimeContext AuditEvent evt; try { - evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown); + evt = BuildCallAuditEvent( + systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters); } catch (Exception buildEx) { @@ -828,7 +838,8 @@ public class ScriptRuntimeContext DateTime occurredAtUtc, int durationMs, ExternalCallResult? result, - Exception? thrown) + Exception? thrown, + IReadOnlyDictionary? parameters) { // Status: Delivered on a Success result; Failed otherwise (the // ExternalSystemClient already maps HTTP non-2xx + transient @@ -885,13 +896,41 @@ public class ScriptRuntimeContext DurationMs = durationMs, ErrorMessage = errorMessage, ErrorDetail = errorDetail, - RequestSummary = null, - ResponseSummary = null, + // Payload capture: the request arguments and the response body. + // The audit writer's payload filter applies the configured size + // cap and header/secret redaction downstream — the emitter just + // hands over the raw values. + RequestSummary = SerializeRequest(parameters), + ResponseSummary = result?.ResponseJson, PayloadTruncated = false, Extra = null, ForwardState = AuditForwardState.Pending, }; } + + /// + /// Serialises the outbound-call argument dictionary into the JSON + /// RequestSummary stamped on ApiOutbound audit rows. + /// Returns null for a null/empty argument set. Serialization + /// failure is swallowed (returns null) — a payload that cannot be + /// summarised must never abort the best-effort audit emission. + /// + private static string? SerializeRequest(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + { + return null; + } + + try + { + return JsonSerializer.Serialize(parameters); + } + catch (Exception) + { + return null; + } + } } /// diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 3d56ffe..7516822 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -94,6 +94,42 @@ public class ExternalSystemCachedCallEmissionTests Assert.Null(packet.Operational.TerminalAtUtc); } + [Fact] + public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody() + { + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder); + var args = new Dictionary { ["orderId"] = 42 }; + await helper.CachedCall("ERP", "GetOrder", args); + + // Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve. + Assert.Equal(3, forwarder.Telemetry.Count); + var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit); + var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached); + var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve); + + // Every row carries the request args; the two post-call rows also carry + // the response body (Submit precedes the call, so it has no response). + Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary); + Assert.Null(submit.Audit.ResponseSummary); + + Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary); + Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary); + + Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary); + Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary); + } + [Fact] public async Task CachedCall_ReturnsTrackedOperationId() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index 3aad973..d021181 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -81,6 +81,29 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); Assert.NotEqual(Guid.Empty, evt.EventId); Assert.False(evt.PayloadTruncated); + // No call arguments → null request summary; the response body is captured. + Assert.Null(evt.RequestSummary); + Assert.Equal("{}", evt.ResponseSummary); + } + + [Fact] + public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null)); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + var args = new Dictionary { ["city"] = "Dublin" }; + await helper.Call("Weather", "GetCurrent", args); + + var evt = Assert.Single(writer.Events); + // RequestSummary is the serialized argument dictionary; ResponseSummary + // is the verbatim response body. (Cap + redaction are the writer's job.) + Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary); + Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary); } [Fact]