diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs index 3eb35004..cd97e90a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs @@ -196,6 +196,12 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers /// the first item that ingests cleanly — see . private bool _ingestFaultLogged; + /// Whether the current run of unsupported-kind drops has already been logged, so a backend that + /// drops a whole class of value (every comms-loss marker on the Gateway backend, say) logs once per run rather + /// than once per value. The counter still counts every value. Cleared by + /// the next value that is captured. + private bool _droppedKindLogged; + /// Last observed state of , so a change is logged once rather /// than on every value. Seeded open: a gate that starts closed logs its first suppression. private bool _captureGateOpen = true; @@ -553,10 +559,18 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers // (e.g. String/Empty on the gateway's numeric-only SQL analog path) — drop and meter. No value // content is logged. _droppedUnsupportedKind++; - _log.Debug("ContinuousHistorization: dropped a value of a kind the historian backend does not support."); + if (!_droppedKindLogged) + { + _droppedKindLogged = true; + _log.Debug("ContinuousHistorization: dropped a value of a kind the historian backend does not support. " + + "Further drops are only counted (DroppedUnsupportedKind) until a value is captured."); + } + return; } + _droppedKindLogged = false; + var timestampUtc = DateTime.SpecifyKind(msg.TimestampUtc, DateTimeKind.Utc); entries ??= new List(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationMetricsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationMetricsTests.cs new file mode 100644 index 00000000..044d82c1 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationMetricsTests.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.Metrics; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Observability; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; + +/// +/// The otopcua.historian.outbox.unreadable gauge reports the bound outbox's +/// . The listener is filtered by the instrument INSTANCE (the +/// shared object plus the instrument name), never by meter name alone, so a +/// parallel test's measurements on another meter of the same name cannot reach it; and the bound outbox reports a +/// distinctive count, so the asserted value can only have come from this test's outbox. +/// +public sealed class ContinuousHistorizationMetricsTests +{ + private const string GaugeName = "otopcua.historian.outbox.unreadable"; + + [Fact] + public void The_unreadable_gauge_reports_the_bound_outboxs_unreadable_count() + { + const long distinctive = 7_331_919L; + ContinuousHistorizationMetrics.BindOutbox(new UnreadableOutbox(distinctive)); + + var observed = new List(); + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (ReferenceEquals(instrument.Meter, OtOpcUaTelemetry.Meter) && instrument.Name == GaugeName) + l.EnableMeasurementEvents(instrument); + }, + }; + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (instrument.Name == GaugeName) observed.Add(value); + }); + listener.Start(); + listener.RecordObservableInstruments(); + + observed.ShouldBe([distinctive]); + } + + private sealed class UnreadableOutbox(long unreadable) : IHistorizationOutbox + { + public long DroppedCount => 0; + public long UnreadableCount => unreadable; + public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) => ValueTask.CompletedTask; + public ValueTask AppendBatchAsync(IReadOnlyList entries, CancellationToken ct) => ValueTask.CompletedTask; + public ValueTask> PeekBatchAsync(int max, CancellationToken ct) => + ValueTask.FromResult>([]); + public ValueTask RemoveAsync(Guid id, CancellationToken ct) => ValueTask.CompletedTask; + public ValueTask CountAsync(CancellationToken ct) => ValueTask.FromResult(0); + public void Dispose() { } + } +}