feat(scripting): ScriptLogTopicSink — script LogEvent → ScriptLogEntry → publisher

This commit is contained in:
Joseph Doherty
2026-06-10 11:38:54 -04:00
parent 12423899aa
commit 14fe88fc80
5 changed files with 292 additions and 0 deletions
@@ -0,0 +1,19 @@
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Abstraction over the mechanism that publishes <see cref="ScriptLogEntry"/> records
/// to a downstream consumer (e.g. an Akka DPS <c>script-logs</c> 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.
/// </summary>
public interface IScriptLogPublisher
{
/// <summary>
/// Publish a single <see cref="ScriptLogEntry"/>. Implementations must be
/// thread-safe — Serilog sinks may be called from any thread.
/// </summary>
/// <param name="entry">The entry to publish. Must not be <c>null</c>.</param>
void Publish(ScriptLogEntry entry);
}
@@ -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;
/// <summary>
/// Serilog sink that converts each script <see cref="LogEvent"/> into a
/// <see cref="ScriptLogEntry"/> and forwards it to an <see cref="IScriptLogPublisher"/>.
/// The publisher implementation (supplied by a later task) routes the entry onto the
/// Akka DPS <c>script-logs</c> topic so the live Script-log Admin UI page can display it.
/// </summary>
/// <remarks>
/// <para>
/// Expected to be registered in the root script-logger pipeline alongside the
/// rolling <c>scripts-*.log</c> file sink and the
/// <see cref="ScriptLogCompanionSink"/>. Events below the configured minimum level
/// are silently discarded so Debug/Verbose noise does not saturate the cluster bus
/// in production deployments.
/// </para>
/// <para>
/// Identity properties (<c>ScriptId</c>, <c>VirtualTagId</c>, <c>AlarmId</c>,
/// <c>EquipmentId</c>) are lifted from the event's structured-property bag. If
/// <c>ScriptId</c> is absent the sink falls back to the legacy <c>ScriptName</c>
/// property so pipelines that pre-date this sink continue to work correctly. If
/// neither property is present <c>ScriptId</c> defaults to <c>"unknown"</c>.
/// </para>
/// </remarks>
public sealed class ScriptLogTopicSink : ILogEventSink
{
private readonly IScriptLogPublisher _publisher;
private readonly LogEventLevel _min;
/// <summary>Initializes a new instance of the <see cref="ScriptLogTopicSink"/> class.</summary>
/// <param name="publisher">The publisher that routes entries to the cluster bus. Must not be <c>null</c>.</param>
/// <param name="min">Minimum log level to forward. Events below this level are discarded (default: <see cref="LogEventLevel.Information"/>).</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="publisher"/> is <c>null</c>.</exception>
public ScriptLogTopicSink(IScriptLogPublisher publisher, LogEventLevel min = LogEventLevel.Information)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_min = min;
}
/// <summary>
/// Converts the <paramref name="logEvent"/> to a <see cref="ScriptLogEntry"/> and
/// publishes it, unless the event is below the configured minimum level.
/// </summary>
/// <param name="logEvent">The Serilog event to process. Silently ignored when <c>null</c>.</param>
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)));
}
}
@@ -28,6 +28,18 @@ public sealed class ScriptLoggerFactory
/// <summary>Structured property name the enricher binds. Stable for log filtering.</summary>
public const string ScriptNameProperty = "ScriptName";
/// <summary>Structured property name carrying the Script row identifier (<c>Script.ScriptId</c>).</summary>
public const string ScriptIdProperty = "ScriptId";
/// <summary>Structured property name carrying the VirtualTag identifier, when the script runs in a virtual-tag context.</summary>
public const string VirtualTagIdProperty = "VirtualTagId";
/// <summary>Structured property name carrying the ScriptedAlarm identifier, when the script runs in an alarm context.</summary>
public const string AlarmIdProperty = "AlarmId";
/// <summary>Structured property name carrying the Equipment identifier for per-equipment script evaluations.</summary>
public const string EquipmentIdProperty = "EquipmentId";
private readonly ILogger _rootLogger;
/// <summary>Initializes a new instance of the <see cref="ScriptLoggerFactory"/> class.</summary>
@@ -20,6 +20,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
</ItemGroup>
@@ -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;
/// <summary>
/// Verifies <see cref="ScriptLogTopicSink"/> converts Serilog <see cref="LogEvent"/>
/// instances into <see cref="ScriptLogEntry"/> records and hands them to the
/// <see cref="IScriptLogPublisher"/>. Covers level filtering, property extraction,
/// fallback for missing identity props, and message template rendering.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogTopicSinkTests
{
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private sealed class FakePublisher : IScriptLogPublisher
{
/// <summary>Gets the entries that have been published.</summary>
public List<ScriptLogEntry> Published { get; } = [];
/// <inheritdoc/>
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
}
/// <summary>Builds a <see cref="LogEvent"/> with an optional set of string properties.</summary>
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
// ---------------------------------------------------------------------------
/// <summary>
/// An Information event with all identity props present → publisher receives
/// exactly one entry with correctly mapped fields and null AlarmId.
/// </summary>
[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();
}
/// <summary>
/// An AlarmId property on the event is mapped to <see cref="ScriptLogEntry.AlarmId"/>.
/// </summary>
[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");
}
/// <summary>
/// When no identity properties are present all nullable fields are null and
/// ScriptId falls back to <c>"unknown"</c>.
/// </summary>
[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();
}
/// <summary>
/// When only a <c>ScriptName</c> property (no <c>ScriptId</c>) is present,
/// <see cref="ScriptLogEntry.ScriptId"/> falls back to the ScriptName value.
/// </summary>
[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");
}
/// <summary>
/// A Debug event is filtered by the default minimum level (Information) and
/// the publisher receives nothing.
/// </summary>
[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();
}
/// <summary>
/// When the sink is constructed with <c>min: Debug</c>, a Debug event reaches
/// the publisher with <c>Level=="Debug"</c>.
/// </summary>
[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");
}
/// <summary>
/// A templated message is fully rendered before being stored so the consumer
/// sees a plain string rather than a raw template.
/// </summary>
[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");
}
/// <summary>Passing a null publisher to the constructor throws <see cref="ArgumentNullException"/>.</summary>
[Fact]
public void Null_publisher_throws_ArgumentNullException()
{
Should.Throw<ArgumentNullException>(() => new ScriptLogTopicSink(null!));
}
}