diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index 2c1f7911a23..1148e3491b3 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -15,7 +15,9 @@ builder.AddServiceDefaults(); builder.Services.AddOpenTelemetry() - .WithTracing(tracing => tracing.AddSource(TraceCreator.ActivitySourceName, ProducerConsumer.ActivitySourceName)) + .WithTracing(tracing => tracing + .AddSource(TraceCreator.ActivitySourceName, ProducerConsumer.ActivitySourceName) + .AddSource("Services.Api")) .WithMetrics(metrics => metrics.AddMeter(TestMetrics.MeterName)); builder.Services.AddSingleton(); @@ -285,4 +287,68 @@ async IAsyncEnumerable WriteOutput() return $"Created {TraceCount} traces."; }); +app.MapGet("/nested-trace-spans", async () => + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + "Sample Text" + )) + .ToArray(); + ActivitySource source = new("Services.Api", "1.0.0"); + ActivitySource.AddActivityListener(new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }); + using var activity = source.StartActivity("ValidateAndUpdateCacheService.ExecuteAsync"); + await Task.Delay(100); + Debug.Assert(activity is not null); + using var innerActivity = source.StartActivity("ValidateAndUpdateCacheService.activeUser", + ActivityKind.Internal, parentContext: activity.Context); + await Task.Delay(100); + Debug.Assert(innerActivity is not null); + using (source.StartActivity("Perform1", ActivityKind.Internal, parentContext: innerActivity.Context)) + { + await Task.Delay(10); + } + + using (source.StartActivity("Perform2", ActivityKind.Internal, parentContext: innerActivity.Context)) + { + await Task.Delay(20); + } + + using (source.StartActivity("Perform3", ActivityKind.Internal, parentContext: innerActivity.Context)) + { + await Task.Delay(30); + } + + using var innerActivity2 = source.StartActivity("ValidateAndUpdateCacheService.activeUser", + ActivityKind.Internal, parentContext: activity.Context); + await Task.Delay(100); + Debug.Assert(innerActivity2 is not null); + + using (source.StartActivity("Perform1", ActivityKind.Internal, parentContext: innerActivity2.Context)) + { + await Task.Delay(30); + } + + using (source.StartActivity("Perform2", ActivityKind.Internal, parentContext: innerActivity2.Context)) + { + await Task.Delay(20); + } + + using (source.StartActivity("Perform3", ActivityKind.Internal, parentContext: innerActivity2.Context)) + { + await Task.Delay(10); + } + + return forecast; + }) + .WithName("GetWeatherForecast"); + app.Run(); + +public record WeatherForecast(DateOnly Date, int TemperatureC, string Summary); diff --git a/playground/Stress/Stress.AppHost/Program.cs b/playground/Stress/Stress.AppHost/Program.cs index b76feb5b525..53481d49748 100644 --- a/playground/Stress/Stress.AppHost/Program.cs +++ b/playground/Stress/Stress.AppHost/Program.cs @@ -69,6 +69,7 @@ serviceBuilder.WithHttpCommand("/log-message-limit", "Log message limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/multiple-traces-linked", "Multiple traces linked", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/overflow-counter", "Overflow counter", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); +serviceBuilder.WithHttpCommand("/nested-trace-spans", "Out of order nested spans", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); builder.AddProject("stress-telemetryservice") .WithUrls(c => c.Urls.Add(new() { Url = "https://someplace.com", DisplayText = "Some place" })) diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 195ff568cf9..2f6f18eb2ac 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -76,24 +76,31 @@ protected override void OnInitialized() } } - private ValueTask> GetData(GridItemsProviderRequest request) + // Internal to be used in unit tests + internal ValueTask> GetData(GridItemsProviderRequest request) { Debug.Assert(_spanWaterfallViewModels != null); var visibleViewModels = new HashSet(); foreach (var viewModel in _spanWaterfallViewModels) { - if (!viewModel.IsHidden && viewModel.MatchesFilter(_filter, GetResourceName, out var matchedDescendents)) + if (viewModel.IsHidden || visibleViewModels.Contains(viewModel)) + { + continue; + } + + if (viewModel.MatchesFilter(_filter, GetResourceName, out var matchedDescendents)) { visibleViewModels.Add(viewModel); - foreach (var descendent in matchedDescendents) + foreach (var descendent in matchedDescendents.Where(d => !d.IsHidden)) { visibleViewModels.Add(descendent); } } } - var page = visibleViewModels.AsEnumerable(); + var page = _spanWaterfallViewModels.Where(visibleViewModels.Contains).AsEnumerable(); + var totalItemCount = page.Count(); if (request.StartIndex > 0) { page = page.Skip(request.StartIndex); @@ -103,7 +110,7 @@ private ValueTask> GetData(GridI return ValueTask.FromResult(new GridItemsProviderResult { Items = page.ToList(), - TotalItemCount = visibleViewModels.Count + TotalItemCount = totalItemCount }); } @@ -236,6 +243,7 @@ private async Task OnToggleCollapse(SpanWaterfallViewModel viewModel) _collapsedSpanIds.Add(viewModel.Span.SpanId); } + UpdateDetailViewData(); await _dataGrid.SafeRefreshDataAsync(); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index 9e6ea12175a..9deb67cd7da 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -137,6 +137,158 @@ public async Task Render_ChangeTrace_RowsRendered() await AsyncTestHelpers.AssertIsTrueRetryAsync(() => rows.Count == 2, "Expected rows to be rendered."); } + [Fact] + public async Task Render_SpansOrderedByStartTime_RowsRenderedInCorrectOrder() + { + // Arrange + SetupTraceDetailsServices(); + + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + + var dimensionManager = Services.GetRequiredService(); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddTraces(new AddContext(), + new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "1", spanId: "2-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(10), + parentSpanId: "1-1"), + CreateSpan(traceId: "1", spanId: "3-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(10), + parentSpanId: "2-1"), + CreateSpan(traceId: "1", spanId: "3-3", + startTime: s_testTime.AddMinutes(3), + endTime: s_testTime.AddMinutes(5), + parentSpanId: "2-1"), + CreateSpan(traceId: "1", spanId: "3-2", + startTime: s_testTime.AddMinutes(2), + endTime: s_testTime.AddMinutes(6), + parentSpanId: "2-1") + } + } + } + } + }); + + // Act + var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); + var cut = RenderComponent(builder => + { + builder.Add(p => p.TraceId, traceId); + builder.AddCascadingValue(viewport); + }); + + var data = await cut.Instance.GetData(new GridItemsProviderRequest()); + + // Assert + Assert.Collection(data.Items, + item => Assert.Equal("Test span. Id: 1-1", item.Span.Name), + item => Assert.Equal("Test span. Id: 2-1", item.Span.Name), + item => Assert.Equal("Test span. Id: 3-1", item.Span.Name), + item => Assert.Equal("Test span. Id: 3-2", item.Span.Name), + item => Assert.Equal("Test span. Id: 3-3", item.Span.Name)); + } + + [Fact] + public void ToggleCollapse_SpanStateChanges() + { + // Arrange + SetupTraceDetailsServices(); + + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var dimensionManager = Services.GetRequiredService(); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddTraces(new AddContext(), + new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "1", spanId: "2-1", + startTime: s_testTime.AddMinutes(5), + endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"), + CreateSpan(traceId: "1", spanId: "3-1", + startTime: s_testTime.AddMinutes(6), + endTime: s_testTime.AddMinutes(10), parentSpanId: "2-1") + } + } + } + } + }); + + var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); + var cut = RenderComponent(builder => + { + builder.Add(p => p.TraceId, traceId); + builder.AddCascadingValue(viewport); + }); + + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".main-grid-expand-button").Count)); + // Act and assert + + // Collapse the middle span + cut.FindAll(".main-grid-expand-button")[1].Click(); + + cut.WaitForAssertion(() => + { + var expandContainers = cut.FindAll(".main-grid-expand-container"); + // There should now be two containers since the 3rd level element should now be filtered out + Assert.Collection(expandContainers, + container => Assert.True(container.ClassList.Contains("main-grid-expanded")), + container => Assert.True(container.ClassList.Contains("main-grid-collapsed"))); + }); + + // Collapse the parent span + cut.FindAll(".main-grid-expand-button")[0].Click(); + cut.WaitForAssertion(() => + { + var expandContainers = cut.FindAll(".main-grid-expand-container"); + // There should now be one container since the 2nd level element should now be filtered out + Assert.Collection(expandContainers, + container => Assert.True(container.ClassList.Contains("main-grid-collapsed"))); + }); + + // Expand the parent span, we should now see the same two containers as before + cut.FindAll(".main-grid-expand-button")[0].Click(); + cut.WaitForAssertion(() => + { + var expandContainers = cut.FindAll(".main-grid-expand-container"); + // There should now be two containers since the 3rd level element should now be filtered out + Assert.Collection(expandContainers, + container => Assert.True(container.ClassList.Contains("main-grid-expanded")), + container => Assert.True(container.ClassList.Contains("main-grid-collapsed"))); + }); + } + private void SetupTraceDetailsServices() { var version = typeof(FluentMain).Assembly.GetName().Version!;