Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added MongoPackRunApprovalStore for managing approval states with MongoDB.
- Introduced MongoPackRunArtifactUploader for uploading and storing artifacts.
- Created MongoPackRunLogStore to handle logging of pack run events.
- Developed MongoPackRunStateStore for persisting and retrieving pack run states.
- Implemented unit tests for MongoDB stores to ensure correct functionality.
- Added MongoTaskRunnerTestContext for setting up MongoDB test environment.
- Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
master
2025-11-07 10:01:35 +02:00
parent e5ffcd6535
commit a1ce3f74fa
122 changed files with 8730 additions and 914 deletions

View File

@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class RegistrySecretStageExecutorTests
{
[Fact]
public async Task ExecuteAsync_WithSecret_StoresCredentialsAndEmitsMetrics()
{
const string secretJson = """
{
"defaultRegistry": "registry.example.com",
"entries": [
{
"registry": "registry.example.com",
"username": "demo",
"password": "s3cret",
"expiresAt": "2099-01-01T00:00:00Z"
}
]
}
""";
var provider = new StubSecretProvider(secretJson);
var environment = new StubSurfaceEnvironment("tenant-eu");
var metrics = new ScannerWorkerMetrics();
var timeProvider = TimeProvider.System;
var executor = new RegistrySecretStageExecutor(
provider,
environment,
metrics,
timeProvider,
NullLogger<RegistrySecretStageExecutor>.Instance);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["surface.registry.secret"] = "primary"
};
var lease = new StubLease("job-1", "scan-1", metadata);
using var contextCancellation = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None);
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), contextCancellation.Token);
var measurements = new List<(long Value, KeyValuePair<string, object?>[] Tags)>();
using var listener = CreateCounterListener("scanner_worker_registry_secret_requests_total", measurements);
await executor.ExecuteAsync(context, CancellationToken.None);
listener.RecordObservableInstruments();
Assert.True(context.Analysis.TryGet<RegistryAccessSecret>(ScanAnalysisKeys.RegistryCredentials, out var secret));
Assert.NotNull(secret);
Assert.Single(secret!.Entries);
Assert.Contains(
measurements,
measurement => measurement.Value == 1 &&
HasTagValue(measurement.Tags, "secret.result", "resolved") &&
HasTagValue(measurement.Tags, "secret.name", "primary"));
}
[Fact]
public async Task ExecuteAsync_SecretMissing_RecordsMissingMetric()
{
var provider = new MissingSecretProvider();
var environment = new StubSurfaceEnvironment("tenant-eu");
var metrics = new ScannerWorkerMetrics();
var executor = new RegistrySecretStageExecutor(
provider,
environment,
metrics,
TimeProvider.System,
NullLogger<RegistrySecretStageExecutor>.Instance);
var lease = new StubLease("job-2", "scan-2", new Dictionary<string, string>());
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
var measurements = new List<(long Value, KeyValuePair<string, object?>[] Tags)>();
using var listener = CreateCounterListener("scanner_worker_registry_secret_requests_total", measurements);
await executor.ExecuteAsync(context, CancellationToken.None);
listener.RecordObservableInstruments();
Assert.False(context.Analysis.TryGet<RegistryAccessSecret>(ScanAnalysisKeys.RegistryCredentials, out _));
Assert.Contains(
measurements,
measurement => measurement.Value == 1 &&
HasTagValue(measurement.Tags, "secret.result", "missing") &&
HasTagValue(measurement.Tags, "secret.name", "default"));
}
private static MeterListener CreateCounterListener(
string instrumentName,
ICollection<(long Value, KeyValuePair<string, object?>[] Tags)> measurements)
{
var listener = new MeterListener
{
InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName &&
instrument.Name == instrumentName)
{
meterListener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var copy = tags.ToArray();
measurements.Add((measurement, copy));
});
listener.Start();
return listener;
}
private static bool HasTagValue(IEnumerable<KeyValuePair<string, object?>> tags, string key, string expected)
=> tags.Any(tag => string.Equals(tag.Key, key, StringComparison.OrdinalIgnoreCase) &&
string.Equals(tag.Value?.ToString(), expected, StringComparison.OrdinalIgnoreCase));
private sealed class StubSecretProvider : ISurfaceSecretProvider
{
private readonly string _json;
public StubSecretProvider(string json)
{
_json = json;
}
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
{
var bytes = Encoding.UTF8.GetBytes(_json);
return ValueTask.FromResult(SurfaceSecretHandle.FromBytes(bytes));
}
}
private sealed class MissingSecretProvider : ISurfaceSecretProvider
{
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
=> throw new SurfaceSecretNotFoundException(request);
}
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
{
public StubSurfaceEnvironment(string tenant)
{
Settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"bucket",
"region",
new DirectoryInfo(Path.GetTempPath()),
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("inline", tenant, null, null, null, AllowInline: true),
tenant,
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
};
RawVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; }
}
private sealed class StubLease : IScanJobLease
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public StubLease(string jobId, string scanId, IReadOnlyDictionary<string, string> metadata)
{
JobId = jobId;
ScanId = scanId;
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1);
LeasedAtUtc = DateTimeOffset.UtcNow;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; } = TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceManifestStageExecutorTests
{
[Fact]
public async Task ExecuteAsync_WhenNoPayloads_SkipsPublishAndRecordsSkipMetric()
{
var metrics = new ScannerWorkerMetrics();
var publisher = new TestSurfaceManifestPublisher();
var cache = new RecordingSurfaceCache();
var environment = new TestSurfaceEnvironment("tenant-a");
using var listener = new WorkerMeterListener();
listener.Start();
var executor = new SurfaceManifestStageExecutor(
publisher,
cache,
environment,
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance);
var context = CreateContext();
await executor.ExecuteAsync(context, CancellationToken.None);
Assert.Equal(0, publisher.PublishCalls);
Assert.Empty(cache.Entries);
var skipMetrics = listener.Measurements
.Where(m => m.InstrumentName == "scanner_worker_surface_manifests_skipped_total")
.ToArray();
Assert.Single(skipMetrics);
Assert.Equal(1, skipMetrics[0].Value);
Assert.Equal("skipped", skipMetrics[0]["surface.result"]);
}
[Fact]
public async Task ExecuteAsync_PublishesPayloads_CachesArtifacts_AndRecordsMetrics()
{
var metrics = new ScannerWorkerMetrics();
var publisher = new TestSurfaceManifestPublisher("tenant-a");
var cache = new RecordingSurfaceCache();
var environment = new TestSurfaceEnvironment("tenant-a");
using var listener = new WorkerMeterListener();
listener.Start();
var executor = new SurfaceManifestStageExecutor(
publisher,
cache,
environment,
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance);
var context = CreateContext();
PopulateAnalysis(context);
await executor.ExecuteAsync(context, CancellationToken.None);
Assert.Equal(1, publisher.PublishCalls);
Assert.True(context.Analysis.TryGet<SurfaceManifestPublishResult>(ScanAnalysisKeys.SurfaceManifest, out var result));
Assert.NotNull(result);
Assert.Equal(publisher.LastManifestDigest, result!.ManifestDigest);
Assert.Equal(4, cache.Entries.Count);
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.graph" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.ndjson" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.layer.fragments" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.manifests" && key.Tenant == "tenant-a");
var publishedMetrics = listener.Measurements
.Where(m => m.InstrumentName == "scanner_worker_surface_manifests_published_total")
.ToArray();
Assert.Single(publishedMetrics);
Assert.Equal(1, publishedMetrics[0].Value);
Assert.Equal("published", publishedMetrics[0]["surface.result"]);
Assert.Equal(3, Convert.ToInt32(publishedMetrics[0]["surface.payload_count"]));
var payloadMetrics = listener.Measurements
.Where(m => m.InstrumentName == "scanner_worker_surface_payload_persisted_total")
.ToArray();
Assert.Equal(3, payloadMetrics.Length);
Assert.Contains(payloadMetrics, m => Equals("entrytrace.graph", m["surface.kind"]));
Assert.Contains(payloadMetrics, m => Equals("entrytrace.ndjson", m["surface.kind"]));
Assert.Contains(payloadMetrics, m => Equals("layer.fragments", m["surface.kind"]));
}
private static ScanJobContext CreateContext()
{
var lease = new FakeJobLease();
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
}
private static void PopulateAnalysis(ScanJobContext context)
{
var node = new EntryTraceNode(
Id: 1,
Kind: EntryTraceNodeKind.Command,
DisplayName: "/bin/entry",
Arguments: ImmutableArray<string>.Empty,
InterpreterKind: EntryTraceInterpreterKind.None,
Evidence: null,
Span: null,
Metadata: null);
var graph = new EntryTraceGraph(
Outcome: EntryTraceOutcome.Resolved,
Nodes: ImmutableArray.Create(node),
Edges: ImmutableArray<EntryTraceEdge>.Empty,
Diagnostics: ImmutableArray<EntryTraceDiagnostic>.Empty,
Plans: ImmutableArray<EntryTracePlan>.Empty,
Terminals: ImmutableArray<EntryTraceTerminal>.Empty);
context.Analysis.Set(ScanAnalysisKeys.EntryTraceGraph, graph);
var ndjson = ImmutableArray.Create("{\"entry\":\"/bin/entry\"}\n");
context.Analysis.Set(ScanAnalysisKeys.EntryTraceNdjson, ndjson);
var component = new ComponentRecord
{
Identity = ComponentIdentity.Create("pkg:test", "test", "1.0.0"),
LayerDigest = "sha256:layer-1",
Evidence = ImmutableArray<ComponentEvidence>.Empty,
Usage = ComponentUsage.Create(true, new[] { "/bin/entry" })
};
var fragment = LayerComponentFragment.Create("sha256:layer-1", new[] { component });
context.Analysis.Set(ScanAnalysisKeys.LayerComponentFragments, ImmutableArray.Create(fragment));
}
private sealed class RecordingSurfaceCache : ISurfaceCache
{
private readonly Dictionary<SurfaceCacheKey, byte[]> _entries = new();
public IReadOnlyDictionary<SurfaceCacheKey, byte[]> Entries => _entries;
public Task<T> GetOrCreateAsync<T>(
SurfaceCacheKey key,
Func<CancellationToken, Task<T>> factory,
Func<T, ReadOnlyMemory<byte>> serializer,
Func<ReadOnlyMemory<byte>, T> deserializer,
CancellationToken cancellationToken = default)
{
if (_entries.TryGetValue(key, out var payload))
{
return Task.FromResult(deserializer(payload));
}
return CreateAsync(key, factory, serializer, cancellationToken);
}
public Task<T?> TryGetAsync<T>(
SurfaceCacheKey key,
Func<ReadOnlyMemory<byte>, T> deserializer,
CancellationToken cancellationToken = default)
{
if (_entries.TryGetValue(key, out var payload))
{
return Task.FromResult<T?>(deserializer(payload));
}
return Task.FromResult<T?>(default);
}
public Task SetAsync(
SurfaceCacheKey key,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken = default)
{
_entries[key] = payload.ToArray();
return Task.CompletedTask;
}
private async Task<T> CreateAsync<T>(
SurfaceCacheKey key,
Func<CancellationToken, Task<T>> factory,
Func<T, ReadOnlyMemory<byte>> serializer,
CancellationToken cancellationToken)
{
var value = await factory(cancellationToken).ConfigureAwait(false);
_entries[key] = serializer(value).ToArray();
return value;
}
}
private sealed class TestSurfaceManifestPublisher : ISurfaceManifestPublisher
{
private readonly string _tenant;
private readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public TestSurfaceManifestPublisher(string tenant = "tenant-a")
{
_tenant = tenant;
}
public int PublishCalls { get; private set; }
public SurfaceManifestRequest? LastRequest { get; private set; }
public string? LastManifestDigest { get; private set; }
public Task<SurfaceManifestPublishResult> PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken)
{
PublishCalls++;
LastRequest = request;
var artifacts = request.Payloads.Select(payload =>
{
var digest = ComputeDigest(payload.Content.Span);
return new SurfaceManifestArtifact
{
Kind = payload.Kind,
Uri = $"cas://test/{payload.Kind}/{digest}",
Digest = digest,
MediaType = payload.MediaType,
Format = payload.ArtifactFormat.ToString().ToLowerInvariant(),
SizeBytes = payload.Content.Length,
View = payload.View,
Metadata = payload.Metadata,
Storage = new SurfaceManifestStorage
{
Bucket = "test-bucket",
ObjectKey = $"objects/{digest}",
SizeBytes = payload.Content.Length,
ContentType = payload.MediaType
}
};
}).ToImmutableArray();
var document = new SurfaceManifestDocument
{
Tenant = _tenant,
ImageDigest = request.ImageDigest,
ScanId = request.ScanId,
GeneratedAt = DateTimeOffset.UtcNow,
Source = new SurfaceManifestSource
{
Component = request.Component,
Version = request.Version,
WorkerInstance = request.WorkerInstance,
Attempt = request.Attempt
},
Artifacts = artifacts
};
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(document, _options);
var manifestDigest = ComputeDigest(manifestBytes);
LastManifestDigest = manifestDigest;
var result = new SurfaceManifestPublishResult(
ManifestDigest: manifestDigest,
ManifestUri: $"cas://test/manifests/{manifestDigest}",
ArtifactId: $"surface-manifest::{manifestDigest}",
Document: document);
return Task.FromResult(result);
}
private static string ComputeDigest(ReadOnlySpan<byte> content)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(content, hash);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
private sealed class TestSurfaceEnvironment : ISurfaceEnvironment
{
public TestSurfaceEnvironment(string tenant)
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "surface-cache-test"));
Settings = new SurfaceEnvironmentSettings(
SurfaceFsEndpoint: new Uri("https://surface.local"),
SurfaceFsBucket: "test-bucket",
SurfaceFsRegion: null,
CacheRoot: cacheRoot,
CacheQuotaMegabytes: 512,
PrefetchEnabled: false,
FeatureFlags: Array.Empty<string>(),
Secrets: new SurfaceSecretsConfiguration("none", tenant, null, null, null, false),
Tenant: tenant,
Tls: new SurfaceTlsConfiguration(null, null, null));
}
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; } = new Dictionary<string, string>();
}
private sealed class FakeJobLease : IScanJobLease
{
private readonly Dictionary<string, string> _metadata = new()
{
["queue"] = "tests",
["job.kind"] = "unit"
};
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; } = DateTimeOffset.UtcNow.AddMinutes(-1);
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
public TimeSpan LeaseDuration { get; } = TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,61 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Globalization;
using StellaOps.Scanner.Worker.Diagnostics;
namespace StellaOps.Scanner.Worker.Tests.TestInfrastructure;
public sealed class WorkerMeterListener : IDisposable
{
private readonly MeterListener _listener;
public ConcurrentBag<Measurement> Measurements { get; } = new();
public WorkerMeterListener()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
AddMeasurement(instrument, measurement, tags);
});
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
AddMeasurement(instrument, measurement, tags);
});
}
public void Start() => _listener.Start();
public void Dispose() => _listener.Dispose();
public sealed record Measurement(string InstrumentName, double Value, IReadOnlyDictionary<string, object?> Tags)
{
public object? this[string name] => Tags.TryGetValue(name, out var value) ? value : null;
}
private void AddMeasurement<T>(Instrument instrument, T measurement, ReadOnlySpan<KeyValuePair<string, object?>> tags)
where T : struct, IConvertible
{
var tagDictionary = new Dictionary<string, object?>(tags.Length, StringComparer.Ordinal);
foreach (var tag in tags)
{
tagDictionary[tag.Key] = tag.Value;
}
var value = Convert.ToDouble(measurement, System.Globalization.CultureInfo.InvariantCulture);
Measurements.Add(new Measurement(instrument.Name, value, tagDictionary));
}
}

View File

@@ -1,19 +1,19 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
@@ -48,8 +48,8 @@ public sealed class WorkerBasicScanScenarioTests
var scheduler = new ControlledDelayScheduler();
var analyzer = new TestAnalyzerDispatcher(scheduler);
using var listener = new WorkerMetricsListener();
listener.Start();
using var listener = new WorkerMeterListener();
listener.Start();
using var services = new ServiceCollection()
.AddLogging(builder =>
@@ -341,46 +341,6 @@ public sealed class WorkerBasicScanScenarioTests
}
}
private sealed class WorkerMetricsListener : IDisposable
{
private readonly MeterListener _listener;
public ConcurrentBag<Measurement> Measurements { get; } = new();
public WorkerMetricsListener()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
var tagDictionary = new Dictionary<string, object?>(tags.Length, StringComparer.Ordinal);
foreach (var tag in tags)
{
tagDictionary[tag.Key] = tag.Value;
}
Measurements.Add(new Measurement(instrument.Name, measurement, tagDictionary));
});
}
public void Start() => _listener.Start();
public void Dispose() => _listener.Dispose();
}
public sealed record Measurement(string InstrumentName, double Value, IReadOnlyDictionary<string, object?> Tags)
{
public object? this[string name] => Tags.TryGetValue(name, out var value) ? value : null;
}
private sealed class TestLoggerProvider : ILoggerProvider
{
private readonly ConcurrentQueue<TestLogEntry> _entries = new();