using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
///
/// Serilog sink that converts each script into a
/// and forwards it to an .
/// The production publisher (DpsScriptLogPublisher) routes the entry onto the
/// Akka DPS script-logs topic so the live Script-log Admin UI page can display it.
///
///
///
/// Expected to be registered in the root script-logger pipeline alongside the
/// rolling scripts-*.log file sink and the
/// . Events below the configured minimum level
/// are silently discarded so Debug/Verbose noise does not saturate the cluster bus
/// in production deployments.
///
///
/// Identity properties (ScriptId, VirtualTagId, AlarmId,
/// EquipmentId) are lifted from the event's structured-property bag. If
/// ScriptId is absent the sink falls back to the legacy ScriptName
/// property so pipelines that pre-date this sink continue to work correctly. If
/// neither property is present ScriptId defaults to "unknown".
///
///
/// Rate-limited per source. A script that logs at Information on every evaluation of a 1 Hz
/// input put one rendered string per second per script onto the cluster bus; 200 such scripts shed
/// everyone's entries out of the bounded telemetry channels. A
/// keyed by the entry's identity (script + virtual tag + alarm) admits 20 entries per minute per
/// source and publishes one summary entry naming the dropped count when the window rolls.
///
///
public sealed class ScriptLogTopicSink : ILogEventSink
{
private readonly IScriptLogPublisher _publisher;
private readonly LogEventLevel _min;
private readonly ScriptLogRateLimiter _limiter;
/// Initializes a new instance of the class.
/// The publisher that routes entries to the cluster bus. Must not be null.
/// Minimum log level to forward. Events below this level are discarded (default: ).
/// Per-source rate limiter; null ⇒ the default 20 per minute.
/// Thrown when is null.
public ScriptLogTopicSink(IScriptLogPublisher publisher, LogEventLevel min = LogEventLevel.Information,
ScriptLogRateLimiter? limiter = null)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_min = min;
_limiter = limiter ?? new ScriptLogRateLimiter();
}
///
/// Converts the to a and
/// publishes it, unless the event is below the configured minimum level or the source is over its
/// per-minute budget.
///
/// The Serilog event to process. Silently ignored when null.
public void Emit(LogEvent logEvent)
{
if (logEvent is null || logEvent.Level < _min) return;
// Extract a string scalar property by key, returning null when absent or non-string.
string? P(string key) =>
logEvent.Properties.TryGetValue(key, out var v) && v is ScalarValue { Value: string s }
? s
: null;
var scriptId = P(ScriptLoggerFactory.ScriptIdProperty)
?? P(ScriptLoggerFactory.ScriptNameProperty)
?? "unknown";
var virtualTagId = P(ScriptLoggerFactory.VirtualTagIdProperty);
var alarmId = P(ScriptLoggerFactory.AlarmIdProperty);
var equipmentId = P(ScriptLoggerFactory.EquipmentIdProperty);
var admission = _limiter.Admit(string.Concat(scriptId, "|", virtualTagId, "|", alarmId));
if (admission.SuppressedInPreviousWindow > 0)
{
_publisher.Publish(new ScriptLogEntry(
ScriptId: scriptId,
Level: nameof(LogEventLevel.Warning),
Message: $"{admission.SuppressedInPreviousWindow} script-log entr{(admission.SuppressedInPreviousWindow == 1 ? "y" : "ies")} " +
$"from this source were suppressed in the previous {_limiter.Window.TotalSeconds:F0} s " +
$"(limit {_limiter.LimitPerWindow}/window); see scripts-*.log for the full stream",
TimestampUtc: logEvent.Timestamp.UtcDateTime,
VirtualTagId: virtualTagId,
AlarmId: alarmId,
EquipmentId: equipmentId));
}
if (!admission.Admitted) return;
_publisher.Publish(new ScriptLogEntry(
ScriptId: scriptId,
Level: logEvent.Level.ToString(),
Message: logEvent.RenderMessage(),
TimestampUtc: logEvent.Timestamp.UtcDateTime,
VirtualTagId: virtualTagId,
AlarmId: alarmId,
EquipmentId: equipmentId));
}
}