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