diff --git a/.github/instructions/dashboard.instructions.md b/.github/instructions/dashboard.instructions.md index 1b91436274d..682cf19d3ec 100644 --- a/.github/instructions/dashboard.instructions.md +++ b/.github/instructions/dashboard.instructions.md @@ -13,3 +13,16 @@ applyTo: "src/Aspire.Dashboard/**/*.{cs,razor,js}" - For bounded channels feeding one consumer, prefer `BoundedChannelFullMode.DropOldest` and set `SingleReader = true`. - Use `FormatHelpers` for culture-aware date/time/number display. Reserve invariant formatting for intentionally fixed diagnostic formats. - Localize user-visible dashboard text with resource-backed localizers. Prefer typed localizers and `nameof` keys when practical, but existing model/helpers also generate localized UI text. + +## Blazor components + +- Avoid `@code` blocks and substantial C# logic in `.razor` files. Keep `.razor` files focused on markup, directives, and simple binding or event expressions; put component state, lifecycle methods, event handlers, and other logic in the matching `.razor.cs` code-behind partial class for better IDE and compiler support. +- Declare injected component dependencies as public, required, init-only properties: + + ```csharp + [Inject] + public required IDashboardClient DashboardClient { get; init; } + ``` + +- `public` keeps dependencies visible to component and test infrastructure, `required` expresses that the component cannot operate without the service, and `init` prevents reassignment after component activation. +- Do not use non-public injected properties, mutable `set` accessors, or null-forgiving initializers such as `= null!;`. These weaken compile-time validation and hide missing dependencies when components are constructed in tests. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5083725bc81..eedd5542fa1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -123,6 +123,7 @@ + @@ -141,6 +142,7 @@ + diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj index d075e13ae28..ce55a933ac6 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj +++ b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj @@ -14,6 +14,8 @@ + + diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs new file mode 100644 index 00000000000..2d35b912f1e --- /dev/null +++ b/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs @@ -0,0 +1,217 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.ServiceClient; +using BenchmarkDotNet.Attributes; +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; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Collector.Trace.V1; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Resource.V1; +using OpenTelemetry.Proto.Trace.V1; +using OtlpProtoSpan = OpenTelemetry.Proto.Trace.V1.Span; + +namespace Aspire.Dashboard.Benchmarks; + +[MemoryDiagnoser] +[Config(typeof(Config))] +public class SqliteTraceBenchmarks +{ + private const string TraceFileEnvironmentVariable = "ASPIRE_DASHBOARD_TRACE_BENCHMARK_FILE"; + private const int GeneratedSpanCount = 10_000; + private static readonly DateTime s_generatedTraceStartTime = DateTime.UnixEpoch; + + private string _temporaryDirectory = null!; + private DashboardSqliteDatabase _database = null!; + private SqliteTelemetryRepository _repository = null!; + private RepeatedField _appendResourceSpans = null!; + private long _appendIndex; + + [GlobalSetup] + public async Task Setup() + { + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-trace-benchmark-").FullName; + _database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db")); + _repository = CreateRepository(_database); + + var resourceSpans = LoadResourceSpans(); + var context = new AddContext(); + await _repository.AddTracesAsync(context, resourceSpans); + if (context.FailureCount != 0) + { + throw new InvalidOperationException($"Failed to ingest {context.FailureCount} benchmark spans."); + } + + _appendResourceSpans = CreateAppendResourceSpans(resourceSpans); + } + + [GlobalCleanup] + public void Cleanup() + { + _repository.Dispose(); + _database.ClearPool(); + _database.Dispose(); + Directory.Delete(_temporaryDirectory, recursive: true); + } + + [Benchmark(Description = "SQLite: summarize large trace")] + public int GetTraceSummaries() + { + var response = _repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 100, + Filters = [] + }); + + return response.PagedResult.Items.Count; + } + + [Benchmark(Description = "SQLite: append one span to large trace")] + public async Task AppendSpan() + { + var appendSpan = _appendResourceSpans[0].ScopeSpans[0].Spans[0]; + appendSpan.SpanId = ByteString.CopyFrom(BitConverter.GetBytes(long.MaxValue - Interlocked.Increment(ref _appendIndex))); + var context = new AddContext(); + await _repository.AddTracesAsync(context, _appendResourceSpans); + return context.SuccessCount; + } + + private static RepeatedField LoadResourceSpans() + { + var traceFile = Environment.GetEnvironmentVariable(TraceFileEnvironmentVariable); + if (string.IsNullOrWhiteSpace(traceFile)) + { + return CreateGeneratedResourceSpans(); + } + + var request = JsonParser.Default.Parse(File.ReadAllText(traceFile)); + return request.ResourceSpans; + } + + private static RepeatedField CreateGeneratedResourceSpans() + { + var resourceSpans = new ResourceSpans + { + Resource = CreateResource("benchmark-app"), + ScopeSpans = + { + new ScopeSpans + { + Scope = new InstrumentationScope { Name = "BenchmarkScope" } + } + } + }; + var scopeSpans = resourceSpans.ScopeSpans[0]; + var traceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("benchmark-trace")); + for (var spanIndex = 0; spanIndex < GeneratedSpanCount; spanIndex++) + { + var spanStartTime = s_generatedTraceStartTime.AddTicks(spanIndex); + scopeSpans.Spans.Add(new OtlpProtoSpan + { + TraceId = traceId, + SpanId = CreateSpanId(spanIndex), + ParentSpanId = spanIndex == 0 ? ByteString.Empty : CreateSpanId(spanIndex - 1), + Name = spanIndex == 0 ? "root-span" : $"span-{spanIndex}", + Kind = OtlpProtoSpan.Types.SpanKind.Internal, + StartTimeUnixNano = DateTimeToUnixNanoseconds(spanStartTime), + EndTimeUnixNano = DateTimeToUnixNanoseconds(spanStartTime.AddMilliseconds(5)) + }); + } + + return [resourceSpans]; + } + + private static RepeatedField CreateAppendResourceSpans(RepeatedField resourceSpans) + { + var firstSpan = resourceSpans.SelectMany(resource => resource.ScopeSpans).SelectMany(scope => scope.Spans).First(); + return + [ + new ResourceSpans + { + Resource = CreateResource("append-app"), + ScopeSpans = + { + new ScopeSpans + { + Scope = new InstrumentationScope { Name = "AppendScope" }, + Spans = + { + new OtlpProtoSpan + { + TraceId = firstSpan.TraceId, + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("append-span-0001")), + ParentSpanId = firstSpan.SpanId, + Name = "appended-span", + Kind = OtlpProtoSpan.Types.SpanKind.Internal, + StartTimeUnixNano = firstSpan.EndTimeUnixNano + 100, + EndTimeUnixNano = firstSpan.EndTimeUnixNano + 200 + } + } + } + } + } + ]; + } + + private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabase database) + { + return new SqliteTelemetryRepository( + database, + NullLoggerFactory.Instance, + Options.Create(new DashboardOptions + { + TelemetryLimits = new TelemetryLimitOptions { MaxTraceCount = 1_000 } + }), + new PauseManager(), + TimeProvider.System, + []); + } + + private static Resource CreateResource(string name) + { + return new Resource + { + Attributes = + { + new KeyValue { Key = "service.name", Value = new AnyValue { StringValue = name } } + } + }; + } + + private static ByteString CreateSpanId(int spanIndex) => + ByteString.CopyFrom(Encoding.UTF8.GetBytes($"span-{spanIndex:0000}")); + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var timeSinceEpoch = dateTime.ToUniversalTime() - DateTime.UnixEpoch; + return (ulong)timeSinceEpoch.Ticks * 100; + } + + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default + .WithToolchain(InProcessNoEmitToolchain.Instance) + .WithWarmupCount(1) + .WithIterationCount(3) + .WithInvocationCount(1) + .WithUnrollFactor(1)); + + AddDiagnoser(MemoryDiagnoser.Default); + } + } +} \ No newline at end of file diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs index 74995d7d651..f5a6e5afc9c 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.ServiceClient; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; @@ -63,30 +64,45 @@ public class TelemetryRepositoryBenchmarks ]; private RepeatedField _resourceSpans = []; - private TelemetryRepository _queryRepository = null!; + private string _temporaryDirectory = null!; + private DashboardSqliteDatabase _queryDatabase = null!; + private SqliteTelemetryRepository _queryRepository = null!; [GlobalSetup] - public void Setup() + public async Task Setup() { _resourceSpans = CreateResourceSpans(TraceCount, SpansPerTrace); - _queryRepository = CreateRepository(); - _queryRepository.AddTraces(new AddContext(), _resourceSpans); + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-benchmark-").FullName; + _queryDatabase = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "query.db")); + _queryRepository = CreateRepository(_queryDatabase); + await _queryRepository.AddTracesAsync(new AddContext(), _resourceSpans); } [GlobalCleanup] public void Cleanup() { _queryRepository.Dispose(); + _queryDatabase.ClearPool(); + _queryDatabase.Dispose(); + Directory.Delete(_temporaryDirectory, recursive: true); } [Benchmark(Description = "TelemetryRepository: add 10k spans")] - public int AddTracesLargeBatch() + public async Task AddTracesLargeBatch() { - using var repository = CreateRepository(); - var context = new AddContext(); - repository.AddTraces(context, _resourceSpans); + var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-add-benchmark-"); + using var database = new DashboardSqliteDatabase(Path.Combine(temporaryDirectory.FullName, "add.db")); + int successCount; + using (var repository = CreateRepository(database)) + { + var context = new AddContext(); + await repository.AddTracesAsync(context, _resourceSpans); + successCount = context.SuccessCount; + } + database.ClearPool(); + Directory.Delete(temporaryDirectory.FullName, recursive: true); - return context.SuccessCount; + return successCount; } [Benchmark(Description = "TelemetryRepository: query no filters")] @@ -94,8 +110,7 @@ public int GetTracesNoFilters() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = [], StartIndex = 0, Count = 100 @@ -109,8 +124,7 @@ public int GetTracesDurationFilter() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = _durationFilters, StartIndex = 0, Count = 100 @@ -124,8 +138,7 @@ public int GetTracesNoMatchDurationFilter() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = _noMatchDurationFilters, StartIndex = 0, Count = 100 @@ -139,8 +152,7 @@ public int GetTracesNoMatchAttributeFilter() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = _noMatchAttributeFilters, StartIndex = 0, Count = 100 @@ -149,9 +161,10 @@ public int GetTracesNoMatchAttributeFilter() return result.PagedResult.Items.Count; } - private static TelemetryRepository CreateRepository() + private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabase database) { - return new TelemetryRepository( + return new SqliteTelemetryRepository( + database, NullLoggerFactory.Instance, Options.Create(new DashboardOptions { @@ -161,6 +174,7 @@ private static TelemetryRepository CreateRepository() } }), new PauseManager(), + TimeProvider.System, []); } diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs new file mode 100644 index 00000000000..e8c2b6a4ecb --- /dev/null +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs @@ -0,0 +1,394 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Dashboard.Components; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.ServiceClient; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using Google.Protobuf; +using Google.Protobuf.Collections; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Resource.V1; + +namespace Aspire.Dashboard.Benchmarks; + +[MemoryDiagnoser] +[ThreadingDiagnoser] +[Config(typeof(Config))] +public class TelemetryRepositoryMetricsBenchmarks +{ + private const int MetricSamplesPerBatch = 100; + private const string MetricMeterName = "benchmark-meter"; + private const string MetricInstrumentName = "benchmark.metric"; + private const string HistogramMetricInstrumentName = "benchmark.histogram"; + 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); + private static readonly TimeSpan s_metricExemplarInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_metricDataPointInterval = MetricDataPointInterval.Get(s_metricDisplayDuration); + private static readonly TimeSpan s_metricHistoryDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, s_metricDataPointInterval.Ticks)); + private static readonly ResourceKey s_metricResourceKey = new("benchmark-app", "benchmark-instance"); + + private string _temporaryDirectory = null!; + private DashboardSqliteDatabase _database = null!; + private SqliteTelemetryRepository _queryRepository = null!; + private IReadOnlyList _incrementalCursors = null!; + + [Params(1, 5)] + public int DimensionCount { get; set; } + + [GlobalSetup(Target = nameof(GetMetricsLongDuration))] + public Task SetupMetrics() => SetupAsync(isHistogram: false); + + [GlobalSetup(Target = nameof(GetHistogramMetricsLongDuration))] + public Task SetupHistogramMetrics() => SetupAsync(isHistogram: true); + + [GlobalSetup(Target = nameof(GetMetricsLongDurationRollup))] + public Task SetupMetricsRollup() => SetupAsync(isHistogram: false); + + [GlobalSetup(Target = nameof(GetHistogramMetricsLongDurationRollup))] + public Task SetupHistogramMetricsRollup() => SetupAsync(isHistogram: true); + + [GlobalSetup(Target = nameof(GetMetricsIncrementalRollup))] + public Task SetupMetricsIncrementalRollup() => SetupIncrementalAsync(isHistogram: false, MetricInstrumentName); + + [GlobalSetup(Target = nameof(GetHistogramMetricsIncrementalRollup))] + public Task SetupHistogramMetricsIncrementalRollup() => SetupIncrementalAsync(isHistogram: true, HistogramMetricInstrumentName); + + private async Task SetupAsync(bool isHistogram) + { + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-metrics-benchmark-").FullName; + _database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "query.db")); + _queryRepository = CreateRepository(_database); + var addContext = new AddContext(); + foreach (var batch in CreateLongDurationMetricBatches(DimensionCount, isHistogram)) + { + await _queryRepository.AddMetricsAsync(addContext, batch); + } + if (addContext.FailureCount > 0) + { + throw new InvalidOperationException($"Failed to add {addContext.FailureCount} benchmark metric points."); + } + } + + private async Task SetupIncrementalAsync(bool isHistogram, string instrumentName) + { + await SetupAsync(isHistogram); + var instrument = GetLongDurationInstrument(instrumentName, s_metricDataPointInterval); + _incrementalCursors = instrument.Dimensions.Select(dimension => + { + var latestValue = dimension.Values[^1]; + return new MetricDimensionCursor + { + Attributes = dimension.Attributes, + StartTime = latestValue.End.Subtract(s_metricDataPointInterval) + }; + }).ToArray(); + } + + [GlobalCleanup] + public void Cleanup() + { + _queryRepository.Dispose(); + _database.ClearPool(); + _database.Dispose(); + Directory.Delete(_temporaryDirectory, recursive: true); + } + + [Benchmark(Description = "TelemetryRepository: query 6h metrics display")] + public int GetMetricsLongDuration() + { + var instrument = GetLongDurationInstrument(MetricInstrumentName); + + return instrument.Dimensions.Sum(dimension => dimension.Values.Count); + } + + [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics display")] + public int GetHistogramMetricsLongDuration() + { + var instrument = GetLongDurationInstrument(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() + { + var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval); + + return instrument.Dimensions.Sum(dimension => dimension.Values.Count); + } + + [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics with dashboard rollup")] + public int GetHistogramMetricsLongDurationRollup() + { + var instrument = GetLongDurationInstrument(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() + { + var instrument = GetLongDurationInstrument(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() + { + var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); + + return instrument.Dimensions.Sum(dimension => + dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); + } + + private OtlpInstrumentData GetLongDurationInstrument( + string instrumentName, + TimeSpan? dataPointInterval = null, + IReadOnlyList? dimensionCursors = null) + { + var endTime = _queryRepository.GetInstrumentLatestEndTime(s_metricResourceKey, MetricMeterName, instrumentName) + ?? 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 + { + ResourceKey = s_metricResourceKey, + MeterName = MetricMeterName, + InstrumentName = instrumentName, + StartTime = endTime.Subtract(s_metricDisplayDuration + s_metricHistoryDuration), + EndTime = endTime, + DataPointInterval = dataPointInterval, + PopulateExemplarAttributes = false, + DimensionCursors = dimensionCursors ?? [] + }) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'."); + } + + private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabase database) + { + return new SqliteTelemetryRepository( + database, + NullLoggerFactory.Instance, + Options.Create(new DashboardOptions()), + new PauseManager(), + TimeProvider.System, + []); + } + + private static IEnumerable> CreateLongDurationMetricBatches(int dimensionCount, bool isHistogram) + { + var startTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + double[] observations = [5, 25, 75, 150]; + 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) + { + var sampleCount = Math.Min(MetricSamplesPerBatch, totalSampleCount - firstSampleIndex); + yield return CreateLongDurationMetrics( + startTime, + dimensionCount, + firstSampleIndex, + sampleCount, + observations, + bucketCounts, + sums, + isHistogram); + } + } + + private static RepeatedField CreateLongDurationMetrics( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount, + double[] observations, + ulong[][] bucketCounts, + double[] sums, + bool isHistogram) + { + return + [ + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = new InstrumentationScope { Name = MetricMeterName }, + Metrics = + { + CreateMetric(startTime, dimensionCount, firstSampleIndex, sampleCount, observations, bucketCounts, sums, isHistogram) + } + } + } + } + ]; + } + + private static Metric CreateMetric( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount, + double[] observations, + ulong[][] bucketCounts, + double[] sums, + bool isHistogram) + { + return isHistogram + ? new Metric + { + Name = HistogramMetricInstrumentName, + Description = "Long-running benchmark histogram metric", + Unit = "ms", + Histogram = new Histogram + { + AggregationTemporality = AggregationTemporality.Cumulative, + DataPoints = { CreateHistogramMetricPoints(startTime, dimensionCount, firstSampleIndex, sampleCount, observations, bucketCounts, sums) } + } + } + : new Metric + { + Name = MetricInstrumentName, + Description = "Long-running benchmark metric", + Unit = "requests", + Sum = new Sum + { + AggregationTemporality = AggregationTemporality.Cumulative, + IsMonotonic = true, + DataPoints = { CreateMetricPoints(startTime, dimensionCount, firstSampleIndex, sampleCount) } + } + }; + } + + private static IEnumerable CreateMetricPoints( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount) + { + for (var sampleOffset = 0; sampleOffset < sampleCount; sampleOffset++) + { + var sampleIndex = firstSampleIndex + sampleOffset; + var pointTime = startTime.AddTicks(s_metricInterval.Ticks * sampleIndex); + for (var dimensionIndex = 0; dimensionIndex < dimensionCount; dimensionIndex++) + { + yield return new NumberDataPoint + { + AsInt = sampleIndex, + StartTimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + TimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + Attributes = { CreateDimensionAttribute(dimensionIndex) } + }; + } + } + } + + private static IEnumerable CreateHistogramMetricPoints( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount, + double[] observations, + ulong[][] bucketCounts, + double[] sums) + { + for (var sampleOffset = 0; sampleOffset < sampleCount; sampleOffset++) + { + var sampleIndex = firstSampleIndex + sampleOffset; + var pointTime = startTime.AddTicks(s_metricInterval.Ticks * sampleIndex); + var observationIndex = sampleIndex % observations.Length; + var observation = observations[observationIndex]; + for (var dimensionIndex = 0; dimensionIndex < dimensionCount; dimensionIndex++) + { + bucketCounts[dimensionIndex][observationIndex]++; + sums[dimensionIndex] += observation; + + var point = new HistogramDataPoint + { + Count = checked((ulong)sampleIndex + 1), + Sum = sums[dimensionIndex], + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + TimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + ExplicitBounds = { 10, 50, 100 }, + Attributes = { CreateDimensionAttribute(dimensionIndex) } + }; + point.BucketCounts.Add(bucketCounts[dimensionIndex]); + + if ((pointTime - startTime).Ticks % s_metricExemplarInterval.Ticks == 0) + { + point.Exemplars.Add(CreateMetricExemplar(pointTime, observation)); + } + + yield return point; + } + } + } + + private static KeyValue CreateDimensionAttribute(int dimensionIndex) + { + return new KeyValue + { + Key = "benchmark.dimension", + Value = new AnyValue { StringValue = dimensionIndex.ToString(CultureInfo.InvariantCulture) } + }; + } + + private static Exemplar CreateMetricExemplar(DateTime pointTime, double value) + { + return new Exemplar + { + TimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + AsDouble = value, + SpanId = ByteString.CopyFromUtf8("span-id0"), + TraceId = ByteString.CopyFromUtf8("trace-id00000000") + }; + } + + private static Resource CreateResource() + { + return new Resource + { + Attributes = + { + new KeyValue { Key = "service.name", Value = new AnyValue { StringValue = "benchmark-app" } }, + new KeyValue { Key = "service.instance.id", Value = new AnyValue { StringValue = "benchmark-instance" } } + } + }; + } + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var timeSinceEpoch = dateTime.ToUniversalTime() - unixEpoch; + + return (ulong)timeSinceEpoch.Ticks * 100; + } + + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Dry); + + AddDiagnoser(MemoryDiagnoser.Default); + } + } +} \ No newline at end of file diff --git a/docs/specs/dashboard-persistence.md b/docs/specs/dashboard-persistence.md new file mode 100644 index 00000000000..be1865b85a6 --- /dev/null +++ b/docs/specs/dashboard-persistence.md @@ -0,0 +1,359 @@ +# Dashboard persistence + +**Status:** Implemented by [microsoft/aspire#18768](https://github.com/microsoft/aspire/pull/18768) + +## Summary + +The Aspire Dashboard stores resource snapshots and telemetry in SQLite. Persistence makes completed application runs available after the AppHost and Dashboard processes stop, while retaining the live Dashboard experience for the active run. + +The design has three persistence modes: + +| Mode | Database lifetime | Historical run selection | Default use | +|------|-------------------|--------------------------|-------------| +| `None` | One temporary database per Dashboard process | No | Standalone Dashboard | +| `Run` | One persistent database per Dashboard process | Yes | AppHost Dashboard | +| `Resume` | One persistent database reused by later Dashboard processes | No | Explicit opt-in | + +## Usage examples + +### `None`: temporary standalone Dashboard + +The standalone Dashboard defaults to `None`: + +```bash +aspire dashboard run +``` + +This mode is useful for inspecting telemetry during a single development or diagnostic session. Data is lost when the CLI process or standalone Dashboard container stops. + +### `Run`: compare AppHost runs + +An AppHost-launched Dashboard defaults to `Run`; no additional configuration is required. Each time the AppHost starts, the Dashboard creates a separate run database. After changing the application code and restarting the AppHost, use the run selector to compare the current telemetry and resources with historical runs and evaluate the impact of the change. + +### `Resume`: long-running standalone Dashboard + +A standalone Dashboard can reuse one persistent database across restarts. For a CLI-managed Dashboard, select `Resume` and give the application a stable name: + +```bash +aspire dashboard run --application-name my-app --persistence Resume +``` + +The CLI uses the configured data directory or the default directory under `ASPIRE_HOME`. A container must mount the configured data directory from persistent storage. For example, a Docker named volume preserves the database when the container stops or is replaced: + +```bash +docker volume create aspire-dashboard-data + +docker run --rm -d \ + --name aspire-dashboard \ + -p 18888:18888 \ + -p 4317:18889 \ + -p 4318:18890 \ + --mount type=volume,source=aspire-dashboard-data,target=/dashboard-data \ + --env ASPIRE_DASHBOARD_APPLICATION_NAME=my-app \ + --env ASPIRE_DASHBOARD_DATA_DIRECTORY=/dashboard-data \ + --env ASPIRE_DASHBOARD_PERSISTENCE_MODE=Resume \ + mcr.microsoft.com/dotnet/aspire-dashboard:latest +``` + +Restart the container with the same volume, application name, data directory, and persistence mode to continue using the existing database. The Dashboard can remain running through idle periods, and its data remains available after stop and restart, subject to the configured telemetry retention limits. + +The AppHost console-log protocol does not identify the AppHost generation that produced a line. Resume prioritizes preserving logs from later AppHost generations, whose line numbers restart at 1. If the Dashboard restarts while the same AppHost remains running, console logs replayed by that AppHost can therefore be persisted again. + +## Goals + +- Preserve resources, console logs, structured logs, traces, spans, and metrics after an application run ends. +- Let users switch between the current run and completed runs without reloading the Dashboard. +- Keep historical runs read-only so viewing old data cannot affect the active application. +- Execute filtering, searching, paging, aggregation, field-value lookup, and retention in SQLite instead of loading the full data set into memory. +- Support high-volume ingestion through normalized storage, batched commands, transactions, and targeted caches. +- Keep the in-memory and SQLite telemetry repository contracts behaviorally equivalent where practical. + +## Non-goals + +- Providing a durable production telemetry backend or replacing an observability service. +- Sharing a writable database across machines or Dashboard processes. +- Replication, backup, or database-level user authentication. +- Encryption at rest. Protection of persisted data is delegated to the containing directory's access controls. +- Migrating persisted data between incompatible schema versions. + +## Configuration + +The Dashboard binds persistence settings from configuration or their environment-variable equivalents: + +| Configuration key | Environment variable | Description | +|-------------------|----------------------|-------------| +| `Dashboard:ApplicationName` | `ASPIRE_DASHBOARD_APPLICATION_NAME` | Logical application name that partitions persisted data | +| `Dashboard:Data:Directory` | `ASPIRE_DASHBOARD_DATA_DIRECTORY` | Root directory for persistent Dashboard data | +| `Dashboard:Data:PersistenceMode` | `ASPIRE_DASHBOARD_PERSISTENCE_MODE` | `None`, `Run`, or `Resume` | + +An AppHost-launched Dashboard receives the normalized application name and defaults to `Run`. The AppHost can override the mode with `Aspire:Dashboard:PersistenceMode` or `ASPIRE_DASHBOARD_PERSISTENCE_MODE`. + +The standalone `aspire dashboard run` command accepts `--application-name` and `--persistence`. It does not set a persistence default, so the Dashboard default of `None` applies unless configuration, an environment variable, or the command-line option selects another mode. + +When no data directory is configured, persistent modes use the `dashboard` directory under `ASPIRE_HOME`, whose default is the current user's `.aspire` directory. The configured directory must be scoped to and protected for the current user. + +On Unix, the per-application directory beneath the data root is created with owner-only (`0700`) permissions. Existing application directories are also restricted to that mode before persistent data is accessed. On Windows, the directory is created without Unix permission flags and uses the inherited ACL. + +## Storage layout + +Persistent data is partitioned by application. The application directory name contains a readable prefix and a stable hash: + +```text +-<16-character-xxhash3> +``` + +The prefix preserves ASCII letters, digits, `-`, and `_`; other characters become `-`. It is trimmed and truncated so the complete directory name is at most 80 characters. The hash prevents different original names that sanitize to the same prefix from colliding. + +### `None` + +```text +/aspire-dashboard-/ +└── dashboard.db +``` + +The Dashboard creates the directory with `Directory.CreateTempSubdirectory`, removes it on clean shutdown, and opportunistically removes abandoned `aspire-dashboard-*` directories that contain a database and are not locked by another process. + +### `Run` + +```text +/ +└── / + └── runs/ + ├── .lock + └── / + ├── dashboard.db + └── run.json +``` + +Run IDs use the UTC start time in `yyyyMMddTHHmmssfffZ` format. `run.json` contains: + +- schema version +- run ID +- UTC start and end times +- clean-shutdown flag +- application name +- database file name + +The metadata is written only after the database schema is initialized, so an interrupted startup does not publish an unusable run. On graceful disposal it is rewritten with the end time and `CleanShutdown` set to `true`. A published run left with `CleanShutdown` set to `false` is still discoverable after its process releases the lock; the flag records how it ended rather than disqualifying its data. + +### `Resume` + +```text +/ +├── .lock +└── / + └── dashboard.db +``` + +Later Dashboard processes continue writing to the same database. This mode does not create run metadata or expose the run selector. + +SQLite can also create `dashboard.db-wal` and `dashboard.db-shm` beside a writable database. + +## Run ownership and retention + +Each writable run has an adjacent lock file held open with exclusive sharing and `DeleteOnClose`. An adjacent lock lets a cooperating process hold the lock while deleting the run directory on Windows. A second Dashboard cannot use the same `Run` directory or `Resume` application database while its owner holds the lock. + +Historical discovery only includes directories that: + +- are not the current run; +- are not locked by another Dashboard process; +- have readable, valid `run.json` metadata; and +- have the current metadata schema version. + +Run discovery orders pinned runs before unpinned runs, then orders each group by descending start time. The run selector applies its presentation order separately: the current run is first, followed by pinned historical runs and then unpinned historical runs, with each historical group ordered by descending start time. Pin state is stored in `run.json`; both current and historical runs can be pinned or unpinned. + +`Run` mode retains the five newest unpinned historical run directories. The current run and pinned historical runs do not count toward this limit, so the total number of retained runs is not fixed. Pruning happens after a new run writes its metadata. Before deleting a candidate, the pruner acquires its lock and rechecks its pin state. A historical run selected by another Dashboard circuit or a run owned by another Dashboard process is skipped, which can temporarily leave more than five unpinned historical runs. I/O and access failures are logged and do not prevent Dashboard startup. + +## Database lifecycle + +All modes use the same SQLite repositories. `None` changes only the directory lifetime. + +The Dashboard uses the `Microsoft.Data.Sqlite.Core` package and explicitly references `SQLitePCLRaw.bundle_e_sqlite3` so the native SQLite runtime can be updated independently from the managed provider. + +The current schema version is stored as exactly one row in `dashboard_schema`. Ordered embedded scripts under `ServiceClient/DatabaseSchema` initialize the schema in one transaction. Schema creation enables: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +``` + +WAL lets readers continue while the writer commits. `synchronous=NORMAL` preserves database consistency while allowing a power loss to discard the newest transactions, which is an acceptable tradeoff for development telemetry. + +There are no schema migrations. Behavior on incompatibility depends on the mode: + +- `None` always starts with a new temporary database. +- `Run` creates a new database for each process and ignores historical runs whose metadata schema version is incompatible. +- `Resume` deletes the database, WAL, and shared-memory files and creates a new database only after reading a schema version that is incompatible. Failures while probing compatibility are surfaced and leave the existing files in place. +- Opening a selected historical run validates that its database schema matches its metadata and fails the switch if it does not. + +## Data model + +The schema is normalized and uses SQLite `STRICT` tables, foreign keys, cascade deletion, and query-specific indexes. OTLP payloads are not stored as opaque JSON or protobuf blobs. + +### Resources and console logs + +`dashboard_resources` stores the latest snapshot for each resource replica. Child tables preserve ordered environment variables, URLs, volumes, health reports, relationships, properties, commands, command inputs, input options, and validation errors. + +`console_logs` stores resource name, line number, content, and whether the line came from standard error. An index supports ordered retrieval by resource. + +### Shared telemetry identity + +`telemetry_resources` identifies a resource and optional service instance and records which signal types it has produced. Resource views preserve the resource attributes associated with an individual telemetry item. + +`telemetry_scopes` and `telemetry_scope_attributes` normalize instrumentation scope data shared by logs, spans, and metrics. Scope identity intentionally uses only the scope name; the version and attributes from the first observed scope are retained. + +### Structured logs + +`telemetry_logs` stores resource and scope references, timestamp, severity, message, trace and span correlation, flags, original format, and event name. `telemetry_log_attributes` stores ordered key/value attributes. + +Indexes support global and per-resource time ordering, trace and span correlation, and case-insensitive attribute filtering. + +### Traces and spans + +`telemetry_traces` stores query-oriented summaries, including first and last timestamps, duration, display name, primary span, error state, and generative-AI state. + +`telemetry_spans` stores span identity, parent, resource view, scope, name, kind, timestamps, status, trace state, and resolved uninstrumented peer. Related tables store: + +- per-trace resource summaries and ordering; +- span attributes; +- events and event attributes; and +- links and link attributes. + +Summary tables and indexes let trace list queries avoid reconstructing every trace. When a late parent or another change invalidates an incremental summary, the repository rebuilds the affected trace data rather than all stored traces. + +### Metrics + +`telemetry_metric_instruments` identifies instruments by resource, scope, and name and stores type, temporality, monotonicity, unit, and description. + +Metric dimensions are normalized into an attribute set and stable non-cryptographic hash. Points store timestamps, point type, repeated-value count, integer or floating-point values, and histogram data. Histogram bucket counts and explicit bounds are compact binary values rather than JSON. Exemplars and their filtered attributes are separate rows correlated to trace and span IDs. + +Indexes support instrument lookup, dimension matching, time-window queries, retention, and exemplar lookup. + +## Repository behavior + +`DashboardDataSourcePool` owns the current writable database and pools selected historical databases across Blazor circuits. `DashboardDataSource` is scoped to a circuit and exposes the resource and telemetry repositories for that circuit's selected run. + +```mermaid +flowchart LR + AppHost[AppHost resource stream] --> CurrentResources[Current resource repository] + OTLP[OTLP receivers] --> CurrentTelemetry[Current telemetry repository] + CurrentResources --> CurrentDB[(Current dashboard.db)] + CurrentTelemetry --> CurrentDB + RunSelector[Run selector] --> DataSource[DashboardDataSource] + DataSource --> CurrentResources + DataSource --> CurrentTelemetry + DataSource --> HistoricalResources[Historical resource repository] + DataSource --> HistoricalTelemetry[Historical telemetry repository] + HistoricalResources --> HistoricalDB[(Historical dashboard.db, read-only)] + HistoricalTelemetry --> HistoricalDB +``` + +Selecting `Current` uses the pool-owned writable repositories. Selecting a historical run: + +1. Acquires the run lock so pruning cannot remove it while selected. +2. Opens the database with `SqliteOpenMode.ReadOnly`. +3. Validates the schema version. +4. Reference-counts and shares the database across circuits. +5. Releases the database and run lock after the last circuit stops using it. + +The replacement database and repositories are acquired and validated before the selected data source changes. If acquisition or validation fails, the previous repositories and run lease remain active. Interactive failures are logged without terminating the Blazor circuit, and browser session storage retains the run that remains selected. + +The repository interfaces preserve subscriptions and watcher behavior for the live database. Historical repositories are immutable snapshots: write methods reject calls, and pages do not register for incoming telemetry updates. + +## Write and query strategy + +SQLite has one writer. `DashboardSqliteDatabase.WriteLock` serializes resource and telemetry writes before they begin transactions. Add operations batch rows and commands, perform related writes in a transaction, and roll back the batch on failure. + +Connection strings enable foreign keys and pooling and use a five-second default timeout. Repository-level caches avoid repeated lookups for stable resource views, scopes, telemetry resources, instruments, and dimensions. Writes invalidate the affected query caches. + +The repositories push work into SQL for: + +- text and structured-field filtering; +- attribute key and value lookup; +- stable paging and ordering; +- trace summaries and resource aggregation; +- metric dimension and time-window aggregation; and +- count- and time-based retention cleanup. + +This keeps memory proportional to the requested page or active ingestion batch rather than the complete run history. + +## Data limits and performance + +The Dashboard applies these default ingestion limits: + +| Configuration key | Default | Scope and behavior | +|-------------------|---------|--------------------| +| `Dashboard:TelemetryLimits:MaxLogCount` | 10,000 | Structured logs per database | +| `Dashboard:TelemetryLimits:MaxTraceCount` | 10,000 | Traces per database | +| `Dashboard:TelemetryLimits:MaxMetricsCount` | 50,000 | Metric points per dimension | +| `Dashboard:TelemetryLimits:MaxAttributeCount` | 128 | Attributes per telemetry item | +| `Dashboard:TelemetryLimits:MaxAttributeLength` | Unlimited | Attribute value length | +| `Dashboard:TelemetryLimits:MaxSpanEventCount` | Unlimited | Events per span | +| `Dashboard:TelemetryLimits:MaxResourceCount` | 10,000 | Distinct OTLP resources | + +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. + +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 10,000, but persisted console logs are unbounded. Historical runs can therefore omit uncaptured logs or contain many captured logs. + +### Database file size + +The Dashboard does not limit SQLite file size. `Run` retains ten databases but does not limit each database; long-running `Resume` databases require monitoring. Deleted rows leave reusable pages but do not shrink `dashboard.db` because the Dashboard does not run `VACUUM`. WAL files also use disk space. Telemetry limits are not disk quotas because attributes, span events, console logs, and metric cardinality can increase storage. + +### UI performance + +Views are virtualized; logs and traces use paged SQL, and metrics query selected dimensions and time ranges. Larger limits, broad searches, high cardinality, large traces, and long console streams increase I/O, memory use, and UI latency. Keep the defaults unless more history is required. For `Resume`, set finite attribute and event limits, control metric cardinality, and monitor disk space and query latency. + +## Dashboard experience + +`Run` mode adds a selector to the Dashboard header. `Current` is visually distinct from completed runs, which are labeled by their local start time. Switching the selector replaces the repositories used by resource, console log, structured log, trace, and metric pages without reloading the browser. + +Historical runs are read-only throughout the UI: + +- resource commands and parameter mutations are disabled; +- clearing telemetry is disabled; +- pausing incoming data is disabled; +- telemetry import is disabled; and +- metric charts and tables use the latest stored metric timestamp as their fixed end time. + +The repository and database layers also enforce read-only access so UI checks are not the only protection. + +## Security and privacy + +Network ingestion, endpoint authentication and authorization, transport security, and data-read APIs are existing Dashboard concerns and are outside the scope of this persistence specification. See [Dashboard configuration: OTLP authentication](https://aspire.dev/dashboard/configuration#otlp-authentication) and [Security considerations: Secure telemetry endpoint](https://aspire.dev/dashboard/security-considerations#secure-telemetry-endpoint). + +Persisted Dashboard data can include sensitive application-supplied content, including resource properties, environment values, console output, structured log bodies and attributes, trace attributes and events, metric dimensions, and exemplars. + +Resource property values marked as sensitive are persisted without redaction or encryption. The sensitivity marker is also persisted, so these values continue to be masked when displayed in the UI for current and historical runs. UI masking does not protect values read directly from the database, WAL files, backups, or copies. + +When `ASPIRE_HOME` is not configured, it defaults to the `.aspire` directory in the current user's profile, such as `%USERPROFILE%\.aspire` on Windows or `$HOME/.aspire` on Unix-like systems. Persisted Dashboard data is stored under `/dashboard/` by default. This places the content in the user's directory on the machine. + +The database has no independent authorization or encryption layer. Its security boundary is the local file system. The Dashboard does not set or validate specific access control lists or Unix file modes. Before enabling persistence, the operator must ensure that `ASPIRE_HOME` or `Dashboard:Data:Directory` and all files beneath it have restrictive access controls that prevent unauthorized local users from reading or modifying persisted data. This requirement applies even when the directory is inherited from the user's profile, and equivalent protection is required for backups and copies. + +SQL values are parameterized. Schema SQL is compiled into the Dashboard assembly. Historical databases are opened with SQLite read-only mode in addition to application-level mutation checks. + +## Failure behavior + +- A lock collision fails startup instead of allowing two writers to share a run database. +- An invalid persistence mode fails Dashboard option validation and lists the accepted values. +- Incomplete or unreadable historical metadata is ignored during discovery. +- A historical run that disappears between discovery and selection leaves the previous run selected. +- A historical schema mismatch fails that run switch without releasing the previous data source. +- An interactive run-switch failure is logged and does not terminate the Blazor circuit. +- Failure to prune an expired or abandoned directory is logged and does not fail startup. +- A write attempted through a historical repository throws `InvalidOperationException`. + +## Files of interest + +| Concern | File | +|---------|------| +| Persistence options | `src/Aspire.Dashboard/Configuration/DashboardOptions.cs` | +| AppHost defaults and environment | `src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs` | +| Standalone CLI options | `src/Aspire.Cli/Commands/DashboardRunCommand.cs` | +| Run layout, locking, discovery, and pruning | `src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs` | +| Per-circuit run selection | `src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs` | +| Historical database pooling | `src/Aspire.Dashboard/ServiceClient/DashboardDataSourcePool.cs` | +| SQLite initialization and compatibility | `src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs` | +| Resource repository | `src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs` | +| Telemetry repository | `src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs` | +| Versioned schema | `src/Aspire.Dashboard/ServiceClient/DatabaseSchema/` | +| Run selector | `src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor` | diff --git a/playground/Stress/Stress.ApiService/LargeTelemetryGenerationOptions.cs b/playground/Stress/Stress.ApiService/LargeTelemetryGenerationOptions.cs new file mode 100644 index 00000000000..5a45762ab07 --- /dev/null +++ b/playground/Stress/Stress.ApiService/LargeTelemetryGenerationOptions.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Stress.ApiService; + +public sealed class LargeTelemetryGenerationOptions +{ + public bool GenerateTraces { get; init; } = true; + public int TraceCount { get; init; } = LargeTelemetryGenerator.TraceCount; + public int SpansPerTrace { get; init; } = LargeTelemetryGenerator.SpansPerTrace; + public bool GenerateLargeTrace { get; init; } = true; + public int LargeTraceSpanCount { get; init; } = LargeTelemetryGenerator.LargeTraceSpanCount; + public bool GenerateStructuredLogs { get; init; } = true; + public int StructuredLogCount { get; init; } = LargeTelemetryGenerator.StructuredLogCount; + public bool GenerateConsoleLogs { get; init; } = true; + public int ConsoleLogCount { get; init; } = LargeTelemetryGenerator.ConsoleLogCount; + public bool GenerateMetrics { get; init; } = true; + public int MetricDurationHours { get; init; } = LargeTelemetryGenerator.MetricDurationHours; + public int MetricDimensionCount { get; init; } = LargeTelemetryGenerator.MetricDimensionCount; + + public string? GetValidationError() + { + if (!GenerateTraces && !GenerateLargeTrace && !GenerateStructuredLogs && !GenerateConsoleLogs && !GenerateMetrics) + { + return "At least one telemetry kind must be enabled."; + } + if (GenerateTraces && TraceCount <= 0) + { + return "TraceCount must be positive when trace generation is enabled."; + } + if (GenerateTraces && SpansPerTrace <= 0) + { + return "SpansPerTrace must be positive when trace generation is enabled."; + } + if (GenerateLargeTrace && LargeTraceSpanCount <= 0) + { + return "LargeTraceSpanCount must be positive when large trace generation is enabled."; + } + if (GenerateStructuredLogs && StructuredLogCount <= 0) + { + return "StructuredLogCount must be positive when structured log generation is enabled."; + } + if (GenerateConsoleLogs && ConsoleLogCount <= 0) + { + return "ConsoleLogCount must be positive when console log generation is enabled."; + } + if (GenerateMetrics && MetricDurationHours <= 0) + { + return "MetricDurationHours must be positive when metric generation is enabled."; + } + if (GenerateMetrics && MetricDimensionCount <= 0) + { + return "MetricDimensionCount must be positive when metric generation is enabled."; + } + + return null; + } +} \ No newline at end of file diff --git a/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs b/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs new file mode 100644 index 00000000000..115946a2c91 --- /dev/null +++ b/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs @@ -0,0 +1,492 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Globalization; +using Google.Protobuf; +using Grpc.Core; +using Grpc.Net.Client; +using OpenTelemetry.Proto.Collector.Logs.V1; +using OpenTelemetry.Proto.Collector.Metrics.V1; +using OpenTelemetry.Proto.Collector.Trace.V1; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Resource.V1; +using OpenTelemetry.Proto.Trace.V1; +using OtlpSpan = OpenTelemetry.Proto.Trace.V1.Span; + +namespace Stress.ApiService; + +public sealed class LargeTelemetryGenerator(ILogger logger, IConfiguration configuration) +{ + public const int TraceCount = 50_000; + public const int SpansPerTrace = 6; + public const int LargeTraceSpanCount = 50_000; + public const int StructuredLogCount = 100_000; + public const int ConsoleLogCount = 100_000; + public const int MetricDurationHours = 24; + public const int MetricDimensionCount = 5; + + private const int TraceBatchSize = 1_000; + private const int LargeTraceBatchSize = 1_000; + private const int LogBatchSize = 1_000; + private const int MetricSecondsPerBatch = 100; + private const int ProgressInterval = 10_000; + private static readonly double[] s_histogramValues = [5, 25, 75, 150]; + private readonly SemaphoreSlim _runLock = new(1, 1); + + public async Task TryGenerateAsync(LargeTelemetryGenerationOptions options, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + if (options.GetValidationError() is { } validationError) + { + throw new ArgumentException(validationError, nameof(options)); + } + + if (!await _runLock.WaitAsync(0, cancellationToken)) + { + return false; + } + + try + { + var endpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] + ?? throw new InvalidOperationException("OTEL_EXPORTER_OTLP_ENDPOINT is required."); + using var channel = GrpcChannel.ForAddress(endpoint); + var metadata = CreateMetadata(configuration["OTEL_EXPORTER_OTLP_HEADERS"]); + + if (options.GenerateTraces) + { + logger.LogInformation("Generating {TraceCount} traces with {SpansPerTrace} spans each.", options.TraceCount, options.SpansPerTrace); + var traceClient = new TraceService.TraceServiceClient(channel); + await ExportTracesAsync(traceClient, metadata, options.TraceCount, options.SpansPerTrace, cancellationToken); + } + + if (options.GenerateLargeTrace) + { + logger.LogInformation("Generating one trace with {SpanCount} spans.", options.LargeTraceSpanCount); + var traceClient = new TraceService.TraceServiceClient(channel); + await ExportLargeTraceAsync(traceClient, metadata, options.LargeTraceSpanCount, cancellationToken); + } + + if (options.GenerateStructuredLogs) + { + logger.LogInformation("Generating {LogCount} structured logs.", options.StructuredLogCount); + var logsClient = new LogsService.LogsServiceClient(channel); + await ExportStructuredLogsAsync(logsClient, metadata, options.StructuredLogCount, cancellationToken); + } + + if (options.GenerateConsoleLogs) + { + logger.LogInformation("Writing {LogCount} console logs through ILogger.", options.ConsoleLogCount); + var nextProgressCount = ProgressInterval; + for (var logIndex = 0; logIndex < options.ConsoleLogCount; logIndex++) + { + logger.LogInformation("Large console log {LogIndex}.", logIndex + 1); + LogProgress(logIndex + 1, ref nextProgressCount, "console logs"); + if ((logIndex + 1) % LogBatchSize == 0) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + } + } + } + + if (options.GenerateMetrics) + { + logger.LogInformation( + "Generating {DurationHours} hours of counter and histogram data across {DimensionCount} dimensions.", + options.MetricDurationHours, + options.MetricDimensionCount); + var metricsClient = new MetricsService.MetricsServiceClient(channel); + await ExportMetricsAsync( + metricsClient, + metadata, + options.MetricDurationHours, + options.MetricDimensionCount, + Math.Max(options.TraceCount, 1), + Math.Max(options.SpansPerTrace, 1), + cancellationToken); + } + + logger.LogInformation("Large telemetry generation completed."); + return true; + } + finally + { + _runLock.Release(); + } + } + + private async Task ExportTracesAsync( + TraceService.TraceServiceClient client, + Metadata metadata, + int totalTraceCount, + int spansPerTrace, + CancellationToken cancellationToken) + { + var finalTraceTime = DateTime.UtcNow; + var nextProgressCount = ProgressInterval; + for (var firstTraceIndex = 0; firstTraceIndex < totalTraceCount; firstTraceIndex += TraceBatchSize) + { + var scopeSpans = CreateScopeSpans("LargeTelemetry.ManyTraces"); + var traceCount = Math.Min(TraceBatchSize, totalTraceCount - firstTraceIndex); + for (var traceOffset = 0; traceOffset < traceCount; traceOffset++) + { + var traceIndex = firstTraceIndex + traceOffset; + var traceId = CreateTraceId(discriminator: 1, traceIndex + 1); + var traceStart = finalTraceTime.AddMilliseconds(traceIndex - totalTraceCount); + for (var spanIndex = 0; spanIndex < spansPerTrace; spanIndex++) + { + var isRoot = spanIndex == 0; + scopeSpans.Spans.Add(CreateSpan( + traceId, + CreateSpanId(spanIndex + 1), + isRoot ? ByteString.Empty : CreateSpanId(1), + $"GET /stress/page/{spanIndex + 1}", + traceStart.AddMilliseconds(spanIndex), + isRoot ? traceStart.AddMilliseconds(spansPerTrace) : traceStart.AddMilliseconds(spanIndex + 1))); + } + } + + await ExportTraceBatchAsync(client, metadata, scopeSpans, cancellationToken); + LogProgress(firstTraceIndex + traceCount, ref nextProgressCount, "traces"); + } + } + + private async Task ExportLargeTraceAsync( + TraceService.TraceServiceClient client, + Metadata metadata, + int totalSpanCount, + CancellationToken cancellationToken) + { + var traceId = CreateTraceId(discriminator: 2, value: 1); + var traceStart = DateTime.UtcNow.AddMinutes(-1); + var nextProgressCount = ProgressInterval; + for (var firstSpanIndex = 0; firstSpanIndex < totalSpanCount; firstSpanIndex += LargeTraceBatchSize) + { + var scopeSpans = CreateScopeSpans("LargeTelemetry.LargeTrace"); + var spanCount = Math.Min(LargeTraceBatchSize, totalSpanCount - firstSpanIndex); + for (var spanOffset = 0; spanOffset < spanCount; spanOffset++) + { + var spanIndex = firstSpanIndex + spanOffset; + var isRoot = spanIndex == 0; + scopeSpans.Spans.Add(CreateSpan( + traceId, + CreateSpanId(spanIndex + 1), + isRoot ? ByteString.Empty : CreateSpanId(1), + $"large-trace-span-{spanIndex + 1}", + traceStart.AddTicks(spanIndex * 10L), + isRoot ? traceStart.AddTicks(totalSpanCount * 10L) : traceStart.AddTicks((spanIndex + 1L) * 10L))); + } + + await ExportTraceBatchAsync(client, metadata, scopeSpans, cancellationToken); + LogProgress(firstSpanIndex + spanCount, ref nextProgressCount, "large trace spans"); + } + } + + private static async Task ExportTraceBatchAsync( + TraceService.TraceServiceClient client, + Metadata metadata, + ScopeSpans scopeSpans, + CancellationToken cancellationToken) + { + // Export requests use the OTLP shape ResourceSpans -> ScopeSpans -> Span. Keeping each request + // below 6,000 spans avoids large gRPC messages while allowing one trace to cross request boundaries. + var request = new ExportTraceServiceRequest + { + ResourceSpans = + { + new ResourceSpans + { + Resource = CreateResource("large-telemetry-traces"), + ScopeSpans = { scopeSpans } + } + } + }; + var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); + if (response.PartialSuccess is { RejectedSpans: > 0 } partialSuccess) + { + throw new InvalidOperationException($"Dashboard rejected {partialSuccess.RejectedSpans} spans: {partialSuccess.ErrorMessage}"); + } + } + + private async Task ExportStructuredLogsAsync( + LogsService.LogsServiceClient client, + Metadata metadata, + int totalLogCount, + CancellationToken cancellationToken) + { + var finalLogTime = DateTime.UtcNow; + var nextProgressCount = ProgressInterval; + for (var firstLogIndex = 0; firstLogIndex < totalLogCount; firstLogIndex += LogBatchSize) + { + var scopeLogs = new ScopeLogs + { + Scope = new InstrumentationScope { Name = "LargeTelemetry.StructuredLogs" } + }; + var logCount = Math.Min(LogBatchSize, totalLogCount - firstLogIndex); + for (var logOffset = 0; logOffset < logCount; logOffset++) + { + var logIndex = firstLogIndex + logOffset; + var timestamp = DateTimeToUnixNanoseconds(finalLogTime.AddMilliseconds(logIndex - totalLogCount)); + scopeLogs.LogRecords.Add(new LogRecord + { + TimeUnixNano = timestamp, + ObservedTimeUnixNano = timestamp, + SeverityNumber = SeverityNumber.Info, + SeverityText = "Information", + Body = new AnyValue { StringValue = $"Large structured log {logIndex + 1}." }, + Attributes = + { + new KeyValue { Key = "{OriginalFormat}", Value = new AnyValue { StringValue = "Large structured log {LogIndex}." } }, + new KeyValue { Key = "LogIndex", Value = new AnyValue { IntValue = logIndex + 1 } } + } + }); + } + + // Log batches use the OTLP shape ResourceLogs -> ScopeLogs -> LogRecord. + var request = new ExportLogsServiceRequest + { + ResourceLogs = + { + new ResourceLogs + { + Resource = CreateResource("large-telemetry-structured-logs"), + ScopeLogs = { scopeLogs } + } + } + }; + var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); + if (response.PartialSuccess is { RejectedLogRecords: > 0 } partialSuccess) + { + throw new InvalidOperationException($"Dashboard rejected {partialSuccess.RejectedLogRecords} logs: {partialSuccess.ErrorMessage}"); + } + + LogProgress(firstLogIndex + logCount, ref nextProgressCount, "structured logs"); + } + } + + private async Task ExportMetricsAsync( + MetricsService.MetricsServiceClient client, + Metadata metadata, + int durationHours, + int dimensionCount, + int exemplarTraceCount, + int exemplarSpansPerTrace, + CancellationToken cancellationToken) + { + var durationSeconds = checked(durationHours * 60 * 60); + var startTime = DateTime.UtcNow.AddSeconds(-durationSeconds); + var addedMetricCount = 0L; + var nextProgressCount = ProgressInterval; + for (var firstSecond = 0; firstSecond < durationSeconds; firstSecond += MetricSecondsPerBatch) + { + var counter = new Metric + { + Name = "large.telemetry.counter", + Description = "One-second counter data.", + Unit = "requests", + Sum = new Sum + { + AggregationTemporality = AggregationTemporality.Cumulative, + IsMonotonic = true + } + }; + var histogram = new Metric + { + Name = "large.telemetry.histogram", + Description = "One-second histogram data with exemplars.", + Unit = "ms", + Histogram = new Histogram + { + AggregationTemporality = AggregationTemporality.Cumulative + } + }; + + var secondCount = Math.Min(MetricSecondsPerBatch, durationSeconds - firstSecond); + for (var secondOffset = 0; secondOffset < secondCount; secondOffset++) + { + var secondIndex = firstSecond + secondOffset; + var pointTime = startTime.AddSeconds(secondIndex + 1L); + var timestamp = DateTimeToUnixNanoseconds(pointTime); + for (var dimensionIndex = 0; dimensionIndex < dimensionCount; dimensionIndex++) + { + var dimension = new KeyValue + { + Key = "stress.dimension", + Value = new AnyValue { StringValue = dimensionIndex.ToString(CultureInfo.InvariantCulture) } + }; + counter.Sum.DataPoints.Add(new NumberDataPoint + { + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + TimeUnixNano = timestamp, + AsInt = (long)(secondIndex + 1) * (dimensionIndex + 1), + Attributes = { dimension } + }); + + var observationIndex = secondIndex % s_histogramValues.Length; + var histogramPoint = new HistogramDataPoint + { + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + TimeUnixNano = timestamp, + Count = (ulong)secondIndex + 1, + Sum = CalculateHistogramSum(secondIndex + 1), + ExplicitBounds = { 10, 50, 100 }, + Attributes = { dimension }, + Exemplars = + { + new Exemplar + { + TimeUnixNano = timestamp, + AsDouble = s_histogramValues[observationIndex], + TraceId = CreateTraceId(discriminator: 1, (secondIndex % exemplarTraceCount) + 1), + SpanId = CreateSpanId((dimensionIndex % exemplarSpansPerTrace) + 1) + } + } + }; + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 0)); + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 1)); + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 2)); + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 3)); + histogram.Histogram.DataPoints.Add(histogramPoint); + } + } + + // Metric batches use ResourceMetrics -> ScopeMetrics -> Metric and contain 100 seconds at a + // time. At five dimensions and two instruments this caps each request at 1,000 data points. + var request = new ExportMetricsServiceRequest + { + ResourceMetrics = + { + new ResourceMetrics + { + Resource = CreateResource("large-telemetry-metrics"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = new InstrumentationScope { Name = "LargeTelemetry.Metrics" }, + Metrics = { counter, histogram } + } + } + } + } + }; + var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); + if (response.PartialSuccess is { RejectedDataPoints: > 0 } partialSuccess) + { + throw new InvalidOperationException($"Dashboard rejected {partialSuccess.RejectedDataPoints} metric points: {partialSuccess.ErrorMessage}"); + } + + addedMetricCount += (long)secondCount * dimensionCount * 2; + LogProgress(addedMetricCount, ref nextProgressCount, "metric data points"); + } + } + + private void LogProgress(long addedCount, ref int nextProgressCount, string telemetryKind) + { + while (addedCount >= nextProgressCount) + { + logger.LogInformation("{TelemetryCount} {TelemetryKind} added.", nextProgressCount, telemetryKind); + nextProgressCount += ProgressInterval; + } + } + + private static ScopeSpans CreateScopeSpans(string name) => new() + { + Scope = new InstrumentationScope { Name = name } + }; + + private static OtlpSpan CreateSpan( + ByteString traceId, + ByteString spanId, + ByteString parentSpanId, + string name, + DateTime startTime, + DateTime endTime) => new() + { + TraceId = traceId, + SpanId = spanId, + ParentSpanId = parentSpanId, + Name = name, + Kind = OtlpSpan.Types.SpanKind.Server, + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + EndTimeUnixNano = DateTimeToUnixNanoseconds(endTime), + Status = new OpenTelemetry.Proto.Trace.V1.Status + { + Code = OpenTelemetry.Proto.Trace.V1.Status.Types.StatusCode.Ok + } + }; + + private static Resource CreateResource(string serviceName) => new() + { + Attributes = + { + new KeyValue { Key = "service.name", Value = new AnyValue { StringValue = serviceName } }, + new KeyValue { Key = "service.instance.id", Value = new AnyValue { StringValue = "large-telemetry-1" } } + } + }; + + private static Metadata CreateMetadata(string? headers) + { + var metadata = new Metadata(); + if (string.IsNullOrWhiteSpace(headers)) + { + return metadata; + } + + // OTEL_EXPORTER_OTLP_HEADERS uses comma-separated key=value pairs. Values may contain '=' and + // may be URL encoded, so split each pair only at its first '=' delimiter. + foreach (var pair in headers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separatorIndex = pair.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + metadata.Add( + pair[..separatorIndex].Trim().ToLowerInvariant(), + Uri.UnescapeDataString(pair[(separatorIndex + 1)..].Trim())); + } + return metadata; + } + + private static ByteString CreateTraceId(byte discriminator, int value) + { + var id = new byte[16]; + id[0] = discriminator; + BinaryPrimitives.WriteInt32BigEndian(id.AsSpan(12), value); + return ByteString.CopyFrom(id); + } + + private static ByteString CreateSpanId(int value) + { + var id = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(id.AsSpan(4), value); + return ByteString.CopyFrom(id); + } + + private static ulong CalculateBucketCount(int sampleCount, int observationIndex) + { + var completeCycles = sampleCount / s_histogramValues.Length; + var remainder = sampleCount % s_histogramValues.Length; + return (ulong)(completeCycles + (observationIndex < remainder ? 1 : 0)); + } + + private static double CalculateHistogramSum(int sampleCount) + { + var completeCycles = sampleCount / s_histogramValues.Length; + var remainder = sampleCount % s_histogramValues.Length; + var cycleSum = s_histogramValues.Sum(); + return completeCycles * cycleSum + s_histogramValues.Take(remainder).Sum(); + } + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var timeSinceEpoch = dateTime.ToUniversalTime() - DateTime.UnixEpoch; + return (ulong)timeSinceEpoch.Ticks * 100; + } +} \ No newline at end of file diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index 9806fdae36f..e04ed1efab0 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -31,6 +31,7 @@ }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -43,6 +44,19 @@ app.MapGet("/", () => "Hello world"); +app.MapPost("/large-telemetry", async (LargeTelemetryGenerationOptions options, LargeTelemetryGenerator generator, CancellationToken cancellationToken) => +{ + if (options.GetValidationError() is { } validationError) + { + return Results.BadRequest(validationError); + } + + var generated = await generator.TryGenerateAsync(options, cancellationToken); + return generated + ? Results.Ok() + : Results.Conflict("Large telemetry generation is already running."); +}); + app.MapGet("/write-console", () => { for (var i = 0; i < 5000; i++) diff --git a/playground/Stress/Stress.ApiService/Stress.ApiService.csproj b/playground/Stress/Stress.ApiService/Stress.ApiService.csproj index a1e9c07d5ba..36e2a968076 100644 --- a/playground/Stress/Stress.ApiService/Stress.ApiService.csproj +++ b/playground/Stress/Stress.ApiService/Stress.ApiService.csproj @@ -16,4 +16,16 @@ + + + + + + + + + + diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index d64551bf923..8436443c55c 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -9,6 +9,7 @@ var builder = DistributedApplication.CreateBuilder(args); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient("large-telemetry", client => client.Timeout = Timeout.InfiniteTimeSpan); builder.Services.AddHealthChecks().AddAsyncCheck("health-test", async (ct) => { await Task.Delay(5_000, ct); @@ -99,7 +100,28 @@ // dashboard launch experience, Refer to Directory.Build.props for the path to // the dashboard binary (defaults to the Aspire.Dashboard bin output in the // artifacts dir). -builder.AddProject(KnownResourceNames.AspireDashboard); +var dashboardBuilder = builder.AddProject(KnownResourceNames.AspireDashboard); +if (string.Equals(builder.Configuration["STRESS_REMOVE_DASHBOARD_LIMITS"], bool.TrueString, StringComparison.OrdinalIgnoreCase)) +{ + var unlimited = int.MaxValue.ToString(CultureInfo.InvariantCulture); + dashboardBuilder + .WithEnvironment("Dashboard__TelemetryLimits__MaxLogCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxTraceCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxMetricsCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxAttributeCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxAttributeLength", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxSpanEventCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxResourceCount", unlimited); +} +if (builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is { Length: > 0 } dashboardOtlpEndpoint) +{ + // The AppHost normally points every project at its own dashboard. Preserve an explicitly configured + // external endpoint for dashboard self-telemetry so its activities can be inspected separately. + dashboardBuilder + .WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", dashboardOtlpEndpoint) + .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", builder.Configuration["OTEL_EXPORTER_OTLP_PROTOCOL"] ?? "grpc") + .WithEnvironment("OTEL_EXPORTER_OTLP_HEADERS", builder.Configuration["OTEL_EXPORTER_OTLP_HEADERS"] ?? string.Empty); +} #endif builder.AddExecutable("executableWithSingleArg", "dotnet", Environment.CurrentDirectory, "--version"); diff --git a/playground/Stress/Stress.AppHost/CommandResources.cs b/playground/Stress/Stress.AppHost/CommandResources.cs index ec45e503b62..08e56ca3a4e 100644 --- a/playground/Stress/Stress.AppHost/CommandResources.cs +++ b/playground/Stress/Stress.AppHost/CommandResources.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; +using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +10,16 @@ internal static class CommandResources { + // AppHost project references identify orchestrated projects without exposing their runtime assemblies. + // Keep these command defaults aligned with LargeTelemetryGenerator in Stress.ApiService. + private const int DefaultTraceCount = 50_000; + private const int DefaultSpansPerTrace = 6; + private const int DefaultLargeTraceSpanCount = 50_000; + private const int DefaultStructuredLogCount = 100_000; + private const int DefaultConsoleLogCount = 100_000; + private const int DefaultMetricDurationHours = 24; + private const int DefaultMetricDimensionCount = 5; + private static string NodeExecutablePath { get; } = Environment.GetEnvironmentVariable("NODE") ?? "node"; private static string DotnetExecutablePath { get; } = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"; @@ -889,13 +901,9 @@ private static void AddServiceCommands(IDistributedApplicationBuilder builder, I }); serviceBuilder.WithHttpCommand("/write-console", "Write to console", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); - serviceBuilder.WithHttpCommand("/write-console-large", "Write to console large", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/increment-counter", "Increment counter", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/big-trace", "Big trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); - serviceBuilder.WithHttpCommand("/trace-limit", "Trace limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/log-message", "Log message", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); - serviceBuilder.WithHttpCommand("/log-message-limit", "Log message limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); - serviceBuilder.WithHttpCommand("/log-message-limit-large", "Log message limit large", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/http-command-auto-result", "HTTP command auto result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Auto, Description = "Run an HTTP command and infer the result format from the response content type" }); serviceBuilder.WithHttpCommand("/http-command-json-result", "HTTP command JSON result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Json, Description = "Run an HTTP command and flow the JSON response back to the caller" }); serviceBuilder.WithHttpCommand("/http-command-text-result", "HTTP command text result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Text, Description = "Run an HTTP command and flow the plain-text response back to the caller" }); @@ -908,7 +916,175 @@ private static void AddServiceCommands(IDistributedApplicationBuilder builder, I serviceBuilder.WithHttpCommand("/genai-trace-display-error", "Gen AI trace display error", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/genai-evaluations", "Gen AI evaluations", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/log-formatting", "Log formatting", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); - serviceBuilder.WithHttpCommand("/big-nested-trace", "Big nested trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + + var largeTelemetryCommands = builder.AddHttpCommandGroup("large-telemetry-commands", serviceBuilder.Resource); + largeTelemetryCommands.WithHttpCommand( + "/write-console-large", + "Write to console large", + endpointSelector: () => serviceBuilder.GetEndpoint("http"), + commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + largeTelemetryCommands.WithHttpCommand( + "/trace-limit", + "Trace limit", + endpointSelector: () => serviceBuilder.GetEndpoint("http"), + commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + largeTelemetryCommands.WithHttpCommand( + "/log-message-limit", + "Log message limit", + endpointSelector: () => serviceBuilder.GetEndpoint("http"), + commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + largeTelemetryCommands.WithHttpCommand( + "/log-message-limit-large", + "Log message limit large", + endpointSelector: () => serviceBuilder.GetEndpoint("http"), + commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + largeTelemetryCommands.WithHttpCommand( + "/big-nested-trace", + "Big nested trace", + endpointSelector: () => serviceBuilder.GetEndpoint("http"), + commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + largeTelemetryCommands.WithHttpCommand( + "/large-telemetry", + "Generate large telemetry data", + endpointSelector: () => serviceBuilder.GetEndpoint("http"), + commandOptions: new() + { + Method = HttpMethod.Post, + HttpClientName = "large-telemetry", + IconName = "DatabaseLightning", + ConfirmationMessage = "Generate a large telemetry workload. This operation can take several minutes and consume significant disk space.", + ValidateArguments = context => + { + var options = ReadLargeTelemetryGenerationOptions(context.Inputs); + if (!options.GenerateTraces && !options.GenerateLargeTrace && !options.GenerateStructuredLogs && !options.GenerateConsoleLogs && !options.GenerateMetrics) + { + context.AddValidationError("generateTraces", "At least one telemetry kind must be enabled."); + } + + AddPositiveCountValidation(context, options.GenerateTraces, "traceCount"); + AddPositiveCountValidation(context, options.GenerateTraces, "spansPerTrace"); + AddPositiveCountValidation(context, options.GenerateLargeTrace, "largeTraceSpanCount"); + AddPositiveCountValidation(context, options.GenerateStructuredLogs, "structuredLogCount"); + AddPositiveCountValidation(context, options.GenerateConsoleLogs, "consoleLogCount"); + AddPositiveCountValidation(context, options.GenerateMetrics, "metricDurationHours"); + AddPositiveCountValidation(context, options.GenerateMetrics, "metricDimensionCount"); + return Task.CompletedTask; + }, + PrepareRequest = context => + { + context.Request.Content = JsonContent.Create(ReadLargeTelemetryGenerationOptions(context.Arguments)); + return Task.CompletedTask; + }, + Arguments = + [ + new InteractionInput + { + Name = "generateTraces", + Label = "Generate traces", + Description = "Generate many traces with a configurable number of spans.", + InputType = InputType.Boolean, + Required = true, + Value = "true" + }, + new InteractionInput + { + Name = "traceCount", + Label = "Trace count", + InputType = InputType.Number, + Required = true, + Value = DefaultTraceCount.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateTraces") + }, + new InteractionInput + { + Name = "spansPerTrace", + Label = "Spans per trace", + InputType = InputType.Number, + Required = true, + Value = DefaultSpansPerTrace.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateTraces") + }, + new InteractionInput + { + Name = "generateLargeTrace", + Label = "Generate large trace", + Description = "Generate one trace containing many spans.", + InputType = InputType.Boolean, + Required = true, + Value = "true" + }, + new InteractionInput + { + Name = "largeTraceSpanCount", + Label = "Large trace span count", + InputType = InputType.Number, + Required = true, + Value = DefaultLargeTraceSpanCount.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateLargeTrace") + }, + new InteractionInput + { + Name = "generateStructuredLogs", + Label = "Generate structured logs", + InputType = InputType.Boolean, + Required = true, + Value = "true" + }, + new InteractionInput + { + Name = "structuredLogCount", + Label = "Structured log count", + InputType = InputType.Number, + Required = true, + Value = DefaultStructuredLogCount.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateStructuredLogs") + }, + new InteractionInput + { + Name = "generateConsoleLogs", + Label = "Generate console logs", + InputType = InputType.Boolean, + Required = true, + Value = "true" + }, + new InteractionInput + { + Name = "consoleLogCount", + Label = "Console log count", + InputType = InputType.Number, + Required = true, + Value = DefaultConsoleLogCount.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateConsoleLogs") + }, + new InteractionInput + { + Name = "generateMetrics", + Label = "Generate metrics", + Description = "Generate counter and histogram data points.", + InputType = InputType.Boolean, + Required = true, + Value = "true" + }, + new InteractionInput + { + Name = "metricDurationHours", + Label = "Metric duration (hours)", + InputType = InputType.Number, + Required = true, + Value = DefaultMetricDurationHours.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateMetrics") + }, + new InteractionInput + { + Name = "metricDimensionCount", + Label = "Metric dimension count", + InputType = InputType.Number, + Required = true, + Value = DefaultMetricDimensionCount.ToString(CultureInfo.InvariantCulture), + DynamicLoading = CreateEnabledWhenTrue("generateMetrics") + } + ] + }); } private static void AddTelemetryCommands(IDistributedApplicationBuilder builder, IResourceBuilder telemetryBuilder) @@ -1083,6 +1259,46 @@ private static ValidateCommandArguments ReadValidateCommandArguments(Interaction }; } + private static LargeTelemetryCommandOptions ReadLargeTelemetryGenerationOptions(InteractionInputCollection arguments) + { + return new LargeTelemetryCommandOptions + { + GenerateTraces = arguments.GetBoolean("generateTraces"), + TraceCount = arguments.GetInt32("traceCount"), + SpansPerTrace = arguments.GetInt32("spansPerTrace"), + GenerateLargeTrace = arguments.GetBoolean("generateLargeTrace"), + LargeTraceSpanCount = arguments.GetInt32("largeTraceSpanCount"), + GenerateStructuredLogs = arguments.GetBoolean("generateStructuredLogs"), + StructuredLogCount = arguments.GetInt32("structuredLogCount"), + GenerateConsoleLogs = arguments.GetBoolean("generateConsoleLogs"), + ConsoleLogCount = arguments.GetInt32("consoleLogCount"), + GenerateMetrics = arguments.GetBoolean("generateMetrics"), + MetricDurationHours = arguments.GetInt32("metricDurationHours"), + MetricDimensionCount = arguments.GetInt32("metricDimensionCount") + }; + } + + private static void AddPositiveCountValidation(InputsDialogValidationContext context, bool enabled, string inputName) + { + if (enabled && context.Inputs.GetInt32(inputName) <= 0) + { + context.AddValidationError(inputName, "The value must be positive when this telemetry kind is enabled."); + } + } + + private static InputLoadOptions CreateEnabledWhenTrue(string inputName) + { + return new InputLoadOptions + { + DependsOnInputs = [inputName], + LoadCallback = context => + { + context.Input.Disabled = !bool.TryParse(context.AllInputs.GetString(inputName), out var enabled) || !enabled; + return Task.CompletedTask; + } + }; + } + private static IReadOnlyList CreateArgumentStressInputs(int fieldCount) { var inputs = new List @@ -1224,6 +1440,22 @@ private sealed class ValidateCommandArguments public bool RequireHealthy { get; set; } = true; } + + private sealed class LargeTelemetryCommandOptions + { + public bool GenerateTraces { get; init; } + public int TraceCount { get; init; } + public int SpansPerTrace { get; init; } + public bool GenerateLargeTrace { get; init; } + public int LargeTraceSpanCount { get; init; } + public bool GenerateStructuredLogs { get; init; } + public int StructuredLogCount { get; init; } + public bool GenerateConsoleLogs { get; init; } + public int ConsoleLogCount { get; init; } + public bool GenerateMetrics { get; init; } + public int MetricDurationHours { get; init; } + public int MetricDimensionCount { get; init; } + } } #pragma warning restore ASPIREPROCESSCOMMAND001 diff --git a/playground/Stress/Stress.AppHost/Properties/launchSettings.json b/playground/Stress/Stress.AppHost/Properties/launchSettings.json index 6b730759796..6b348fa860c 100644 --- a/playground/Stress/Stress.AppHost/Properties/launchSettings.json +++ b/playground/Stress/Stress.AppHost/Properties/launchSettings.json @@ -12,6 +12,33 @@ "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" } }, + "https-unlimited-dashboard": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://Stress.dev.localhost:16309;http://Stress.dev.localhost:16310", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "STRESS_REMOVE_DASHBOARD_LIMITS": "true" + } + }, + "https-unlimited-dashboard (profiling)": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://Stress.dev.localhost:16309;http://Stress.dev.localhost:16310", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "STRESS_REMOVE_DASHBOARD_LIMITS": "true", + "AppHost__DefaultLaunchProfileName": "https", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" + } + }, "http": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/playground/Stress/Stress.AppHost/TestResource.cs b/playground/Stress/Stress.AppHost/TestResource.cs index f737d1c5de6..18b0561e495 100644 --- a/playground/Stress/Stress.AppHost/TestResource.cs +++ b/playground/Stress/Stress.AppHost/TestResource.cs @@ -65,6 +65,23 @@ public static IResourceBuilder AddCommandGroup(this IDistr return rb; } + [AspireExportIgnore(Reason = "Stress playground helper; not part of the supported ATS surface.")] + public static IResourceBuilder AddHttpCommandGroup(this IDistributedApplicationBuilder builder, string name, IResource parent) + { + var rb = builder.AddResource(new HttpCommandGroupResource(name, parent)) + .WithInitialState(new() + { + ResourceType = "Command Group", + State = "Running", + Properties = [ + new(KnownProperties.Resource.ParentName, parent.Name) + ] + }) + .ExcludeFromManifest(); + + return rb; + } + [AspireExportIgnore(Reason = "Stress playground helper; not part of the supported ATS surface.")] public static IResourceBuilder AddNoStatusResource(this IDistributedApplicationBuilder builder, string name) { @@ -201,6 +218,11 @@ sealed class CommandGroupResource(string name, IResource parent) : Resource(name public IResource Parent { get; } = parent; } +sealed class HttpCommandGroupResource(string name, IResource parent) : Resource(name), IResourceWithParent, IResourceWithEndpoints +{ + public IResource Parent { get; } = parent; +} + sealed class NoStatusResource(string name) : Resource(name) { } diff --git a/playground/TestShop/TestShop.AppHost/AppHost.cs b/playground/TestShop/TestShop.AppHost/AppHost.cs index 27445ba5366..d95eac3ba22 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -136,7 +136,16 @@ // dashboard launch experience. The opt-out and project reference are defined in // playground/Directory.Build.targets. The repo-root Directory.Build.props sets the // default dashboard binary path to the Aspire.Dashboard output in the artifacts dir. -builder.AddProject(KnownResourceNames.AspireDashboard); +var dashboardBuilder = builder.AddProject(KnownResourceNames.AspireDashboard); +if (builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is { Length: > 0 } dashboardOtlpEndpoint) +{ + // The AppHost normally points every project at its own dashboard. Preserve an explicitly configured + // external endpoint for dashboard self-telemetry so its activities can be inspected separately. + dashboardBuilder + .WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", dashboardOtlpEndpoint) + .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", builder.Configuration["OTEL_EXPORTER_OTLP_PROTOCOL"] ?? "grpc") + .WithEnvironment("OTEL_EXPORTER_OTLP_HEADERS", builder.Configuration["OTEL_EXPORTER_OTLP_HEADERS"] ?? string.Empty); +} #endif builder.Build().Run(); diff --git a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json index 880a3578bc0..888be547e88 100644 --- a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json +++ b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json @@ -10,6 +10,19 @@ "DOTNET_ENVIRONMENT": "Development" } }, + "https (profiling)": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "applicationUrl": "https://TestShop.dev.localhost:16319;http://TestShop.dev.localhost:16320", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "AppHost__DefaultLaunchProfileName": "https", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" + } + }, "http": { "commandName": "Project", "launchBrowser": true, diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index aab93f1ed02..54a443f7cec 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -135,7 +135,10 @@ + + + diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs index 485a6fcab1a..abaadb56f9f 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs @@ -9,6 +9,7 @@ using System.Text; using System.Text.RegularExpressions; using Aspire.Cli.Certificates; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Certificates.Generation; @@ -540,7 +541,7 @@ protected override void CreateDirectoryWithPermissions(string directoryPath) } else { - Directory.CreateDirectory(directoryPath, DirectoryPermissions); + DirectoryHelper.CreateWithOwnerOnlyPermissions(directoryPath); } #pragma warning restore CA1416 // Validate platform compatibility } diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index b7a248b7108..aa5efdd5182 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using Aspire.Cli; using Aspire.Cli.Certificates; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Certificates.Generation; @@ -577,7 +578,7 @@ protected override void CreateDirectoryWithPermissions(string directoryPath) } else { - Directory.CreateDirectory(directoryPath, DirectoryPermissions); + DirectoryHelper.CreateWithOwnerOnlyPermissions(directoryPath); } #pragma warning restore CA1416 // Validate platform compatibility } diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 3ff83e71fb9..12ccbba4ca8 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -59,6 +59,16 @@ internal sealed class DashboardRunCommand : BaseCommand Description = DashboardCommandStrings.AllowAnonymousOptionDescription }; + private static readonly Option s_applicationNameOption = new("--application-name") + { + Description = DashboardCommandStrings.ApplicationNameOptionDescription + }; + + private static readonly Option s_persistenceModeOption = new("--persistence") + { + Description = DashboardCommandStrings.PersistenceModeOptionDescription + }; + private static readonly Option s_configFilePathOption = new("--config-file-path") { Description = DashboardCommandStrings.ConfigFilePathOptionDescription @@ -83,6 +93,8 @@ public DashboardRunCommand( Options.Add(s_otlpGrpcUrlOption); Options.Add(s_otlpHttpUrlOption); Options.Add(s_allowAnonymousOption); + Options.Add(s_applicationNameOption); + Options.Add(s_persistenceModeOption); Options.Add(s_configFilePathOption); TreatUnmatchedTokensAsErrors = false; } @@ -114,7 +126,12 @@ protected override async Task ExecuteAsync(ParseResult parseResul // Tokens and keys are passed via environment variables (not command-line args) // to avoid exposing them in process listings (e.g. ps, Task Manager). string? browserToken = null; - var environmentVariables = new Dictionary(); + var environmentVariables = new Dictionary + { + // Dashboard output is captured in the CLI log instead of written to the console, + // so include debug details without increasing console verbosity. + ["Logging__LogLevel__Default"] = LogLevel.Debug.ToString() + }; layoutLease?.AddEnvironment(environmentVariables); if (!allowAnonymous && !ConfigSettingHasValue(unmatchedTokens, _environment, KnownConfigNames.DashboardUnsecuredAllowAnonymous)) { @@ -167,6 +184,8 @@ private static void AddOptionArgs(ParseResult parseResult, List args, IR AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_otlpGrpcUrlOption, KnownConfigNames.DashboardOtlpGrpcEndpointUrl, defaultValue: "http://localhost:4317"); AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_otlpHttpUrlOption, KnownConfigNames.DashboardOtlpHttpEndpointUrl, defaultValue: "http://localhost:4318"); AddBoolOptionArg(parseResult, args, unmatchedTokens, environment, s_allowAnonymousOption, KnownConfigNames.DashboardUnsecuredAllowAnonymous); + AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_applicationNameOption, DashboardConfigNames.DashboardApplicationName.EnvVarName, defaultValue: null); + AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_persistenceModeOption, DashboardConfigNames.DashboardPersistenceModeName.EnvVarName, defaultValue: null); // Always enable the telemetry API so CLI commands (e.g. aspire otel) can query the dashboard, // unless the user has explicitly configured either the enabled or disabled setting. diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs index e3e972b1fb1..675f4695802 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Layout; using Aspire.Cli.Utils; using Aspire.Hosting; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Aspire.Cli.NuGet; diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 5af7d954c02..6b89bcc1fea 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -15,6 +15,7 @@ using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Hosting.Utils; +using Aspire.Shared; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Spectre.Console; diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs index 7eec55423c2..cfa4bc74085 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs @@ -129,6 +129,18 @@ public static string AllowAnonymousOptionDescription { } } + public static string ApplicationNameOptionDescription { + get { + return ResourceManager.GetString("ApplicationNameOptionDescription", resourceCulture); + } + } + + public static string PersistenceModeOptionDescription { + get { + return ResourceManager.GetString("PersistenceModeOptionDescription", resourceCulture); + } + } + public static string ConfigFilePathOptionDescription { get { return ResourceManager.GetString("ConfigFilePathOptionDescription", resourceCulture); diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx index ca8a6a5bd07..92929f97237 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx @@ -159,6 +159,12 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + + + The dashboard data persistence mode: None, Run, or Resume + The path for a JSON configuration file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf index 622b1e2e1c4..3e3ff0be09c 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf index 1c1bae874e0..2e031372d17 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf index d6053101689..2c9f1618996 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf index b7bfd5860d0..d4322027fd0 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf index 5ce223ccea0..894111334fc 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf index e60d3b7b420..f65408beeb1 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf index cac5f89e8c4..e85952960dd 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf index 1dcdfcde143..c2708d3bf4d 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf index 59b7e57ab01..10d051c5cbd 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf index 88f7f7162ac..7c62f8198b5 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf index c70d48db2f1..3839f289611 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf index 6f5f77b0dd3..e93be1bec06 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf index 976480d845a..33529e6b1e8 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard and used to scope persisted dashboard data + The application name displayed in the dashboard and used to scope persisted dashboard data + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -107,6 +112,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 32001109622..5ed66775e08 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -5,13 +5,14 @@ using System.Text; using Aspire.Cli.Acquisition; using Aspire.Hosting.Backchannel; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils; internal static class CliPathHelper { - internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME"; + internal const string AspireHomeEnvironmentVariable = AspireHomeDirectory.EnvironmentVariable; /// /// Name of the directory under ASPIRE_HOME that holds NuGet package caches keyed by @@ -49,15 +50,11 @@ internal static string GetAspireHomeDirectory(string? processPath = null, ILogge } internal static string GetDefaultAspireHomeDirectory() - => GetDefaultAspireHomeDirectory( - Environment.GetEnvironmentVariable(AspireHomeEnvironmentVariable), - GetUserProfileDirectory()); + => AspireHomeDirectory.GetDefault(); internal static string GetDefaultAspireHomeDirectory(string? configuredAspireHome, string userProfileDirectory) { - return string.IsNullOrWhiteSpace(configuredAspireHome) - ? Path.Combine(userProfileDirectory, ".aspire") - : configuredAspireHome; + return AspireHomeDirectory.GetDefault(configuredAspireHome, userProfileDirectory); } /// @@ -151,9 +148,6 @@ internal static string GetStagingNuGetPackagesDirectory(DirectoryInfo aspireHome return Path.GetDirectoryName(dogfoodDir); } - internal static string GetUserProfileDirectory() - => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - /// /// Normalizes the casing of a filesystem so that paths which differ only /// by case collapse to a single value when used as a comparison or hash key. diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index d365c608d38..cb02666dec8 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -15,21 +15,17 @@ namespace Aspire.Dashboard.Api; /// /// Handles telemetry API requests, returning data in OTLP JSON format. /// -internal sealed class TelemetryApiService( - TelemetryRepository telemetryRepository, - IEnumerable outgoingPeerResolvers) +internal sealed class TelemetryApiService(ITelemetryRepository telemetryRepository) { private const int DefaultLimit = 200; private const int DefaultTraceLimit = 100; - private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); - /// /// Gets spans in OTLP JSON format. /// Returns null if resource filter is specified but not found. /// Supports multiple resource names. /// - public TelemetryApiResponse? GetSpans(string[]? resourceNames, string? traceId, bool? hasError, int? limit, string? search = null) + public async Task GetSpansAsync(string[]? resourceNames, string? traceId, bool? hasError, int? limit, CancellationToken cancellationToken, string? search = null) { // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); @@ -46,7 +42,7 @@ internal sealed class TelemetryApiService( var searchTextFragments = ParseAndApplySearchFilters(search, spanFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); // Get spans for all resource keys (empty list means no filter / all resources) - var result = telemetryRepository.GetSpans(new GetSpansRequest + var result = await telemetryRepository.GetSpansAsync(new GetSpansRequest { ResourceKeys = resourceKeys, StartIndex = 0, @@ -55,7 +51,7 @@ internal sealed class TelemetryApiService( TraceId = traceId, HasError = hasError, TextFragments = searchTextFragments - }); + }, cancellationToken).ConfigureAwait(false); var allSpans = result.PagedResult.Items; var totalCount = allSpans.Count; @@ -67,7 +63,7 @@ internal sealed class TelemetryApiService( spans = spans.Skip(spans.Count - effectiveLimit).ToList(); } - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); return new TelemetryApiResponse { @@ -82,7 +78,7 @@ internal sealed class TelemetryApiService( /// Returns null if resource filter is specified but not found. /// Supports multiple resource names. /// - public TelemetryApiResponse? GetTraces(string[]? resourceNames, bool? hasError, int? limit, string? search = null) + public async Task GetTracesAsync(string[]? resourceNames, bool? hasError, int? limit, CancellationToken cancellationToken, string? search = null) { // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); @@ -96,32 +92,28 @@ internal sealed class TelemetryApiService( // Convert structured search qualifiers into TelemetryFilter objects for repository-level filtering var traceFilters = new List(); + if (hasError is not null) + { + traceFilters.Add(new FieldTelemetryFilter + { + Field = KnownTraceFields.StatusField, + Value = nameof(OtlpSpanStatusCode.Error), + Condition = hasError.Value ? FilterCondition.Equals : FilterCondition.NotEqual + }); + } var searchTextFragments = ParseAndApplySearchFilters(search, traceFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); // Get traces for all resource keys (empty list means no filter / all resources) - var result = telemetryRepository.GetTraces(new GetTracesRequest + var result = await telemetryRepository.GetTracesAsync(new GetTracesRequest { ResourceKeys = resourceKeys, StartIndex = 0, Count = int.MaxValue, Filters = traceFilters, TextFragments = searchTextFragments - }); - var allTraces = result.PagedResult.Items; - - var traces = allTraces; - - // Filter traces by hasError - if (hasError == true) - { - traces = traces.Where(t => t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList(); - } - else if (hasError == false) - { - traces = traces.Where(t => !t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList(); - } - - var totalCount = traces.Count; + }, cancellationToken).ConfigureAwait(false); + var traces = result.PagedResult.Items; + var totalCount = result.PagedResult.TotalItemCount; // Apply limit (take from end for most recent) if (traces.Count > effectiveLimit) @@ -132,7 +124,7 @@ internal sealed class TelemetryApiService( var spans = traces.SelectMany(t => t.Spans).ToList(); var returnedCount = traces.Count; - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); return new TelemetryApiResponse { @@ -156,7 +148,7 @@ internal sealed class TelemetryApiService( var spans = trace.Spans.ToList(); - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); return new TelemetryApiResponse { @@ -171,7 +163,7 @@ internal sealed class TelemetryApiService( /// Returns null if resource filter is specified but not found. /// Supports multiple resource names. /// - public TelemetryApiResponse? GetLogs(string[]? resourceNames, string? traceId, string? severity, int? limit, string? search = null) + public async Task GetLogsAsync(string[]? resourceNames, string? traceId, string? severity, int? limit, CancellationToken cancellationToken, string? search = null) { // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); @@ -213,14 +205,14 @@ internal sealed class TelemetryApiService( var searchTextFragments = ParseAndApplySearchFilters(search, filters, AddLogFiltersFromQualifiers, key => ResolveLogFieldKey(key) is not null); // Get logs for all resource keys (empty list means no filter / all resources) - var result = telemetryRepository.GetLogs(new GetLogsContext + var result = await telemetryRepository.GetLogsAsync(new GetLogsContext { ResourceKeys = resourceKeys, StartIndex = 0, Count = int.MaxValue, Filters = filters, TextFragments = searchTextFragments - }); + }, cancellationToken).ConfigureAwait(false); var logs = result.Items; @@ -275,7 +267,7 @@ public async IAsyncEnumerable FollowSpansAsync( await foreach (var span in telemetryRepository.WatchSpansAsync(watchRequest, cancellationToken).ConfigureAwait(false)) { // Use compact JSON for NDJSON streaming (no indentation) - yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false); + yield return TelemetryExportService.ConvertSpanToJson(span, logs: null, indent: false); } } diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 9466089f39c..b9d79ed8edb 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -44,12 +44,15 @@ ServiceClient\dashboard_service.proto + + + @@ -57,8 +60,14 @@ + + + + + + @@ -262,9 +271,12 @@ + + + diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor index 2a56fb60419..7af5b8f3340 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor @@ -35,14 +35,32 @@ return @ @if (item.Icon != null) { - - + + } + @if (item.SecondaryActionIcon is not null) + { + + + + } ; } } + private static string GetIconSlot(MenuItemRole? role) => role switch + { + MenuItemRole.MenuItemCheckbox => "checkbox-indicator", + MenuItemRole.MenuItemRadio => "radio-indicator", + _ => "start" + }; + private RenderFragment? RenderNestedItems(MenuButtonItem item) { if (item.NestedMenuItems is null || item.NestedMenuItems.Count == 0) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs index b40cdb543e4..aae2d0498d7 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs @@ -36,6 +36,12 @@ public partial class AspireMenu : FluentComponentBase [Parameter] public EventCallback OnRenderComplete { get; set; } + /// + /// Raised after a menu item's secondary action completes so the owner can regenerate the menu items. + /// + [Parameter] + public EventCallback OnSecondaryActionComplete { get; set; } + [Parameter] public required IReadOnlyList Items { get; set; } @@ -157,6 +163,28 @@ private async Task HandleItemClicked(MenuButtonItem item) } } + private async Task HandleSecondaryActionClicked(MenuButtonItem item) + { + if (item.OnSecondaryActionClick is { } onSecondaryActionClick) + { + await onSecondaryActionClick(); + } + + if (OnSecondaryActionComplete.HasDelegate) + { + await OnSecondaryActionComplete.InvokeAsync(); + } + else + { + StateHasChanged(); + + if (_menu is { Id: not null } menu) + { + await MenuService.RefreshMenuAsync(menu.Id, isOpen: true); + } + } + } + private async Task OnOpenChanged(bool open) { await SetOpenAsync(open); diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.css b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.css new file mode 100644 index 00000000000..4d5272cc3b4 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.css @@ -0,0 +1,27 @@ +::deep .aspire-menu-secondary-action { + opacity: 0; + visibility: hidden; + padding-left: 8px; +} + +::deep .aspire-menu-secondary-action::part(control) { + background-color: transparent; +} + +::deep .aspire-menu-secondary-action:hover::part(control) { + background-color: var(--neutral-fill-secondary-hover); +} + +fluent-menu-item:hover > span[slot="end"] ::deep .aspire-menu-secondary-action, +fluent-menu-item:focus-within > span[slot="end"] ::deep .aspire-menu-secondary-action, +::deep .aspire-menu-secondary-action.selected { + opacity: 1; + visibility: visible; +} + +@media (hover: none) { + fluent-menu-item > span[slot="end"] ::deep .aspire-menu-secondary-action { + opacity: 1; + visibility: visible; + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index fbf48e4e24d..0dfac61c112 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -12,7 +12,12 @@ }; } - + + @if (IconStart is not null) + { + + } + @if (string.IsNullOrWhiteSpace(Text)) { @@ -31,5 +36,5 @@ @* Render lazily because FluentMenu is expensive: building menu items and initializing its JavaScript interop add significant overhead when many menu buttons are displayed. *@ @if (_renderMenu) { - + } diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index 0039485cece..2c23123e7f1 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -24,6 +24,7 @@ public partial class AspireMenuButton : FluentComponentBase, IAsyncDisposable private Icon? _icon; private MenuButtonItem[] _items = []; private bool _disabled; + private bool _hasActionableItems = true; private Func>? _renderedItemsProvider; [Parameter] @@ -32,6 +33,15 @@ public partial class AspireMenuButton : FluentComponentBase, IAsyncDisposable [Parameter] public Icon? IconStart { get; set; } + [Parameter] + public string? IconStartClass { get; set; } + + [Parameter] + public Color? IconStartColor { get; set; } + + [Parameter] + public string? IconStartCustomColor { get; set; } + [Parameter] public Icon? Icon { get; set; } @@ -50,6 +60,9 @@ public partial class AspireMenuButton : FluentComponentBase, IAsyncDisposable [Parameter] public required Func> ItemsProvider { get; set; } + // Exposed only for tests to inspect the rendered menu items. + internal IReadOnlyList Items => _items; + [Parameter] public Appearance? ButtonAppearance { get; set; } @@ -62,6 +75,15 @@ public partial class AspireMenuButton : FluentComponentBase, IAsyncDisposable [Parameter] public bool HideIcon { get; set; } + /// + /// Gets or sets a value indicating whether the button is unconditionally disabled. + /// + /// + /// This is independent of the automatic disabling that happens when the menu has no actionable items. + /// + [Parameter] + public bool Disabled { get; set; } + /// /// Gets or sets a value indicating whether focus should return to this menu button after a menu item is clicked. /// @@ -81,7 +103,10 @@ protected override void OnParametersSet() if (!ReferenceEquals(_renderedItemsProvider, ItemsProvider)) { _renderedItemsProvider = ItemsProvider; - _disabled = false; + + // The provider hasn't run for this delegate yet, so the menu contents are unknown. + // Assume it has content so only Disabled can disable the button before the first open. + _hasActionableItems = true; } if (_visible || _openWhenMenuRenderCompletes) @@ -93,6 +118,10 @@ protected override void OnParametersSet() OnMenuOpenChanged(false); } } + else + { + UpdateDisabled(); + } } private async Task ToggleMenu() @@ -136,7 +165,13 @@ private async Task ToggleMenu() private void RefreshItems() { _items = ItemsProvider().ToArray(); - _disabled = !_items.Any(i => !i.IsDivider); + _hasActionableItems = _items.Any(i => !i.IsDivider); + UpdateDisabled(); + } + + private void UpdateDisabled() + { + _disabled = Disabled || !_hasActionableItems; } private async Task OnMenuRenderComplete() diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 95dceb762d4..ccaee145af0 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -29,6 +29,10 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable private OtlpInstrumentKey? _renderedInstrument; private string? _renderedTheme; private bool _renderedShowCount; + private DateTimeOffset? _previousDataEndTime; + + // Full updates always run. This interval only throttles recurring tick updates. + protected virtual TimeSpan UpdateInterval => TimeSpan.FromSeconds(0.2); [Inject] public required IStringLocalizer Loc { get; init; } @@ -40,7 +44,9 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required PauseManager PauseManager { get; init; } @@ -54,6 +60,9 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable [Parameter] public required List Resources { get; set; } + [Parameter] + public DateTimeOffset? DataEndTime { get; set; } + // Stores a cache of the last set of spans returned as exemplars. // This dictionary is replaced each time the chart is updated. private Dictionary _currentCache = new Dictionary(); @@ -63,8 +72,8 @@ protected override void OnInitialized() { // Copy the token so there is no chance it is accessed on CTS after it is disposed. CancellationToken = _cts.Token; - _currentDataStartTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); - InstrumentViewModel.DataUpdateSubscriptions.Add(OnInstrumentDataUpdate); + _currentDataStartTime = GetCurrentDataTime(out _); + InstrumentViewModel.AddDataUpdateSubscription(OnInstrumentDataUpdate); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -77,11 +86,10 @@ InstrumentViewModel.MatchedDimensions is null || return; } - var inProgressDataTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); + var inProgressDataTime = GetCurrentDataTime(out var isTimeFrozen); - // Only advance the time window when not paused. When paused, keep the chart's - // time axis stable so filter changes don't cause the x-axis to jump. - if (pausedAt is null) + // Keep the time axis stable for historical data and while live data is paused. + if (!isTimeFrozen) { while (_currentDataStartTime.Add(_tickDuration) < inProgressDataTime) { @@ -101,8 +109,11 @@ InstrumentViewModel.MatchedDimensions is null || _renderedTheme = InstrumentViewModel.Theme; _renderedShowCount = InstrumentViewModel.ShowCount; await UpdateChartAsync(tickUpdate: false, inProgressDataTime).ConfigureAwait(false); + _lastUpdateTime = TimeProvider.GetUtcNow(); + // Derived components can update Blazor-rendered state during OnAfterRenderAsync. + await InvokeAsync(StateHasChanged); } - else if (_lastUpdateTime.Add(TimeSpan.FromSeconds(0.2)) < TimeProvider.GetUtcNow()) + else if (_lastUpdateTime.Add(UpdateInterval) < TimeProvider.GetUtcNow()) { // Throttle how often the chart is updated. _lastUpdateTime = TimeProvider.GetUtcNow(); @@ -113,11 +124,16 @@ InstrumentViewModel.MatchedDimensions is null || protected override void OnParametersSet() { _tickDuration = Duration / GraphPointCount; + if (_previousDataEndTime != DataEndTime) + { + _currentDataStartTime = GetCurrentDataTime(out _); + _previousDataEndTime = DataEndTime; + } } private Task OnInstrumentDataUpdate() { - return InvokeAsync(StateHasChanged); + return CancellationToken.IsCancellationRequested ? Task.CompletedTask : InvokeAsync(StateHasChanged); } private string FormatTooltip(string name, double yValue, DateTimeOffset xValue) @@ -230,9 +246,23 @@ private void ResolveExemplarSpans(List exemplars) } } - private DateTimeOffset GetCurrentDataTime() + private DateTimeOffset GetCurrentDataTime(out bool isTimeFrozen) { - return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); // Compensate for delay in receiving metrics from services. + if (DataEndTime is { } dataEndTime) + { + isTimeFrozen = true; + return dataEndTime; + } + + if (PauseManager.AreMetricsPaused(out var pausedAt)) + { + isTimeFrozen = true; + return pausedAt.Value; + } + + isTimeFrozen = false; + // Compensate for delay in receiving metrics from services. + return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); } private string GetDisplayedUnit(OtlpInstrumentSummary instrument) @@ -249,6 +279,7 @@ private string GetDisplayedUnit(OtlpInstrumentSummary instrument) protected virtual ValueTask DisposeAsync(bool disposing) { _cts.Cancel(); + InstrumentViewModel.RemoveDataUpdateSubscription(OnInstrumentDataUpdate); _cts.Dispose(); return ValueTask.CompletedTask; } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index ce181c6a958..e6fa3672c42 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -30,24 +30,27 @@ else } + @* Avoid rendering the inactive graph or table because both subscribe to and process metric data updates. *@
- - + +
- - + +
diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 0a5a1eb14e2..8d274ed4473 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -2,10 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; using System.Runtime.InteropServices; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; -using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; @@ -15,12 +15,21 @@ namespace Aspire.Dashboard.Components; public partial class ChartContainer : ComponentBase, IAsyncDisposable { + private static readonly TimeSpan s_chartUpdateInterval = TimeSpan.FromSeconds(0.2); + private static readonly TimeSpan s_dataFetchInterval = TimeSpan.FromSeconds(1); + + private readonly object _instrumentUpdateLock = new(); private OtlpInstrumentData? _instrument; - private PeriodicTimer? _tickTimer; + private readonly CancellationTokenSource _disposeCts = new(); private Task? _tickTask; private IDisposable? _themeChangedSubscription; - private int _renderedDimensionsCount; private readonly InstrumentViewModel _instrumentViewModel = new InstrumentViewModel(); + private (ResourceKey ResourceKey, string MeterName, string InstrumentName)? _dataEndTimeKey; + private (ResourceKey ResourceKey, string MeterName, string InstrumentName, TimeSpan Duration)? _instrumentRequestKey; + private DateTimeOffset? _dataEndTime; + private long _lastDataFetchTimestamp = -1; + private long _instrumentUpdateVersion; + private int _disposed; [Parameter, EditorRequired] public required ResourceKey ResourceKey { get; set; } @@ -44,7 +53,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable public required List Resources { get; set; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required ILogger Logger { get; init; } @@ -55,6 +66,12 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Inject] public required PauseManager PauseManager { get; init; } + [Inject] + public required DashboardActivitySource DashboardActivitySource { get; init; } + + [Inject] + public required TimeProvider TimeProvider { get; init; } + public ImmutableList DimensionFilters { get; set; } = []; public string? PreviousMeterName { get; set; } public string? PreviousInstrumentName { get; set; } @@ -63,9 +80,13 @@ protected override async Task OnInitializedAsync() { await ThemeManager.EnsureInitializedAsync(); - // Update the graph every 200ms. This displays the latest data and moves time forward. - _tickTimer = new PeriodicTimer(TimeSpan.FromSeconds(0.2)); - _tickTask = Task.Run(UpdateDataAsync); + if (!TelemetryRepository.IsReadOnly) + { + // Update the graph every 200ms. This displays the latest data and moves time forward. + var cancellationToken = _disposeCts.Token; + // Don't suppress the execution context because graph updates use the current culture for formatting. + _tickTask = Task.Run(() => UpdateDataAsync(cancellationToken)); + } _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => { _instrumentViewModel.Theme = ThemeManager.EffectiveTheme; @@ -75,43 +96,82 @@ protected override async Task OnInitializedAsync() public async ValueTask DisposeAsync() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + lock (_instrumentUpdateLock) + { + _instrumentUpdateVersion++; + } + _themeChangedSubscription?.Dispose(); - _tickTimer?.Dispose(); + _disposeCts.Cancel(); // Wait for UpdateData to complete. if (_tickTask is { } t) { await t; } + + _disposeCts.Dispose(); } - private async Task UpdateDataAsync() + private async Task UpdateDataAsync(CancellationToken cancellationToken) { - var timer = _tickTimer; - while (await timer!.WaitForNextTickAsync()) + using var timer = new PeriodicTimer(s_chartUpdateInterval, TimeProvider); + try { - _instrument = GetInstrument(); - if (_instrument == null || PauseManager.AreMetricsPaused(out _)) + while (await timer.WaitForNextTickAsync(cancellationToken)) { - continue; - } + var lastDataFetchTimestamp = Volatile.Read(ref _lastDataFetchTimestamp); + if (lastDataFetchTimestamp < 0 || TimeProvider.GetElapsedTime(lastDataFetchTimestamp) >= s_dataFetchInterval) + { + using var activity = DashboardActivitySource.ActivitySource.StartActivity("Update metric chart data from tick"); + + var result = await GetInstrumentAsync(useIncrementalCache: true, cancellationToken).ConfigureAwait(false); + if (!TryPublishInstrument(result)) + { + continue; + } + + if (_instrument is not null && HaveDimensionFilterValuesChanged(_instrument)) + { + await InvokeAsync(() => + { + UpdateDimensionFilters(hasInstrumentChanged: false); + StateHasChanged(); + }); + } + } + + if (_instrument == null || PauseManager.AreMetricsPaused(out _)) + { + continue; + } - if (_instrument.Dimensions.Count > _renderedDimensionsCount) - { - // Re-render the entire control if the number of dimensions has changed. - _renderedDimensionsCount = _instrument.Dimensions.Count; - await InvokeAsync(StateHasChanged); - } - else - { await UpdateInstrumentDataAsync(_instrument); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error in UpdateDataAsync"); + } } public async Task DimensionValuesChangedAsync(DimensionFilterViewModel dimensionViewModel) { - if (_instrument == null) + var result = await GetInstrumentAsync(useIncrementalCache: false, _disposeCts.Token).ConfigureAwait(false); + if (!TryPublishInstrument(result)) + { + return; + } + + if (_instrument is null) { return; } @@ -121,93 +181,183 @@ public async Task DimensionValuesChangedAsync(DimensionFilterViewModel dimension private async Task UpdateInstrumentDataAsync(OtlpInstrumentData instrument) { - var matchedDimensions = instrument.Dimensions.Where(MatchDimension).ToList(); - // Only update data in plotly - await _instrumentViewModel.UpdateDataAsync(instrument.Summary, matchedDimensions); + await _instrumentViewModel.UpdateDataAsync(instrument.Summary, instrument.Dimensions); } - private bool MatchDimension(DimensionScope dimension) + private async Task ShowCountChangedAsync(bool showCount) { - foreach (var dimensionFilter in DimensionFilters) + if (_instrumentViewModel.ShowCount == showCount) { - if (!MatchFilter(dimension.Attributes, dimensionFilter)) - { - return false; - } + return; + } + + _instrumentViewModel.ShowCount = showCount; + if (_instrument is not null) + { + await UpdateInstrumentDataAsync(_instrument); } - return true; } - private static bool MatchFilter(KeyValuePair[] attributes, DimensionFilterViewModel filter) + protected override async Task OnParametersSetAsync() { - var selectedValues = filter.SelectedValues; - - // No filter selected. - if (selectedValues.Count == 0) + var requestKey = (ResourceKey, MeterName, InstrumentName, Duration); + if (_instrumentRequestKey != requestKey) { - return false; + _instrumentRequestKey = requestKey; + await RefreshChartAsync(); } + } - var value = OtlpHelpers.GetValue(attributes, filter.Name); - foreach (var item in selectedValues) + private async Task RefreshChartAsync() + { + // Track the selection change before awaiting. hasInstrumentChanged describes the selected + // parameters, not the fetched data, so it must not be lost when a concurrent tick wins the + // version race below. + var hasInstrumentChanged = PreviousMeterName != MeterName || PreviousInstrumentName != InstrumentName; + PreviousMeterName = MeterName; + PreviousInstrumentName = InstrumentName; + + var result = await GetInstrumentAsync(useIncrementalCache: false, _disposeCts.Token).ConfigureAwait(false); + if (!TryPublishInstrument(result)) { - if (item.Value == value) + // A newer fetch owns _instrument now, but the dimension filters still belong to this + // selection. The tick loop only ever calls UpdateDimensionFilters with + // hasInstrumentChanged: false, which carries the previous instrument's selections forward by + // attribute name, so returning here would leave the new instrument filtered by the old one. + if (hasInstrumentChanged) { - return true; + UpdateDimensionFilters(hasInstrumentChanged: true); } - } - return false; - } - - protected override async Task OnParametersSetAsync() - { - _instrument = GetInstrument(); + return; + } if (_instrument == null) { return; } - var hasInstrumentChanged = PreviousMeterName != MeterName || PreviousInstrumentName != InstrumentName; - PreviousMeterName = MeterName; - PreviousInstrumentName = InstrumentName; - - // Replace filters collection on change. Filters can be accessed from a background task so it is immutable for thread safety. - DimensionFilters = ImmutableList.Create(CollectionsMarshal.AsSpan(CreateUpdatedFilters(hasInstrumentChanged))); + UpdateDimensionFilters(hasInstrumentChanged); await UpdateInstrumentDataAsync(_instrument); } - private OtlpInstrumentData? GetInstrument() + private async Task<(long UpdateVersion, OtlpInstrumentData? Instrument)> GetInstrumentAsync(bool useIncrementalCache, CancellationToken cancellationToken) { - // When paused, use the paused time to keep the data window stable. - // This ensures filter changes while paused still show the same data. - var endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; - // Get more data than is being displayed. Histogram graph uses some historical data to calculate bucket counts. - // It's ok to get more data than is needed here. An additional date filter is applied when building chart values. - var startDate = endDate.Subtract(Duration + TimeSpan.FromSeconds(30)); - - var instrument = TelemetryRepository.GetInstrument(new GetInstrumentRequest + long updateVersion; + OtlpInstrumentData? baseInstrument; + lock (_instrumentUpdateLock) { - ResourceKey = ResourceKey, - MeterName = MeterName, - InstrumentName = InstrumentName, - StartTime = startDate, - EndTime = endDate, - }); + updateVersion = ++_instrumentUpdateVersion; - if (instrument == null) + // Capture the base instrument under the same lock that stamps the version. TryPublishInstrument + // writes _instrument under this lock, so reading it outside would let an incremental refresh + // merge the previously selected instrument's series into the newly selected one: the guard below + // only proves no newer call started, not that the base snapshot still matches the selection. + baseInstrument = _instrument; + } + + var resourceKey = ResourceKey; + var meterName = MeterName; + var instrumentName = InstrumentName; + var duration = Duration; + var dimensionFilters = DimensionFilters + .Where(filter => filter.AreAllValuesSelected is not true) + .ToDictionary( + filter => filter.Name, + filter => (IReadOnlyList)filter.SelectedValues.Select(value => value.Value).ToArray()); + + var instrumentSummary = TelemetryRepository.GetInstrumentSummary(resourceKey, meterName, instrumentName); + if (instrumentSummary is null) { Logger.LogDebug( "Unable to find instrument. ResourceKey: {ResourceKey}, MeterName: {MeterName}, InstrumentName: {InstrumentName}", - ResourceKey, - MeterName, - InstrumentName); + resourceKey, + meterName, + instrumentName); + return (updateVersion, Instrument: null); + } + + DateTime endDate; + if (TelemetryRepository.IsReadOnly) + { + EnsureDataEndTime(resourceKey, meterName, instrumentName); + endDate = _dataEndTime?.UtcDateTime ?? DateTime.UtcNow; } + else + { + // When paused, use the paused time to keep the data window stable. + // This ensures filter changes while paused still show the same data. + endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; + } + + var dataPointInterval = MetricDataPointInterval.Get(duration); + var includeExemplars = instrumentSummary.Type == OtlpInstrumentType.Histogram; - return instrument; + // Histogram graphs need one preceding rollup to calculate bucket count changes at the beginning of the chart. + var historyDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, dataPointInterval.Ticks)); + var startDate = endDate.Subtract(duration + historyDuration); + var cursors = useIncrementalCache && baseInstrument is not null + ? MetricInstrumentDataCache.CreateCursors(baseInstrument, historyDuration, dataPointInterval) + : []; + + var refreshedInstrument = await TelemetryRepository.GetInstrumentAsync(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = meterName, + InstrumentName = instrumentName, + StartTime = startDate, + EndTime = endDate, + DataPointInterval = dataPointInterval, + IncludeExemplars = includeExemplars, + PopulateExemplarAttributes = false, + DimensionCursors = cursors, + DimensionFilters = dimensionFilters + }, cancellationToken).ConfigureAwait(false); + Debug.Assert(refreshedInstrument is not null); + + lock (_instrumentUpdateLock) + { + if (updateVersion != _instrumentUpdateVersion) + { + return (updateVersion, Instrument: null); + } + + Volatile.Write(ref _lastDataFetchTimestamp, TimeProvider.GetTimestamp()); + } + + var instrument = baseInstrument is not null && cursors.Count > 0 + ? MetricInstrumentDataCache.Merge(baseInstrument, refreshedInstrument, cursors, startDate) + : refreshedInstrument; + return (updateVersion, instrument); + } + + private bool TryPublishInstrument((long UpdateVersion, OtlpInstrumentData? Instrument) result) + { + lock (_instrumentUpdateLock) + { + if (result.UpdateVersion != _instrumentUpdateVersion) + { + return false; + } + + _instrument = result.Instrument; + return true; + } + } + + private void EnsureDataEndTime(ResourceKey resourceKey, string meterName, string instrumentName) + { + var key = (resourceKey, meterName, instrumentName); + if (_dataEndTimeKey == key) + { + return; + } + + var latestEndTime = TelemetryRepository.GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); + _dataEndTime = latestEndTime is not null ? new DateTimeOffset(latestEndTime.Value) : null; + _dataEndTimeKey = key; } private List CreateUpdatedFilters(bool hasInstrumentChanged) @@ -272,6 +422,74 @@ private List CreateUpdatedFilters(bool hasInstrumentCh return filters; } + private bool UpdateDimensionFilters(bool hasInstrumentChanged) + { + var updatedFilters = ImmutableList.Create(CollectionsMarshal.AsSpan(CreateUpdatedFilters(hasInstrumentChanged))); + if (HaveSameDimensionFilterContent(DimensionFilters, updatedFilters)) + { + return false; + } + + // Filters can be accessed from a background task, so replace the immutable collection atomically. + DimensionFilters = updatedFilters; + return true; + } + + private bool HaveDimensionFilterValuesChanged(OtlpInstrumentData instrument) + { + if (instrument.KnownAttributeValues.Count != DimensionFilters.Count) + { + return true; + } + + var index = 0; + foreach (var attribute in instrument.KnownAttributeValues.OrderBy(attribute => attribute.Key)) + { + var filter = DimensionFilters[index++]; + if (filter.Name != attribute.Key || + !filter.Values.Select(value => value.Value).SequenceEqual(attribute.Value)) + { + return true; + } + } + + return false; + } + + private static bool HaveSameDimensionFilterContent( + ImmutableList currentFilters, + ImmutableList updatedFilters) + { + if (currentFilters.Count != updatedFilters.Count) + { + return false; + } + + for (var filterIndex = 0; filterIndex < currentFilters.Count; filterIndex++) + { + var currentFilter = currentFilters[filterIndex]; + var updatedFilter = updatedFilters[filterIndex]; + if (currentFilter.Name != updatedFilter.Name || currentFilter.Values.Count != updatedFilter.Values.Count) + { + return false; + } + + for (var valueIndex = 0; valueIndex < currentFilter.Values.Count; valueIndex++) + { + var currentValue = currentFilter.Values[valueIndex]; + var updatedValue = updatedFilter.Values[valueIndex]; + if (currentValue.Text != updatedValue.Text || + currentValue.Value != updatedValue.Value || + currentFilter.SelectedValues.Contains(currentValue) != updatedFilter.SelectedValues.Contains(updatedValue)) + { + return false; + } + } + } + + return true; + } + private Task OnTabChangeAsync(FluentTab newTab) { var id = newTab.Id?.Substring("tab-".Length); diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor index c296a4ad120..d1b39955bdd 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor @@ -25,15 +25,15 @@ } - @if (Instrument.Summary.Type == OtlpInstrumentType.Histogram) + @if (InstrumentType == OtlpInstrumentType.Histogram) {
@Loc[nameof(ControlsStrings.ChartContainerOptionsHeader)]
+ Value="@ShowCount" + ValueChanged="@OnShowCountChangedAsync"/>
} diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs index ce253c4c755..fa743e3e9d1 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs @@ -11,10 +11,13 @@ namespace Aspire.Dashboard.Components; public partial class ChartFilters { [Parameter, EditorRequired] - public required OtlpInstrumentData Instrument { get; set; } + public required OtlpInstrumentType InstrumentType { get; set; } [Parameter, EditorRequired] - public required InstrumentViewModel InstrumentViewModel { get; set; } + public required bool ShowCount { get; set; } + + [Parameter] + public EventCallback ShowCountChanged { get; set; } [Parameter, EditorRequired] public required ImmutableList DimensionFilters { get; set; } @@ -22,19 +25,5 @@ public partial class ChartFilters [Parameter] public EventCallback OnDimensionValuesChanged { get; set; } - public bool ShowCounts { get; set; } - - protected override void OnInitialized() - { - InstrumentViewModel.DataUpdateSubscriptions.Add(() => - { - ShowCounts = InstrumentViewModel.ShowCount; - return Task.CompletedTask; - }); - } - - private void ShowCountChanged() - { - InstrumentViewModel.ShowCount = ShowCounts; - } + private Task OnShowCountChangedAsync(bool value) => ShowCountChanged.InvokeAsync(value); } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs new file mode 100644 index 00000000000..c7e7b88ff7e --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Components; + +/// +/// Selects the metric data point interval for a chart duration. +/// +internal static class MetricDataPointInterval +{ + /// + /// Gets the metric data point interval for the specified chart duration. + /// + public static TimeSpan Get(TimeSpan duration) + { + if (duration <= TimeSpan.FromMinutes(5)) + { + return TimeSpan.FromSeconds(1); + } + + if (duration <= TimeSpan.FromMinutes(15)) + { + return TimeSpan.FromSeconds(2); + } + + if (duration <= TimeSpan.FromMinutes(30)) + { + return TimeSpan.FromSeconds(5); + } + + if (duration <= TimeSpan.FromHours(1)) + { + return TimeSpan.FromSeconds(10); + } + + if (duration <= TimeSpan.FromHours(3)) + { + return TimeSpan.FromSeconds(30); + } + + if (duration <= TimeSpan.FromHours(6)) + { + return TimeSpan.FromMinutes(1); + } + + if (duration <= TimeSpan.FromHours(12)) + { + return TimeSpan.FromMinutes(2); + } + + return TimeSpan.FromMinutes(5); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs new file mode 100644 index 00000000000..d5a1db52445 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Otlp.Storage; + +namespace Aspire.Dashboard.Components; + +internal static class MetricInstrumentDataCache +{ + public static List CreateCursors(OtlpInstrumentData data, TimeSpan refreshLookback, TimeSpan dataPointInterval) + { + ArgumentOutOfRangeException.ThrowIfLessThan(refreshLookback, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(dataPointInterval, TimeSpan.Zero); + + var cursors = new List(data.Dimensions.Count); + foreach (var dimension in data.Dimensions) + { + if (dimension.Values.Count == 0) + { + continue; + } + + var latestValue = dimension.Values[^1]; + // Refresh recent complete buckets because data can arrive behind the latest displayed value. Starting from + // the rolled value's start when it is earlier also retains source points that determine its representative. + var lookbackStartTicks = latestValue.End.Ticks - Math.Min(latestValue.End.Ticks, refreshLookback.Ticks); + var alignedLookbackStartTicks = lookbackStartTicks - (lookbackStartTicks % dataPointInterval.Ticks); + cursors.Add(new MetricDimensionCursor + { + Attributes = dimension.Attributes, + StartTime = new DateTime(Math.Min(latestValue.Start.Ticks, alignedLookbackStartTicks), DateTimeKind.Utc) + }); + } + return cursors; + } + + public static OtlpInstrumentData Merge( + OtlpInstrumentData cached, + OtlpInstrumentData refreshed, + IReadOnlyList cursors, + DateTime windowStart) + { + var dimensions = new List(refreshed.Dimensions.Count); + foreach (var refreshedDimension in refreshed.Dimensions) + { + var cachedDimension = cached.Dimensions.FirstOrDefault(dimension => AttributesEqual(dimension.Attributes, refreshedDimension.Attributes)); + var cursor = cursors.FirstOrDefault(cursor => AttributesEqual(cursor.Attributes, refreshedDimension.Attributes)); + if (cachedDimension is null || cursor is null) + { + dimensions.Add(CloneDimension(refreshedDimension, windowStart)); + continue; + } + + var mergedDimension = new DimensionScope(refreshedDimension.Capacity, refreshedDimension.Attributes); + foreach (var value in cachedDimension.Values) + { + if (value.End >= windowStart && value.End < cursor.StartTime) + { + mergedDimension.Values.Add(MetricValueBase.Clone(value)); + } + } + foreach (var value in refreshedDimension.Values) + { + if (value.End >= windowStart) + { + mergedDimension.Values.Add(MetricValueBase.Clone(value)); + } + } + dimensions.Add(mergedDimension); + } + + return new OtlpInstrumentData + { + Summary = refreshed.Summary, + Dimensions = dimensions, + KnownAttributeValues = refreshed.KnownAttributeValues, + HasOverflow = refreshed.HasOverflow + }; + } + + private static DimensionScope CloneDimension(DimensionScope dimension, DateTime windowStart) + { + var clone = new DimensionScope(dimension.Capacity, dimension.Attributes); + foreach (var value in dimension.Values) + { + if (value.End >= windowStart) + { + clone.Values.Add(MetricValueBase.Clone(value)); + } + } + return clone; + } + + private static bool AttributesEqual(KeyValuePair[] left, KeyValuePair[] right) + => left.AsSpan().SequenceEqual(right); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor index b5217f4bace..92081fd00c8 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor @@ -95,7 +95,14 @@ } -  @Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)] + @if (_hasUpdated) + { + @: @Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)] + } + else + { + @Loc[nameof(ControlsStrings.Loading)] + } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs index 311ad399cac..4774e690373 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs @@ -27,7 +27,9 @@ public partial class MetricTable : ChartBase private OtlpInstrumentSummary? _instrument; private bool _showCount; - private DateTimeOffset? _lastUpdate; + private bool _hasUpdated; + + protected override TimeSpan UpdateInterval => TimeSpan.FromSeconds(1); private IQueryable _metricsView => _metrics.Values.AsEnumerable().Reverse().ToList().AsQueryable(); @@ -46,14 +48,6 @@ protected override async Task OnChartUpdatedAsync(List traces, List< { Debug.Assert(_jsModule != null, "The module should be initialized before chart data is sent to control."); - // Only update the data grid once per second to avoid additional DOM re-renders. - if (inProgressDataTime - _lastUpdate < TimeSpan.FromSeconds(1)) - { - return; - } - - _lastUpdate = inProgressDataTime; - if (!Equals(_instrument?.Name, InstrumentViewModel.Instrument?.Name) || _showCount != InstrumentViewModel.ShowCount) { _metrics.Clear(); @@ -66,12 +60,19 @@ protected override async Task OnChartUpdatedAsync(List traces, List< _metrics = UpdateMetrics(out var xValuesToAnnounce, traces, xValues, exemplars); _exemplars = exemplars; + // A newly activated table can calculate before its first live tick reaches the current data window. + // Keep showing the loading state until rows are available or a tick confirms that the window is empty. + _hasUpdated |= tickUpdate || _metrics.Count > 0; + + // Render the updated rows before delaying their accessibility announcement. + await InvokeAsync(StateHasChanged); if (xValuesToAnnounce.Count == 0) { return; } + // Give the data grid time to render the new rows before announcing their positions to screen readers. await Task.Delay(500, cancellationToken); var metricView = _metricsView.ToList(); diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor index c918f2af3c6..6da04514f6a 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor @@ -9,4 +9,5 @@ ItemsProvider="@(() => _clearMenuItems)" ButtonAppearance="Appearance.Neutral" ButtonClass="clear-button" + Disabled="@TelemetryRepository.IsReadOnly" Title="@Loc[nameof(ControlsStrings.ClearSignalsButtonTitle)]" /> diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs index b9c10c7530a..1e604c7bb5a 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs @@ -17,6 +17,11 @@ public partial class ClearSignalsButton : ComponentBase private static readonly Icon s_clearSelectedResourceIcon = new Icons.Regular.Size16.SelectAllOn(); private static readonly Icon s_clearAllResourcesIcon = new Icons.Regular.Size16.Stack(); + [Inject] + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + [Inject] public required IStringLocalizer ControlsStringsLoc { get; init; } @@ -37,6 +42,7 @@ protected override void OnParametersSet() Id = "clear-menu-all", Icon = s_clearAllResourcesIcon, OnClick = () => HandleClearSignal(null), + IsDisabled = TelemetryRepository.IsReadOnly, Text = ControlsStringsLoc[name: nameof(ControlsStrings.ClearAllResources)], }); @@ -45,7 +51,7 @@ protected override void OnParametersSet() Id = "clear-menu-resource", Icon = s_clearSelectedResourceIcon, OnClick = () => HandleClearSignal(SelectedResource.Id?.GetResourceKey()), - IsDisabled = SelectedResource.Id == null, + IsDisabled = TelemetryRepository.IsReadOnly || SelectedResource.Id == null, Text = SelectedResource.Id == null ? ControlsStringsLoc[nameof(ControlsStrings.ClearPendingSelectedResource)] : string.Format(CultureInfo.InvariantCulture, ControlsStringsLoc[name: nameof(ControlsStrings.ClearSelectedResource)], SelectedResource.Name), diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor new file mode 100644 index 00000000000..7a5df3d0f13 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor @@ -0,0 +1,13 @@ +
+ +
diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs new file mode 100644 index 00000000000..2eecec1a6aa --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.FluentUI.AspNetCore.Components; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; +using LayoutResources = Aspire.Dashboard.Resources.Layout; + +namespace Aspire.Dashboard.Components.Controls; + +public partial class DashboardRunSelect : ComponentBase +{ + private static readonly Icon s_checkmarkIcon = new Icons.Regular.Size16.Checkmark(); + private static readonly Icon s_pinIcon = new Icons.Regular.Size16.Pin(); + private static readonly Icon s_pinnedIcon = new Icons.Filled.Size16.Pin(); + + private string RunSelectTitle => Loc[nameof(LayoutResources.DashboardRunSelectTitle)]; + private string RunSelectAccessibleLabel => Loc[nameof(LayoutResources.DashboardRunSelectAccessibleLabel), SelectedRunText]; + private string SelectedRunText => SelectedRunIsCurrent + ? Loc[nameof(LayoutResources.DashboardRunSelectCurrent)] + : FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, SelectedRunStartedAtUtc.UtcDateTime); + + [Parameter, EditorRequired] + public required string SelectedRunId { get; set; } + + [Parameter] + public bool SelectedRunIsCurrent { get; set; } + + [Parameter] + public DateTimeOffset SelectedRunStartedAtUtc { get; set; } + + [Parameter] + public EventCallback SelectedRunIdChanged { get; set; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required BrowserTimeProvider TimeProvider { get; init; } + + [Inject] + public required IDashboardRunStore RunStore { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + private IList LoadRuns() + { + var runs = RunStore.GetRuns() + .Where(run => !run.IsPruned) + .OrderByDescending(run => run.IsCurrent) + .ThenByDescending(run => run.IsPinned) + .ThenByDescending(run => run.StartedAtUtc) + .ToArray(); + var menuItems = new List(); + foreach (var run in runs) + { + var menuItem = new MenuButtonItem + { + Text = FormatRunOption(run), + Role = MenuItemRole.MenuItemRadio, + Checked = string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal), + Icon = s_checkmarkIcon, + SecondaryActionIcon = run.IsPinned ? s_pinnedIcon : s_pinIcon, + SecondaryActionAriaLabel = Loc[run.IsPinned + ? nameof(LayoutResources.DashboardRunSelectUnpin) + : nameof(LayoutResources.DashboardRunSelectPin)], + IsSecondaryActionSelected = run.IsPinned, + OnSecondaryActionClick = () => + { + SetRunPinned(run, !run.IsPinned); + return Task.CompletedTask; + }, + OnClick = () => SelectedRunIdChanged.InvokeAsync(run.IsCurrent ? null : run.RunId) + }; + menuItems.Add(menuItem); + + if (run.IsCurrent && runs.Any(candidate => !candidate.IsCurrent)) + { + menuItems.Add(new MenuButtonItem { IsDivider = true }); + } + } + + return menuItems; + } + + private void SetRunPinned(DashboardRunDescriptor run, bool isPinned) + { + try + { + RunStore.SetRunPinned(run, isPinned); + } + catch (Exception exception) + { + Logger.LogError(exception, "Failed to update the pinned state of dashboard run '{RunId}'.", run.RunId); + } + } + + private string FormatRunOption(DashboardRunDescriptor run) + { + if (run.IsCurrent) + { + return Loc[nameof(LayoutResources.DashboardRunSelectCurrent)]; + } + + return FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, run.StartedAtUtc.UtcDateTime); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css new file mode 100644 index 00000000000..cfedba283d9 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css @@ -0,0 +1,8 @@ +.application-run-select ::deep .application-run-select-button { + margin-right: 8px; +} + +.application-run-select ::deep .application-run-select-button::part(control) { + padding-left: 8px; + padding-right: 8px; +} diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor index 23a9cc0d5ba..b93a9136b1a 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor @@ -16,7 +16,7 @@ {
} else if (!string.IsNullOrWhiteSpace(FilterText) && logEntries.EntriesCount > 0 && GetVisibleEntries().Count == 0) diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs index ee5bf9952e0..fd5ec215650 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs @@ -58,6 +58,9 @@ public sealed partial class LogViewer [Parameter] public bool ShowNoLogsMessage { get; set; } + [Parameter] + public string? NoLogsMessage { get; set; } + [Parameter] public string? FilterText { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor b/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor index f584645d426..19c67f05ad8 100644 --- a/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor +++ b/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor @@ -1,6 +1,7 @@ @using Aspire.Dashboard.Model @using Aspire.Dashboard.Resources @inject IStringLocalizer ControlsStringsLoc +@inject IDashboardClient DashboardClient @if (SetCommand is not null) { @@ -29,7 +30,7 @@ else public required Func IsCommandExecuting { get; set; } private CommandViewModel? SetCommand { get; set; } - private bool IsSetCommandDisabled => SetCommand is null || SetCommand.State == CommandViewModelState.Disabled || IsSetCommandExecuting; + private bool IsSetCommandDisabled => DashboardClient.IsReadOnly || SetCommand is null || SetCommand.State == CommandViewModelState.Disabled || IsSetCommandExecuting; private bool IsSetCommandExecuting => SetCommand is not null && IsCommandExecuting(Resource, SetCommand); protected override void OnParametersSet() @@ -48,7 +49,8 @@ else private Task OnClickAsync() { - if (SetCommand is not { } setCommand || + if (DashboardClient.IsReadOnly || + SetCommand is not { } setCommand || setCommand.State == CommandViewModelState.Disabled || IsCommandExecuting(Resource, setCommand)) { diff --git a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor index de03ac0948d..5f9b61ce64b 100644 --- a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor +++ b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor @@ -2,6 +2,7 @@ @inject IStringLocalizer Loc @if (IsPaused) diff --git a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs index 3d04ab4ff97..b0fa5df2a48 100644 --- a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs @@ -12,8 +12,16 @@ public partial class PauseIncomingDataSwitch : ComponentBase [Parameter] public EventCallback IsPausedChanged { get; set; } + [Parameter] + public bool Disabled { get; set; } + private async Task OnTogglePauseCoreAsync() { + if (Disabled) + { + return; + } + IsPaused = !IsPaused; await IsPausedChanged.InvokeAsync(IsPaused); } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs index 4d460ca7eca..59ad01c2f08 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs @@ -27,6 +27,9 @@ public partial class ResourceNameButtonValue [Inject] public required IDashboardClient DashboardClient { get; init; } + [Inject] + public required DashboardDataSource DataSource { get; init; } + [Inject] public required IconResolver IconResolver { get; init; } @@ -39,7 +42,7 @@ protected override void OnParametersSet() if (DashboardClient.IsEnabled) { - _resource = DashboardClient.GetResource(Resource.ResourceKey.ToString()); + _resource = DataSource.ResourceRepository.GetResource(Resource.ResourceKey.ToString()); if (_resource != null) { _resourceIcon = ResourceIconHelpers.GetIconForResource(IconResolver, _resource, IconSize.Size16, IconVariant.Regular); diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs index ae638f67e10..52ab3ca72a2 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs @@ -24,7 +24,9 @@ public partial class SpanIdButtonValue public required DashboardDialogService DialogService { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required IStringLocalizer Loc { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor index 8906adef851..60d03e1879d 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor @@ -17,7 +17,7 @@ { var highlightedCommand = _highlightedCommands[i]; - + } diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs index f7b70adb841..5acb567734f 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs @@ -32,6 +32,9 @@ public partial class ResourceActions : ComponentBase [Inject] public required IconResolver IconResolver { get; init; } + [Inject] + public required IDashboardClient DashboardClient { get; init; } + [Parameter] public required EventCallback CommandSelected { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor index f73652631ce..5d07f3c3bb3 100644 --- a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor +++ b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor @@ -3,7 +3,7 @@ @if (ViewportInformation.IsDesktop) { - + } @@ -12,7 +12,7 @@ else @ControlsStringsLoc[nameof(ControlsStrings.ResourceActions)] - + diff --git a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs index 3311c7bc1b2..ba102f828ef 100644 --- a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs @@ -9,6 +9,11 @@ namespace Aspire.Dashboard.Components.Controls; public partial class SignalsActionsDisplay { + [Inject] + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + [CascadingParameter] public required ViewportInformation ViewportInformation { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs index 4bfdbf5b917..57df5495725 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs @@ -34,7 +34,9 @@ public partial class SpanDetails : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required IJSRuntime JS { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs index 1a04471b45d..743902eaa66 100644 --- a/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; -using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; @@ -23,7 +23,7 @@ public partial class StructuredLogActions : ComponentBase public required EventCallback OnViewDetails { get; set; } [Parameter] - public required OtlpLogEntry LogEntry { get; set; } + public required LogSummary LogEntry { get; set; } private IList GetMenuItems() { diff --git a/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor b/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor index a31e65bf95f..d5c15587aa0 100644 --- a/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor +++ b/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor @@ -2,7 +2,14 @@ diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 4e9d4e6e992..5bcfda9bde5 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -18,6 +18,8 @@ namespace Aspire.Dashboard.Components.Layout; public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable { private bool _isNavMenuOpen; + private bool _runSelectionChanged; + private bool _isSwitchingRuns; // Desktop nav rail layout. false = collapsed to icons only (default, most content space, // labels still available via each item's tooltip); true = expanded so each item shows its @@ -77,11 +79,46 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable [Inject] public required ILocalStorage LocalStorage { get; init; } + [Inject] + public required ISessionStorage SessionStorage { get; init; } + + [Inject] + public required IDashboardRunStore RunStore { get; init; } + + [Inject] + public required IDashboardRunSelection RunSelection { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + [CascadingParameter] public required ViewportInformation ViewportInformation { get; set; } protected override async Task OnInitializedAsync() { + if (RunStore.SupportsRunSelection) + { + var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); + var selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; + if (!_runSelectionChanged && !string.IsNullOrEmpty(selectedRunId)) + { + try + { + RunSelection.SelectRun(selectedRunId); + } + catch (Exception exception) + { + Logger.LogError(exception, "Failed to restore dashboard run '{RunId}'. Falling back to the current run.", selectedRunId); + RunSelection.SelectRun(runId: null); + } + + if (RunSelection.SelectedRun.IsCurrent) + { + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, string.Empty); + } + } + } + // Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window. // Note that this event could be raised from a settings dialog opened in a different browser window. _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => @@ -225,6 +262,41 @@ protected override void OnParametersSet() private string GetDefaultReturnFocusElementId(string desktopButtonId) => ViewportInformation.IsDesktop ? desktopButtonId : NavigationButtonId; + private async Task SwitchDashboardRunAsync(string? runId) + { + _runSelectionChanged = true; + var selectedRunId = RunSelection.SelectedRun is { IsCurrent: false } selectedRun ? selectedRun.RunId : null; + if (string.Equals(runId, selectedRunId, StringComparison.Ordinal)) + { + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, runId ?? string.Empty); + return; + } + + _isSwitchingRuns = true; + await InvokeAsync(StateHasChanged); + + try + { + RunSelection.SelectRun(runId); + } + catch (Exception exception) + { + Logger.LogError( + exception, + "Failed to switch to dashboard run '{RunId}'. Keeping dashboard run '{SelectedRunId}' selected.", + runId, + RunSelection.SelectedRun.RunId); + } + finally + { + _isSwitchingRuns = false; + await InvokeAsync(StateHasChanged); + } + + var persistedRunId = RunSelection.SelectedRun is { IsCurrent: false } actualSelectedRun ? actualSelectedRun.RunId : string.Empty; + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, persistedRunId); + } + private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId) { // Dialog launchers move between the desktop header and the mobile navigation menu. diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 54ebd08ee5d..6cfd899db51 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -46,7 +46,7 @@ { @@ -153,6 +153,7 @@ IsTimestampUtc="@_isTimestampUtc" NoWrapLogs="@_noWrapLogs" ShowNoLogsMessage="@_showNoLogsMessage" + NoLogsMessage="@GetNoLogsMessage()" ShowResourcePrefix="@_isSubscribedToAll"/>
@@ -173,6 +174,7 @@ IsTimestampUtc="@_isTimestampUtc" NoWrapLogs="@_noWrapLogs" ShowNoLogsMessage="@_showNoLogsMessage" + NoLogsMessage="@GetNoLogsMessage()" ShowResourcePrefix="@_isSubscribedToAll"/> } diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index ee0c5146540..21f223eb226 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -82,7 +82,9 @@ public void Cancel() public required ISessionStorage SessionStorage { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required ILogger Logger { get; init; } @@ -195,6 +197,7 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in private bool _isTimestampUtc; private bool _noWrapLogs; private bool _showNoLogsMessage; + private bool _consoleLogsWereLoaded = true; private string _logFilter = string.Empty; public ConsoleLogsViewModel PageViewModel { get; set; } = null!; private IDisposable? _consoleLogsFiltersChangedSubscription; @@ -206,7 +209,7 @@ protected override async Task OnInitializedAsync() { TelemetryContextProvider.Initialize(TelemetryContext); _resourceSubscriptionToken = _resourceSubscriptionCts.Token; - _logEntries = new(Options.Value.Frontend.MaxConsoleLogCount); + _logEntries = new(DashboardUIHelpers.GetVirtualizedItemCount(Options.Value.Frontend.MaxConsoleLogCount)); _allResource = new() { Id = null, Name = ControlsStringsLoc[nameof(ControlsStrings.LabelAll)] }; PageViewModel = new ConsoleLogsViewModel { SelectedResource = _allResource, Status = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsLoadingResources)] }; _logEntryChannelReaderTask = StartLogEntryChannelReaderTask(); @@ -265,7 +268,7 @@ async Task TrackResourceSnapshotsAsync() return; } - var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_resourceSubscriptionToken); + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_resourceSubscriptionToken); Logger.LogDebug("Received initial resource snapshot with {ResourceCount} resources.", snapshot.Length); @@ -518,8 +521,12 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa _activeView = ConsoleLogsView.Console; } - if (!isAllSelected && selectedResourceName is not null && - _resourceByName.TryGetValue(selectedResourceName, out var selectedResource) && + var selectedResource = selectedResourceName is not null + ? _resourceByName.GetValueOrDefault(selectedResourceName) + : null; + + if (!DashboardClient.IsReadOnly && + !isAllSelected && selectedResource is not null && selectedResource.HasTerminal() && selectedResource.TryGetTerminalReplicaInfo(out var replicaIndex, out _)) { @@ -560,6 +567,8 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa } ResetNoLogsMessage(); + _consoleLogsWereLoaded = !DashboardClient.IsReadOnly || WereConsoleLogsLoaded(isAllSelected, selectedResource); + await InvokeAsync(_logViewerRef.SafeRefreshDataAsync); if (isAllSelected) @@ -568,11 +577,11 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa _isSubscribedToAll = true; await SubscribeToAllResourcesAsync(); } - else if (selectedResourceName is not null && _resourceByName.TryGetValue(selectedResourceName, out var resource)) + else if (selectedResource is not null) { // Subscribe to single resource _isSubscribedToAll = false; - await SubscribeToSingleResourceAsync(resource); + await SubscribeToSingleResourceAsync(selectedResource); } else { @@ -593,6 +602,22 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa UpdateMenuButtons(); } + private bool WereConsoleLogsLoaded(bool isAllSelected, ResourceViewModel? selectedResource) + { + if (isAllSelected) + { + return _resourceByName.Values + .Where(resource => !resource.IsResourceHidden(_showHiddenResources)) + .Any(resource => resource.ConsoleLogsLoaded); + } + + return selectedResource?.ConsoleLogsLoaded == true; + } + + private string GetNoLogsMessage() => _consoleLogsWereLoaded + ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoLogsFound)] + : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNotCapturedForRun)]; + private bool IsAllSelected() { return PageViewModel?.SelectedResource is not null && PageViewModel.SelectedResource == _allResource; @@ -1210,6 +1235,12 @@ private string GetFileName() private async Task ClearConsoleLogs(ResourceKey? key) { var now = TimeProvider.GetUtcNow().UtcDateTime; + var resourceNames = key is null + ? DataSource.ResourceRepository.GetResources().Select(resource => resource.Name).ToList() + : [key.Value.ToString()]; + + await DataSource.ResourceRepository.ClearConsoleLogsAsync(resourceNames, now); + var newFilters = key is null ? ConsoleLogsFilters.CreateClearAll(now) : ConsoleLogsManager.Filters.WithResourceCleared(key.Value.ToString(), now); diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index 3be0b4e1e7a..c7bb5a4a5e4 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -60,7 +60,12 @@ public partial class Metrics : IDisposable, IComponentWithTelemetry, IPageWithSe public required ISessionStorage SessionStorage { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required ILogger Logger { get; init; } @@ -176,7 +181,7 @@ public Task UpdateViewModelFromQueryAsync(MetricsViewModel viewModel) private void UpdateInstruments(MetricsViewModel viewModel) { var selectedInstance = viewModel.SelectedResource.Id?.GetResourceKey(); - viewModel.Instruments = selectedInstance != null ? TelemetryRepository.GetInstrumentsSummaries(selectedInstance.Value) : null; + viewModel.Instruments = selectedInstance != null ? TelemetryRepository.GetInstrumentSummaries(selectedInstance.Value) : null; } private void UpdateResources() @@ -236,8 +241,8 @@ private bool ShouldClearSelectedMetrics(List instruments) private Task ClearMetrics(ResourceKey? key) { - TelemetryRepository.ClearMetrics(key); - return Task.CompletedTask; + DataSource.EnsureWritable(); + return TelemetryRepositoryWriter.ClearMetricsAsync(key); } private Task HandleSelectedDurationChangedAsync() @@ -330,7 +335,7 @@ private void UpdateSubscription() if (selectedResourceKey != null) { // If there are more instruments than before then update the UI. - var instruments = TelemetryRepository.GetInstrumentsSummaries(selectedResourceKey.Value); + var instruments = TelemetryRepository.GetInstrumentSummaries(selectedResourceKey.Value); if (PageViewModel.Instruments is null || instruments.Count != PageViewModel.Instruments.Count) { diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index bd8bcb25802..7c2eb53b0d2 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -45,7 +45,9 @@ public partial class Resources : ComponentBase, IComponentWithTelemetry, IAsyncD [Inject] public required IDashboardClient DashboardClient { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required NavigationManager NavigationManager { get; init; } [Inject] @@ -256,7 +258,7 @@ protected override async Task OnInitializedAsync() async Task SubscribeResourcesAsync() { - var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_cts.Token); + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_cts.Token); // Apply snapshot. foreach (var resource in snapshot) diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor index 8e2e9648e24..85bd7371056 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor @@ -3,6 +3,7 @@ @using Aspire.Dashboard.Resources @using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.Dashboard.Otlp.Storage @inject IJSRuntime JS @implements IDisposable @@ -71,16 +72,16 @@ ResizableColumns="true" ResizeColumnOnAllRows="false" ItemsProvider="@GetData" - TGridItem="OtlpLogEntry" + TGridItem="LogSummary" GridTemplateColumns="@_manager.GetGridTemplateColumns()" ShowHover="true" ItemKey="@(r => r.InternalId)" OnRowClick="@(r => r.ExecuteOnDefault(d => OnShowPropertiesAsync(d, focusElementId: ScrollContainerId)))" Class="main-grid enable-row-click"> - - - @GetResourceName(context.ResourceView) + + + @GetResourceName(context.Resource) @@ -91,7 +92,7 @@ @* Tooltip is displayed by the message GridValue instance *@ - + @if (!string.IsNullOrEmpty(context.TraceId)) @@ -131,8 +132,10 @@ diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 26ed4136cc9..5a69bc5a948 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -41,12 +41,13 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA private readonly CancellationTokenSource _cts = new(); private Subscription? _resourcesSubscription; private Subscription? _logsSubscription; + private int? _displayedItemCount; private bool _resourceChanged; private string? _elementIdBeforeDetailsViewOpened; private string? _pendingFocusElementId; private AspirePageContentLayout? _contentLayout; private string _filter = string.Empty; - private FluentDataGrid? _dataGrid; + private FluentDataGrid? _dataGrid; private GridColumnManager _manager = null!; private IList _gridColumns = null!; @@ -58,7 +59,12 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA public StructuredLogsPageViewModel PageViewModel { get; set; } = null!; [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required StructuredLogsViewModel ViewModel { get; init; } @@ -82,7 +88,7 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA public required ITelemetryErrorRecorder ErrorRecorder { get; init; } [Inject] - public required DimensionManager DimensionManager { get; set; } + public required DimensionManager DimensionManager { get; init; } [Inject] public required IOptions DashboardOptions { get; init; } @@ -122,37 +128,43 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA [SupplyParameterFromQuery] public long? LogEntryId { get; set; } - private async ValueTask> GetData(GridItemsProviderRequest request) + private async ValueTask> GetData(GridItemsProviderRequest request) { ViewModel.StartIndex = request.StartIndex; ViewModel.Count = request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount; - var logs = ViewModel.GetLogs(); + var logs = await ViewModel.GetLogsAsync(request.CancellationToken); - if (logs.IsFull && !TelemetryRepository.HasDisplayedMaxLogLimitMessage) + if (!TelemetryRepository.IsReadOnly) { - TelemetryRepository.MaxLogLimitMessage = await DashboardUIHelpers.DisplayMaxLimitMessageAsync( - MessageService, - Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitTitle)], - string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitBody)], DashboardOptions.Value.TelemetryLimits.MaxLogCount), - () => TelemetryRepository.MaxLogLimitMessage = null); + if (logs.IsFull && !TelemetryRepository.HasDisplayedMaxLogLimitMessage) + { + TelemetryRepository.MaxLogLimitMessage = await DashboardUIHelpers.DisplayMaxLimitMessageAsync( + MessageService, + Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitTitle)], + string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitBody)], DashboardOptions.Value.TelemetryLimits.MaxLogCount), + () => TelemetryRepository.MaxLogLimitMessage = null); - TelemetryRepository.HasDisplayedMaxLogLimitMessage = true; - } - else if (!logs.IsFull && TelemetryRepository.MaxLogLimitMessage is { } message) - { - // Telemetry could have been cleared from the dashboard. Automatically remove full message on data update. - message.Close(); + TelemetryRepository.HasDisplayedMaxLogLimitMessage = true; + } + else if (!logs.IsFull && TelemetryRepository.MaxLogLimitMessage is { } message) + { + // Telemetry could have been cleared from the dashboard. Automatically remove full message on data update. + message.Close(); + } } + var virtualizedLogCount = DashboardUIHelpers.GetVirtualizedItemCount(logs.TotalItemCount); + _displayedItemCount = virtualizedLogCount < logs.TotalItemCount ? virtualizedLogCount : null; + // Updating the total item count as a field doesn't work because it isn't updated with the grid. // The workaround is to explicitly update and refresh the control. _totalItemsCount = logs.TotalItemCount; - _totalItemsFooter?.UpdateDisplayedCount(_totalItemsCount); + _totalItemsFooter?.UpdateDisplayedCount(_totalItemsCount, _displayedItemCount); TelemetryRepository.MarkViewedErrorLogs(ViewModel.ResourceKey); - return GridItemsProviderResult.From(logs.Items, logs.TotalItemCount); + return GridItemsProviderResult.From(logs.Items, virtualizedLogCount); } protected override void OnInitialized() @@ -300,6 +312,12 @@ private async Task OnShowPropertiesAsync(OtlpLogEntry entry, string? focusElemen } } + private Task OnShowPropertiesAsync(LogSummary summary, string? focusElementId) + { + var entry = TelemetryRepository.GetLog(summary.InternalId); + return entry is null ? Task.CompletedTask : OnShowPropertiesAsync(entry, focusElementId); + } + private Task ClearSelectedLogEntryAsync(bool causedByUserAction = false) { PageViewModel.SelectedLogEntry = null; @@ -321,13 +339,14 @@ private async Task OpenFilterAsync(FieldTelemetryFilter? entry) await _contentLayout.CloseMobileToolbarAsync(); } + var resourceKey = PageViewModel.SelectedResource.Id?.GetResourceKey(); await FilterHelpers.OpenFilterAsync( entry, DialogService, DialogService.CreateDialogCallback(this, HandleFilterDialog), - propertyKeys: TelemetryRepository.GetLogPropertyKeys(PageViewModel.SelectedResource.Id?.GetResourceKey()), + getPropertyKeysAsync: cancellationToken => TelemetryRepository.GetLogPropertyKeysAsync(resourceKey, cancellationToken), knownKeys: KnownStructuredLogFields.AllFields, - getFieldValues: TelemetryRepository.GetLogsFieldValues, + getFieldValuesAsync: TelemetryRepository.GetLogsFieldValuesAsync, FilterLoc); } @@ -372,9 +391,9 @@ private async Task HandleAfterFilterBindAsync() } } - private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); + private string GetResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, _resources); - private string GetRowClass(OtlpLogEntry entry) + private string GetRowClass(LogSummary entry) { if (entry.InternalId == PageViewModel.SelectedLogEntry?.LogEntry.InternalId) { @@ -401,7 +420,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Check to see whether max item count should be set on every render. // This is required because the data grid's virtualize component can be recreated on data change. - if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) + if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) { StateHasChanged(); } @@ -507,23 +526,7 @@ public async Task UpdateViewModelFromQueryAsync(StructuredLogsPageViewModel view await InvokeAsync(_dataGrid.SafeRefreshDataAsync); } - private bool IsGenAILogEntry(OtlpLogEntry logEntry) - { - if (string.IsNullOrEmpty(logEntry.SpanId) || string.IsNullOrEmpty(logEntry.TraceId)) - { - return false; - } - - if (GenAIHelpers.HasGenAIAttribute(logEntry.Attributes)) - { - // GenAI telemetry is on the log entry. - return true; - } - - return ViewModel.HasGenAISpan(logEntry.TraceId, logEntry.SpanId); - } - - private async Task LaunchGenAIVisualizerAsync(OtlpLogEntry logEntry) + private async Task LaunchGenAIVisualizerAsync(LogSummary logEntry) { var available = await TraceLinkHelpers.WaitForSpanToBeAvailableAsync( logEntry.TraceId, @@ -532,7 +535,7 @@ private async Task LaunchGenAIVisualizerAsync(OtlpLogEntry logEntry) DialogService, InvokeAsync, DialogsLoc, - CancellationToken.None).ConfigureAwait(false); + _cts.Token).ConfigureAwait(false); if (available) { @@ -563,14 +566,15 @@ await GenAIVisualizerDialog.OpenDialogAsync( } return genAISpans; - }); + }, + _cts.Token); } } private Task ClearStructureLogs(ResourceKey? key) { - TelemetryRepository.ClearStructuredLogs(key); - return Task.CompletedTask; + DataSource.EnsureWritable(); + return TelemetryRepositoryWriter.ClearStructuredLogsAsync(key); } public class StructuredLogsPageViewModel diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor index a8bc9fbc84f..69649597642 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -247,13 +247,13 @@ @foreach (var item in context.SpanLogs) { var buttonId = $"{context.Span.SpanId}-{item.LogEntry.InternalId}"; - var eventName = StructureLogsDetailsViewModel.GetEventName(item.LogEntry, StructuredLogsLoc); + var eventName = item.LogEntry.EventName ?? StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)]; var isSelected = SelectedData?.LogEntryViewModel?.LogEntry.InternalId == item.LogEntry.InternalId; var htmlTooltip = item.Index < 500; var buttonColor = item.LogEntry.IsError ? "var(--error)" : spanColor;