feat(site-runtime): Log global on ScriptRuntimeContext/ScriptGlobals; runtime lines attributed by TraceStepScope (D4-D6)

This commit is contained in:
Joseph Doherty
2026-09-09 11:53:06 -04:00
parent 9d9bb70a15
commit 488d955ba2
3 changed files with 368 additions and 16 deletions
@@ -344,6 +344,9 @@ public class ScriptGlobals
/// </summary>
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>
/// Parent composition (null when this script is on a root-level template).
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
@@ -45,7 +45,24 @@ public class ScriptRuntimeContext
private readonly int _currentCallDepth;
private readonly int _maxCallDepth;
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;
/// <summary>
@@ -189,6 +206,40 @@ public class ScriptRuntimeContext
/// </summary>
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>
/// </summary>
public ScriptLogger Log { get; }
/// <summary>
/// Initializes a new script runtime context with all dependencies for script
/// execution, external system calls, database access, and notification delivery.
@@ -240,6 +291,11 @@ public class ScriptRuntimeContext
/// </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="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>
public ScriptRuntimeContext(
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
@@ -263,7 +319,8 @@ public class ScriptRuntimeContext
ISiteEventLogger? siteEventLogger = null,
CancellationToken scriptTimeoutToken = default,
bool isTraced = false,
FlowTraceCollector? traceCollector = null)
FlowTraceCollector? traceCollector = null,
int maxLogLinesPerRun = 200)
{
_instanceActor = instanceActor;
_sharedScriptLibrary = sharedScriptLibrary;
@@ -271,6 +328,7 @@ public class ScriptRuntimeContext
_maxCallDepth = maxCallDepth;
_askTimeout = askTimeout;
_instanceName = instanceName;
_innerLogger = logger;
_logger = logger;
_externalSystemClient = externalSystemClient;
_databaseGateway = databaseGateway;
@@ -298,6 +356,24 @@ public class ScriptRuntimeContext
_isTraced = isTraced;
_traceCollector = traceCollector;
_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)..];
Log = new ScriptLogger(
logger, isTraced ? EmitLog : null, _maxLogLinesPerRun, _instanceName, _logTarget, _executionId);
if (isTraced)
{
_logger = new TraceCapturingLogger(logger, Log);
}
}
/// <summary>
@@ -378,7 +454,9 @@ public class ScriptRuntimeContext
_maxCallDepth,
_askTimeout,
_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,
_databaseGateway,
_storeAndForward,
@@ -401,7 +479,10 @@ public class ScriptRuntimeContext
// 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);
traceCollector: _traceCollector,
// Step logs (D7): the inline shared script is its own execution node with its own
// Log counter, but it inherits the same per-run cap.
maxLogLinesPerRun: _maxLogLinesPerRun);
}
/// <summary>
@@ -411,9 +492,22 @@ public class ScriptRuntimeContext
/// 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>
/// <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(
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;
AuditEvent evt;
@@ -421,6 +515,7 @@ public class ScriptRuntimeContext
{
evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, kind, status,
eventId: eventId,
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
actor: _sourceScript, target: target, sourceNode: _sourceNode,
executionId: _executionId, parentExecutionId: _parentExecutionId,
@@ -456,6 +551,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>
private static string? TraceJson(object? value)
{
@@ -618,6 +786,11 @@ public class ScriptRuntimeContext
var startedUtc = DateTime.UtcNow;
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;
try
{
@@ -648,7 +821,8 @@ public class ScriptRuntimeContext
{
WaitAttribute = name, WaitTarget = targetValueEncoded ?? (predicate is null ? null : "<predicate>"),
WaitTimeoutMs = (int)timeout.TotalMilliseconds, WaitOutcome = "cancelled",
});
},
eventId: stepId);
}
throw;
}
@@ -670,7 +844,8 @@ public class ScriptRuntimeContext
WaitTimeoutMs = (int)timeout.TotalMilliseconds,
WaitOutcome = outcome,
ObservedValue = observed,
});
},
eventId: stepId);
}
return response;
@@ -706,6 +881,10 @@ public class ScriptRuntimeContext
var startedUtc = DateTime.UtcNow;
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;
try
{
@@ -717,7 +896,8 @@ public class ScriptRuntimeContext
{
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)] });
new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attributeName, value, false, ex.Message)] },
eventId: stepId);
throw;
}
@@ -732,7 +912,8 @@ public class ScriptRuntimeContext
extra: new ScriptTraceExtra
{
Writes = [new AttributeWriteDetail(attributeName, value, response.Success, response.ErrorMessage)],
});
},
eventId: stepId);
}
if (!response.Success)
@@ -772,6 +953,10 @@ public class ScriptRuntimeContext
// than an AskTimeoutException here. Honors the script execution-timeout token.
var startedUtc = DateTime.UtcNow;
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>(
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
@@ -789,7 +974,8 @@ public class ScriptRuntimeContext
input: TraceJson(new { values = encodedValues, trigger = triggerAttr, triggerValue = triggerEncoded }),
output: null,
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)
@@ -1207,6 +1393,12 @@ public class ScriptRuntimeContext
// audit emission is best-effort.
var occurredAtUtc = DateTime.UtcNow;
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. The trace sink is non-null exactly when the run
// is traced with an inline collector; an untraced run mints nothing and opens no scope.
Guid? stepId = _traceSink is not null ? Guid.NewGuid() : null;
using var step = stepId is { } callStepId ? TraceStepScope.Enter(callStepId) : null;
ExternalCallResult? result = null;
Exception? thrown = null;
try
@@ -1223,7 +1415,7 @@ public class ScriptRuntimeContext
{
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 1000d / Stopwatch.Frequency);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters, stepId);
}
}
@@ -1610,6 +1802,18 @@ public class ScriptRuntimeContext
/// row. Any exception thrown by the writer is logged and swallowed —
/// audit-write failures must never abort the user-facing action.
/// </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(
string systemName,
string methodName,
@@ -1617,7 +1821,8 @@ public class ScriptRuntimeContext
int durationMs,
ExternalCallResult? result,
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
IReadOnlyDictionary<string, object?>? parameters,
Guid? eventId = null)
{
if (_auditWriter == null)
{
@@ -1628,7 +1833,7 @@ public class ScriptRuntimeContext
try
{
evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters, eventId);
}
catch (Exception buildEx)
{
@@ -1687,7 +1892,8 @@ public class ScriptRuntimeContext
int durationMs,
ExternalCallResult? result,
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
IReadOnlyDictionary<string, object?>? parameters,
Guid? eventId = null)
{
// Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient
@@ -1728,6 +1934,8 @@ public class ScriptRuntimeContext
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: status,
// Step logs (D6): the id the caller already published as the ambient step.
eventId: eventId,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
// Outbound channel: per the Audit Log Actor-column spec the actor
// is the calling script. Null when no single script owns the call