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 6bcf1c8a819..1222c8f0813 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -119,6 +119,7 @@ + diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj index d075e13ae28..7ca5c79dab5 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj +++ b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj @@ -14,6 +14,7 @@ + diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs b/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs index 858a15a4ed3..403362a5fa2 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs @@ -9,6 +9,7 @@ internal static class Program { private static void Main(string[] args) { + SQLitePCL.Batteries_V2.Init(); BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); } } 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..4482579b556 --- /dev/null +++ b/docs/specs/dashboard-persistence.md @@ -0,0 +1,354 @@ +# 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. + +## 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. + +## 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 when the run starts. On graceful disposal it is rewritten with the end time and `CleanShutdown` set to `true`. A 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 schema version. + +The current run is listed first. Historical runs are ordered by descending start time. + +`Run` mode retains at most ten runs per application: the current run and the nine newest historical run directories. Pruning happens after a new run writes its metadata. A run is deleted only after the pruner acquires its lock, so a historical run selected by another Dashboard circuit or a run owned by another Dashboard process is skipped. 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` package. Its native SQLite runtime is supplied by the transitive `SQLitePCLRaw.bundle_e_sqlite3` and `SQLitePCLRaw.lib.e_sqlite3` packages. + +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. +- 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. + +If a requested run was pruned or otherwise became unavailable, selection falls back to the current run. The selected historical run ID is stored in browser session storage and restored when possible. + +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. +- A selected historical run that disappears falls back to `Current`. +- A historical schema mismatch fails that run switch. +- 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 2be43b45244..479ba73dfd8 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -136,7 +136,16 @@ // 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 (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 d8889b489ec..7b6b103b55d 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -136,6 +136,7 @@ + diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 2fb506ee86f..6c5dace0b0b 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -53,6 +53,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 @@ -77,6 +87,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; } @@ -108,7 +120,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)) { @@ -146,6 +163,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/Resources/DashboardCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs index 2c3d133d1df..3d483fabdd4 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs @@ -123,6 +123,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 8980e3db482..ee6762b792e 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx @@ -156,6 +156,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 a0bf40721eb..0ffe422a4f4 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. @@ -102,6 +107,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 be5c1dfe154..a11e2d51397 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. @@ -102,6 +107,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 29f803eaede..05176be676a 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. @@ -102,6 +107,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 7f284cdd6ae..53adbcaa0db 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. @@ -102,6 +107,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 c347a854d30..f5c4e219646 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. @@ -102,6 +107,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 cbefc65051b..cb5d81d5439 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. @@ -102,6 +107,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 8d94679f91d..40f0980748b 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. @@ -102,6 +107,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 89adb78c19a..fa50fddd424 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. @@ -102,6 +107,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 3f0c68537df..b8b3b756673 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. @@ -102,6 +107,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 bd962f3aae3..74a3d538f30 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. @@ -102,6 +107,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 9ba06488e3a..faf26ce4e9f 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. @@ -102,6 +107,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 a90ec95d60f..853f3cdbf4f 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. @@ -102,6 +107,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 abad5d743de..d9491b6be01 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. @@ -102,6 +107,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..61b451229cd 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -15,15 +15,11 @@ 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. @@ -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 { @@ -96,6 +92,15 @@ 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) @@ -107,21 +112,8 @@ internal sealed class TelemetryApiService( 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; + 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 { @@ -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 2897628bb1e..63d555a69f7 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -44,12 +44,15 @@ ServiceClient\dashboard_service.proto + + + @@ -58,9 +61,14 @@ + + + + + @@ -264,6 +272,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index b81cab8bfaa..743de923f42 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -11,7 +11,12 @@ }; } - + + @if (IconStart is not null) + { + + } + @if (string.IsNullOrWhiteSpace(Text)) { diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index 1443388f52d..963fe1f90fb 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -24,6 +24,15 @@ public partial class AspireMenuButton : FluentComponentBase [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; } @@ -51,6 +60,15 @@ public partial class AspireMenuButton : FluentComponentBase [Parameter] public bool HideIcon { get; set; } + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets the callback invoked immediately before the menu is opened. + /// + [Parameter] + public EventCallback OnOpening { get; set; } + /// /// Gets or sets a value indicating whether focus should return to this menu button after a menu item is clicked. /// @@ -63,19 +81,30 @@ public partial class AspireMenuButton : FluentComponentBase protected override void OnParametersSet() { _icon = Icon ?? s_defaultIcon; + UpdateItems(); + } + private void UpdateItems() + { if (Items != null && !_items.SequenceEqual(Items)) { _items = Items.ToArray(); - - // Disabled if there are no actionable items - _disabled = !_items.Any(i => !i.IsDivider); } + + _disabled = Disabled || (!OnOpening.HasDelegate && !_items.Any(i => !i.IsDivider)); } - private void ToggleMenu() + private async Task ToggleMenuAsync() { - _visible = !_visible; + if (_visible) + { + _visible = false; + return; + } + + await OnOpening.InvokeAsync(); + UpdateItems(); + _visible = true; } private void OnKeyDown(KeyboardEventArgs args) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 95dceb762d4..05fbeccaf06 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -29,6 +29,7 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable private OtlpInstrumentKey? _renderedInstrument; private string? _renderedTheme; private bool _renderedShowCount; + private DateTimeOffset? _previousDataEndTime; [Inject] public required IStringLocalizer Loc { get; init; } @@ -40,7 +41,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 +57,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 +69,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 +83,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) { @@ -113,11 +118,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 +240,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 +273,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 a5590f8ce68..3d7e683b6c1 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 db941dbfa17..6be308ae0da 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,19 @@ 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 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 int _disposed; [Parameter, EditorRequired] public required ResourceKey ResourceKey { get; set; } @@ -47,7 +54,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable public required string? PauseText { 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; } @@ -58,6 +67,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; } @@ -66,9 +81,15 @@ 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; + using (ExecutionContext.SuppressFlow()) + { + _tickTask = Task.Run(() => UpdateDataAsync(cancellationToken)); + } + } _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => { _instrumentViewModel.Theme = ThemeManager.EffectiveTheme; @@ -78,43 +99,68 @@ protected override async Task OnInitializedAsync() public async ValueTask DisposeAsync() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + _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"); + + _instrument = GetInstrument(useIncrementalCache: true); + + 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) + _instrument = GetInstrument(useIncrementalCache: false); + if (_instrument is null) { return; } @@ -124,47 +170,37 @@ 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; } - return true; - } - private static bool MatchFilter(KeyValuePair[] attributes, DimensionFilterViewModel filter) - { - // No filter selected. - if (filter.SelectedValues.Count == 0) + _instrumentViewModel.ShowCount = showCount; + if (_instrument is not null) { - return false; + await UpdateInstrumentDataAsync(_instrument); } + } - var value = OtlpHelpers.GetValue(attributes, filter.Name); - foreach (var item in filter.SelectedValues) + protected override async Task OnParametersSetAsync() + { + var requestKey = (ResourceKey, MeterName, InstrumentName, Duration); + if (_instrumentRequestKey != requestKey) { - if (item.Value == value) - { - return true; - } + _instrumentRequestKey = requestKey; + await RefreshChartAsync(); } - - return false; } - protected override async Task OnParametersSetAsync() + private async Task RefreshChartAsync() { - _instrument = GetInstrument(); + _instrument = GetInstrument(useIncrementalCache: false); if (_instrument == null) { @@ -175,40 +211,83 @@ protected override async Task OnParametersSetAsync() 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 OtlpInstrumentData? GetInstrument(bool useIncrementalCache) { - // 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 + 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); + return null; + } + + DateTime endDate; + if (TelemetryRepository.IsReadOnly) + { + EnsureDataEndTime(); + 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; + + // 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 && _instrument is not null + ? MetricInstrumentDataCache.CreateCursors(_instrument, historyDuration, dataPointInterval) + : []; + + var refreshedInstrument = TelemetryRepository.GetInstrument(new GetInstrumentRequest { ResourceKey = ResourceKey, MeterName = MeterName, InstrumentName = InstrumentName, StartTime = startDate, EndTime = endDate, + DataPointInterval = dataPointInterval, + IncludeExemplars = includeExemplars, + PopulateExemplarAttributes = false, + DimensionCursors = cursors, + DimensionFilters = DimensionFilters + .Where(filter => filter.AreAllValuesSelected is not true) + .ToDictionary( + filter => filter.Name, + filter => (IReadOnlyList)filter.SelectedValues.Select(value => value.Value).ToArray()) }); + Debug.Assert(refreshedInstrument is not null); + Volatile.Write(ref _lastDataFetchTimestamp, TimeProvider.GetTimestamp()); + + return _instrument is not null && cursors.Count > 0 + ? MetricInstrumentDataCache.Merge(_instrument, refreshedInstrument, cursors, startDate) + : refreshedInstrument; + } - if (instrument == null) + private void EnsureDataEndTime() + { + var key = (ResourceKey, MeterName, InstrumentName); + if (_dataEndTimeKey == key) { - Logger.LogDebug( - "Unable to find instrument. ResourceKey: {ResourceKey}, MeterName: {MeterName}, InstrumentName: {InstrumentName}", - ResourceKey, - MeterName, - InstrumentName); + return; } - return instrument; + var latestEndTime = TelemetryRepository.GetInstrumentLatestEndTime(ResourceKey, MeterName, InstrumentName); + _dataEndTime = latestEndTime is not null ? new DateTimeOffset(latestEndTime.Value) : null; + _dataEndTimeKey = key; } private List CreateUpdatedFilters(bool hasInstrumentChanged) @@ -284,6 +363,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/ClearSignalsButton.razor b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor index 47843d590af..9b2ed07e37f 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor @@ -9,4 +9,5 @@ Items="@_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..d746eb5f471 --- /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..9ceebf2fe0b --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -0,0 +1,73 @@ +// 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 Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; +using LayoutResources = Aspire.Dashboard.Resources.Layout; + +namespace Aspire.Dashboard.Components.Controls; + +public partial class DashboardRunSelect : ComponentBase +{ + private readonly List _menuItems = []; + private string RunSelectAriaLabel => Loc[nameof(LayoutResources.DashboardRunSelectAriaLabel)]; + 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; } + + private void LoadRuns() + { + var runs = RunStore.GetRuns(); + _menuItems.Clear(); + foreach (var run in runs) + { + _menuItems.Add(new MenuButtonItem + { + Text = FormatRunOption(run), + Icon = string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal) + ? new Icons.Regular.Size16.Checkmark() + : null, + OnClick = () => SelectedRunIdChanged.InvokeAsync(run.IsCurrent ? null : run.RunId) + }); + + if (run.IsCurrent && runs.Any(candidate => !candidate.IsCurrent)) + { + _menuItems.Add(new MenuButtonItem { IsDivider = true }); + } + } + } + + 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..5837f06977f --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css @@ -0,0 +1,3 @@ +.application-run-select ::deep .application-run-select-button { + margin-right: 8px; +} \ No newline at end of file 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 f11f265ea49..b1851ed6d1f 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor @@ -11,7 +11,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 606f25ef39a..c409003589b 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 de7b00c993d..4ffd6ea3c79 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 readonly List _menuItems = new(); diff --git a/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs index 20b4c0b7ed6..a7135ec3e77 100644 --- a/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TraceActions.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; @@ -20,7 +20,7 @@ public partial class TraceActions : ComponentBase public required IStringLocalizer ControlsLoc { get; init; } [Parameter] - public required OtlpTrace Trace { get; set; } + public required TraceSummary Summary { get; set; } private readonly List _menuItems = new(); @@ -28,6 +28,6 @@ protected override void OnParametersSet() { _menuItems.Clear(); - TraceMenuBuilder.AddMenuItems(_menuItems, Trace); + TraceMenuBuilder.AddMenuItems(_menuItems, Summary); } } diff --git a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs index 44b2947c7ca..e0b77afb73d 100644 --- a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs @@ -19,7 +19,9 @@ public partial class TreeMetricSelector public bool IncludeLabel { get; set; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; public void OnResourceChanged() { diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs index f318a57284e..267d48557c2 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs @@ -27,7 +27,9 @@ public partial class ExemplarsDialog : 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; public IQueryable MetricView => Content.Exemplars.AsQueryable(); diff --git a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs index ebbcca61d47..a9c49786593 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs @@ -30,7 +30,9 @@ private SelectViewModel CreateFilterSelectViewModel(FilterCondi public FilterDialogViewModel Content { get; set; } = default!; [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/Dialogs/GenAIVisualizerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs index 5d515933230..b42e7db08c2 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs @@ -47,7 +47,9 @@ public partial class GenAIVisualizerDialog : ComponentBase, IComponentWithTeleme 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 IStringLocalizer Loc { get; init; } @@ -371,7 +373,7 @@ public void Dispose() public static async Task OpenDialogAsync(DashboardDialogService dialogService, OtlpSpan span, long? selectedLogEntryId, - TelemetryRepository telemetryRepository, ITelemetryErrorRecorder errorRecorder, List resources, Func> getContextGenAISpans) + ITelemetryRepository telemetryRepository, ITelemetryErrorRecorder errorRecorder, List resources, Func> getContextGenAISpans) { var title = span.Name; var width = dialogService.IsDesktop ? "75vw" : "100vw"; diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs index 9ee965a113f..75a20d54941 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -34,9 +34,6 @@ public partial class InteractionsInputDialog : IAsyncDisposable [Inject] public required IJSRuntime JS { get; init; } - [Inject] - public required IDashboardClient DashboardClient { get; init; } - private readonly CancellationTokenSource _disposalCts = new(); private InteractionsInputsDialogViewModel? _content; private EditContext _editContext = default!; @@ -245,7 +242,7 @@ private async Task OnInputFileChangeAsync(InputViewModel inputModel, InputFileCh try { using var stream = file.OpenReadStream(maxFileSize); - var fileId = await DashboardClient.UploadFileAsync(stream, file.Name, file.Size, _disposalCts.Token); + var fileId = await Content.DashboardClient.UploadFileAsync(stream, file.Name, file.Size, _disposalCts.Token); fileReferences.Add(new FileReferenceViewModel { Id = fileId, Name = file.Name }); } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor index 7ef0ece5f9d..1c80c985c55 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor @@ -140,13 +140,13 @@ @Loc[nameof(Dialogs.ManageDataRemoveButtonText)] - @if (TelemetryImportService.IsImportEnabled) + @if (TelemetryImportService.IsImportEnabled && !TelemetryRepository.IsReadOnly) {
DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -150,7 +155,7 @@ private void UpdateData() private 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) @@ -552,7 +557,8 @@ private async Task RemoveSelectedAsync() var selectedResources = GetSelectedResourcesAndDataTypes(); // Clear telemetry signals via repository - TelemetryRepository.ClearSelectedSignals(selectedResources); + DataSource.EnsureWritable(); + await TelemetryRepositoryWriter.ClearSelectedSignalsAsync(selectedResources); // Handle console logs filtering separately (not stored in TelemetryRepository) // Console logs are only available when the dashboard client is enabled diff --git a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs index 1d610847428..998646468de 100644 --- a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs +++ b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs @@ -65,7 +65,7 @@ internal async Task GetMessagesProcessedAsync() } } - [Inject] + [Inject(Key = ServiceClient.DashboardClient.LiveAppHostServiceKey)] public required IDashboardClient DashboardClient { get; init; } [Inject] @@ -232,6 +232,7 @@ private async Task InteractionsDisplayAsync() { Interaction = item, Message = GetMessageHtml(item), + DashboardClient = DashboardClient, OnSubmitCallback = async (savedInteraction, update) => { var request = new WatchInteractionsRequestUpdate diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index 9a8ffc88598..30ac82e29ec 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -20,6 +20,15 @@
+ @if (RunStore.SupportsRunSelection) + { + var selectedRun = RunSelection.SelectedRun; + + }
- + @if (RunStore.SupportsRunSelection) + { + var selectedRun = RunSelection.SelectedRun; + + } - @Body + @if (!_isSwitchingRuns) + { + var selectedRun = RunSelection.SelectedRun; + + @Body + + }
diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 50fc3cd78ad..b34f4e6beae 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; private IDisposable? _themeChangedSubscription; private IDisposable? _locationChangingRegistration; @@ -72,11 +74,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 () => @@ -213,6 +250,33 @@ 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); + } + 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 2da6176b1fa..7ff9dffdeee 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -46,7 +46,7 @@ { @@ -151,6 +151,7 @@ IsTimestampUtc="@_isTimestampUtc" NoWrapLogs="@_noWrapLogs" ShowNoLogsMessage="@_showNoLogsMessage" + NoLogsMessage="@GetNoLogsMessage()" ShowResourcePrefix="@_isSubscribedToAll"/>
@@ -171,6 +172,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 5770b662999..b0dd1234e72 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; @@ -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; diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index 1cf0e7542c5..af51c038bb9 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 53a02f30961..0495cc13dc1 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 cb4a726ebb4..4679abd13f5 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 @@ -123,16 +124,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) @@ -143,7 +144,7 @@ @* Tooltip is displayed by the message GridValue instance *@ - + @if (!string.IsNullOrEmpty(context.TraceId)) diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index c385a5a8ba2..103041b252c 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -46,7 +46,7 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA 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 +58,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 +87,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,27 +127,30 @@ 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(); - 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(); + } } // Updating the total item count as a field doesn't work because it isn't updated with the grid. @@ -300,6 +308,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; @@ -372,9 +386,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 +415,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 +521,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, @@ -569,8 +567,8 @@ await GenAIVisualizerDialog.OpenDialogAsync( 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 28c8bdc0bae..26a6665c0ac 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -288,13 +288,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;