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)
* 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<string, object?>? 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<string, object?>? 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<string, object?>? 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<string, object?>? 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,
};
}
/// <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>