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,46 @@
using Serilog.Context;
namespace ZB.MOM.WW.ScadaBridge.Commons.Observability;
/// <summary>
/// Stamps an execution id onto the Serilog <c>LogContext</c> (ScadaBridge#56), so every line
/// logged inside the scope - through Serilog directly or through a Microsoft
/// <c>ILogger</c> backed by Serilog (<c>UseSerilog</c>) - carries an <c>ExecutionId</c>
/// property once the host enables <c>Enrich.FromLogContext()</c>.
/// </summary>
/// <remarks>
/// <para><b>Why per message.</b> <c>LogContext</c> is an <c>AsyncLocal</c>: it follows one
/// async call chain but does not cross an Akka message hop (the dispatcher does not flow the
/// sender's execution context into the receiver). An actor that serves an execution therefore
/// re-establishes the id from the message itself, for the duration of that one message:
/// <code>
/// protected override bool AroundReceive(Receive receive, object message)
/// {
/// using (ExecutionLogContext.PushFor(message))
/// return base.AroundReceive(receive, message);
/// }
/// </code>
/// Continuations the handler starts (<c>ContinueWith</c>, <c>PipeTo</c>, an adapter call)
/// capture the context at creation, so their lines carry the id too.</para>
/// <para><b>Cost.</b> A message that is not <see cref="IExecutionCorrelated"/>, or names no
/// execution, costs one type test and allocates nothing.</para>
/// <para><b>Nesting.</b> A later push of the same property wins, so a child run started while
/// serving its parent's message logs under its OWN id (the launcher pushes it).</para>
/// </remarks>
public static class ExecutionLogContext
{
/// <summary>The log-event property carrying the execution id.</summary>
public const string ExecutionIdProperty = "ExecutionId";
/// <summary>Pushes <paramref name="executionId"/> as <see cref="ExecutionIdProperty"/>.</summary>
/// <param name="executionId">The execution id; null pushes nothing.</param>
/// <returns>A disposable that pops the property, or null when nothing was pushed.</returns>
public static IDisposable? Push(Guid? executionId)
=> executionId is { } id ? LogContext.PushProperty(ExecutionIdProperty, id) : null;
/// <summary>Pushes the execution a message serves, when it names one.</summary>
/// <param name="message">Any actor message.</param>
/// <returns>A disposable that pops the property, or null when the message names no execution.</returns>
public static IDisposable? PushFor(object? message)
=> message is IExecutionCorrelated correlated ? Push(correlated.CorrelatedExecutionId) : null;
}