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)); } }