diff --git a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs index eb0f9c9..2662f3f 100644 --- a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -145,6 +145,10 @@ public sealed class AuditWriteMiddleware OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiInbound, Kind = kind, + // Audit Log #23: a fresh per-request correlation id so the + // inbound row carries a request identifier (closes the design + // gap that inbound rows should be correlatable). + CorrelationId = Guid.NewGuid(), Actor = actor, Target = methodName, Status = status, diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs index 223d294..a056214 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs @@ -37,6 +37,7 @@ internal sealed class AuditingDbCommand : DbCommand private readonly string _siteId; private readonly string _instanceName; private readonly string? _sourceScript; + private readonly Guid _correlationId; private readonly ILogger _logger; private DbConnection? _wrappingConnection; @@ -47,6 +48,7 @@ internal sealed class AuditingDbCommand : DbCommand string siteId, string instanceName, string? sourceScript, + Guid correlationId, ILogger logger) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); @@ -55,6 +57,7 @@ internal sealed class AuditingDbCommand : DbCommand _siteId = siteId ?? string.Empty; _instanceName = instanceName ?? string.Empty; _sourceScript = sourceScript; + _correlationId = correlationId; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -426,7 +429,9 @@ internal sealed class AuditingDbCommand : DbCommand OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.DbOutbound, Kind = AuditKind.DbWrite, - CorrelationId = null, + // Audit Log #23: the execution-wide correlation id, so all the + // sync ApiCall/DbWrite rows from one script run share an id. + CorrelationId = _correlationId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs index a1e6121..33edf62 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs @@ -36,6 +36,7 @@ internal sealed class AuditingDbConnection : DbConnection private readonly string _siteId; private readonly string _instanceName; private readonly string? _sourceScript; + private readonly Guid _correlationId; private readonly ILogger _logger; public AuditingDbConnection( @@ -45,6 +46,7 @@ internal sealed class AuditingDbConnection : DbConnection string siteId, string instanceName, string? sourceScript, + Guid correlationId, ILogger logger) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); @@ -53,6 +55,7 @@ internal sealed class AuditingDbConnection : DbConnection _siteId = siteId ?? string.Empty; _instanceName = instanceName ?? string.Empty; _sourceScript = sourceScript; + _correlationId = correlationId; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -92,6 +95,7 @@ internal sealed class AuditingDbConnection : DbConnection _siteId, _instanceName, _sourceScript, + _correlationId, _logger); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 250dcc1..1708173 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -105,6 +105,21 @@ public class ScriptRuntimeContext /// private readonly ICachedCallTelemetryForwarder? _cachedForwarder; + /// + /// Audit Log #23: the execution-wide audit correlation id. Every sync + /// trust-boundary audit row emitted by this script execution + /// (ApiCall, DbWrite) is stamped with this id so all the + /// rows from one script run can be correlated together. + /// + private readonly Guid _correlationId; + + /// + /// Audit Log #23: the execution-wide audit correlation id. When omitted + /// (tag-change / timer-triggered executions) a fresh id is generated; an + /// inbound caller may supply one to tie the execution to an upstream + /// request. Stamped on the sync ApiCall/DbWrite audit rows + /// this execution emits. + /// public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, @@ -122,7 +137,8 @@ public class ScriptRuntimeContext string? sourceScript = null, IAuditWriter? auditWriter = null, IOperationTrackingStore? operationTrackingStore = null, - ICachedCallTelemetryForwarder? cachedForwarder = null) + ICachedCallTelemetryForwarder? cachedForwarder = null, + Guid? correlationId = null) { _instanceActor = instanceActor; _self = self; @@ -141,6 +157,7 @@ public class ScriptRuntimeContext _auditWriter = auditWriter; _operationTrackingStore = operationTrackingStore; _cachedForwarder = cachedForwarder; + _correlationId = correlationId ?? Guid.NewGuid(); } /// @@ -241,7 +258,7 @@ public class ScriptRuntimeContext /// ExternalSystem.CachedCall("systemName", "methodName", params) /// public ExternalSystemHelper ExternalSystem => new( - _externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript, + _externalSystemClient, _instanceName, _logger, _correlationId, _auditWriter, _siteId, _sourceScript, // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // on every ExternalSystem.CachedCall enqueue. _cachedForwarder); @@ -255,6 +272,7 @@ public class ScriptRuntimeContext _databaseGateway, _instanceName, _logger, + _correlationId, // 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 @@ -362,6 +380,7 @@ public class ScriptRuntimeContext private readonly IExternalSystemClient? _client; private readonly string _instanceName; private readonly ILogger _logger; + private readonly Guid _correlationId; private readonly IAuditWriter? _auditWriter; private readonly string _siteId; private readonly string? _sourceScript; @@ -374,6 +393,7 @@ public class ScriptRuntimeContext IExternalSystemClient? client, string instanceName, ILogger logger, + Guid correlationId, IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, @@ -382,6 +402,7 @@ public class ScriptRuntimeContext _client = client; _instanceName = instanceName; _logger = logger; + _correlationId = correlationId; _auditWriter = auditWriter; _siteId = siteId; _sourceScript = sourceScript; @@ -882,7 +903,9 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, - CorrelationId = null, + // Audit Log #23: the execution-wide correlation id, so all the + // sync ApiCall/DbWrite rows from one script run share an id. + CorrelationId = _correlationId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -949,6 +972,7 @@ public class ScriptRuntimeContext private readonly IDatabaseGateway? _gateway; private readonly string _instanceName; private readonly ILogger _logger; + private readonly Guid _correlationId; private readonly string _siteId; private readonly string? _sourceScript; private readonly ICachedCallTelemetryForwarder? _cachedForwarder; @@ -969,6 +993,7 @@ public class ScriptRuntimeContext IDatabaseGateway? gateway, string instanceName, ILogger logger, + Guid correlationId, IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, @@ -977,6 +1002,7 @@ public class ScriptRuntimeContext _gateway = gateway; _instanceName = instanceName; _logger = logger; + _correlationId = correlationId; _auditWriter = auditWriter; _siteId = siteId; _sourceScript = sourceScript; @@ -1011,6 +1037,7 @@ public class ScriptRuntimeContext siteId: _siteId, instanceName: _instanceName, sourceScript: _sourceScript, + correlationId: _correlationId, logger: _logger); } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs index 7c505a8..9399fb7 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs @@ -150,6 +150,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture + { + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + var evt = Assert.Single(writer.Events); + Assert.NotNull(evt.CorrelationId); + Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value); + } + + [Fact] + public async Task SeparateRequests_GetDistinct_CorrelationIds() + { + var writer = new RecordingAuditWriter(); + var mw = CreateMiddleware(hc => + { + hc.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(BuildContext()); + await mw.InvokeAsync(BuildContext()); + + Assert.Equal(2, writer.Events.Count); + Assert.NotEqual(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId); + } + [Fact] public async Task DurationMs_IsRecorded() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index 991bbaf..042a031 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -47,6 +47,9 @@ public class DatabaseCachedWriteEmissionTests gateway, InstanceName, NullLogger.Instance, + // Audit Log #23: execution-wide correlation id. Cached rows keep + // CorrelationId = TrackedOperationId, so any value works here. + Guid.NewGuid(), siteId: SiteId, sourceScript: SourceScript, cachedForwarder: forwarder); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs index 2f6f9df..40f2986 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs @@ -48,14 +48,28 @@ public class DatabaseSyncEmissionTests private const string SourceScript = "ScriptActor:Sync"; private const string ConnectionName = "machineData"; + /// + /// Audit Log #23: a fixed execution-wide correlation id used by the + /// default + /// overload so assertions can compare against a known value. + /// + private static readonly Guid TestCorrelationId = Guid.NewGuid(); + private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter) + => CreateHelper(gateway, auditWriter, TestCorrelationId); + + private static ScriptRuntimeContext.DatabaseHelper CreateHelper( + IDatabaseGateway gateway, + IAuditWriter? auditWriter, + Guid correlationId) { return new ScriptRuntimeContext.DatabaseHelper( gateway, InstanceName, NullLogger.Instance, + correlationId, auditWriter: auditWriter, siteId: SiteId, sourceScript: SourceScript, @@ -268,10 +282,34 @@ public class DatabaseSyncEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - Assert.Null(evt.CorrelationId); + // Audit Log #23: the sync DbWrite row now carries the execution-wide + // correlation id the helper was constructed with. + Assert.Equal(TestCorrelationId, evt.CorrelationId); Assert.NotEqual(Guid.Empty, evt.EventId); } + [Fact] + public async Task SyncDbWrite_StampsExecutionCorrelationId() + { + using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared"); + var inner = NewInMemoryDb(out var _); + var gateway = new Mock(); + gateway + .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) + .ReturnsAsync(inner); + var writer = new CapturingAuditWriter(); + var correlationId = Guid.NewGuid(); + + var helper = CreateHelper(gateway.Object, writer, correlationId); + await using var conn = await helper.Connection(ConnectionName); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')"; + await cmd.ExecuteNonQueryAsync(); + + var evt = Assert.Single(writer.Events); + Assert.Equal(correlationId, evt.CorrelationId); + } + [Fact] public async Task DurationMs_NonZero() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 7516822..f2edfa8 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -49,6 +49,9 @@ public class ExternalSystemCachedCallEmissionTests client, InstanceName, NullLogger.Instance, + // Audit Log #23: execution-wide correlation id. Cached rows keep + // CorrelationId = TrackedOperationId, so any value works here. + Guid.NewGuid(), auditWriter: null, siteId: SiteId, sourceScript: SourceScript, diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index d021181..209946e 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -45,14 +45,28 @@ public class ExternalSystemCallAuditEmissionTests private const string InstanceName = "Plant.Pump42"; private const string SourceScript = "ScriptActor:CheckPressure"; + /// + /// Audit Log #23: a fixed execution-wide correlation id used by the + /// default + /// overload so assertions can compare against a known value. + /// + private static readonly Guid TestCorrelationId = Guid.NewGuid(); + private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter) + => CreateHelper(client, auditWriter, TestCorrelationId); + + private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( + IExternalSystemClient client, + IAuditWriter? auditWriter, + Guid correlationId) { return new ScriptRuntimeContext.ExternalSystemHelper( client, InstanceName, NullLogger.Instance, + correlationId, auditWriter, SiteId, SourceScript); @@ -211,7 +225,47 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - Assert.Null(evt.CorrelationId); + // Audit Log #23: the sync ApiCall row now carries the execution-wide + // correlation id the helper was constructed with. + Assert.Equal(TestCorrelationId, evt.CorrelationId); + } + + [Fact] + public async Task Call_SyncApiCall_StampsExecutionCorrelationId() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + var correlationId = Guid.NewGuid(); + + var helper = CreateHelper(client.Object, writer, correlationId); + await helper.Call("ERP", "GetOrder"); + + var evt = Assert.Single(writer.Events); + Assert.Equal(correlationId, evt.CorrelationId); + } + + [Fact] + public async Task Call_TwoCallsOnSameHelper_ShareTheSameCorrelationId() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync(It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + var correlationId = Guid.NewGuid(); + + var helper = CreateHelper(client.Object, writer, correlationId); + await helper.Call("ERP", "GetOrder"); + await helper.Call("ERP", "GetCustomer"); + + Assert.Equal(2, writer.Events.Count); + // Both sync ApiCall rows from one execution carry the same id. + Assert.Equal(correlationId, writer.Events[0].CorrelationId); + Assert.Equal(correlationId, writer.Events[1].CorrelationId); + Assert.Equal(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId); } [Fact]