using System.Diagnostics; using System.Diagnostics.Metrics; using Microsoft.Extensions.Logging; 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; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; /// /// Covers — the deadline and TTL cache the OPC UA impersonation /// callback's key lookup runs under (arch-review 03/S18). /// /// /// /// The deadline cases are driven through the real /// rather than the store directly, because the fail-closed conversion — a throw becoming the /// standard opaque denial — is part of what is being asserted, and it lives in the authenticator's /// catch-all rather than in the decorator. /// /// /// ⚠️ Deadline cases use a REAL short timeout; TTL cases use the fake clock. The hand-rolled /// overrides GetUtcNow() only, so CreateTimer falls through to /// the base implementation and produces a real system timer — advancing the fake clock does not /// fire a CancelAfter. A deadline test "improved" to advance the fake clock would pass for /// the wrong reason: the inner task simply never completing. /// /// public sealed class BoundedApiKeyStoreTests { private const string Pepper = "unit-test-pepper"; private const string Principal = "scadabridge-dcl"; /// /// A store that never answers must not hold the activation open: the deadline fires and the /// activation is denied. /// /// /// The SDK raises impersonation under a SessionManager-wide lock, so a lookup that does not /// return stalls every session activation in the server — not just this one. That is the whole of /// 03/S18, and it is why the bound is a deadline rather than a cache. /// [Fact] public async Task A_blocked_store_denies_without_hanging_the_activation() { var (presented, inner) = SetUp(); inner.BlockForever = true; var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0)); var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None) .WaitAsync(TimeSpan.FromSeconds(5)); result.Success.ShouldBeFalse(); result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); } /// /// The positive control for : /// the same fake, answering normally, still authenticates. /// /// /// Without it a build whose decorator denied everything would pass every deadline case in /// this class, and the bound would look correct while having locked the fleet out. /// [Fact] public async Task A_reachable_store_still_authenticates() { var (presented, inner) = SetUp(); var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0)); var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None); result.Success.ShouldBeTrue(); result.Principal.ShouldBe(Principal); } /// /// A lookup that completes with a perfectly valid key after the deadline still denies — /// nothing races back in past the bound. /// [Fact] public async Task A_deadline_never_admits() { var (presented, inner) = SetUp(); var gate = new TaskCompletionSource(); inner.Gate = gate.Task; var auth = Auth(Bounded(inner, timeoutMs: 100, cacheSeconds: 0)); var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None); // Let the deadline pass, THEN hand the inner store its (valid) answer. await Task.Delay(400); gate.SetResult(); var result = await pending.WaitAsync(TimeSpan.FromSeconds(5)); result.Success.ShouldBeFalse(); result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); } // ---------- the deadline is HARD, not cooperative (S18 live-gate follow-up, 2026-08-20) ---------- /// /// An inner store that ignores its cancellation token is abandoned at the deadline anyway: /// control returns to the activation inside the bound, not when the store eventually answers. /// /// /// /// ⚠️ This is the live-gate failure, and every other deadline case in this class misses /// it. They drive , whose waits take the token, so a /// cooperative CancelAfter is enough to unblock them. Real /// Microsoft.Data.SqlClient is not cooperative while a connect or login is in flight on /// a black-holed socket: the 2026-08-20 gate paused the SQL container and watched a keyed /// activation hang 42.9 s past a 2000 ms bound (the Warning fired only at unpause), with /// an unrelated open subscription stalled 57 s behind the SessionManager lock. /// /// /// The wall-clock assertion is what makes this test about the bound rather than about the /// exception type: a cooperative implementation does not throw late here, it does not return /// at all, so the test-side WaitAsync is the red. /// /// [Fact] public async Task An_uncancellable_store_is_abandoned_at_the_deadline() { var (_, inner) = SetUpUncooperative(); var sut = Bounded(inner, timeoutMs: 200, cacheSeconds: 5); var started = Stopwatch.StartNew(); await Should.ThrowAsync( () => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default) .WaitAsync(TimeSpan.FromSeconds(2))); started.Stop(); started.Elapsed.ShouldBeLessThan( TimeSpan.FromSeconds(2), "the deadline must return control on its own, not wait for the store to answer"); // The abandoned lookup is released only now — the assertions above already hold without it. inner.Release(); } /// /// A lookup abandoned at the deadline must never land in the cache when it completes later — the /// next activation re-reads the store. /// /// /// Caching it would be worse than caching a timeout: the answer was taken before the deadline and /// is served after it, so a key revoked or expired in the meantime would be admitted from a /// lookup nobody was still waiting for. The call count is the assertion — a build that stored the /// late result would serve the second call from the cache and leave Calls at 1. /// [Fact] public async Task An_abandoned_lookup_that_completes_later_is_not_cached() { var (_, inner) = SetUpUncooperative(); var sut = Bounded(inner, timeoutMs: 200, cacheSeconds: 60); await Should.ThrowAsync( () => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default) .WaitAsync(TimeSpan.FromSeconds(2))); // The abandoned inner task now completes, with a perfectly valid key, into nobody's hands. inner.Release(); await inner.Completed.WaitAsync(TimeSpan.FromSeconds(2)); // Well inside the 60 s TTL: a cached late answer would make this a hit. (await sut.FindAsync(inner.KeyId, DateTime.UtcNow, default).WaitAsync(TimeSpan.FromSeconds(2))) .ShouldNotBeNull("the store answers immediately once released"); inner.Calls.ShouldBe(2, "the abandoned lookup must not have populated the cache"); } /// /// An abandoned lookup that later faults has its exception observed and swallowed, so it /// can never surface as an UnobservedTaskException on the finalizer thread. /// /// /// Nobody awaits the abandoned task, so without an explicit continuation its fault is unobserved — /// which on some host configurations escalates. The Debug line is the observable proof the /// continuation ran; asserting the absence of a finalizer-thread event instead would depend on GC /// timing and prove nothing on a green run. /// [Fact] public async Task An_abandoned_lookup_that_faults_later_is_observed_and_swallowed() { var (_, inner) = SetUpUncooperative(); var log = new CapturingLogger(); var sut = Bounded(inner, timeoutMs: 200, cacheSeconds: 0, log: log); await Should.ThrowAsync( () => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default) .WaitAsync(TimeSpan.FromSeconds(2))); inner.ReleaseWithFault(new InvalidOperationException("SqlClient gave up")); await inner.Completed.WaitAsync(TimeSpan.FromSeconds(2)); // The continuation is a separate task from the one just awaited; give it a moment to run. var deadline = Stopwatch.StartNew(); while (log.Lines.Count == 0 && deadline.Elapsed < TimeSpan.FromSeconds(2)) await Task.Delay(10); log.Lines.ShouldContain(line => line.Contains("abandoned", StringComparison.OrdinalIgnoreCase)); } /// /// A non-positive StoreTimeoutMs disables the deadline, restoring the unbounded behaviour /// byte-for-byte — the documented rollback. /// [Fact] public async Task A_zero_timeout_disables_the_deadline() { var (presented, inner) = SetUp(); var gate = new TaskCompletionSource(); inner.Gate = gate.Task; var auth = Auth(Bounded(inner, timeoutMs: 0, cacheSeconds: 0)); var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None); // Far longer than any deadline this class configures; an active bound would have denied by now. await Task.Delay(300); pending.IsCompleted.ShouldBeFalse("a disabled deadline must not cancel a slow lookup"); gate.SetResult(); (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Success.ShouldBeTrue(); } /// The anonymous activation path gets the same deadline, and the same denial. /// /// It runs on every unauthenticated connect, so leaving it unbounded would leave the /// server-wide-stall hazard fully open on the busiest activation path. /// [Fact] public async Task Anonymous_grant_resolution_is_bounded_too() { var (_, inner) = SetUp(); inner.BlockForever = true; var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0)); var result = await auth.AuthenticateAnonymousAsync(CancellationToken.None) .WaitAsync(TimeSpan.FromSeconds(5)); result.Success.ShouldBeFalse(); } // ---------- TTL read-through cache ---------- /// A second activation inside the TTL is served from the cache and never reaches the store. [Fact] public async Task A_second_activation_within_the_TTL_does_not_reach_the_store() { var (_, inner) = SetUp(); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); clock.Advance(TimeSpan.FromSeconds(2)); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); inner.Calls.ShouldBe(1); } /// /// FALSIFICATION CONTROL. A key revoked in the store is denied once the TTL has passed — the /// cache expires rather than merely existing. /// /// /// A cache with no expiry at all passes every other case in this class: the hit test, the /// read-through test and the fault test are all satisfied by "store once, serve forever". This is /// the one that makes StoreCacheSeconds a bound rather than a claim. /// [Fact] public async Task A_revoked_key_is_denied_once_the_TTL_has_passed() { var (_, inner) = SetUp(); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)) .ShouldNotBeNull("non-vacuity: the key really was usable before the revocation"); inner.Revoked = true; // Still inside the TTL — the documented, accepted staleness window. clock.Advance(TimeSpan.FromSeconds(3)); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); // Past it — the store is re-read and the revocation lands. clock.Advance(TimeSpan.FromSeconds(3)); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull(); inner.Calls.ShouldBe(2); } /// A non-positive TTL disables the cache: every call reads through, today's behaviour. [Fact] public async Task TTL_zero_reads_through_every_time() { var (_, inner) = SetUp(); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 0, clock); for (var i = 0; i < 3; i++) await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default); inner.Calls.ShouldBe(3); } /// /// A fault is never cached: the next call reaches the store, so an outage cannot outlive itself. /// /// /// Caching a deny would be fail-closed and is still wrong — it would go on denying valid keys for /// a further TTL after the database came back. /// [Fact] public async Task A_faulted_store_is_not_cached() { var (_, inner) = SetUp(); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock); inner.ThrowsRemaining = 1; await Should.ThrowAsync( () => sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)); // Same instant — a cached fault would still be "fresh" here, so the clock cannot rescue this. (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); inner.Calls.ShouldBe(2); } /// The anonymous grant lookup is cached on the same terms, keyed by principal. [Fact] public async Task Anonymous_grants_are_cached_too() { var (_, inner) = SetUp(); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock); await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default); await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default); inner.Calls.ShouldBe(1); clock.Advance(TimeSpan.FromSeconds(6)); await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default); inner.Calls.ShouldBe(2); } /// /// Past the entry ceiling the cache clears wholesale and keeps answering — a key-id guessing flood /// is a bounded memory event, not an unbounded one. /// /// /// Wholesale rather than per-entry eviction for the reason SessionAclDecisionCache already /// documents: choosing WHICH entry to drop needs knowledge the cache does not have, and a /// repopulate is cheap and cannot be wrong. /// [Fact] public async Task Cache_overflow_clears_wholesale_and_keeps_serving() { var (_, inner) = SetUp(); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock); // Seed the real key, then flood past the ceiling with ids that do not exist. (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); for (var i = 0; i < BoundedApiKeyStore.MaxCacheEntries + 1; i++) await sut.FindAsync($"ghost-{i}", clock.GetUtcNow().UtcDateTime, default); var callsAfterFlood = inner.Calls; // Still correct after the clear — and the real key was evicted, so it is re-read rather than // served from a cache the flood was supposed to have emptied. (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); inner.Calls.ShouldBe(callsAfterFlood + 1); } /// /// A cached key whose ExpiresUtc passes inside the TTL is not served: the store is /// re-read instead. /// /// /// /// Without this the TTL would resurrect an expired key for up to StoreCacheSeconds — /// the exact axis 03/S19 exists to close, re-opened by 03/S18's own cache. The cache key is /// apiKeyId alone (utcNow changes every call and would defeat the cache), so the /// clock check has to happen on the way OUT of the cache. /// /// /// The inner store is flipped to Revoked first, so "served from cache" and "re-read" /// give different ANSWERS rather than only different call counts — a build that re-read and /// then ignored the result could not pass. /// /// [Fact] public async Task A_cached_key_that_expires_by_the_clock_is_not_served() { var start = new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero); var (_, inner) = SetUp(expiresUtc: start.UtcDateTime.AddSeconds(2)); var clock = new FakeClock(start); var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)) .ShouldNotBeNull("non-vacuity: the key was usable and IS now cached"); inner.Revoked = true; // Past ExpiresUtc (+2 s) but far inside the 60 s TTL — the entry is still "fresh" to the cache. clock.Advance(TimeSpan.FromSeconds(5)); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull(); inner.Calls.ShouldBe(2, "the expired entry must be re-read, not served"); } // ---------- metering ---------- /// A deadline that fires meters outcome=timeout — the alarm signal for a sick ConfigDb. [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( () => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default).WaitAsync(TimeSpan.FromSeconds(5))); meter.WithOutcome("timeout").ShouldBe(1); meter.WithOutcome("cache_hit").ShouldBe(0); } /// /// A cache hit meters outcome=cache_hit — the ratio that tells an operator the TTL is /// doing something. A missing stream on a busy fused central is how you notice /// StoreCacheSeconds was set to 0 by accident. /// [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); } /// A plain read-through — no deadline hit, no cache hit — meters nothing at all. /// /// 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. /// [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( IApiKeyStore inner, int timeoutMs, int cacheSeconds, TimeProvider? clock = null, ILogger? log = null) => new( inner, Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper, StoreTimeoutMs = timeoutMs, StoreCacheSeconds = cacheSeconds, }), log ?? NullLogger.Instance, clock ?? TimeProvider.System); private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new( store, Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }), NullLogger.Instance); private static (string Presented, GatedApiKeyStore Store) SetUp(DateTime? expiresUtc = null) { var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper); var key = new ApiKey { ApiKeyId = apiKeyId, Name = "test key", SecretHash = hash, Principal = Principal, Enabled = true, CreatedUtc = DateTime.UtcNow, ExpiresUtc = expiresUtc, }; return (presented, new GatedApiKeyStore(key)); } /// Builds the same key in front of an inner store that does NOT honour cancellation. /// The presented credential and the uncooperative store. private static (string Presented, UncooperativeApiKeyStore Store) SetUpUncooperative() { var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper); return (presented, new UncooperativeApiKeyStore(new ApiKey { ApiKeyId = apiKeyId, Name = "test key", SecretHash = hash, Principal = Principal, Enabled = true, CreatedUtc = DateTime.UtcNow, })); } /// /// An inner store whose answer can be withheld — a hang the EF in-memory provider cannot /// represent — and whose calls are counted. /// private sealed class GatedApiKeyStore(ApiKey key) : IApiKeyStore { /// The id of the key this store holds. public string KeyId => key.ApiKeyId; /// When set, every call awaits this before answering. public Task? Gate { get; set; } /// When true, no call ever completes. public bool BlockForever { get; set; } /// How many calls reached this store (i.e. were not served from the cache). public int Calls => _calls; /// When true, the key lookup answers "no usable key" — a revocation. public bool Revoked { get; set; } /// Number of leading calls that throw before the store starts answering. public int ThrowsRemaining { get; set; } private int _calls; public async Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) { await WaitAsync(ct).ConfigureAwait(false); if (Revoked) return null; return string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal) ? new ApiKeyLookup(key, []) : null; } public async Task> FindGrantsAsync(string principal, CancellationToken ct) { await WaitAsync(ct).ConfigureAwait(false); return []; } private async Task WaitAsync(CancellationToken ct) { Interlocked.Increment(ref _calls); if (ThrowsRemaining > 0) { ThrowsRemaining--; throw new InvalidOperationException("store is down"); } if (BlockForever) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); if (Gate is { } gate) await gate.WaitAsync(ct).ConfigureAwait(false); } } /// /// An inner store that ignores its cancellation token entirely — the shape real /// Microsoft.Data.SqlClient takes on a black-holed connection, and the one every other fake /// in this class cannot represent. /// /// /// passes the token into every wait, so a cooperative deadline /// unblocks it and a cooperative implementation looks correct. This one only ever completes when /// the test releases it, so the deadline has to be a real race to return at all. /// private sealed class UncooperativeApiKeyStore(ApiKey key) : IApiKeyStore { private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _completed = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _calls; /// The id of the key this store holds. public string KeyId => key.ApiKeyId; /// How many calls reached this store (i.e. were not served from the cache). public int Calls => _calls; /// Completes once an in-flight inner call has run to completion or fault. public Task Completed => _completed.Task; /// Lets the in-flight (and every subsequent) call answer normally. public void Release() => _gate.TrySetResult(); /// Lets the in-flight call fail, the way SqlClient eventually gives up on its own. /// The fault to surface from the abandoned inner task. public void ReleaseWithFault(Exception error) => _gate.TrySetException(error); public async Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) { Interlocked.Increment(ref _calls); try { // ⚠️ ct is deliberately NOT passed — that is the whole point of this fake. await _gate.Task.ConfigureAwait(false); } finally { _completed.TrySetResult(); } return string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal) ? new ApiKeyLookup(key, []) : null; } public async Task> FindGrantsAsync(string principal, CancellationToken ct) { Interlocked.Increment(ref _calls); try { await _gate.Task.ConfigureAwait(false); } finally { _completed.TrySetResult(); } return []; } } /// Collects rendered log messages so a "logged and swallowed" claim can be asserted. private sealed class CapturingLogger : ILogger { private readonly List _lines = []; private readonly Lock _gate = new(); /// Every line rendered so far. public IReadOnlyList Lines { get { lock (_gate) return [.. _lines]; } } /// public IDisposable? BeginScope(TState state) where TState : notnull => null; /// public bool IsEnabled(LogLevel logLevel) => true; /// public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (_gate) _lines.Add(formatter(state, exception)); } } /// Collects otopcua.opcua.apikey_store.bounded measurements by their outcome tag. /// /// ⚠️ 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 two checkable reasons: /// is the only thing in the product that writes this instrument, /// and this assembly declares CollectionBehavior(DisableTestParallelization = true) in /// AssemblyInfo.cs, so there is no parallel test to leak from. Removing that attribute /// means adding a discriminating tag here. /// private sealed class MeterRecorder : IDisposable { private readonly MeterListener _listener; private readonly List[]> _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((_, value, tags, _) => { lock (_gate) { _total += value; _tagSets.Add(tags.ToArray()); } }); _listener.Start(); } /// Every measurement seen, regardless of tags. public long Total { get { lock (_gate) return _total; } } /// How many measurements carried outcome=. /// The outcome tag value to count. /// The measurement count. public int WithOutcome(string outcome) { lock (_gate) return _tagSets.Count(set => set.Any(t => t.Key == "outcome" && Equals(t.Value, outcome))); } /// public void Dispose() => _listener.Dispose(); } /// /// A hand-advanced clock. Overrides GetUtcNow() only — see the class remarks for why that /// confines it to the TTL cases. /// private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider { private DateTimeOffset _now = utcNow; /// public override DateTimeOffset GetUtcNow() => _now; /// Moves the clock forward. /// How far forward to move. public void Advance(TimeSpan by) => _now = _now.Add(by); } }