feat(siteeventlog): emit alarm-category events on alarm transitions (M1.5)

AlarmActor (computed) and NativeAlarmActor (native mirror) now fire-and-forget
an 'alarm' site operational event on every state transition:
- raise/activate: Error (priority/severity >= 700) or Warning
- clear/return-to-normal, ack, inter-band transition: Info

Both actors take a new optional IServiceProvider? ctor param (default null so
existing direct-construction tests still compile); InstanceActor passes its
_serviceProvider at the two Props.Create sites. Resolution is optional and the
LogEventAsync call is fire-and-forget, so a logging failure never affects alarm
evaluation. Rehydration replays are not re-logged.

Adds a capturing FakeSiteEventLogger test helper + SingleServiceProvider.
This commit is contained in:
Joseph Doherty
2026-06-15 12:23:04 -04:00
parent f49ac51771
commit a00e43c4f9
6 changed files with 368 additions and 9 deletions
@@ -1,11 +1,13 @@
using System.Text.Json;
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
@@ -35,6 +37,14 @@ public class NativeAlarmActor : ReceiveActor
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly AlarmKind _nativeKind;
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// M1.5: severity at or above which a native-alarm raise is logged as
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
/// Mirrors the 01000 condition-severity scale.
/// </summary>
private const int ErrorSeverityThreshold = 700;
/// <summary>Current mirrored conditions, keyed by source reference.</summary>
private readonly Dictionary<string, NativeAlarmTransition> _alarms = new();
@@ -54,6 +64,9 @@ public class NativeAlarmActor : ReceiveActor
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="nativeKind">Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
/// Instance Actor from the connection protocol. Defaults to <see cref="AlarmKind.NativeOpcUa"/>.</param>
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
/// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects the mirror.</param>
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
string instanceName,
@@ -62,7 +75,8 @@ public class NativeAlarmActor : ReceiveActor
SiteStorageService storage,
SiteRuntimeOptions options,
ILogger logger,
AlarmKind nativeKind = AlarmKind.NativeOpcUa)
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
IServiceProvider? serviceProvider = null)
{
_source = source;
_instanceName = instanceName;
@@ -72,6 +86,7 @@ public class NativeAlarmActor : ReceiveActor
_options = options;
_logger = logger;
_nativeKind = nativeKind;
_serviceProvider = serviceProvider;
Receive<RehydrationCompleted>(HandleRehydration);
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
@@ -150,7 +165,10 @@ public class NativeAlarmActor : ReceiveActor
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
null, row.LastTransitionAt, string.Empty, string.Empty);
_alarms[row.SourceReference] = t;
Emit(t, t.Condition);
// M1.5: rehydration replays last-known state on (re)start — surface it
// upward for the DebugView but do NOT re-log it as a fresh operational
// event (it is not a live transition).
Emit(t, t.Condition, logSiteEvent: false);
}
}
@@ -277,8 +295,16 @@ public class NativeAlarmActor : ReceiveActor
}
}
/// <summary>Builds and tells the parent an enriched <see cref="AlarmStateChanged"/> for a condition.</summary>
private void Emit(NativeAlarmTransition t, AlarmConditionState condition)
/// <summary>
/// Builds and tells the parent an enriched <see cref="AlarmStateChanged"/> for a condition.
/// </summary>
/// <param name="t">The mirrored transition.</param>
/// <param name="condition">The condition state to surface (may differ from <paramref name="t"/>'s
/// own condition, e.g. a synthesised return-to-normal on snapshot swap).</param>
/// <param name="logSiteEvent">M1.5: when <c>true</c> (live + snapshot transitions), emit an
/// <c>alarm</c> operational event. Suppressed for SQLite rehydration so a node restart does not
/// re-log every last-known condition.</param>
private void Emit(NativeAlarmTransition t, AlarmConditionState condition, bool logSiteEvent = true)
{
var change = new AlarmStateChanged(
_instanceName,
@@ -301,6 +327,49 @@ public class NativeAlarmActor : ReceiveActor
};
_instanceActor.Tell(change);
if (logSiteEvent)
{
LogAlarmEvent(t, condition);
}
}
/// <summary>
/// M1.5: fire-and-forget an <c>alarm</c> operational event mirroring a native
/// condition transition. An active condition is a raise (severity by the
/// condition's severity); an inactive condition is a return-to-normal; an
/// acknowledge transition is informational. Resolved optionally and never
/// awaited so a logging failure cannot affect the mirror (matching the
/// established ScriptActor/ScriptExecutionActor pattern).
/// </summary>
private void LogAlarmEvent(NativeAlarmTransition t, AlarmConditionState condition)
{
var logger = _serviceProvider?.GetService<ISiteEventLogger>();
if (logger == null)
{
return;
}
string severity;
string message;
if (t.Kind == AlarmTransitionKind.Acknowledge)
{
severity = "Info";
message = $"Native alarm {t.SourceReference} acknowledged";
}
else if (condition.Active)
{
severity = condition.Severity >= ErrorSeverityThreshold ? "Error" : "Warning";
message = $"Native alarm {t.SourceReference} active (severity {condition.Severity})";
}
else
{
severity = "Info";
message = $"Native alarm {t.SourceReference} returned to normal";
}
_ = logger.LogEventAsync(
"alarm", severity, _instanceName, $"NativeAlarmActor:{_source.CanonicalName}", message);
}
private void PersistUpsert(NativeAlarmTransition t)