using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// /// The row-emitting callback a uses: level name, source, message, /// optional exception, owning step id (design 2026-09-09 D1/D2). /// /// Level name as it appears on the wire: debug|info|warn|error. /// Line origin: script (the author's own Log.* call) or runtime. /// The already-clipped message text. /// The exception attached to the line, if any. /// The step row the line belongs to, or to fall back to the run's anchor row. public delegate void ScriptLogEmitter(string level, string source, string message, Exception? exception, Guid? stepEventId); /// /// Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the Log global site scripts call. /// /// Every line ALWAYS forwards to the node's — an untraced run is a /// normal logging surface and pays nothing else. A ScriptLog audit row is emitted only /// when the run is traced, i.e. only when the owning ScriptRuntimeContext supplied an /// emitter; untraced runs construct the logger with a emitter and the row /// path is a single null check. /// /// Bounded (D7). At most maxLines rows per run — one /// instance is one run's counter, and inline shared scripts share the context, hence the counter — /// plus a hard cap per message. Lines past the cap are counted and /// dropped; emits the single closing warning that names the count. /// /// Never throws into the script. A logging or emitting failure is swallowed: this is a /// debug observer, and a broken observer must not fail a production script run. /// public sealed class ScriptLogger { /// Per-message character cap (design D7); longer messages are truncated and suffixed with an ellipsis. public const int MaxMessageChars = 2_000; private readonly ILogger _logger; private readonly ScriptLogEmitter? _emit; private readonly int _maxLines; private readonly string _instance; private readonly string _script; private readonly Guid? _executionId; private int _emitted; private int _dropped; /// Creates the Log global for one script run. /// The node logger every line forwards to, traced or not. /// Row emitter; when the run is not traced (no rows are produced). /// Per-run row cap (design D7); must be greater than zero. /// Instance unique name, for the structured log entry. /// Script name, for the structured log entry. /// The run's execution id, for the structured log entry. /// is null. /// is not greater than zero. public ScriptLogger(ILogger logger, ScriptLogEmitter? emit, int maxLines, string instance, string script, Guid? executionId) { ArgumentNullException.ThrowIfNull(logger); ArgumentOutOfRangeException.ThrowIfLessThan(maxLines, 1); _logger = logger; _emit = emit; _maxLines = maxLines; _instance = instance; _script = script; _executionId = executionId; } /// Logs a diagnostic line at Debug level. /// The message text (clipped at ). /// An optional exception to attach to the line. public void Debug(string message, Exception? exception = null) => Write(LogLevel.Debug, "debug", message, exception); /// Logs an informational line. /// The message text (clipped at ). /// An optional exception to attach to the line. public void Info(string message, Exception? exception = null) => Write(LogLevel.Information, "info", message, exception); /// Logs a warning line. /// The message text (clipped at ). /// An optional exception to attach to the line. public void Warn(string message, Exception? exception = null) => Write(LogLevel.Warning, "warn", message, exception); /// Logs an error line. Does not fail the run — the script decides what to do next. /// The message text (clipped at ). /// An optional exception to attach to the line. public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, "error", message, exception); /// /// Runtime-sourced line (fed by ); same caps, source /// runtime; does NOT forward to the — the caller already logged it, /// and re-logging would loop back through the decorator. /// /// Level name (info|warn|error). /// The already-formatted message text. /// The exception attached to the entry, if any. internal void Runtime(string level, string message, Exception? exception) => WriteRow(level, "runtime", message, exception); /// Forwards to the node logger, then records the trace row (when traced). private void Write(LogLevel level, string name, string message, Exception? exception) { message = Clip(message); try { _logger.Log(level, exception, "[script] {Instance}/{Script} {ExecutionId}: {Message}", _instance, _script, _executionId, message); } catch { // Deliberate swallow: a failing logger sink must never propagate into the script body. } WriteRow(name, "script", message, exception); } /// Emits one trace row, subject to the per-run line cap. private void WriteRow(string level, string source, string message, Exception? exception) { if (_emit is null) { return; // Untraced run: no rows, no counting. } if (Interlocked.Increment(ref _emitted) > _maxLines) { Interlocked.Increment(ref _dropped); return; } try { _emit(level, source, Clip(message), exception, TraceStepScope.Current); } catch { // Deliberate swallow: the trace collector/audit writer is a debug observer, never a run failure. } } /// /// Run end: emits one closing warning naming the dropped count (nothing when none were /// dropped). Bypasses the counter on purpose — it is the one row that must always land. /// Idempotent: the count is cleared as it is reported, so a second call emits nothing. /// public void FlushDropped() { if (_emit is null) { return; } var n = Interlocked.Exchange(ref _dropped, 0); if (n == 0) { return; } try { // Final review M3: worded identically to the central InboundScriptLogger's closing row, // so the two surfaces read the same in the step-log panel (only the option key differs). _emit("warn", "runtime", $"log line cap reached - {n} further log line(s) were not captured " + $"(ScadaBridge:SiteRuntime:MaxLogLinesPerRun = {_maxLines})", null, null); } catch { // Deliberate swallow: same reason as WriteRow — the observer must not fail the run. } } /// /// Applies the per-message cap, marking a truncated message with a trailing ellipsis. /// /// Cuts one char short when the cap would land between the two halves of a surrogate /// pair, so an emoji straddling the boundary is dropped whole rather than leaving a lone /// surrogate that breaks JSON serialization downstream. Re-clipping an already-clipped message /// is a no-op, which matters because the script path clips once before logging and again /// before emitting. /// private static string Clip(string? m) { if (m is null) { return string.Empty; } if (m.Length <= MaxMessageChars) { return m; } var keep = char.IsHighSurrogate(m[MaxMessageChars - 1]) ? MaxMessageChars - 1 : MaxMessageChars; return m[..keep] + "…"; } }