From 14fe88fc8063139112c5761e6127794cad2a2fbd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 11:38:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(scripting):=20ScriptLogTopicSink=20?= =?UTF-8?q?=E2=80=94=20script=20LogEvent=20=E2=86=92=20ScriptLogEntry=20?= =?UTF-8?q?=E2=86=92=20publisher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IScriptLogPublisher.cs | 19 ++ .../ScriptLogTopicSink.cs | 70 +++++++ .../ScriptLoggerFactory.cs | 12 ++ .../ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj | 1 + .../ScriptLogTopicSinkTests.cs | 190 ++++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs new file mode 100644 index 00000000..010316cf --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs @@ -0,0 +1,19 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Abstraction over the mechanism that publishes records +/// to a downstream consumer (e.g. an Akka DPS script-logs topic). Implemented by +/// the concrete DPS publisher in a later task; the sink depends only on this interface +/// so the Core.Scripting layer has no Akka dependency. +/// +public interface IScriptLogPublisher +{ + /// + /// Publish a single . Implementations must be + /// thread-safe — Serilog sinks may be called from any thread. + /// + /// The entry to publish. Must not be null. + void Publish(ScriptLogEntry entry); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs new file mode 100644 index 00000000..026d5d33 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs @@ -0,0 +1,70 @@ +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 publisher implementation (supplied by a later task) 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". +/// +/// +public sealed class ScriptLogTopicSink : ILogEventSink +{ + private readonly IScriptLogPublisher _publisher; + private readonly LogEventLevel _min; + + /// 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: ). + /// Thrown when is null. + public ScriptLogTopicSink(IScriptLogPublisher publisher, LogEventLevel min = LogEventLevel.Information) + { + _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); + _min = min; + } + + /// + /// Converts the to a and + /// publishes it, unless the event is below the configured minimum level. + /// + /// 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; + + _publisher.Publish(new ScriptLogEntry( + ScriptId: P(ScriptLoggerFactory.ScriptIdProperty) + ?? P(ScriptLoggerFactory.ScriptNameProperty) + ?? "unknown", + Level: logEvent.Level.ToString(), + Message: logEvent.RenderMessage(), + TimestampUtc: logEvent.Timestamp.UtcDateTime, + VirtualTagId: P(ScriptLoggerFactory.VirtualTagIdProperty), + AlarmId: P(ScriptLoggerFactory.AlarmIdProperty), + EquipmentId: P(ScriptLoggerFactory.EquipmentIdProperty))); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs index c37c7635..604ece8e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs @@ -28,6 +28,18 @@ public sealed class ScriptLoggerFactory /// Structured property name the enricher binds. Stable for log filtering. public const string ScriptNameProperty = "ScriptName"; + /// Structured property name carrying the Script row identifier (Script.ScriptId). + public const string ScriptIdProperty = "ScriptId"; + + /// Structured property name carrying the VirtualTag identifier, when the script runs in a virtual-tag context. + public const string VirtualTagIdProperty = "VirtualTagId"; + + /// Structured property name carrying the ScriptedAlarm identifier, when the script runs in an alarm context. + public const string AlarmIdProperty = "AlarmId"; + + /// Structured property name carrying the Equipment identifier for per-equipment script evaluations. + public const string EquipmentIdProperty = "EquipmentId"; + private readonly ILogger _rootLogger; /// Initializes a new instance of the class. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj index a99df539..b1b73630 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs new file mode 100644 index 00000000..8304b847 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs @@ -0,0 +1,190 @@ +using Serilog.Events; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// Verifies converts Serilog +/// instances into records and hands them to the +/// . Covers level filtering, property extraction, +/// fallback for missing identity props, and message template rendering. +/// +[Trait("Category", "Unit")] +public sealed class ScriptLogTopicSinkTests +{ + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private sealed class FakePublisher : IScriptLogPublisher + { + /// Gets the entries that have been published. + public List Published { get; } = []; + + /// + public void Publish(ScriptLogEntry entry) => Published.Add(entry); + } + + /// Builds a with an optional set of string properties. + private static LogEvent Evt( + LogEventLevel level, + string template, + params (string Key, string Val)[] props) + { + var parser = new Serilog.Parsing.MessageTemplateParser(); + var tmpl = parser.Parse(template); + var logProps = props.Select(p => new LogEventProperty(p.Key, new ScalarValue(p.Val))); + return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, tmpl, logProps); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + /// + /// An Information event with all identity props present → publisher receives + /// exactly one entry with correctly mapped fields and null AlarmId. + /// + [Fact] + public void Information_event_with_all_identity_props_is_published_correctly() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher); + var evt = Evt(LogEventLevel.Information, "hello", + (ScriptLoggerFactory.ScriptIdProperty, "S1"), + (ScriptLoggerFactory.VirtualTagIdProperty, "V1"), + (ScriptLoggerFactory.EquipmentIdProperty, "EQ1")); + + sink.Emit(evt); + + publisher.Published.Count.ShouldBe(1); + var entry = publisher.Published[0]; + entry.ScriptId.ShouldBe("S1"); + entry.Level.ShouldBe("Information"); + entry.Message.ShouldBe("hello"); + entry.VirtualTagId.ShouldBe("V1"); + entry.EquipmentId.ShouldBe("EQ1"); + entry.AlarmId.ShouldBeNull(); + } + + /// + /// An AlarmId property on the event is mapped to . + /// + [Fact] + public void AlarmId_prop_is_mapped_to_entry_AlarmId() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher); + var evt = Evt(LogEventLevel.Information, "alarm fired", + (ScriptLoggerFactory.ScriptIdProperty, "S2"), + (ScriptLoggerFactory.AlarmIdProperty, "A1")); + + sink.Emit(evt); + + publisher.Published[0].AlarmId.ShouldBe("A1"); + } + + /// + /// When no identity properties are present all nullable fields are null and + /// ScriptId falls back to "unknown". + /// + [Fact] + public void Absent_identity_props_yield_null_fields_and_unknown_ScriptId() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher); + var evt = Evt(LogEventLevel.Warning, "bare message"); + + sink.Emit(evt); + + publisher.Published.Count.ShouldBe(1); + var entry = publisher.Published[0]; + entry.ScriptId.ShouldBe("unknown"); + entry.VirtualTagId.ShouldBeNull(); + entry.AlarmId.ShouldBeNull(); + entry.EquipmentId.ShouldBeNull(); + } + + /// + /// When only a ScriptName property (no ScriptId) is present, + /// falls back to the ScriptName value. + /// + [Fact] + public void ScriptName_fallback_used_when_ScriptId_absent() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher); + var evt = Evt(LogEventLevel.Information, "using name fallback", + (ScriptLoggerFactory.ScriptNameProperty, "NameFallback")); + + sink.Emit(evt); + + publisher.Published[0].ScriptId.ShouldBe("NameFallback"); + } + + /// + /// A Debug event is filtered by the default minimum level (Information) and + /// the publisher receives nothing. + /// + [Fact] + public void Debug_event_filtered_by_default_minimum_level() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher); // default min = Information + var evt = Evt(LogEventLevel.Debug, "debug noise", + (ScriptLoggerFactory.ScriptIdProperty, "S3")); + + sink.Emit(evt); + + publisher.Published.ShouldBeEmpty(); + } + + /// + /// When the sink is constructed with min: Debug, a Debug event reaches + /// the publisher with Level=="Debug". + /// + [Fact] + public void Debug_event_published_when_sink_min_level_is_Debug() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher, min: LogEventLevel.Debug); + var evt = Evt(LogEventLevel.Debug, "debug detail", + (ScriptLoggerFactory.ScriptIdProperty, "S4")); + + sink.Emit(evt); + + publisher.Published.Count.ShouldBe(1); + publisher.Published[0].Level.ShouldBe("Debug"); + } + + /// + /// A templated message is fully rendered before being stored so the consumer + /// sees a plain string rather than a raw template. + /// + [Fact] + public void Templated_message_is_rendered_before_publishing() + { + var publisher = new FakePublisher(); + var sink = new ScriptLogTopicSink(publisher); + + // Build the event manually to include a numeric property (can't be passed as string + // in the helper since we need an integer ScalarValue for realistic rendering). + var parser = new Serilog.Parsing.MessageTemplateParser(); + var tmpl = parser.Parse("v={V}"); + var props = new[] { new LogEventProperty("V", new ScalarValue(3)) }; + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, tmpl, props); + + sink.Emit(evt); + + publisher.Published[0].Message.ShouldBe("v=3"); + } + + /// Passing a null publisher to the constructor throws . + [Fact] + public void Null_publisher_throws_ArgumentNullException() + { + Should.Throw(() => new ScriptLogTopicSink(null!)); + } +}