feat(observability): meter bounded API-key store lookups
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
@@ -270,6 +272,61 @@ public sealed class BoundedApiKeyStoreTests
|
||||
inner.Calls.ShouldBe(callsAfterFlood + 1);
|
||||
}
|
||||
|
||||
// ---------- metering ----------
|
||||
|
||||
/// <summary>A deadline that fires meters <c>outcome=timeout</c> — the alarm signal for a sick ConfigDb.</summary>
|
||||
[Fact]
|
||||
public async Task A_timeout_meters_outcome_timeout()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
inner.BlockForever = true;
|
||||
using var meter = new MeterRecorder();
|
||||
var sut = Bounded(inner, timeoutMs: 100, cacheSeconds: 0);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default).WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
meter.WithOutcome("timeout").ShouldBe(1);
|
||||
meter.WithOutcome("cache_hit").ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cache hit meters <c>outcome=cache_hit</c> — the ratio that tells an operator the TTL is
|
||||
/// doing something. A missing stream on a busy fused central is how you notice
|
||||
/// <c>StoreCacheSeconds</c> was set to 0 by accident.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_cache_hit_meters_outcome_cache_hit()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
using var meter = new MeterRecorder();
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||
|
||||
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
|
||||
meter.WithOutcome("cache_hit").ShouldBe(0, "the first call is a miss");
|
||||
|
||||
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
|
||||
meter.WithOutcome("cache_hit").ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A plain read-through — no deadline hit, no cache hit — meters nothing at all.</summary>
|
||||
/// <remarks>
|
||||
/// The counter names lookups the bound INTERVENED on. Metering every lookup would bury the two
|
||||
/// outcomes that matter under the ordinary traffic they have to be spotted in.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_plain_read_through_meters_neither()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
using var meter = new MeterRecorder();
|
||||
var sut = Bounded(inner, timeoutMs: 2000, cacheSeconds: 0);
|
||||
|
||||
await sut.FindAsync(inner.KeyId, DateTime.UtcNow, default);
|
||||
|
||||
meter.Total.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static BoundedApiKeyStore Bounded(
|
||||
@@ -361,6 +418,65 @@ public sealed class BoundedApiKeyStoreTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Collects <c>otopcua.opcua.apikey_store.bounded</c> measurements by their outcome tag.</summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Filtered by instrument name only, which is normally the metrics-test leak (a listener sees
|
||||
/// a PARALLEL test's measurements too). It is safe HERE for a checkable reason:
|
||||
/// <see cref="BoundedApiKeyStore"/> is the only thing in the product that writes this instrument,
|
||||
/// and this class is the only one in the assembly that constructs one — and xUnit never runs two
|
||||
/// tests of the same class in parallel. Adding a class that exercises the decorator means adding
|
||||
/// a discriminating tag here.
|
||||
/// </remarks>
|
||||
private sealed class MeterRecorder : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<KeyValuePair<string, object?>[]> _tagSets = [];
|
||||
private readonly Lock _gate = new();
|
||||
private long _total;
|
||||
|
||||
public MeterRecorder()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName
|
||||
&& instrument.Name == "otopcua.opcua.apikey_store.bounded")
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
},
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_total += value;
|
||||
_tagSets.Add(tags.ToArray());
|
||||
}
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>Every measurement seen, regardless of tags.</summary>
|
||||
public long Total
|
||||
{
|
||||
get { lock (_gate) return _total; }
|
||||
}
|
||||
|
||||
/// <summary>How many measurements carried <c>outcome=<paramref name="outcome"/></c>.</summary>
|
||||
/// <param name="outcome">The outcome tag value to count.</param>
|
||||
/// <returns>The measurement count.</returns>
|
||||
public int WithOutcome(string outcome)
|
||||
{
|
||||
lock (_gate)
|
||||
return _tagSets.Count(set => set.Any(t => t.Key == "outcome" && Equals(t.Value, outcome)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A hand-advanced clock. Overrides <c>GetUtcNow()</c> only — see the class remarks for why that
|
||||
/// confines it to the TTL cases.
|
||||
|
||||
Reference in New Issue
Block a user