Merge wt/step-logs-t5: Task 5 Log global wired into ScriptRuntimeContext
This commit is contained in:
@@ -344,6 +344,9 @@ public class ScriptGlobals
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public AlarmsAccessor Alarms => Instance.Alarms;
|
public AlarmsAccessor Alarms => Instance.Alarms;
|
||||||
|
|
||||||
|
/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary>
|
||||||
|
public ScriptLogger Log => Instance.Log;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parent composition (null when this script is on a root-level template).
|
/// Parent composition (null when this script is on a root-level template).
|
||||||
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
||||||
|
|||||||
@@ -45,7 +45,24 @@ public class ScriptRuntimeContext
|
|||||||
private readonly int _currentCallDepth;
|
private readonly int _currentCallDepth;
|
||||||
private readonly int _maxCallDepth;
|
private readonly int _maxCallDepth;
|
||||||
private readonly TimeSpan _askTimeout;
|
private readonly TimeSpan _askTimeout;
|
||||||
private readonly ILogger _logger;
|
|
||||||
|
/// <summary>
|
||||||
|
/// The logger every runtime diagnostic in this context goes through. NOT readonly because a
|
||||||
|
/// TRACED run swaps in a <see cref="TraceCapturingLogger"/> in the constructor (step-logs
|
||||||
|
/// design 2026-09-09 D6), so the warnings the runtime logs on the script's behalf become
|
||||||
|
/// <c>ScriptLog</c> rows attributed to the step that was running. An untraced run keeps the
|
||||||
|
/// injected logger untouched — no decorator, no allocation.
|
||||||
|
/// </summary>
|
||||||
|
private ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The UNDECORATED injected logger. Every diagnostic emitted from inside the log path itself
|
||||||
|
/// (<see cref="EmitLog"/>) must use this one: routing it through <see cref="_logger"/> would be
|
||||||
|
/// captured by the decorator and re-enter <see cref="EmitLog"/> in a loop. Also the logger a
|
||||||
|
/// child (inline shared-script) context is built with, since
|
||||||
|
/// <see cref="TraceCapturingLogger"/> refuses to wrap itself.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ILogger _innerLogger;
|
||||||
private readonly string _instanceName;
|
private readonly string _instanceName;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -189,6 +206,48 @@ public class ScriptRuntimeContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Action<AuditEvent>? _traceSink;
|
private readonly Action<AuditEvent>? _traceSink;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step logs (design 2026-09-09 D7): the per-run <c>ScriptLog</c> row cap this context's
|
||||||
|
/// <see cref="Log"/> enforces, carried over verbatim into an inline shared-script child.
|
||||||
|
/// </summary>
|
||||||
|
private readonly int _maxLogLinesPerRun;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step logs (design D4): the bare script name a <c>ScriptLog</c> row targets — the SAME value
|
||||||
|
/// the run's <c>ScriptRun</c> anchor row uses, so the Flow Debugger's anchor fallback lines up.
|
||||||
|
/// Derived from <c>_sourceScript</c> by dropping its <c>ScriptActor:</c> / <c>AlarmActor:</c>
|
||||||
|
/// prefix; the instance name when no script owns the context.
|
||||||
|
/// </summary>
|
||||||
|
private readonly string _logTarget;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step logs (design D4): per-run emission counter stamped on every <c>ScriptLog</c> row as
|
||||||
|
/// <c>ScriptTraceExtra.Seq</c>, so lines produced inside the same millisecond still sort.
|
||||||
|
/// </summary>
|
||||||
|
private int _logSeq;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step logs (design 2026-09-09 D4/D5): the <c>Log</c> global site scripts call —
|
||||||
|
/// <c>Log.Debug/Info/Warn/Error(message, exception?)</c>.
|
||||||
|
///
|
||||||
|
/// <para>Every line ALWAYS reaches the node's <see cref="ILogger"/>, traced or not. A
|
||||||
|
/// <c>ScriptLog</c> trace row is additionally produced only when this run is traced: an
|
||||||
|
/// untraced context builds the logger with a null emitter, so the row path is one null check
|
||||||
|
/// and the untraced run is byte-identical to the pre-feature behaviour.</para>
|
||||||
|
///
|
||||||
|
/// <para>Bounded per run (D7) and never throws into the script — see
|
||||||
|
/// <see cref="ScriptLogger"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>An inline shared script SHARES its caller's logger</b> (design §5): a context built
|
||||||
|
/// by <see cref="CreateChildContextForSharedScript"/> is handed this same instance rather than
|
||||||
|
/// making its own, so the caller and every shared script it calls share ONE per-run line cap and
|
||||||
|
/// ONE closing "dropped lines" row. The consequence to know: a shared script's lines carry the
|
||||||
|
/// PARENT run's <c>ExecutionId</c> and therefore land on the parent's <c>ScriptRun</c> node in
|
||||||
|
/// the Flow Debugger — not on the shared script's own execution node, even though its
|
||||||
|
/// <c>ApiCall</c>/<c>DbWrite</c>/step rows still chain under their own execution.</para>
|
||||||
|
/// </summary>
|
||||||
|
public ScriptLogger Log { get; }
|
||||||
|
|
||||||
/// <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.
|
||||||
@@ -240,6 +299,17 @@ public class ScriptRuntimeContext
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="isTraced">Flow Debugger trace flag — see design 2026-09-05 §5.2. Defaults to untraced.</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>
|
/// <param name="traceCollector">Flow Debugger per-run inline trace sink (design §5.4); null when untraced.</param>
|
||||||
|
/// <param name="maxLogLinesPerRun">
|
||||||
|
/// Step logs (design 2026-09-09 D7): the per-run <c>ScriptLog</c> row cap for <see cref="Log"/>
|
||||||
|
/// — mirrors <c>SiteRuntimeOptions.MaxLogLinesPerRun</c>, which the script run launcher passes
|
||||||
|
/// in. Values below 1 are clamped to 1: a mis-set option must not make every script run throw.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="sharedLog">
|
||||||
|
/// Step logs (design §5): an existing <see cref="ScriptLogger"/> to adopt as <see cref="Log"/>
|
||||||
|
/// instead of creating one — supplied only by
|
||||||
|
/// <see cref="CreateChildContextForSharedScript"/>, so an inline shared script shares its
|
||||||
|
/// caller's line cap and anchor. Null (the default) creates this run's own logger.
|
||||||
|
/// </param>
|
||||||
public ScriptRuntimeContext(
|
public ScriptRuntimeContext(
|
||||||
IActorRef instanceActor,
|
IActorRef instanceActor,
|
||||||
SharedScriptLibrary sharedScriptLibrary,
|
SharedScriptLibrary sharedScriptLibrary,
|
||||||
@@ -263,7 +333,9 @@ public class ScriptRuntimeContext
|
|||||||
ISiteEventLogger? siteEventLogger = null,
|
ISiteEventLogger? siteEventLogger = null,
|
||||||
CancellationToken scriptTimeoutToken = default,
|
CancellationToken scriptTimeoutToken = default,
|
||||||
bool isTraced = false,
|
bool isTraced = false,
|
||||||
FlowTraceCollector? traceCollector = null)
|
FlowTraceCollector? traceCollector = null,
|
||||||
|
int maxLogLinesPerRun = 200,
|
||||||
|
ScriptLogger? sharedLog = null)
|
||||||
{
|
{
|
||||||
_instanceActor = instanceActor;
|
_instanceActor = instanceActor;
|
||||||
_sharedScriptLibrary = sharedScriptLibrary;
|
_sharedScriptLibrary = sharedScriptLibrary;
|
||||||
@@ -271,6 +343,7 @@ public class ScriptRuntimeContext
|
|||||||
_maxCallDepth = maxCallDepth;
|
_maxCallDepth = maxCallDepth;
|
||||||
_askTimeout = askTimeout;
|
_askTimeout = askTimeout;
|
||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
|
_innerLogger = logger;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_externalSystemClient = externalSystemClient;
|
_externalSystemClient = externalSystemClient;
|
||||||
_databaseGateway = databaseGateway;
|
_databaseGateway = databaseGateway;
|
||||||
@@ -298,6 +371,28 @@ public class ScriptRuntimeContext
|
|||||||
_isTraced = isTraced;
|
_isTraced = isTraced;
|
||||||
_traceCollector = traceCollector;
|
_traceCollector = traceCollector;
|
||||||
_traceSink = traceCollector is null ? null : Collect;
|
_traceSink = traceCollector is null ? null : Collect;
|
||||||
|
|
||||||
|
// Step logs (design 2026-09-09 D4-D6). ORDER MATTERS: the Log global is built over the
|
||||||
|
// UNDECORATED logger first, and only then — when traced — does the context's own logger
|
||||||
|
// become the capturing decorator over that same sink. Building them the other way round
|
||||||
|
// would hand ScriptLogger the decorator (every Log.* line logged, captured and re-emitted
|
||||||
|
// as a second row) and TraceCapturingLogger refuses to wrap itself for exactly that reason.
|
||||||
|
_maxLogLinesPerRun = Math.Max(1, maxLogLinesPerRun);
|
||||||
|
_logTarget = string.IsNullOrEmpty(_sourceScript)
|
||||||
|
// No single script owns the context — the instance is the closest honest target.
|
||||||
|
? _instanceName
|
||||||
|
// "ScriptActor:Foo" / "AlarmActor:Foo" -> "Foo"; a bare name is unchanged (IndexOf -1).
|
||||||
|
: _sourceScript[(_sourceScript.IndexOf(':') + 1)..];
|
||||||
|
// An inline shared script adopts its caller's logger (design §5) — one counter, one anchor,
|
||||||
|
// one closing row for the whole inline chain. Everyone else builds their own.
|
||||||
|
Log = sharedLog ?? new ScriptLogger(
|
||||||
|
logger, isTraced ? EmitLog : null, _maxLogLinesPerRun, _instanceName, _logTarget, _executionId);
|
||||||
|
if (isTraced)
|
||||||
|
{
|
||||||
|
// The child's OWN undecorated logger, decorated over the SHARED sink: the capturing
|
||||||
|
// decorator is per-context (it must not be handed on), the sink behind it is not.
|
||||||
|
_logger = new TraceCapturingLogger(logger, Log);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -378,7 +473,9 @@ public class ScriptRuntimeContext
|
|||||||
_maxCallDepth,
|
_maxCallDepth,
|
||||||
_askTimeout,
|
_askTimeout,
|
||||||
_instanceName,
|
_instanceName,
|
||||||
_logger,
|
// Step logs (D6): the UNDECORATED logger — the child installs its own capturing
|
||||||
|
// decorator when traced, and TraceCapturingLogger refuses to wrap another one.
|
||||||
|
_innerLogger,
|
||||||
_externalSystemClient,
|
_externalSystemClient,
|
||||||
_databaseGateway,
|
_databaseGateway,
|
||||||
_storeAndForward,
|
_storeAndForward,
|
||||||
@@ -401,7 +498,12 @@ public class ScriptRuntimeContext
|
|||||||
// Flow Debugger (design §5.2): a traced root traces its whole tree — the inline
|
// 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.
|
// shared script shares the same flag AND the same inline sink.
|
||||||
isTraced: _isTraced,
|
isTraced: _isTraced,
|
||||||
traceCollector: _traceCollector);
|
traceCollector: _traceCollector,
|
||||||
|
maxLogLinesPerRun: _maxLogLinesPerRun,
|
||||||
|
// Step logs (design §5): the inline shared script is its own execution node for step
|
||||||
|
// rows, but it SHARES this run's Log — one line cap and one anchor for the whole
|
||||||
|
// inline chain, so its lines carry this run's ExecutionId.
|
||||||
|
sharedLog: Log);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -411,9 +513,22 @@ public class ScriptRuntimeContext
|
|||||||
/// throws into the script, and hands raw payloads to the writer whose redactor applies
|
/// 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.
|
/// the configured size cap (§9) exactly as the other channels do.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="kind">The trace row's kind.</param>
|
||||||
|
/// <param name="status">The row's lifecycle status.</param>
|
||||||
|
/// <param name="target">The row's target (attribute name, script name, …).</param>
|
||||||
|
/// <param name="startedUtc">When the traced action started — the row's <c>OccurredAtUtc</c>.</param>
|
||||||
|
/// <param name="durationMs">How long the action took.</param>
|
||||||
|
/// <param name="input">Request summary.</param>
|
||||||
|
/// <param name="output">Response summary.</param>
|
||||||
|
/// <param name="error">Error message on a failed row.</param>
|
||||||
|
/// <param name="extra">Kind-specific extra detail.</param>
|
||||||
|
/// <param name="eventId">
|
||||||
|
/// Step logs (design 2026-09-09 D6): the step's PRE-MINTED <c>EventId</c>, so the log lines the
|
||||||
|
/// runtime produced inside the step's <see cref="TraceStepScope"/> can name it. Null mints one.
|
||||||
|
/// </param>
|
||||||
private void EmitTrace(
|
private void EmitTrace(
|
||||||
AuditKind kind, AuditStatus status, string target, DateTime startedUtc, int durationMs,
|
AuditKind kind, AuditStatus status, string target, DateTime startedUtc, int durationMs,
|
||||||
string? input, string? output, string? error, ScriptTraceExtra? extra)
|
string? input, string? output, string? error, ScriptTraceExtra? extra, Guid? eventId = null)
|
||||||
{
|
{
|
||||||
if (!_isTraced) return;
|
if (!_isTraced) return;
|
||||||
AuditEvent evt;
|
AuditEvent evt;
|
||||||
@@ -421,6 +536,7 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
evt = ScadaBridgeAuditEventFactory.Create(
|
evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
AuditChannel.ScriptTrace, kind, status,
|
AuditChannel.ScriptTrace, kind, status,
|
||||||
|
eventId: eventId,
|
||||||
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
|
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
|
||||||
actor: _sourceScript, target: target, sourceNode: _sourceNode,
|
actor: _sourceScript, target: target, sourceNode: _sourceNode,
|
||||||
executionId: _executionId, parentExecutionId: _parentExecutionId,
|
executionId: _executionId, parentExecutionId: _parentExecutionId,
|
||||||
@@ -456,6 +572,79 @@ public class ScriptRuntimeContext
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step logs (design 2026-09-09 D4/D7): emits one <c>ScriptTrace</c>/<c>ScriptLog</c> row for a
|
||||||
|
/// single log line — the collector's LOW-PRIORITY lane first (log lines must never crowd out
|
||||||
|
/// the step rows a run emits at its end), then the best-effort <see cref="IAuditWriter"/>.
|
||||||
|
///
|
||||||
|
/// <para>This is the emitter handed to <see cref="Log"/>, and it is wired only when the run is
|
||||||
|
/// traced. Every diagnostic it logs itself goes to <see cref="_innerLogger"/>, NEVER to
|
||||||
|
/// <see cref="_logger"/>: on a traced run the latter is the capturing decorator, which would
|
||||||
|
/// feed the warning straight back into this method.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="level">Level name as it appears on the wire: <c>debug|info|warn|error</c>.</param>
|
||||||
|
/// <param name="source">Line origin: <c>script</c> (a <c>Log.*</c> call) or <c>runtime</c>.</param>
|
||||||
|
/// <param name="message">The already-clipped message text.</param>
|
||||||
|
/// <param name="exception">The exception attached to the line, if any.</param>
|
||||||
|
/// <param name="stepEventId">The owning step's <c>EventId</c>; null falls back to the run's anchor row (D2).</param>
|
||||||
|
private void EmitLog(string level, string source, string message, Exception? exception, Guid? stepEventId)
|
||||||
|
{
|
||||||
|
if (!_isTraced) return;
|
||||||
|
AuditEvent evt;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
|
AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
||||||
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
|
actor: _sourceScript, target: _logTarget, sourceNode: _sourceNode,
|
||||||
|
executionId: _executionId, parentExecutionId: _parentExecutionId,
|
||||||
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
|
sourceInstanceId: _instanceName, sourceScript: _sourceScript,
|
||||||
|
errorMessage: exception?.Message, errorDetail: exception?.ToString(),
|
||||||
|
requestSummary: message,
|
||||||
|
extra: new ScriptTraceExtra
|
||||||
|
{
|
||||||
|
LogLevel = level,
|
||||||
|
LogSource = source,
|
||||||
|
StepEventId = stepEventId,
|
||||||
|
Seq = Interlocked.Increment(ref _logSeq),
|
||||||
|
}.ToJson());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_innerLogger.LogWarning(ex, "Flow trace log-line build failed at {Level}", level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Low-priority lane (D7): dropped lines set LogsTruncated, not Truncated.
|
||||||
|
_traceCollector?.Add(evt, lowPriority: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_innerLogger.LogWarning(ex, "Flow trace collector rejected log line ({EventId})", evt.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_auditWriter is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var t = _auditWriter.WriteAsync(evt, CancellationToken.None);
|
||||||
|
if (!t.IsCompletedSuccessfully)
|
||||||
|
{
|
||||||
|
_ = t.ContinueWith(
|
||||||
|
x => _innerLogger.LogWarning(x.Exception, "Flow trace log-line write failed ({EventId})", evt.EventId),
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_innerLogger.LogWarning(ex, "Flow trace log-line write failed ({EventId})", evt.EventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Serialises a small trace payload; null on failure (best-effort, like the audit helpers).</summary>
|
/// <summary>Serialises a small trace payload; null on failure (best-effort, like the audit helpers).</summary>
|
||||||
private static string? TraceJson(object? value)
|
private static string? TraceJson(object? value)
|
||||||
{
|
{
|
||||||
@@ -618,6 +807,11 @@ public class ScriptRuntimeContext
|
|||||||
|
|
||||||
var startedUtc = DateTime.UtcNow;
|
var startedUtc = DateTime.UtcNow;
|
||||||
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
||||||
|
// Step logs (design D6): pre-mint this step's row id and make it the ambient step for the
|
||||||
|
// body, so a warning the runtime logs while the wait is in flight names THIS row. Nothing
|
||||||
|
// is minted and no scope is opened on an untraced run.
|
||||||
|
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
|
||||||
|
using var step = stepId is { } waitStepId ? TraceStepScope.Enter(waitStepId) : null;
|
||||||
WaitForAttributeResponse response;
|
WaitForAttributeResponse response;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -648,7 +842,8 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
WaitAttribute = name, WaitTarget = targetValueEncoded ?? (predicate is null ? null : "<predicate>"),
|
WaitAttribute = name, WaitTarget = targetValueEncoded ?? (predicate is null ? null : "<predicate>"),
|
||||||
WaitTimeoutMs = (int)timeout.TotalMilliseconds, WaitOutcome = "cancelled",
|
WaitTimeoutMs = (int)timeout.TotalMilliseconds, WaitOutcome = "cancelled",
|
||||||
});
|
},
|
||||||
|
eventId: stepId);
|
||||||
}
|
}
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@@ -670,7 +865,8 @@ public class ScriptRuntimeContext
|
|||||||
WaitTimeoutMs = (int)timeout.TotalMilliseconds,
|
WaitTimeoutMs = (int)timeout.TotalMilliseconds,
|
||||||
WaitOutcome = outcome,
|
WaitOutcome = outcome,
|
||||||
ObservedValue = observed,
|
ObservedValue = observed,
|
||||||
});
|
},
|
||||||
|
eventId: stepId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -706,6 +902,10 @@ public class ScriptRuntimeContext
|
|||||||
|
|
||||||
var startedUtc = DateTime.UtcNow;
|
var startedUtc = DateTime.UtcNow;
|
||||||
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
||||||
|
// Step logs (design D6): one pre-minted row id shared by BOTH emit points below, opened as
|
||||||
|
// the ambient step so runtime warnings logged during the write are attributed to it.
|
||||||
|
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
|
||||||
|
using var step = stepId is { } writeStepId ? TraceStepScope.Enter(writeStepId) : null;
|
||||||
SetStaticAttributeResponse response;
|
SetStaticAttributeResponse response;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -717,7 +917,8 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
EmitTrace(AuditKind.AttributeWrite, AuditStatus.Failed, attributeName, startedUtc,
|
EmitTrace(AuditKind.AttributeWrite, AuditStatus.Failed, attributeName, startedUtc,
|
||||||
(int)sw.ElapsedMilliseconds, TraceJson(new { attribute = attributeName, value }), null, ex.Message,
|
(int)sw.ElapsedMilliseconds, TraceJson(new { attribute = attributeName, value }), null, ex.Message,
|
||||||
new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attributeName, value, false, ex.Message)] });
|
new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attributeName, value, false, ex.Message)] },
|
||||||
|
eventId: stepId);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +933,8 @@ public class ScriptRuntimeContext
|
|||||||
extra: new ScriptTraceExtra
|
extra: new ScriptTraceExtra
|
||||||
{
|
{
|
||||||
Writes = [new AttributeWriteDetail(attributeName, value, response.Success, response.ErrorMessage)],
|
Writes = [new AttributeWriteDetail(attributeName, value, response.Success, response.ErrorMessage)],
|
||||||
});
|
},
|
||||||
|
eventId: stepId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.Success)
|
if (!response.Success)
|
||||||
@@ -772,6 +974,10 @@ public class ScriptRuntimeContext
|
|||||||
// 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 startedUtc = DateTime.UtcNow;
|
||||||
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
var sw = _isTraced ? Stopwatch.StartNew() : null;
|
||||||
|
// Step logs (design D6): the batch's own step. The trailing WaitAttribute opens its own
|
||||||
|
// nested scope and restores this one on the way out, so each leg owns its lines.
|
||||||
|
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
|
||||||
|
using var step = stepId is { } batchStepId ? TraceStepScope.Enter(batchStepId) : null;
|
||||||
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
|
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
|
||||||
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
|
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
|
||||||
|
|
||||||
@@ -789,7 +995,8 @@ public class ScriptRuntimeContext
|
|||||||
input: TraceJson(new { values = encodedValues, trigger = triggerAttr, triggerValue = triggerEncoded }),
|
input: TraceJson(new { values = encodedValues, trigger = triggerAttr, triggerValue = triggerEncoded }),
|
||||||
output: null,
|
output: null,
|
||||||
error: batchResp.ErrorMessage,
|
error: batchResp.ErrorMessage,
|
||||||
extra: new ScriptTraceExtra { TriggerAttribute = triggerAttr, TriggerValue = triggerEncoded, Writes = writes });
|
extra: new ScriptTraceExtra { TriggerAttribute = triggerAttr, TriggerValue = triggerEncoded, Writes = writes },
|
||||||
|
eventId: stepId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!batchResp.Success)
|
if (!batchResp.Success)
|
||||||
@@ -873,7 +1080,9 @@ public class ScriptRuntimeContext
|
|||||||
// it onto SiteCallOperational.SourceNode.
|
// it onto SiteCallOperational.SourceNode.
|
||||||
_sourceNode,
|
_sourceNode,
|
||||||
// Flow Debugger: the outbound rows ride the inline collector (null when untraced).
|
// Flow Debugger: the outbound rows ride the inline collector (null when untraced).
|
||||||
traceSink: _traceSink);
|
traceSink: _traceSink,
|
||||||
|
// Step logs (D6): the run's trace flag — the authority for the sync Call's step scope.
|
||||||
|
isTraced: _isTraced);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides access to database operations.
|
/// Provides access to database operations.
|
||||||
@@ -1131,6 +1340,15 @@ public class ScriptRuntimeContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Action<AuditEvent>? _traceSink;
|
private readonly Action<AuditEvent>? _traceSink;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step logs (design 2026-09-09 D6): whether the owning run is traced. The authority for
|
||||||
|
/// opening a <see cref="TraceStepScope"/> around <see cref="Call"/>. Deliberately NOT
|
||||||
|
/// <see cref="_traceSink"/>: the sink is null whenever the run has no inline collector,
|
||||||
|
/// which is a different question from "is this run traced" — a traced run without a
|
||||||
|
/// collector still writes durable <c>ScriptLog</c> rows that must name their step.
|
||||||
|
/// </summary>
|
||||||
|
private readonly bool _isTraced;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new external system helper for script API calls.
|
/// Initializes a new external system helper for script API calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1145,6 +1363,7 @@ public class ScriptRuntimeContext
|
|||||||
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
|
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
|
||||||
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
|
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
|
||||||
/// <param name="traceSink">Flow Debugger inline collector — receives the same <see cref="AuditEvent"/> each emit hands to the writer/forwarder; null when untraced.</param>
|
/// <param name="traceSink">Flow Debugger inline collector — receives the same <see cref="AuditEvent"/> each emit hands to the writer/forwarder; null when untraced.</param>
|
||||||
|
/// <param name="isTraced">Step logs (design D6): true when the owning run is traced, so <see cref="Call"/> pre-mints its row id and publishes it as the ambient step. Defaults to false for direct (test) construction.</param>
|
||||||
// Internal constructor for tests living in ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests
|
// Internal constructor for tests living in ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests
|
||||||
// (via InternalsVisibleTo). Production sites resolve the helper through
|
// (via InternalsVisibleTo). Production sites resolve the helper through
|
||||||
// ScriptRuntimeContext.ExternalSystem.
|
// ScriptRuntimeContext.ExternalSystem.
|
||||||
@@ -1168,7 +1387,8 @@ public class ScriptRuntimeContext
|
|||||||
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
||||||
Guid? parentExecutionId = null,
|
Guid? parentExecutionId = null,
|
||||||
string? sourceNode = null,
|
string? sourceNode = null,
|
||||||
Action<AuditEvent>? traceSink = null)
|
Action<AuditEvent>? traceSink = null,
|
||||||
|
bool isTraced = false)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
@@ -1181,6 +1401,7 @@ public class ScriptRuntimeContext
|
|||||||
_parentExecutionId = parentExecutionId;
|
_parentExecutionId = parentExecutionId;
|
||||||
_sourceNode = sourceNode;
|
_sourceNode = sourceNode;
|
||||||
_traceSink = GuardTraceSink(traceSink, logger);
|
_traceSink = GuardTraceSink(traceSink, logger);
|
||||||
|
_isTraced = isTraced;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1207,6 +1428,13 @@ public class ScriptRuntimeContext
|
|||||||
// audit emission is best-effort.
|
// audit emission is best-effort.
|
||||||
var occurredAtUtc = DateTime.UtcNow;
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
var startTicks = Stopwatch.GetTimestamp();
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
// Step logs (design D6): pre-mint this call's audit row id and make it the ambient step
|
||||||
|
// so the helper's own warnings — and anything the client logs through this context —
|
||||||
|
// are attributed to the API-call node. Gated on the run's trace flag, NOT on the
|
||||||
|
// presence of an inline collector: a traced run without one still writes durable
|
||||||
|
// ScriptLog rows that need the step id. Untraced mints nothing and opens no scope.
|
||||||
|
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
|
||||||
|
using var step = stepId is { } callStepId ? TraceStepScope.Enter(callStepId) : null;
|
||||||
ExternalCallResult? result = null;
|
ExternalCallResult? result = null;
|
||||||
Exception? thrown = null;
|
Exception? thrown = null;
|
||||||
try
|
try
|
||||||
@@ -1223,7 +1451,7 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
|
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
|
||||||
* 1000d / Stopwatch.Frequency);
|
* 1000d / Stopwatch.Frequency);
|
||||||
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
|
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters, stepId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,6 +1838,18 @@ public class ScriptRuntimeContext
|
|||||||
/// row. Any exception thrown by the writer is logged and swallowed —
|
/// row. Any exception thrown by the writer is logged and swallowed —
|
||||||
/// audit-write failures must never abort the user-facing action.
|
/// audit-write failures must never abort the user-facing action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="systemName">External system name.</param>
|
||||||
|
/// <param name="methodName">Method invoked on it.</param>
|
||||||
|
/// <param name="occurredAtUtc">When the call started.</param>
|
||||||
|
/// <param name="durationMs">How long it took.</param>
|
||||||
|
/// <param name="result">The call result, when one was produced.</param>
|
||||||
|
/// <param name="thrown">The exception the call threw, if any.</param>
|
||||||
|
/// <param name="parameters">The call arguments.</param>
|
||||||
|
/// <param name="eventId">
|
||||||
|
/// Step logs (design 2026-09-09 D6): the PRE-MINTED row id <see cref="Call"/> also opened as
|
||||||
|
/// the ambient <see cref="TraceStepScope"/>, so the log lines produced during the call name
|
||||||
|
/// this row. Null (untraced) mints a fresh id as before.
|
||||||
|
/// </param>
|
||||||
private void EmitCallAudit(
|
private void EmitCallAudit(
|
||||||
string systemName,
|
string systemName,
|
||||||
string methodName,
|
string methodName,
|
||||||
@@ -1617,7 +1857,8 @@ public class ScriptRuntimeContext
|
|||||||
int durationMs,
|
int durationMs,
|
||||||
ExternalCallResult? result,
|
ExternalCallResult? result,
|
||||||
Exception? thrown,
|
Exception? thrown,
|
||||||
IReadOnlyDictionary<string, object?>? parameters)
|
IReadOnlyDictionary<string, object?>? parameters,
|
||||||
|
Guid? eventId = null)
|
||||||
{
|
{
|
||||||
if (_auditWriter == null)
|
if (_auditWriter == null)
|
||||||
{
|
{
|
||||||
@@ -1628,7 +1869,7 @@ public class ScriptRuntimeContext
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
evt = BuildCallAuditEvent(
|
evt = BuildCallAuditEvent(
|
||||||
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
|
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters, eventId);
|
||||||
}
|
}
|
||||||
catch (Exception buildEx)
|
catch (Exception buildEx)
|
||||||
{
|
{
|
||||||
@@ -1687,7 +1928,8 @@ public class ScriptRuntimeContext
|
|||||||
int durationMs,
|
int durationMs,
|
||||||
ExternalCallResult? result,
|
ExternalCallResult? result,
|
||||||
Exception? thrown,
|
Exception? thrown,
|
||||||
IReadOnlyDictionary<string, object?>? parameters)
|
IReadOnlyDictionary<string, object?>? parameters,
|
||||||
|
Guid? eventId = null)
|
||||||
{
|
{
|
||||||
// Status: Delivered on a Success result; Failed otherwise (the
|
// Status: Delivered on a Success result; Failed otherwise (the
|
||||||
// ExternalSystemClient already maps HTTP non-2xx + transient
|
// ExternalSystemClient already maps HTTP non-2xx + transient
|
||||||
@@ -1728,6 +1970,8 @@ public class ScriptRuntimeContext
|
|||||||
channel: AuditChannel.ApiOutbound,
|
channel: AuditChannel.ApiOutbound,
|
||||||
kind: AuditKind.ApiCall,
|
kind: AuditKind.ApiCall,
|
||||||
status: status,
|
status: status,
|
||||||
|
// Step logs (D6): the id the caller already published as the ambient step.
|
||||||
|
eventId: eventId,
|
||||||
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
// Outbound channel: per the Audit Log Actor-column spec the actor
|
// Outbound channel: per the Audit Log Actor-column spec the actor
|
||||||
// is the calling script. Null when no single script owns the call
|
// is the calling script. Null when no single script owns the call
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ public sealed class TraceCapturingLogger : ILogger
|
|||||||
_ => "error",
|
_ => "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
_sink.Runtime(name, formatter(state, exception), exception);
|
string message;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
message = formatter(state, exception);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Deliberate swallow: formatting is the CALLER's code, and a great many of the entries
|
||||||
|
// captured here are the runtime's own best-effort "audit write failed" warnings logged
|
||||||
|
// from inside a catch block. A throwing formatter escaping this decorator would replace
|
||||||
|
// the original failure with a formatting exception on a path whose whole contract is
|
||||||
|
// that it never throws. The line is dropped; the inner logger already saw (or refused)
|
||||||
|
// it above, unchanged.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sink.Runtime(name, message, exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+268
-2
@@ -145,7 +145,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
|||||||
IExternalSystemClient? externalSystemClient = null,
|
IExternalSystemClient? externalSystemClient = null,
|
||||||
IDatabaseGateway? databaseGateway = null,
|
IDatabaseGateway? databaseGateway = null,
|
||||||
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
||||||
StoreAndForwardService? storeAndForward = null)
|
StoreAndForwardService? storeAndForward = null,
|
||||||
|
int maxLogLinesPerRun = 200)
|
||||||
=> new(
|
=> new(
|
||||||
instanceActor, NewLibrary(),
|
instanceActor, NewLibrary(),
|
||||||
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
|
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
|
||||||
@@ -155,7 +156,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
|||||||
siteId: "site-a", sourceScript: "ScriptActor:A", auditWriter: writer,
|
siteId: "site-a", sourceScript: "ScriptActor:A", auditWriter: writer,
|
||||||
cachedForwarder: cachedForwarder,
|
cachedForwarder: cachedForwarder,
|
||||||
executionId: executionId, parentExecutionId: parentExecutionId, sourceNode: "node-a",
|
executionId: executionId, parentExecutionId: parentExecutionId, sourceNode: "node-a",
|
||||||
isTraced: traced, traceCollector: collector);
|
isTraced: traced, traceCollector: collector,
|
||||||
|
maxLogLinesPerRun: maxLogLinesPerRun);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand()
|
public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand()
|
||||||
@@ -556,4 +558,268 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
|||||||
var written = Assert.Single(writer.Events);
|
var written = Assert.Single(writer.Events);
|
||||||
Assert.Equal(AuditKind.ApiCall, Row(written).Kind);
|
Assert.Equal(AuditKind.ApiCall, Row(written).Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Step logs: the Log global and runtime-line attribution (design 2026-09-09 D4-D7) ----
|
||||||
|
|
||||||
|
/// <summary>Fails every write so the context's own best-effort "write failed" warning fires —
|
||||||
|
/// the runtime line test (c) needs a genuine <c>_logger.LogWarning</c> inside a step body.</summary>
|
||||||
|
private sealed class ThrowingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
=> throw new InvalidOperationException("writer down");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_WhenTraced_EmitsOneScriptLogRow_ToTheCollectorAndTheWriter()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector);
|
||||||
|
|
||||||
|
ctx.Log.Info("x");
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Same(evt, Assert.Single(collector.Snapshot()));
|
||||||
|
var row = Row(evt);
|
||||||
|
Assert.Equal(AuditChannel.ScriptTrace, row.Channel);
|
||||||
|
Assert.Equal(AuditKind.ScriptLog, row.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, row.Status);
|
||||||
|
Assert.Equal("x", row.RequestSummary);
|
||||||
|
Assert.Equal(ctx.ExecutionId, row.ExecutionId);
|
||||||
|
Assert.Equal("site-a", row.SourceSiteId);
|
||||||
|
Assert.Equal("node-a", evt.SourceNode);
|
||||||
|
Assert.Equal(InstanceName, row.SourceInstanceId);
|
||||||
|
// Same bare script name the ScriptRun anchor row carries, so the builder's fallback matches.
|
||||||
|
Assert.Equal("A", evt.Target);
|
||||||
|
var extra = ScriptTraceExtra.TryParse(row.Extra)!;
|
||||||
|
Assert.Equal("info", extra.LogLevel);
|
||||||
|
Assert.Equal("script", extra.LogSource);
|
||||||
|
Assert.Null(extra.StepEventId);
|
||||||
|
Assert.Equal(1, extra.Seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_WhenNotTraced_ReachesNeitherTheWriterNorTheCollector()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: false, collector);
|
||||||
|
|
||||||
|
ctx.Log.Info("x");
|
||||||
|
ctx.Log.Error("boom", new InvalidOperationException("e"));
|
||||||
|
ctx.Log.FlushDropped();
|
||||||
|
|
||||||
|
Assert.Empty(writer.Events);
|
||||||
|
Assert.Equal(0, collector.Count);
|
||||||
|
Assert.False(collector.LogsTruncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RuntimeWarning_InsideATracedWrite_IsAttributedToThatWriteStep()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
// The throwing writer makes EmitTrace log its best-effort "write failed" warning while the
|
||||||
|
// AttributeWrite step scope is open — exactly the runtime line D6 exists to attribute.
|
||||||
|
//
|
||||||
|
// It also PINS THE ANTI-RECURSION PROPERTY: EmitLog's own write fails against this writer
|
||||||
|
// too, so its "log-line write failed" warning must go to the UNDECORATED logger. Routed
|
||||||
|
// through the capturing decorator instead, each warning would emit another row whose write
|
||||||
|
// fails again — an unbounded loop, and this test would hang or blow the row count rather
|
||||||
|
// than see exactly one ScriptLog row.
|
||||||
|
var ctx = CreateContext(probe.Ref, new ThrowingAuditWriter(), traced: true, collector);
|
||||||
|
|
||||||
|
var call = ctx.SetAttribute("Setpoint", "42");
|
||||||
|
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
|
||||||
|
await call;
|
||||||
|
|
||||||
|
var rows = collector.Snapshot();
|
||||||
|
var write = rows.Single(e => Row(e).Kind == AuditKind.AttributeWrite);
|
||||||
|
var log = rows.Single(e => Row(e).Kind == AuditKind.ScriptLog);
|
||||||
|
var extra = ScriptTraceExtra.TryParse(Row(log).Extra)!;
|
||||||
|
Assert.Equal("runtime", extra.LogSource);
|
||||||
|
Assert.Equal("warn", extra.LogLevel);
|
||||||
|
Assert.Equal(write.EventId, extra.StepEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_WithException_CarriesTheMessageAndTheFullDetail()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, new FlowTraceCollector());
|
||||||
|
|
||||||
|
ctx.Log.Warn("m", new InvalidOperationException("e"));
|
||||||
|
|
||||||
|
var row = Row(Assert.Single(writer.Events));
|
||||||
|
Assert.Equal("m", row.RequestSummary);
|
||||||
|
Assert.Equal("e", row.ErrorMessage);
|
||||||
|
Assert.StartsWith("System.InvalidOperationException", row.ErrorDetail);
|
||||||
|
Assert.Equal("warn", ScriptTraceExtra.TryParse(row.Extra)!.LogLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogRows_RideTheLowPriorityLane_SoStepRowsKeepTheirReserve()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
// Reserve (50) + 1 => exactly one low-priority slot, while step rows keep the full cap.
|
||||||
|
var collector = new FlowTraceCollector(maxEvents: FlowTraceCollector.LowPriorityReserveEvents + 1);
|
||||||
|
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
|
||||||
|
|
||||||
|
ctx.Log.Info("first");
|
||||||
|
ctx.Log.Info("second");
|
||||||
|
|
||||||
|
Assert.Single(collector.Snapshot());
|
||||||
|
Assert.True(collector.LogsTruncated);
|
||||||
|
Assert.False(collector.Truncated);
|
||||||
|
|
||||||
|
var call = ctx.SetAttribute("Setpoint", "42");
|
||||||
|
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
|
||||||
|
await call;
|
||||||
|
|
||||||
|
Assert.Equal(2, collector.Count);
|
||||||
|
Assert.Contains(collector.Snapshot(), e => Row(e).Kind == AuditKind.AttributeWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlushDropped_EmitsTheClosingRow_OnceThePerRunCapIsExceeded()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector, maxLogLinesPerRun: 2);
|
||||||
|
|
||||||
|
ctx.Log.Info("1");
|
||||||
|
ctx.Log.Info("2");
|
||||||
|
ctx.Log.Info("3");
|
||||||
|
ctx.Log.FlushDropped();
|
||||||
|
|
||||||
|
var rows = collector.Snapshot();
|
||||||
|
Assert.Equal(3, rows.Count);
|
||||||
|
var closing = Row(rows[^1]);
|
||||||
|
var extra = ScriptTraceExtra.TryParse(closing.Extra)!;
|
||||||
|
Assert.Equal("warn", extra.LogLevel);
|
||||||
|
Assert.Equal("runtime", extra.LogSource);
|
||||||
|
Assert.Contains("1 line(s) dropped", closing.RequestSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review I1: the sync <c>ExternalSystem.Call</c> pre-mints its <c>ApiCall</c> row id and
|
||||||
|
/// publishes it as the ambient step, so a warning logged while the call is in flight names
|
||||||
|
/// that node. Provoked the same way as the write case — a writer that throws makes the
|
||||||
|
/// helper's own best-effort "audit write threw" warning fire inside the scope.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RuntimeWarning_InsideATracedExternalCall_IsAttributedToTheApiCallStep()
|
||||||
|
{
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var ctx = CreateContext(CreateTestProbe().Ref, new ThrowingAuditWriter(), traced: true, collector,
|
||||||
|
externalSystemClient: SyncClient(new ExternalCallResult(true, "{}", null)).Object);
|
||||||
|
|
||||||
|
var result = await ctx.ExternalSystem.Call("ERP", "GetOrder");
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
var rows = collector.Snapshot();
|
||||||
|
var call = rows.Single(e => Row(e).Kind == AuditKind.ApiCall);
|
||||||
|
var log = rows.Single(e => Row(e).Kind == AuditKind.ScriptLog);
|
||||||
|
var extra = ScriptTraceExtra.TryParse(Row(log).Extra)!;
|
||||||
|
Assert.Equal("runtime", extra.LogSource);
|
||||||
|
Assert.Equal(call.EventId, extra.StepEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review I1: the step scope is gated on the run's TRACE FLAG, not on the presence of an
|
||||||
|
/// inline collector — a traced run without one still writes durable <c>ScriptLog</c> rows that
|
||||||
|
/// have to name their step.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task TracedExternalCall_WithoutACollector_StillNamesTheApiCallStep()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector: null,
|
||||||
|
externalSystemClient: SyncClient(new ExternalCallResult(true, "{}", null)).Object);
|
||||||
|
|
||||||
|
await ctx.ExternalSystem.Call("ERP", "GetOrder");
|
||||||
|
ctx.Log.Info("after the call, outside any scope");
|
||||||
|
|
||||||
|
var call = writer.Events.Single(e => Row(e).Kind == AuditKind.ApiCall);
|
||||||
|
// The line above is outside the scope, so it falls back to the anchor; what this pins is
|
||||||
|
// that the call's row id was minted and published at all on a collector-less traced run.
|
||||||
|
Assert.NotEqual(Guid.Empty, call.EventId);
|
||||||
|
var log = writer.Events.Single(e => Row(e).Kind == AuditKind.ScriptLog);
|
||||||
|
Assert.Null(ScriptTraceExtra.TryParse(Row(log).Extra)!.StepEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review I2 (design §5): an inline shared script SHARES its caller's <see cref="ScriptLogger"/>
|
||||||
|
/// — one counter, one anchor. Its lines therefore carry the PARENT run's <c>ExecutionId</c> and
|
||||||
|
/// land on the parent's <c>ScriptRun</c> node, even though its own step rows chain under its own
|
||||||
|
/// execution.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SharedScriptChild_SharesTheCallersLogger_ExecutionIdAndCap()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var parent = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector, maxLogLinesPerRun: 2);
|
||||||
|
var child = parent.CreateChildContextForSharedScript(1);
|
||||||
|
|
||||||
|
Assert.Same(parent.Log, child.Log);
|
||||||
|
Assert.NotEqual(parent.ExecutionId, child.ExecutionId);
|
||||||
|
|
||||||
|
parent.Log.Info("from the caller");
|
||||||
|
child.Log.Info("shared");
|
||||||
|
// Third line from either side is over the SHARED cap of 2.
|
||||||
|
child.Log.Info("dropped");
|
||||||
|
parent.Log.Info("also dropped");
|
||||||
|
|
||||||
|
var rows = collector.Snapshot();
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, e => Assert.Equal(parent.ExecutionId, Row(e).ExecutionId));
|
||||||
|
Assert.Equal(["from the caller", "shared"], rows.Select(e => Row(e).RequestSummary));
|
||||||
|
|
||||||
|
parent.Log.FlushDropped();
|
||||||
|
Assert.Contains("2 line(s) dropped", Row(collector.Snapshot()[^1]).RequestSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review item 4: step scopes NEST — the batch leg's lines name the batch row, the wait leg's
|
||||||
|
/// name the wait row, and both unwind so a later line falls back to the run anchor. The
|
||||||
|
/// throwing writer is again what provokes a runtime line inside each leg.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task NestedStepScopes_AttributeEachLegSeparately_AndUnwind()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var collector = new FlowTraceCollector();
|
||||||
|
var ctx = CreateContext(probe.Ref, new ThrowingAuditWriter(), traced: true, collector);
|
||||||
|
|
||||||
|
var call = ctx.WriteBatchAndWait(
|
||||||
|
new Dictionary<string, string?> { ["Recipe"] = "R-17" },
|
||||||
|
"Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
var batchReq = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new WriteAttributeBatchResponse(batchReq.CorrelationId, true, null));
|
||||||
|
var waitReq = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
probe.Reply(new WaitForAttributeResponse(waitReq.CorrelationId, Matched: true, "1", "Good", TimedOut: false));
|
||||||
|
Assert.True(await call);
|
||||||
|
|
||||||
|
// Both scopes have unwound by the time the run continues.
|
||||||
|
ctx.Log.Info("after both legs");
|
||||||
|
|
||||||
|
var rows = collector.Snapshot();
|
||||||
|
var write = rows.Single(e => Row(e).Kind == AuditKind.AttributeWrite);
|
||||||
|
var wait = rows.Single(e => Row(e).Kind == AuditKind.AttributeWait);
|
||||||
|
Assert.NotEqual(write.EventId, wait.EventId);
|
||||||
|
|
||||||
|
var logs = rows.Where(e => Row(e).Kind == AuditKind.ScriptLog)
|
||||||
|
.Select(e => ScriptTraceExtra.TryParse(Row(e).Extra)!)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(3, logs.Count);
|
||||||
|
Assert.Equal(write.EventId, logs[0].StepEventId);
|
||||||
|
Assert.Equal(wait.EventId, logs[1].StepEventId);
|
||||||
|
Assert.Null(logs[2].StepEventId);
|
||||||
|
Assert.Equal(["runtime", "runtime", "script"], logs.Select(l => l.LogSource));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,27 @@ public class TraceCapturingLoggerTests
|
|||||||
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(new RecordingLogger(), null!));
|
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(new RecordingLogger(), null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AThrowingFormatter_IsSwallowed_AndDropsOnlyThatLine()
|
||||||
|
{
|
||||||
|
// Review M3. Most of what this decorator captures is the runtime's own best-effort
|
||||||
|
// "audit write failed" warnings, logged from INSIDE a catch block: a formatter exception
|
||||||
|
// escaping here would replace the original failure with a formatting one on a path whose
|
||||||
|
// contract is that it never throws. The inner logger is disabled for this level so the
|
||||||
|
// decorator's OWN formatter call is the only one under test.
|
||||||
|
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical);
|
||||||
|
|
||||||
|
logger.Log(LogLevel.Warning, new EventId(0), "state", null,
|
||||||
|
(_, _) => throw new InvalidOperationException("formatter broke"));
|
||||||
|
|
||||||
|
Assert.Empty(rows);
|
||||||
|
Assert.Empty(inner.Entries);
|
||||||
|
|
||||||
|
// …and the decorator keeps working for the next line.
|
||||||
|
logger.LogWarning("still alive");
|
||||||
|
Assert.Equal("still alive", Assert.Single(rows).Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BeginScope_DelegatesToTheInnerLogger()
|
public void BeginScope_DelegatesToTheInnerLogger()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user