feat(siteruntime): emit DbOutbound.DbWrite on sync Database.Execute*/ExecuteReader (#23 M4)
Audit Log #23 — M4 Bundle A (Tasks A1+A2): every script-initiated synchronous DB call routed through Database.Connection(name) now emits exactly one DbOutbound/DbWrite audit row. Implementation — three thin ADO.NET decorators in src/ScadaLink.SiteRuntime/Scripts/: - AuditingDbConnection: wraps the gateway-returned DbConnection so CreateDbCommand() hands the script an AuditingDbCommand. All other ADO.NET surface forwards unchanged. - AuditingDbCommand: intercepts ExecuteNonQuery / ExecuteScalar / ExecuteReader (sync + async). On terminal: Channel = DbOutbound, Kind = DbWrite, Status = Delivered|Failed, Extra = {"op":"write","rowsAffected":N} (Execute*), {"op":"read","rowsReturned":N} (ExecuteReader), RequestSummary = JSON of SQL + parameter values (default capture; redaction in M5), Target = "<connection>.<first 60 chars of SQL>", DurationMs captured via Stopwatch, Provenance from ScriptRuntimeContext (SourceSiteId, SourceInstanceId, SourceScript). - AuditingDbDataReader: counts rows on Read/ReadAsync and fires the audit emission exactly once on Close/CloseAsync/Dispose. DatabaseHelper now takes an IAuditWriter; ScriptRuntimeContext.Database threads through _auditWriter. When the writer is null (tests / minimal hosts) Connection() returns the raw inner DbConnection unchanged. Best-effort emission (alog.md §7): mirrors M2 Bundle F's 3-layer fail-safe — build, write, continuation. Audit-build, audit-write, and audit-continuation faults are logged + swallowed; the original ADO.NET result (or original exception) flows back to the script untouched. The SiteAuditWriteFailures counter increments automatically through the existing FallbackAuditWriter (Bundle G). Tests — tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs (7 new, all passing): 1. Execute / INSERT success — one DbWrite row, op=write, rowsAffected=1. 2. ExecuteScalar success — one DbWrite row, op=write. 3. Execute throws — Status=Failed, ErrorMessage + ErrorDetail set. 4. ExecuteReader success — op=read, rowsReturned counts rows pulled. 5. AuditWriter throws — original ADO.NET rowsAffected returned, no events captured, no exception propagates. 6. Provenance populated from context. 7. DurationMs recorded non-zero. Tests use Microsoft.Data.Sqlite in-memory (already transitively available via SiteRuntime). Total SiteRuntime test suite: 251 passing (244 baseline + 7 new). Full solution test suite passes.
This commit is contained in:
@@ -252,7 +252,16 @@ public class ScriptRuntimeContext
|
||||
/// Database.CachedWrite("name", "sql", params)
|
||||
/// </summary>
|
||||
public DatabaseHelper Database => new(
|
||||
_databaseGateway, _instanceName, _logger, _siteId, _sourceScript,
|
||||
_databaseGateway,
|
||||
_instanceName,
|
||||
_logger,
|
||||
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
|
||||
// Database.Connection(name) returns an auditing decorator that
|
||||
// emits one DbOutbound/DbWrite row per script-initiated
|
||||
// Execute / ExecuteScalar / ExecuteReader.
|
||||
_auditWriter,
|
||||
_siteId,
|
||||
_sourceScript,
|
||||
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
|
||||
// every Database.CachedWrite enqueue.
|
||||
_cachedForwarder);
|
||||
@@ -894,10 +903,23 @@ public class ScriptRuntimeContext
|
||||
private readonly string? _sourceScript;
|
||||
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M4 Bundle A): best-effort emitter for synchronous
|
||||
/// <c>Database.Connection</c>-routed Execute / ExecuteScalar /
|
||||
/// ExecuteReader calls. When wired, <see cref="Connection"/> returns
|
||||
/// an <see cref="AuditingDbConnection"/> that intercepts each command
|
||||
/// execution and writes one <c>DbOutbound</c>/<c>DbWrite</c> audit
|
||||
/// row. Optional — when null the helper falls back to the raw
|
||||
/// inner <see cref="System.Data.Common.DbConnection"/> the gateway
|
||||
/// returns (tests / minimal hosts that don't wire audit).
|
||||
/// </summary>
|
||||
private readonly IAuditWriter? _auditWriter;
|
||||
|
||||
internal DatabaseHelper(
|
||||
IDatabaseGateway? gateway,
|
||||
string instanceName,
|
||||
ILogger logger,
|
||||
IAuditWriter? auditWriter = null,
|
||||
string siteId = "",
|
||||
string? sourceScript = null,
|
||||
ICachedCallTelemetryForwarder? cachedForwarder = null)
|
||||
@@ -905,6 +927,7 @@ public class ScriptRuntimeContext
|
||||
_gateway = gateway;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
_auditWriter = auditWriter;
|
||||
_siteId = siteId;
|
||||
_sourceScript = sourceScript;
|
||||
_cachedForwarder = cachedForwarder;
|
||||
@@ -917,7 +940,28 @@ public class ScriptRuntimeContext
|
||||
if (_gateway == null)
|
||||
throw new InvalidOperationException("Database gateway not available");
|
||||
|
||||
return await _gateway.GetConnectionAsync(name, cancellationToken);
|
||||
var inner = await _gateway.GetConnectionAsync(name, cancellationToken);
|
||||
|
||||
// Audit Log #23 (M4 Bundle A): wrap in an auditing decorator so
|
||||
// every script-initiated Execute* / ExecuteReader on the returned
|
||||
// connection emits one DbOutbound/DbWrite audit row. The wrapper
|
||||
// delegates all other ADO.NET behaviour to the inner connection
|
||||
// unchanged — including disposal, so the caller's existing
|
||||
// dispose pattern (await using var conn = ...) still releases
|
||||
// the underlying connection to the pool.
|
||||
if (_auditWriter == null)
|
||||
{
|
||||
return inner;
|
||||
}
|
||||
|
||||
return new AuditingDbConnection(
|
||||
inner,
|
||||
_auditWriter,
|
||||
connectionName: name,
|
||||
siteId: _siteId,
|
||||
instanceName: _instanceName,
|
||||
sourceScript: _sourceScript,
|
||||
logger: _logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user