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] + "…";
}
}