feat(site-runtime): ScriptRuntimeContext emits ScriptTrace AttributeWrite/AttributeWait rows and inherits the Trace flag
Flow Debugger (design 2026-09-05 §5.1/§5.2): the context is the single funnel for SetAttribute / WriteBatchAndWait / WaitInternal, so it emits the write/wait rows (collector first, then the best-effort IAuditWriter whose redactor applies the payload cap), stamps Trace on SetStaticAttributeCommand and ScriptCallRequest, and hands the flag + sink to the inline CallShared child context. Untraced contexts emit nothing (pinned). Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||||
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||||
@@ -164,6 +165,20 @@ public class ScriptRuntimeContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Guid? _parentExecutionId;
|
private readonly Guid? _parentExecutionId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flow Debugger (design 2026-09-05 §5.2): true when this run is traced — the
|
||||||
|
/// <c>Trace</c> flag rode in beside <c>ParentExecutionId</c> on the spawning message
|
||||||
|
/// (or <c>ApiMethod.TraceAllCalls</c> set it at the root). Gates every
|
||||||
|
/// <c>ScriptTrace</c> row this context emits and is inherited by nested runs.
|
||||||
|
/// </summary>
|
||||||
|
private readonly bool _isTraced;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flow Debugger (design §5.4): the per-run inline sink the launcher attaches to
|
||||||
|
/// <c>ScriptCallResult.TraceEvents</c>. Null when untraced.
|
||||||
|
/// </summary>
|
||||||
|
private readonly FlowTraceCollector? _traceCollector;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new script runtime context with all dependencies for script
|
/// Initializes a new script runtime context with all dependencies for script
|
||||||
/// execution, external system calls, database access, and notification delivery.
|
/// execution, external system calls, database access, and notification delivery.
|
||||||
@@ -213,6 +228,8 @@ public class ScriptRuntimeContext
|
|||||||
/// <see cref="CancellationToken.None"/> for callers / tests that do not
|
/// <see cref="CancellationToken.None"/> for callers / tests that do not
|
||||||
/// thread one — those waits are bounded only by their own timeout.
|
/// thread one — those waits are bounded only by their own timeout.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="isTraced">Flow Debugger trace flag — see design 2026-09-05 §5.2. Defaults to untraced.</param>
|
||||||
|
/// <param name="traceCollector">Flow Debugger per-run inline trace sink (design §5.4); null when untraced.</param>
|
||||||
public ScriptRuntimeContext(
|
public ScriptRuntimeContext(
|
||||||
IActorRef instanceActor,
|
IActorRef instanceActor,
|
||||||
SharedScriptLibrary sharedScriptLibrary,
|
SharedScriptLibrary sharedScriptLibrary,
|
||||||
@@ -234,7 +251,9 @@ public class ScriptRuntimeContext
|
|||||||
Guid? parentExecutionId = null,
|
Guid? parentExecutionId = null,
|
||||||
string? sourceNode = null,
|
string? sourceNode = null,
|
||||||
ISiteEventLogger? siteEventLogger = null,
|
ISiteEventLogger? siteEventLogger = null,
|
||||||
CancellationToken scriptTimeoutToken = default)
|
CancellationToken scriptTimeoutToken = default,
|
||||||
|
bool isTraced = false,
|
||||||
|
FlowTraceCollector? traceCollector = null)
|
||||||
{
|
{
|
||||||
_instanceActor = instanceActor;
|
_instanceActor = instanceActor;
|
||||||
_sharedScriptLibrary = sharedScriptLibrary;
|
_sharedScriptLibrary = sharedScriptLibrary;
|
||||||
@@ -266,8 +285,13 @@ public class ScriptRuntimeContext
|
|||||||
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
|
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
|
||||||
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
|
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
|
||||||
_scriptTimeoutToken = scriptTimeoutToken;
|
_scriptTimeoutToken = scriptTimeoutToken;
|
||||||
|
_isTraced = isTraced;
|
||||||
|
_traceCollector = traceCollector;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Flow Debugger (design 2026-09-05 §5.2): whether this run emits <c>ScriptTrace</c> rows.</summary>
|
||||||
|
public bool IsTraced => _isTraced;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This run's own per-execution id. Exposed so a
|
/// This run's own per-execution id. Exposed so a
|
||||||
/// nested <c>Scripts.CallShared</c> can record it as the spawned shared
|
/// nested <c>Scripts.CallShared</c> can record it as the spawned shared
|
||||||
@@ -322,7 +346,69 @@ public class ScriptRuntimeContext
|
|||||||
// WaitForAttribute (spec §4.3): an inline shared-script call shares the
|
// WaitForAttribute (spec §4.3): an inline shared-script call shares the
|
||||||
// parent run's execution-timeout token so a WaitAsync inside the shared
|
// parent run's execution-timeout token so a WaitAsync inside the shared
|
||||||
// script is bounded by the SAME script deadline.
|
// script is bounded by the SAME script deadline.
|
||||||
scriptTimeoutToken: _scriptTimeoutToken);
|
scriptTimeoutToken: _scriptTimeoutToken,
|
||||||
|
// Flow Debugger (design §5.2): a traced root traces its whole tree — the inline
|
||||||
|
// shared script shares the same flag AND the same inline sink.
|
||||||
|
isTraced: _isTraced,
|
||||||
|
traceCollector: _traceCollector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flow Debugger (design 2026-09-05 §5.1): emits one <c>ScriptTrace</c> audit row for a
|
||||||
|
/// traced run — into the inline collector first (so a routed call can return it) and
|
||||||
|
/// then through the best-effort <see cref="IAuditWriter"/>. No-op when untraced, never
|
||||||
|
/// throws into the script, and hands raw payloads to the writer whose redactor applies
|
||||||
|
/// the configured size cap (§9) exactly as the other channels do.
|
||||||
|
/// </summary>
|
||||||
|
private void EmitTrace(
|
||||||
|
AuditKind kind, AuditStatus status, string target, DateTime startedUtc, int durationMs,
|
||||||
|
string? input, string? output, string? error, ScriptTraceExtra? extra)
|
||||||
|
{
|
||||||
|
if (!_isTraced) return;
|
||||||
|
AuditEvent evt;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
|
AuditChannel.ScriptTrace, kind, status,
|
||||||
|
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
|
||||||
|
actor: _sourceScript, target: target, sourceNode: _sourceNode,
|
||||||
|
executionId: _executionId, parentExecutionId: _parentExecutionId,
|
||||||
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
|
sourceInstanceId: _instanceName, sourceScript: _sourceScript,
|
||||||
|
durationMs: durationMs, errorMessage: error,
|
||||||
|
requestSummary: input, responseSummary: output,
|
||||||
|
extra: extra?.ToJson());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Flow trace event build failed for {Kind}", kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_traceCollector?.Add(evt);
|
||||||
|
if (_auditWriter is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var t = _auditWriter.WriteAsync(evt, CancellationToken.None);
|
||||||
|
if (!t.IsCompletedSuccessfully)
|
||||||
|
{
|
||||||
|
_ = t.ContinueWith(
|
||||||
|
x => _logger.LogWarning(x.Exception, "Flow trace write failed for {Kind}", kind),
|
||||||
|
TaskContinuationOptions.OnlyOnFaulted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Flow trace write failed for {Kind}", kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialises a small trace payload; null on failure (best-effort, like the audit helpers).</summary>
|
||||||
|
private static string? TraceJson(object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
try { return JsonSerializer.Serialize(value, ScriptTraceExtra.JsonOptions); }
|
||||||
|
catch (Exception) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -477,9 +563,12 @@ public class ScriptRuntimeContext
|
|||||||
cid, _instanceName, name, targetValueEncoded, predicate, timeout, DateTimeOffset.UtcNow,
|
cid, _instanceName, name, targetValueEncoded, predicate, timeout, DateTimeOffset.UtcNow,
|
||||||
requireGoodQuality);
|
requireGoodQuality);
|
||||||
|
|
||||||
|
var startedUtc = DateTime.UtcNow;
|
||||||
|
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
||||||
|
WaitForAttributeResponse response;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _instanceActor.Ask<WaitForAttributeResponse>(
|
response = await _instanceActor.Ask<WaitForAttributeResponse>(
|
||||||
req, timeout + _askTimeout, _scriptTimeoutToken);
|
req, timeout + _askTimeout, _scriptTimeoutToken);
|
||||||
}
|
}
|
||||||
catch (AskTimeoutException)
|
catch (AskTimeoutException)
|
||||||
@@ -491,9 +580,47 @@ public class ScriptRuntimeContext
|
|||||||
// OperationCanceledException / TaskCanceledException from the
|
// OperationCanceledException / TaskCanceledException from the
|
||||||
// script-deadline token are deliberately NOT caught here: they must
|
// script-deadline token are deliberately NOT caught here: they must
|
||||||
// propagate to abort the script (§4.3).
|
// propagate to abort the script (§4.3).
|
||||||
return new WaitForAttributeResponse(
|
response = new WaitForAttributeResponse(
|
||||||
cid, Matched: false, null, null, TimedOut: true);
|
cid, Matched: false, null, null, TimedOut: true);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Script deadline hit mid-wait: record the cancelled wait for a traced run, then
|
||||||
|
// let the cancellation propagate exactly as before (§4.3).
|
||||||
|
if (sw is not null)
|
||||||
|
{
|
||||||
|
EmitTrace(AuditKind.AttributeWait, AuditStatus.Failed, name, startedUtc,
|
||||||
|
(int)sw.ElapsedMilliseconds, null, null, "Wait cancelled by the script deadline",
|
||||||
|
new ScriptTraceExtra
|
||||||
|
{
|
||||||
|
WaitAttribute = name, WaitTarget = targetValueEncoded ?? (predicate is null ? null : "<predicate>"),
|
||||||
|
WaitTimeoutMs = (int)timeout.TotalMilliseconds, WaitOutcome = "cancelled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sw is not null)
|
||||||
|
{
|
||||||
|
var outcome = response.Matched ? "satisfied" : response.TimedOut ? "timeout" : "error";
|
||||||
|
var observed = AttributeValueCodec.Encode(response.Value);
|
||||||
|
EmitTrace(AuditKind.AttributeWait,
|
||||||
|
response.Matched ? AuditStatus.Delivered : AuditStatus.Failed,
|
||||||
|
name, startedUtc, (int)sw.ElapsedMilliseconds,
|
||||||
|
input: TraceJson(new { attribute = name, target = targetValueEncoded, timeoutMs = (int)timeout.TotalMilliseconds, requireGoodQuality }),
|
||||||
|
output: TraceJson(new { matched = response.Matched, timedOut = response.TimedOut, value = observed, quality = response.Quality }),
|
||||||
|
error: response.Matched ? null : response.ErrorMessage ?? (response.TimedOut ? $"Wait for '{name}' timed out after {timeout.TotalSeconds}s" : null),
|
||||||
|
extra: new ScriptTraceExtra
|
||||||
|
{
|
||||||
|
WaitAttribute = name,
|
||||||
|
WaitTarget = targetValueEncoded ?? (predicate is null ? null : "<predicate>"),
|
||||||
|
WaitTimeoutMs = (int)timeout.TotalMilliseconds,
|
||||||
|
WaitOutcome = outcome,
|
||||||
|
ObservedValue = observed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -519,11 +646,41 @@ public class ScriptRuntimeContext
|
|||||||
// ExecutionId as the write's originating execution. The Instance Actor
|
// ExecutionId as the write's originating execution. The Instance Actor
|
||||||
// carries it onto the published AttributeValueChanged, so an alarm this
|
// carries it onto the published AttributeValueChanged, so an alarm this
|
||||||
// write trips records this run as the on-trigger script's parent.
|
// write trips records this run as the on-trigger script's parent.
|
||||||
SourceExecutionId: _executionId);
|
SourceExecutionId: _executionId,
|
||||||
|
// Flow Debugger (design §5.2/§5.3): a traced run's write arms the instance
|
||||||
|
// actor's echo stamp and rides onto the published AttributeValueChanged.
|
||||||
|
Trace: _isTraced);
|
||||||
|
|
||||||
// Ask — mutation serialized through the Instance Actor mailbox; the reply
|
var startedUtc = DateTime.UtcNow;
|
||||||
// carries the device-write outcome for data-connected attributes.
|
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
||||||
var response = await _instanceActor.Ask<SetStaticAttributeResponse>(command, _askTimeout);
|
SetStaticAttributeResponse response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Ask — mutation serialized through the Instance Actor mailbox; the reply
|
||||||
|
// carries the device-write outcome for data-connected attributes.
|
||||||
|
response = await _instanceActor.Ask<SetStaticAttributeResponse>(command, _askTimeout);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (sw is not null)
|
||||||
|
{
|
||||||
|
EmitTrace(AuditKind.AttributeWrite, AuditStatus.Failed, attributeName, startedUtc,
|
||||||
|
(int)sw.ElapsedMilliseconds, TraceJson(new { attribute = attributeName, value }), null, ex.Message,
|
||||||
|
new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attributeName, value, false, ex.Message)] });
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sw is not null)
|
||||||
|
{
|
||||||
|
EmitTrace(AuditKind.AttributeWrite,
|
||||||
|
response.Success ? AuditStatus.Delivered : AuditStatus.Failed,
|
||||||
|
attributeName, startedUtc, (int)sw.ElapsedMilliseconds,
|
||||||
|
input: TraceJson(new { attribute = attributeName, value }),
|
||||||
|
output: null,
|
||||||
|
error: response.ErrorMessage,
|
||||||
|
extra: new ScriptTraceExtra
|
||||||
|
{
|
||||||
|
Writes = [new AttributeWriteDetail(attributeName, value, response.Success, response.ErrorMessage)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.Success)
|
if (!response.Success)
|
||||||
{
|
{
|
||||||
@@ -560,9 +717,28 @@ public class ScriptRuntimeContext
|
|||||||
// 35s: the InstanceActor's DCL Ask is internally bounded at 30s, so allow a small
|
// 35s: the InstanceActor's DCL Ask is internally bounded at 30s, so allow a small
|
||||||
// margin so the DCL's own typed failure/timeout reply is the one we observe rather
|
// margin so the DCL's own typed failure/timeout reply is the one we observe rather
|
||||||
// than an AskTimeoutException here. Honors the script execution-timeout token.
|
// than an AskTimeoutException here. Honors the script execution-timeout token.
|
||||||
|
var startedUtc = DateTime.UtcNow;
|
||||||
|
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
||||||
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
|
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
|
||||||
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
|
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
|
||||||
|
|
||||||
|
if (sw is not null)
|
||||||
|
{
|
||||||
|
// Flow Debugger (design §5.1): one AttributeWrite row per batch — every value plus the
|
||||||
|
// trigger — with the batch's single outcome on each write.
|
||||||
|
var writes = encodedValues
|
||||||
|
.Select(kv => new AttributeWriteDetail(kv.Key, kv.Value, batchResp.Success, batchResp.ErrorMessage))
|
||||||
|
.Append(new AttributeWriteDetail(triggerAttr, triggerEncoded, batchResp.Success, batchResp.ErrorMessage))
|
||||||
|
.ToList();
|
||||||
|
EmitTrace(AuditKind.AttributeWrite,
|
||||||
|
batchResp.Success ? AuditStatus.Delivered : AuditStatus.Failed,
|
||||||
|
triggerAttr, startedUtc, (int)sw.ElapsedMilliseconds,
|
||||||
|
input: TraceJson(new { values = encodedValues, trigger = triggerAttr, triggerValue = triggerEncoded }),
|
||||||
|
output: null,
|
||||||
|
error: batchResp.ErrorMessage,
|
||||||
|
extra: new ScriptTraceExtra { TriggerAttribute = triggerAttr, TriggerValue = triggerEncoded, Writes = writes });
|
||||||
|
}
|
||||||
|
|
||||||
if (!batchResp.Success)
|
if (!batchResp.Success)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
|
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
|
||||||
@@ -605,7 +781,9 @@ public class ScriptRuntimeContext
|
|||||||
// So A → CallScript(B) yields B.ParentExecutionId == A.ExecutionId,
|
// So A → CallScript(B) yields B.ParentExecutionId == A.ExecutionId,
|
||||||
// building a true multi-level execution tree rather than flattening
|
// building a true multi-level execution tree rather than flattening
|
||||||
// every nested call under the original inbound spawner.
|
// every nested call under the original inbound spawner.
|
||||||
ParentExecutionId: _executionId);
|
ParentExecutionId: _executionId,
|
||||||
|
// Flow Debugger (design §5.2): nested runs inherit the trace flag.
|
||||||
|
Trace: _isTraced);
|
||||||
|
|
||||||
// Ask the Instance Actor, which routes to the appropriate Script Actor
|
// Ask the Instance Actor, which routes to the appropriate Script Actor
|
||||||
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flow Debugger design 2026-09-05 §5.1/§5.2: a traced <see cref="ScriptRuntimeContext"/>
|
||||||
|
/// emits <c>ScriptTrace</c> rows for attribute writes and waits (into the inline collector
|
||||||
|
/// AND the audit writer), stamps <c>Trace</c> on the commands it sends, and inherits the flag
|
||||||
|
/// into nested calls. An untraced context is byte-for-byte the pre-feature behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||||
|
{
|
||||||
|
private const string InstanceName = "Plant.Pump42";
|
||||||
|
|
||||||
|
private sealed class CapturingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
lock (Events) Events.Add(evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SharedScriptLibrary NewLibrary() =>
|
||||||
|
new(new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
|
||||||
|
NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
|
||||||
|
private static ScriptRuntimeContext CreateContext(
|
||||||
|
IActorRef instanceActor, IAuditWriter? writer, bool traced, FlowTraceCollector? collector,
|
||||||
|
Guid? executionId = null, Guid? parentExecutionId = null)
|
||||||
|
=> new(
|
||||||
|
instanceActor, NewLibrary(),
|
||||||
|
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
|
||||||
|
instanceName: InstanceName, logger: NullLogger.Instance,
|
||||||
|
siteId: "site-a", sourceScript: "ScriptActor:A", auditWriter: writer,
|
||||||
|
executionId: executionId, parentExecutionId: parentExecutionId, sourceNode: "node-a",
|
||||||
|
isTraced: traced, traceCollector: collector);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var exec = Guid.NewGuid();
|
||||||
|
var parent = Guid.NewGuid();
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: true, collector, exec, parent);
|
||||||
|
|
||||||
|
var call = ctx.SetAttribute("Setpoint", "42");
|
||||||
|
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.True(cmd.Trace);
|
||||||
|
Assert.Equal(exec, cmd.SourceExecutionId);
|
||||||
|
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
|
||||||
|
await call;
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
var row = evt.AsRow();
|
||||||
|
Assert.Equal("ScriptTrace", evt.Category);
|
||||||
|
Assert.Equal("ScriptTrace.AttributeWrite", evt.Action);
|
||||||
|
Assert.Equal(exec, row.ExecutionId);
|
||||||
|
Assert.Equal(parent, row.ParentExecutionId);
|
||||||
|
Assert.Equal("site-a", row.SourceSiteId);
|
||||||
|
Assert.Equal("node-a", evt.SourceNode);
|
||||||
|
Assert.Equal("Setpoint", evt.Target);
|
||||||
|
Assert.Equal(AuditOutcome.Success, evt.Outcome);
|
||||||
|
var extra = ScriptTraceExtra.TryParse(row.Extra);
|
||||||
|
var write = Assert.Single(extra!.Writes!);
|
||||||
|
Assert.Equal(("Setpoint", "42", true), (write.Attribute, write.Written, write.Success));
|
||||||
|
Assert.Contains("\"value\":\"42\"", row.RequestSummary);
|
||||||
|
|
||||||
|
Assert.Same(evt, Assert.Single(collector.Snapshot()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAttribute_WhenNotTraced_EmitsNothing_AndCommandTraceFalse()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: false, collector: null);
|
||||||
|
|
||||||
|
var call = ctx.SetAttribute("Setpoint", "42");
|
||||||
|
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.False(cmd.Trace);
|
||||||
|
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
|
||||||
|
await call;
|
||||||
|
|
||||||
|
Assert.Empty(writer.Events);
|
||||||
|
Assert.False(ctx.IsTraced);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: true, new FlowTraceCollector());
|
||||||
|
|
||||||
|
var call = ctx.SetAttribute("Setpoint", "42");
|
||||||
|
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", false, "device rejected", DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => call);
|
||||||
|
var row = Assert.Single(writer.Events).AsRow();
|
||||||
|
Assert.Equal("Failed", row.Status.ToString());
|
||||||
|
Assert.Equal("device rejected", row.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitInternal_WhenTraced_EmitsAttributeWait_WithOutcomeSatisfied()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
|
||||||
|
|
||||||
|
var wait = ctx.WaitAttributeFull("Processed", "true", null, TimeSpan.FromSeconds(3));
|
||||||
|
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: true, true, "Good", TimedOut: false));
|
||||||
|
var result = await wait;
|
||||||
|
|
||||||
|
Assert.True(result.Matched);
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal("ScriptTrace.AttributeWait", evt.Action);
|
||||||
|
Assert.Equal("Processed", evt.Target);
|
||||||
|
var extra = ScriptTraceExtra.TryParse(evt.AsRow().Extra)!;
|
||||||
|
Assert.Equal("satisfied", extra.WaitOutcome);
|
||||||
|
Assert.Equal("Processed", extra.WaitAttribute);
|
||||||
|
Assert.Equal("true", extra.WaitTarget);
|
||||||
|
Assert.Equal(3000, extra.WaitTimeoutMs);
|
||||||
|
Assert.Equal("true", extra.ObservedValue, ignoreCase: true);
|
||||||
|
Assert.Single(collector.Snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitInternal_WhenTraced_Timeout_EmitsFailedRow_WithTimeoutOutcome()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: true, new FlowTraceCollector());
|
||||||
|
|
||||||
|
var wait = ctx.WaitAttribute("Processed", "true", null, TimeSpan.FromSeconds(1));
|
||||||
|
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||||||
|
|
||||||
|
Assert.False(await wait);
|
||||||
|
var row = Assert.Single(writer.Events).AsRow();
|
||||||
|
Assert.Equal("Failed", row.Status.ToString());
|
||||||
|
Assert.Contains("timed out", row.ErrorMessage);
|
||||||
|
Assert.Equal("timeout", ScriptTraceExtra.TryParse(row.Extra)!.WaitOutcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitInternal_WhenNotTraced_EmitsNothing()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: false, collector: null);
|
||||||
|
|
||||||
|
var wait = ctx.WaitAttribute("Processed", "true", null, TimeSpan.FromSeconds(1));
|
||||||
|
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: true, true, "Good", TimedOut: false));
|
||||||
|
await wait;
|
||||||
|
|
||||||
|
Assert.Empty(writer.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallScript_WhenTraced_PropagatesTraceOnScriptCallRequest()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var ctx = CreateContext(probe.Ref, null, traced: true, new FlowTraceCollector());
|
||||||
|
|
||||||
|
var call = ctx.CallScript("B");
|
||||||
|
var req = probe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.True(req.Trace);
|
||||||
|
probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null));
|
||||||
|
await call;
|
||||||
|
|
||||||
|
// The inline shared-script child context inherits the flag too.
|
||||||
|
Assert.True(ctx.CreateChildContextForSharedScript(1).IsTraced);
|
||||||
|
Assert.False(CreateContext(probe.Ref, null, traced: false, null).CreateChildContextForSharedScript(1).IsTraced);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallScript_WhenNotTraced_RequestTraceIsFalse()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var ctx = CreateContext(probe.Ref, null, traced: false, null);
|
||||||
|
|
||||||
|
var call = ctx.CallScript("B");
|
||||||
|
var req = probe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.False(req.Trace);
|
||||||
|
probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null));
|
||||||
|
await call;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user