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:
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user