diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs index e8c2b6a4ecb..8eb8cd1d0c8 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs @@ -12,6 +12,7 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; using Google.Protobuf; using Google.Protobuf.Collections; using Microsoft.Extensions.Logging.Abstractions; @@ -23,7 +24,6 @@ namespace Aspire.Dashboard.Benchmarks; [MemoryDiagnoser] -[ThreadingDiagnoser] [Config(typeof(Config))] public class TelemetryRepositoryMetricsBenchmarks { @@ -31,6 +31,7 @@ public class TelemetryRepositoryMetricsBenchmarks private const string MetricMeterName = "benchmark-meter"; private const string MetricInstrumentName = "benchmark.metric"; private const string HistogramMetricInstrumentName = "benchmark.histogram"; + private static readonly double[] s_histogramObservations = [5, 25, 75, 150]; private static readonly TimeSpan s_metricDataDuration = TimeSpan.FromHours(6); private static readonly TimeSpan s_metricDisplayDuration = TimeSpan.FromHours(6); private static readonly TimeSpan s_metricInterval = TimeSpan.FromSeconds(2); @@ -43,6 +44,8 @@ public class TelemetryRepositoryMetricsBenchmarks private DashboardSqliteDatabase _database = null!; private SqliteTelemetryRepository _queryRepository = null!; private IReadOnlyList _incrementalCursors = null!; + private RepeatedField _ingestionMetrics = null!; + private HistogramDataPoint[] _ingestionPoints = null!; [Params(1, 5)] public int DimensionCount { get; set; } @@ -65,13 +68,50 @@ public class TelemetryRepositoryMetricsBenchmarks [GlobalSetup(Target = nameof(GetHistogramMetricsIncrementalRollup))] public Task SetupHistogramMetricsIncrementalRollup() => SetupIncrementalAsync(isHistogram: true, HistogramMetricInstrumentName); - private async Task SetupAsync(bool isHistogram) + [GlobalSetup(Target = nameof(AddHistogramMetricsAtCapacity))] + public async Task SetupHistogramMetricsIngestion() + { + await InitializeRepositoryAsync(); + var retainedPointCount = new TelemetryLimitOptions().MaxMetricsCount; + var startTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var exemplars = s_histogramObservations.Select(value => CreateMetricExemplar(startTime, value)).ToArray(); + var context = new AddContext(); + foreach (var batch in CreateLongDurationMetricBatches(DimensionCount, isHistogram: true, retainedPointCount)) + { + foreach (var point in batch[0].ScopeMetrics[0].Metrics[0].Histogram.DataPoints) + { + // Cumulative reservoirs replay unrefreshed exemplars. Include them in every export to + // exercise point eviction and its cascading exemplar deletes in a long-running app. + point.Exemplars.Clear(); + point.Exemplars.Add(exemplars); + } + await _queryRepository.AddMetricsAsync(context, batch); + _ingestionMetrics = batch; + } + if (context.FailureCount > 0) + { + throw new InvalidOperationException($"Failed to add {context.FailureCount} benchmark metric points."); + } + + var points = _ingestionMetrics[0].ScopeMetrics[0].Metrics[0].Histogram.DataPoints; + _ingestionPoints = points.TakeLast(DimensionCount).ToArray(); + points.Clear(); + points.Add(_ingestionPoints); + } + + private async Task InitializeRepositoryAsync() { _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-metrics-benchmark-").FullName; _database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "query.db")); + await _database.InitializeSchemaAsync(CancellationToken.None); _queryRepository = CreateRepository(_database); + } + + private async Task SetupAsync(bool isHistogram) + { + await InitializeRepositoryAsync(); var addContext = new AddContext(); - foreach (var batch in CreateLongDurationMetricBatches(DimensionCount, isHistogram)) + foreach (var batch in CreateLongDurationMetricBatches(DimensionCount, isHistogram, (int)(s_metricDataDuration / s_metricInterval))) { await _queryRepository.AddMetricsAsync(addContext, batch); } @@ -84,7 +124,7 @@ private async Task SetupAsync(bool isHistogram) private async Task SetupIncrementalAsync(bool isHistogram, string instrumentName) { await SetupAsync(isHistogram); - var instrument = GetLongDurationInstrument(instrumentName, s_metricDataPointInterval); + var instrument = await GetLongDurationInstrumentAsync(instrumentName, s_metricDataPointInterval); _incrementalCursors = instrument.Dimensions.Select(dimension => { var latestValue = dimension.Values[^1]; @@ -106,57 +146,78 @@ public void Cleanup() } [Benchmark(Description = "TelemetryRepository: query 6h metrics display")] - public int GetMetricsLongDuration() + public async Task GetMetricsLongDuration() { - var instrument = GetLongDurationInstrument(MetricInstrumentName); + var instrument = await GetLongDurationInstrumentAsync(MetricInstrumentName); return instrument.Dimensions.Sum(dimension => dimension.Values.Count); } [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics display")] - public int GetHistogramMetricsLongDuration() + public async Task GetHistogramMetricsLongDuration() { - var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName); + var instrument = await GetLongDurationInstrumentAsync(HistogramMetricInstrumentName); return instrument.Dimensions.Sum(dimension => dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); } [Benchmark(Description = "TelemetryRepository: query 6h metrics with dashboard rollup")] - public int GetMetricsLongDurationRollup() + public async Task GetMetricsLongDurationRollup() { - var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval); + var instrument = await GetLongDurationInstrumentAsync(MetricInstrumentName, s_metricDataPointInterval); return instrument.Dimensions.Sum(dimension => dimension.Values.Count); } [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics with dashboard rollup")] - public int GetHistogramMetricsLongDurationRollup() + public async Task GetHistogramMetricsLongDurationRollup() { - var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, s_metricDataPointInterval); + var instrument = await GetLongDurationInstrumentAsync(HistogramMetricInstrumentName, s_metricDataPointInterval); return instrument.Dimensions.Sum(dimension => dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); } [Benchmark(Description = "TelemetryRepository: query incremental metrics with dashboard rollup")] - public int GetMetricsIncrementalRollup() + public async Task GetMetricsIncrementalRollup() { - var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); + var instrument = await GetLongDurationInstrumentAsync(MetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); return instrument.Dimensions.Sum(dimension => dimension.Values.Count); } [Benchmark(Description = "TelemetryRepository: query incremental histogram metrics with dashboard rollup")] - public int GetHistogramMetricsIncrementalRollup() + public async Task GetHistogramMetricsIncrementalRollup() { - var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); + var instrument = await GetLongDurationInstrumentAsync(HistogramMetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); return instrument.Dimensions.Sum(dimension => dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); } - private OtlpInstrumentData GetLongDurationInstrument( + [Benchmark(Description = "TelemetryRepository: ingest histogram metrics at retention capacity")] + public async Task AddHistogramMetricsAtCapacity() + { + foreach (var point in _ingestionPoints) + { + point.Count++; + point.Sum += s_histogramObservations[0]; + point.BucketCounts[0]++; + point.TimeUnixNano += (ulong)s_metricInterval.Ticks * 100; + } + + var context = new AddContext(); + await _queryRepository.AddMetricsAsync(context, _ingestionMetrics); + if (context.SuccessCount != DimensionCount || context.FailureCount > 0) + { + throw new InvalidOperationException($"Expected {DimensionCount} benchmark metric points, added {context.SuccessCount} and rejected {context.FailureCount}."); + } + + return context.SuccessCount; + } + + private async Task GetLongDurationInstrumentAsync( string instrumentName, TimeSpan? dataPointInterval = null, IReadOnlyList? dimensionCursors = null) @@ -165,7 +226,7 @@ private OtlpInstrumentData GetLongDurationInstrument( ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}' end time."); // Match the dashboard metrics display query, which includes one preceding rollup for histogram calculations. - return _queryRepository.GetInstrument(new GetInstrumentRequest + return await _queryRepository.GetInstrumentAsync(new GetInstrumentRequest { ResourceKey = s_metricResourceKey, MeterName = MetricMeterName, @@ -175,7 +236,7 @@ private OtlpInstrumentData GetLongDurationInstrument( DataPointInterval = dataPointInterval, PopulateExemplarAttributes = false, DimensionCursors = dimensionCursors ?? [] - }) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'."); + }, cancellationToken: CancellationToken.None) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'."); } private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabase database) @@ -189,13 +250,12 @@ private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabas []); } - private static IEnumerable> CreateLongDurationMetricBatches(int dimensionCount, bool isHistogram) + private static IEnumerable> CreateLongDurationMetricBatches(int dimensionCount, bool isHistogram, int totalSampleCount) { var startTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - double[] observations = [5, 25, 75, 150]; + var observations = s_histogramObservations; var bucketCounts = Enumerable.Range(0, dimensionCount).Select(_ => new ulong[observations.Length]).ToArray(); var sums = new double[dimensionCount]; - var totalSampleCount = (int)(s_metricDataDuration / s_metricInterval); for (var firstSampleIndex = 0; firstSampleIndex < totalSampleCount; firstSampleIndex += MetricSamplesPerBatch) { @@ -386,7 +446,7 @@ private sealed class Config : ManualConfig { public Config() { - AddJob(Job.Dry); + AddJob(Job.Dry.WithToolchain(InProcessNoEmitToolchain.Instance).DontEnforcePowerPlan()); AddDiagnoser(MemoryDiagnoser.Default); } diff --git a/docs/specs/dashboard-persistence.md b/docs/specs/dashboard-persistence.md index 575fb7c92d4..1bb5eb46fc7 100644 --- a/docs/specs/dashboard-persistence.md +++ b/docs/specs/dashboard-persistence.md @@ -295,6 +295,8 @@ The Dashboard applies these default ingestion limits: The oldest logs, traces, and metric points are removed when their limits are exceeded. Fixed limits of 10,000 also apply to resource views per resource, scopes per database, instruments per resource, and dimensions per instrument; additional identities are rejected. +The `AddHistogramMetricsAtCapacity` benchmark in [TelemetryRepositoryMetricsBenchmarks](../../benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs) measures steady-state ingestion with one or five dimensions already at the default retention limit. Each invocation adds one cumulative histogram point per dimension with four replayed exemplars, exercising eviction and cascading exemplar deletes. Payload construction and history population are outside the measured operation. + Console logs are persisted only after their stream is viewed or exported. The frontend keeps up to `Dashboard:Frontend:MaxConsoleLogCount` entries in memory, which defaults to 100,000. The same limit applies across all console logs in the database, with the oldest entries removed when the limit is exceeded. Historical runs can therefore omit uncaptured logs. ### Database file size diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs index 45fb498acbd..91eb9b7bd5b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs @@ -389,6 +389,7 @@ FROM updates foreach (var point in pointBatch.Inserts) { point.Context.SuccessCount += point.SourcePointCount; + point.Dimension.PointCount++; if (ReferenceEquals(point.Dimension.PendingPoint, point)) { @@ -423,6 +424,7 @@ private MetricDimensionState GetOrAddMetricDimension( var dimensions = connection.Query(""" SELECT d.dimension_id AS DimensionId, + (SELECT COUNT(*) FROM telemetry_metric_points WHERE dimension_id = d.dimension_id) AS PointCount, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue, p.point_id AS PointId, @@ -451,6 +453,7 @@ LIMIT 1 return new MetricDimensionState { DimensionId = group.Key, + PointCount = first.PointCount, Attributes = group .Where(record => record.AttributeKey is not null) .Select(record => KeyValuePair.Create(record.AttributeKey!, record.AttributeValue!)) @@ -733,22 +736,27 @@ INSERT OR IGNORE INTO telemetry_metric_exemplars ( private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensions) { - foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) + foreach (var dimension in dimensions) { + var removeCount = dimension.PointCount - _otlpContext.Options.MaxMetricsCount; + if (removeCount <= 0) + { + continue; + } + + // Count cached points instead of ranking the entire history on every insert. The dimension-order + // index lets SQLite visit only the oldest surplus points, even after the retention limit is reached. connection.Execute(""" DELETE FROM telemetry_metric_points WHERE point_id IN ( SELECT point_id - FROM ( - SELECT - point_id, - ROW_NUMBER() OVER (PARTITION BY dimension_id ORDER BY point_id DESC) AS point_rank - FROM telemetry_metric_points - WHERE dimension_id IN @DimensionIds - ) - WHERE point_rank > @MaxMetricsCount + FROM telemetry_metric_points + WHERE dimension_id = @DimensionId + ORDER BY point_id + LIMIT @RemoveCount ); - """, new { DimensionIds = batch.Select(dimension => dimension.DimensionId).ToArray(), _otlpContext.Options.MaxMetricsCount }, transaction); + """, new { dimension.DimensionId, RemoveCount = removeCount }, transaction); + dimension.PointCount -= removeCount; } } @@ -861,6 +869,7 @@ private sealed record PendingMetricDimensionAttribute(MetricDimensionState Dimen private sealed class MetricDimensionState { public long DimensionId { get; set; } + public long PointCount { get; set; } public required KeyValuePair[] Attributes { get; init; } public MetricPointRecord? LatestPoint { get; set; } public PendingMetricPoint? PendingPoint { get; set; } @@ -940,6 +949,7 @@ private sealed class PendingMetricPoint internal sealed class MetricDimensionStateRecord { public required long DimensionId { get; init; } + public required long PointCount { get; init; } public string? AttributeKey { get; init; } public string? AttributeValue { get; init; } public long? PointId { get; init; } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 01812a2c59d..ddede365faa 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; -using System.Diagnostics; using System.Text; using Aspire.Dashboard.Components; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; -using Aspire.Tests; using Google.Protobuf; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; @@ -1689,10 +1686,6 @@ public async Task GetInstrument_PopulateExemplarAttributesFalse_SkipsAttributes( value: 1, exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(1), 2, [KeyValuePair.Create("key", "value")])])) }); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("metric exemplar attributes test").Start(); - var instrument = await repository.GetInstrumentAsync(new GetInstrumentRequest { ResourceKey = CreateResource().GetResourceKey(), @@ -1705,15 +1698,10 @@ public async Task GetInstrument_PopulateExemplarAttributesFalse_SkipsAttributes( var exemplar = Assert.Single(Assert.Single(Assert.Single(instrument!.Dimensions).Values).Exemplars); Assert.Empty(exemplar.Attributes); - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!); - Assert.DoesNotContain(queries, query => query.Contains("telemetry_metric_exemplar_attributes", StringComparison.Ordinal)); - Assert.Single(queries, query => query.Contains("ranked_metric_points", StringComparison.Ordinal)); } [Fact] - public async Task GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() + public async Task GetInstrument_WithoutTimeRange_ReturnsNoValues() { using var repositoryContext = await CreateRepositoryAsync(); var repository = Assert.IsType(repositoryContext.Repository); @@ -1721,10 +1709,6 @@ public async Task GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() { CreateResourceMetrics(CreateSumMetric("test", s_queryTestTime.AddMinutes(1))) }); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("metric metadata test").Start(); - var instrument = await repository.GetInstrumentAsync(new GetInstrumentRequest { ResourceKey = CreateResource().GetResourceKey(), @@ -1733,10 +1717,6 @@ public async Task GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() }, cancellationToken: CancellationToken.None); Assert.Empty(Assert.Single(instrument!.Dimensions).Values); - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!); - Assert.DoesNotContain(queries, query => query.Contains("telemetry_metric_points", StringComparison.Ordinal)); } [Fact] @@ -2129,49 +2109,6 @@ public async Task GetHistogram_DataPointInterval_ReturnsLatestCoherentSnapshot() Assert.Equal(20, Assert.Single(value.Exemplars).Value); } - [Fact] - public async Task AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() - { - using var repositoryContext = await CreateRepositoryAsync(); - var repository = Assert.IsType(repositoryContext.Repository); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("metric ingestion test").Start(); - - var context = new AddContext(); - await repository.AsWriter().AddMetricsAsync(context, new RepeatedField - { - new ResourceMetrics - { - Resource = CreateResource(), - ScopeMetrics = - { - new ScopeMetrics - { - Scope = CreateScope(name: "test-meter"), - Metrics = - { - CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1), - CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(2), value: 2), - CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(3), value: 2) - } - } - } - } - }); - - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .ToList(); - Assert.Single(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); - Assert.Single(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); - var insertQuery = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_points", StringComparison.Ordinal)); - Assert.Equal(2, insertQuery.Split("@param_dimension_id_", StringSplitOptions.None).Length - 1); - Assert.Equal(3, context.SuccessCount); - } - [Fact] public async Task AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() { @@ -2255,7 +2192,7 @@ public async Task AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip } [Fact] - public async Task AddMetrics_BatchesAndDeduplicatesExemplars() + public async Task AddMetrics_DeduplicatesExemplars() { using var repositoryContext = await CreateRepositoryAsync(); var repository = Assert.IsType(repositoryContext.Repository); @@ -2267,10 +2204,6 @@ public async Task AddMetrics_BatchesAndDeduplicatesExemplars() value: 1, exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(1), 2, [KeyValuePair.Create("first", "value")])])) }); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("metric exemplar test").Start(); - var context = new AddContext(); await repository.AsWriter().AddMetricsAsync(context, new RepeatedField { @@ -2285,14 +2218,6 @@ public async Task AddMetrics_BatchesAndDeduplicatesExemplars() ])) }); - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .ToList(); - Assert.DoesNotContain(queries, query => query.Contains("SELECT EXISTS", StringComparison.Ordinal)); - var exemplarInsert = Assert.Single(queries, query => query.StartsWith("INSERT OR IGNORE INTO telemetry_metric_exemplars", StringComparison.Ordinal)); - Assert.Equal(2, exemplarInsert.Split("@PointId", StringSplitOptions.None).Length - 1); - Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_exemplar_attributes", StringComparison.Ordinal)); Assert.Equal(1, context.SuccessCount); Assert.Equal(0, context.FailureCount); @@ -2311,7 +2236,7 @@ public async Task AddMetrics_BatchesAndDeduplicatesExemplars() } [Fact] - public async Task AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() + public async Task AddMetrics_UpdateOnlyBatch_ExtendsExistingPoint() { using var repositoryContext = await CreateRepositoryAsync(); var repository = Assert.IsType(repositoryContext.Repository); @@ -2319,10 +2244,6 @@ public async Task AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() { CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1)) }); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("metric update test").Start(); - await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics @@ -2344,21 +2265,6 @@ public async Task AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() } }); - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .ToList(); - Assert.DoesNotContain(queries, query => query.Contains("SELECT resource_id", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_scopes", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_scope_attributes", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.StartsWith("UPDATE telemetry_resources SET has_metrics", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); - var updateQuery = Assert.Single(queries, query => query.Contains("UPDATE telemetry_metric_points", StringComparison.Ordinal)); - Assert.Contains("WITH updates", updateQuery, StringComparison.Ordinal); - Assert.Contains("FROM updates", updateQuery, StringComparison.Ordinal); - Assert.DoesNotContain("SELECT end_time_ticks FROM updates", updateQuery, StringComparison.Ordinal); var instrument = await repository.GetInstrumentAsync(new GetInstrumentRequest { ResourceKey = CreateResource().GetResourceKey(), @@ -2372,6 +2278,85 @@ public async Task AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() Assert.Equal(s_queryTestTime.AddMinutes(4), value.End); } + [Fact] + public async Task AddMetrics_Capacity_TrimsOnlyOverflowingDimensions() + { + using var repositoryContext = await CreateRepositoryAsync(maxMetricsCount: 3); + var repository = Assert.IsType(repositoryContext.Repository); + static ResourceMetrics CreatePoint(string dimension, int value) + { + var metric = CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMinutes(value), + value: value, + attributes: [KeyValuePair.Create("dimension", dimension)], + exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(value), value, [KeyValuePair.Create("key", "value")])]); + metric.Sum.DataPoints[0].StartTimeUnixNano = DateTimeToUnixNanoseconds(s_queryTestTime); + + return CreateResourceMetrics(metric); + } + + var context = new AddContext(); + await repository.AsWriter().AddMetricsAsync(context, new RepeatedField + { + CreatePoint("first", 1), + CreatePoint("first", 2), + CreatePoint("first", 3), + CreatePoint("second", 1) + }); + Assert.Equal(4, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + context = new AddContext(); + await repository.AsWriter().AddMetricsAsync(context, new RepeatedField + { + CreatePoint("first", 4), + CreatePoint("first", 4), + CreatePoint("first", 5), + CreatePoint("second", 2) + }); + Assert.Equal(4, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + var instrument = await repository.GetInstrumentAsync(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "test-meter", + InstrumentName = "test", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }, cancellationToken: CancellationToken.None); + Assert.Collection(instrument!.Dimensions, + dimension => + { + Assert.Equal("first", Assert.Single(dimension.Attributes).Value); + Assert.Equal([3L, 4L, 5L], dimension.Values.Select(value => Assert.IsType>(value).Value)); + foreach (var value in dimension.Values) + { + Assert.Equal("key", Assert.Single(Assert.Single(value.Exemplars).Attributes).Key); + } + }, + dimension => + { + Assert.Equal("second", Assert.Single(dimension.Attributes).Value); + Assert.Equal([1L, 2L], dimension.Values.Select(value => Assert.IsType>(value).Value)); + }); + + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField + { + CreatePoint("first", 6) + }); + instrument = await repository.GetInstrumentAsync(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "test-meter", + InstrumentName = "test", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }, cancellationToken: CancellationToken.None); + Assert.Equal([4L, 5L, 6L], instrument!.Dimensions[0].Values.Select(value => Assert.IsType>(value).Value)); + } + [Fact] public async Task ClearMetrics_InvalidatesMetricIngestionCache() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index e95a573ecec..f50e13648bf 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -7,10 +7,7 @@ using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; -using Aspire.Tests; using Aspire.Tests.Shared.DashboardModel; -using System.Collections.Concurrent; -using System.Diagnostics; using Google.Protobuf; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; @@ -198,7 +195,7 @@ public async Task InstrumentedPeer_RemainsInstrumented(bool resolvePeerDuringIng } [Fact] - public async Task Cache_HydratesPersistedMetadataOnce() + public async Task Cache_ReusesPersistedMetadata() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); @@ -223,12 +220,7 @@ public async Task Cache_HydratesPersistedMetadataOnce() } using var historicalContext = await CreateRepositoryAsync(workspace.Path, readOnly: true); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(historicalContext.Repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("cache hydration test").Start(); var firstResource = Assert.Single(historicalContext.Repository.GetResources()); - Assert.NotEmpty(activities); - activities.Clear(); var secondResource = Assert.Single(historicalContext.Repository.GetResources()); var summary = Assert.Single(historicalContext.Repository.GetInstrumentSummaries(firstResource.ResourceKey)); @@ -248,7 +240,6 @@ public async Task Cache_HydratesPersistedMetadataOnce() Assert.Equal(KeyValuePair.Create("resource-key", "resource-value"), property); }); Assert.Equal("requests", summary.Name); - Assert.Empty(activities); } [Fact] @@ -454,6 +445,88 @@ public async Task Metrics_ReopenFromNormalizedRows() Assert.Equal(1L, command.ExecuteScalar()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Metrics_ReopenRestoresPointCountsForRetention(bool failFirstWrite) + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); + var options = Options.Create(new DashboardOptions + { + TelemetryLimits = new TelemetryLimitOptions { MaxMetricsCount = 3 } + }); + + ResourceMetrics CreatePoints(params int[] values) => new() + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope("TestMeter"), + Metrics = + { + values.Select(value => CreateSumMetric( + "requests", + startTime.AddMinutes(value), + attributes: [KeyValuePair.Create("route", "/api"), KeyValuePair.Create("method", "GET")], + value: value)) + } + } + } + }; + + using (var repositoryContext = await SqliteRepositoryTestHelpers.CreateTelemetryRepositoryAsync(databasePath, dashboardOptions: options)) + { + await repositoryContext.Repository.AddMetricsAsync(new AddContext(), new RepeatedField + { + CreatePoints(1, 2, 3) + }); + } + + using var reopenedContext = await SqliteRepositoryTestHelpers.CreateTelemetryRepositoryAsync(databasePath, dashboardOptions: options); + if (failFirstWrite) + { + using var connection = reopenedContext.Database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TRIGGER fail_metric_trim BEFORE DELETE ON telemetry_metric_points + BEGIN + SELECT RAISE(ABORT, 'test metric trim failure'); + END; + """; + command.ExecuteNonQuery(); + + await Assert.ThrowsAsync(() => reopenedContext.Repository.AddMetricsAsync(new AddContext(), new RepeatedField + { + CreatePoints(4) + })); + + command.CommandText = "DROP TRIGGER fail_metric_trim;"; + command.ExecuteNonQuery(); + } + + var context = new AddContext(); + await reopenedContext.Repository.AddMetricsAsync(context, new RepeatedField + { + CreatePoints(3, 4, 4, 5) + }); + Assert.Equal(4, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + var instrument = await reopenedContext.Repository.GetInstrumentAsync(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "TestMeter", + InstrumentName = "requests", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }, cancellationToken: CancellationToken.None); + Assert.Equal([3L, 4L, 5L], Assert.Single(instrument!.Dimensions).Values.Select(value => Assert.IsType>(value).Value)); + } + [Fact] public async Task Metrics_HistogramPackedStorage_ReopensAndRejectsChangedBucketCountLength() {