feat(logging): carry ExecutionId across actor hops onto other actors' log lines (ScadaBridge#56)

LogContext is an AsyncLocal and does not cross an Akka message hop, so the run's id now rides the
message: Commons.Observability.IExecutionCorrelated (explicit interface member, no wire change) on
SetStaticAttributeCommand, AttributeValueChanged (not for an inferred writer), ScriptCallRequest and
the four RouteTo*Requests, plus a new additive SourceExecutionId on GetAttributeRequest,
WaitForAttributeRequest, WriteAttributeBatchRequest, WriteTagRequest and WriteTagBatchRequest.
ScriptRuntimeContext stamps its id on the requests it sends, the Instance Actor hands it on to the
DCL, and the Instance, Script, Alarm, Deployment Manager and both DCL actors re-push it per message in
AroundReceive (ExecutionLogContext.PushFor: one type test when the message names no execution). The
inbound executor pushes the request's ExecutionId for the method execution.

Tests (TestKit): Instance Actor line carries the message's id, no leak from the sender's ambient
context, scope ends with the message, per-write ids inside a batched SetAttributes, ids forwarded on
DCL writes, ScriptRuntimeContext stamps; DCL adapter lines on single/batch writes carry the id;
inbound executor lines carry the request id; Commons mapping + no serialized member. Each mutation-
proved. Still open on #56: a queryable per-execution sink and a Flow Debugger section. The DCL
actors' own Akka _log lines bypass Serilog (filed #120).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 05:28:20 -05:00
parent 1ccd6cfe33
commit edd0ca672f
28 changed files with 905 additions and 33 deletions
@@ -0,0 +1,79 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// ScadaBridge#56: an inbound API method request stamps its <c>ExecutionId</c> on the Serilog
/// <c>LogContext</c> for the whole execution, so a line logged anywhere on the request's async flow
/// (the method script's callees - route, database, communication) carries it.
/// </summary>
public class InboundScriptExecutorLogContextTests
{
private sealed class CaptureSink : ILogEventSink
{
public ConcurrentQueue<LogEvent> Events { get; } = new();
public void Emit(LogEvent logEvent) => Events.Enqueue(logEvent);
}
private readonly CaptureSink _sink = new();
private readonly Serilog.ILogger _log;
private readonly InboundScriptExecutor _executor =
new(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
private readonly RouteHelper _route = new(Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
private readonly ApiMethod _method = new("logs", "x") { Id = 1, TimeoutSeconds = 10 };
/// <summary>Registers a handler that logs one line from inside the method execution.</summary>
public InboundScriptExecutorLogContextTests()
{
_log = new LoggerConfiguration().Enrich.FromLogContext().WriteTo.Sink(_sink).CreateLogger();
_executor.RegisterHandler("logs", async _ =>
{
await Task.Yield(); // the line is written after an await, on a continuation
_log.Information("inside the method");
return null;
});
}
private static Guid? ExecutionIdOf(LogEvent evt)
=> evt.Properties.TryGetValue(ExecutionLogContext.ExecutionIdProperty, out var v)
? Guid.Parse(v.ToString().Trim('"'))
: null;
/// <summary>A line written during the execution carries the request's id; one written after does not.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LinesDuringTheExecution_CarryTheRequestsExecutionId()
{
var executionId = Guid.NewGuid();
var result = await _executor.ExecuteAsync(_method, new Dictionary<string, object?>(), _route,
TimeSpan.FromSeconds(10), parentExecutionId: executionId);
_log.Information("after the method");
Assert.True(result.Success, result.ErrorMessage);
var events = _sink.Events.ToList();
Assert.Equal(2, events.Count);
Assert.Equal(executionId, ExecutionIdOf(events[0]));
Assert.Null(ExecutionIdOf(events[1]));
}
/// <summary>A request with no execution id (outside the inbound flow) logs untagged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoExecutionId_LinesAreUntagged()
{
var result = await _executor.ExecuteAsync(_method, new Dictionary<string, object?>(), _route,
TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Null(ExecutionIdOf(Assert.Single(_sink.Events)));
}
}