fix(auditlog): capture request/response payloads on outbound API audit rows

The outbound ApiCall emitter hard-coded RequestSummary/ResponseSummary to null,
so audited API calls carried no inputs/outputs — contrary to the Audit Log
payload-capture spec. Thread the call arguments into the sync ApiCall emitter
and the cached immediate-completion path (CachedSubmit / ApiCallCached /
CachedResolve), and stamp the response body from ExternalCallResult.ResponseJson.
The writer's payload filter still applies the size cap + redaction downstream.

The S&F retry-loop cached rows are unchanged — request data is not threaded
through the store-and-forward buffer (same boundary as SourceScript).
This commit is contained in:
Joseph Doherty
2026-05-21 10:17:42 -04:00
parent 405de525ca
commit 849a011400
3 changed files with 106 additions and 8 deletions

View File

@@ -420,7 +420,7 @@ public class ScriptRuntimeContext
{ {
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks) var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 1000d / Stopwatch.Frequency); * 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 // Submitted row even if the immediate-delivery attempt happens to
// resolve before this method returns. // resolve before this method returns.
await EmitCachedSubmitTelemetryAsync( await EmitCachedSubmitTelemetryAsync(
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken) systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId // Hand off to the existing cached-call path. The TrackedOperationId
@@ -503,7 +503,7 @@ public class ScriptRuntimeContext
if (result is { WasBuffered: false }) if (result is { WasBuffered: false })
{ {
await EmitImmediateTerminalTelemetryAsync( await EmitImmediateTerminalTelemetryAsync(
systemName, methodName, target, trackedId, result, cancellationToken) systemName, methodName, target, trackedId, result, parameters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -521,6 +521,7 @@ public class ScriptRuntimeContext
string target, string target,
TrackedOperationId trackedId, TrackedOperationId trackedId,
DateTime occurredAtUtc, DateTime occurredAtUtc,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_cachedForwarder == null) if (_cachedForwarder == null)
@@ -544,6 +545,8 @@ public class ScriptRuntimeContext
SourceScript = _sourceScript, SourceScript = _sourceScript,
Target = target, Target = target,
Status = AuditStatus.Submitted, Status = AuditStatus.Submitted,
// Submit precedes the call — request args only, no response yet.
RequestSummary = SerializeRequest(parameters),
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -599,6 +602,7 @@ public class ScriptRuntimeContext
string target, string target,
TrackedOperationId trackedId, TrackedOperationId trackedId,
ExternalCallResult result, ExternalCallResult result,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_cachedForwarder == null) if (_cachedForwarder == null)
@@ -653,6 +657,8 @@ public class ScriptRuntimeContext
Status = AuditStatus.Attempted, Status = AuditStatus.Attempted,
HttpStatus = httpStatus, HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage, ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -712,6 +718,8 @@ public class ScriptRuntimeContext
Status = auditTerminalStatus, Status = auditTerminalStatus,
HttpStatus = httpStatus, HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage, ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -762,7 +770,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc, DateTime occurredAtUtc,
int durationMs, int durationMs,
ExternalCallResult? result, ExternalCallResult? result,
Exception? thrown) Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{ {
if (_auditWriter == null) if (_auditWriter == null)
{ {
@@ -772,7 +781,8 @@ public class ScriptRuntimeContext
AuditEvent evt; AuditEvent evt;
try try
{ {
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown); evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
} }
catch (Exception buildEx) catch (Exception buildEx)
{ {
@@ -828,7 +838,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc, DateTime occurredAtUtc,
int durationMs, int durationMs,
ExternalCallResult? result, ExternalCallResult? result,
Exception? thrown) Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{ {
// Status: Delivered on a Success result; Failed otherwise (the // Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient // ExternalSystemClient already maps HTTP non-2xx + transient
@@ -885,13 +896,41 @@ public class ScriptRuntimeContext
DurationMs = durationMs, DurationMs = durationMs,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
ErrorDetail = errorDetail, ErrorDetail = errorDetail,
RequestSummary = null, // Payload capture: the request arguments and the response body.
ResponseSummary = null, // 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, PayloadTruncated = false,
Extra = null, Extra = null,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}; };
} }
/// <summary>
/// Serialises the outbound-call argument dictionary into the JSON
/// <c>RequestSummary</c> stamped on <c>ApiOutbound</c> audit rows.
/// Returns <c>null</c> for a null/empty argument set. Serialization
/// failure is swallowed (returns <c>null</c>) — a payload that cannot be
/// summarised must never abort the best-effort audit emission.
/// </summary>
private static string? SerializeRequest(IReadOnlyDictionary<string, object?>? parameters)
{
if (parameters is null || parameters.Count == 0)
{
return null;
}
try
{
return JsonSerializer.Serialize(parameters);
}
catch (Exception)
{
return null;
}
}
} }
/// <summary> /// <summary>

View File

@@ -94,6 +94,42 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Null(packet.Operational.TerminalAtUtc); Assert.Null(packet.Operational.TerminalAtUtc);
} }
[Fact]
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var args = new Dictionary<string, object?> { ["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] [Fact]
public async Task CachedCall_ReturnsTrackedOperationId() public async Task CachedCall_ReturnsTrackedOperationId()
{ {

View File

@@ -81,6 +81,29 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId); Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated); 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<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var args = new Dictionary<string, object?> { ["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] [Fact]