// 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.Components.Pages;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Bunit;
using Google.Protobuf.Collections;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.FluentUI.AspNetCore.Components;
using OpenTelemetry.Proto.Trace.V1;
using Xunit;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
namespace Aspire.Dashboard.Components.Tests.Pages;
[UseCulture("en-US")]
public partial class TraceDetailsTests : DashboardTestContext
{
private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private readonly ITestOutputHelper _testOutputHelper;
public TraceDetailsTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}
[Fact]
public async Task Render_HasTrace_SubscriptionRemovedOnDispose()
{
// Arrange
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
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: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1")
}
}
}
}
});
// Act
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
// Assert
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>().FindAll(".fluent-data-grid-row").Count));
cut.WaitForAssertion(() => Assert.Equal(1, telemetryRepository.TraceSubscriptionCount));
DisposeComponents();
Assert.Equal(0, telemetryRepository.TraceSubscriptionCount);
}
[Fact]
public async Task Render_LogQueryPending_PageTitleRendered()
{
SetupTraceDetailsServices();
var queryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var continueQuery = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Services.AddSingleton<ITelemetryRepository>(services =>
{
var inner = services.GetRequiredService<SqliteTelemetryRepository>();
return new TestTelemetryRepository(inner)
{
GetLogSummariesAsyncHandler = async (context, cancellationToken) =>
{
queryStarted.SetResult();
await continueQuery.Task.WaitAsync(cancellationToken);
return await inner.GetLogSummariesAsync(context, cancellationToken);
}
};
});
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
Services.GetRequiredService<DimensionManager>().InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
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))
}
}
}
}
});
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
await queryStarted.Task.WaitAsync(DefaultWaitTimeout);
Assert.NotEmpty(Assert.IsType<string>(cut.Instance.GetPageTitle()));
continueQuery.SetResult();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>().FindAll(".fluent-data-grid-row").Count));
}
[Fact]
public async Task Render_FocusesAccessibleScrollContainerOnInitialRender()
{
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
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))
}
}
}
}
});
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
var scrollContainer = cut.Find("#traceDetailScrollContainer");
var loc = Services.GetRequiredService<IStringLocalizer<Dashboard.Resources.TraceDetail>>();
var controlsLoc = Services.GetRequiredService<IStringLocalizer<Dashboard.Resources.ControlsStrings>>();
var header = cut.Find(".trace-header");
var filterGroup = cut.Find(".trace-header-filters");
Assert.Equal("0", scrollContainer.GetAttribute("tabindex"));
Assert.Equal("region", scrollContainer.GetAttribute("role"));
Assert.Equal(loc[nameof(Dashboard.Resources.TraceDetail.TraceDetailTraceStartHeader)].Value, scrollContainer.GetAttribute("aria-label"));
Assert.Equal("tracedetails-grid-container", scrollContainer.GetAttribute("class"));
Assert.Null(header.GetAttribute("role"));
Assert.Equal("group", filterGroup.GetAttribute("role"));
Assert.Equal(controlsLoc[nameof(Dashboard.Resources.ControlsStrings.PageToolbarLandmark)].Value, filterGroup.GetAttribute("aria-label"));
Assert.Contains(header.Children, element => element.ClassList.Contains("trace-header-details"));
Assert.Contains(header.Children, element => element.ClassList.Contains("trace-header-filters"));
cut.WaitForAssertion(() =>
{
Assert.Contains(JSInterop.Invocations, invocation =>
invocation.Identifier == "focusElement" &&
invocation.Arguments.Count == 2 &&
string.Equals(invocation.Arguments[0]?.ToString(), "traceDetailScrollContainer", StringComparison.Ordinal) &&
string.Equals(invocation.Arguments[1]?.ToString(), bool.TrueString, StringComparison.OrdinalIgnoreCase));
});
}
[Fact]
public async Task Render_ChangeTrace_RowsRendered()
{
// Arrange
var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper);
var logger = loggerFactory.CreateLogger(nameof(Render_ChangeTrace_RowsRendered));
SetupTraceDetailsServices(loggerFactory: loggerFactory);
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
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: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"),
CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(6), endTime: s_testTime.AddMinutes(10))
}
}
}
}
});
// Act
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
// Assert
logger.LogInformation($"Assert row count for '{traceId}'");
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
var grid = cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>();
var rows = grid.FindAll(".fluent-data-grid-row");
return rows.Count == 3;
}, "Expected rows to be rendered.", logger);
traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("2"));
cut.SetParametersAndRender(builder =>
{
builder.Add(p => p.TraceId, traceId);
});
logger.LogInformation($"Assert row count for '{traceId}'");
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
var grid = cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>();
var rows = grid.FindAll(".fluent-data-grid-row");
return rows.Count == 2;
}, "Expected rows to be rendered.", logger);
}
[Fact]
public async Task Render_TraceUpdateWithNewSpans_RowsRendered()
{
// Arrange
var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper);
var logger = loggerFactory.CreateLogger(nameof(Render_ChangeTrace_RowsRendered));
SetupTraceDetailsServices(loggerFactory: loggerFactory);
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
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: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"),
CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(6), endTime: s_testTime.AddMinutes(10))
}
}
}
}
});
// Act
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
// Assert
logger.LogInformation($"Assert row count for '{traceId}'");
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
var grid = cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>();
var rows = grid.FindAll(".fluent-data-grid-row");
return rows.Count == 3;
}, "Expected rows to be rendered.", logger);
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
new ResourceSpans
{
Resource = CreateResource(),
ScopeSpans =
{
new ScopeSpans
{
Scope = CreateScope(),
Spans =
{
CreateSpan(traceId: "1", spanId: "1-3", startTime: s_testTime.AddMinutes(6), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-2"),
}
}
}
}
});
logger.LogInformation($"Assert updated row count for '{traceId}'");
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
var grid = cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>();
var rows = grid.FindAll(".fluent-data-grid-row");
return rows.Count == 4;
}, "Expected rows to be rendered.", logger);
}
[Fact]
public async Task Render_UpdateDifferentTrace_TraceNotUpdated()
{
// Arrange
var testSink = new TestSink();
var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper, testSink: testSink);
var logger = loggerFactory.CreateLogger(nameof(Render_ChangeTrace_RowsRendered));
SetupTraceDetailsServices(loggerFactory: loggerFactory);
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
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: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"),
CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(6), endTime: s_testTime.AddMinutes(10))
}
}
}
}
});
// Act
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
// Assert
logger.LogInformation($"Assert row count for '{traceId}'");
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
var grid = cut.FindComponent<FluentDataGrid<SpanWaterfallViewModel>>();
var rows = grid.FindAll(".fluent-data-grid-row");
return rows.Count == 3;
}, "Expected rows to be rendered.", logger);
logger.LogInformation($"Adding span for difference trace");
await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
new ResourceSpans
{
Resource = CreateResource(),
ScopeSpans =
{
new ScopeSpans
{
Scope = CreateScope(),
Spans =
{
CreateSpan(traceId: "2", spanId: "2-2", startTime: s_testTime.AddMinutes(7), endTime: s_testTime.AddMinutes(10), parentSpanId: "2-1"),
}
}
}
}
});
logger.LogInformation($"Assert not updated for '{traceId}'");
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
return testSink.Writes.Any(w => w.Message?.Contains($"Trace '{traceId}' is unchanged.") ?? false);
}, "Expected trace not updated.", logger);
}
[Fact]
public async Task Render_SpansOrderedByStartTime_RowsRenderedInCorrectOrder()
{
// Arrange
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(),
new RepeatedField<ResourceSpans>
{
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<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
cut.WaitForAssertion(() => Assert.NotNull(cut.Instance.PageViewModel.SpanWaterfallViewModels));
var data = await cut.Instance.GetData(new GridItemsProviderRequest<SpanWaterfallViewModel>());
// 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 ApplySpanFilters_ContextMatchIncludesAncestorsAndDescendants()
{
var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() };
var resource = new OtlpResource("app", "instance", uninstrumentedPeer: false, context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, s_testTime);
var scope = CreateOtlpScope(context);
trace.AddSpan(CreateOtlpSpan(resource, trace, scope, spanId: "root", parentSpanId: null, startDate: s_testTime));
trace.AddSpan(CreateOtlpSpan(resource, trace, scope, spanId: "match", parentSpanId: "root", startDate: s_testTime.AddSeconds(1)));
trace.AddSpan(CreateOtlpSpan(resource, trace, scope, spanId: "descendant", parentSpanId: "match", startDate: s_testTime.AddSeconds(2)));
trace.AddSpan(CreateOtlpSpan(resource, trace, scope, spanId: "sibling", parentSpanId: "root", startDate: s_testTime.AddSeconds(3)));
var viewModels = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [resource]));
var filteredItems = TraceDetail.TraceDetailPageViewModel.ApplySpanFilters(
viewModels,
contextFilterMatches: ["match"],
durationFilterMatches: null);
Assert.Collection(filteredItems,
item => Assert.Equal("root", item.Span.SpanId),
item => Assert.Equal("match", item.Span.SpanId),
item => Assert.Equal("descendant", item.Span.SpanId));
}
[Fact]
public async Task Render_DurationFilter_FiltersShortSpans()
{
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(),
new RepeatedField<ResourceSpans>
{
new ResourceSpans
{
Resource = CreateResource(),
ScopeSpans =
{
new ScopeSpans
{
Scope = CreateScope(),
Spans =
{
// Root is short (1ms) so it doesn't match the duration filter on its own.
CreateSpan(traceId: "1", spanId: "1-1",
startTime: s_testTime,
endTime: s_testTime.AddMilliseconds(1)),
// 1-2 is also short, but it is the parent of a long span (1-3),
// so it stays visible as part of the parent chain.
CreateSpan(traceId: "1", spanId: "1-2",
startTime: s_testTime.AddMilliseconds(1),
endTime: s_testTime.AddMilliseconds(1),
parentSpanId: "1-1"),
CreateSpan(traceId: "1", spanId: "1-3",
startTime: s_testTime.AddMilliseconds(2),
endTime: s_testTime.AddMilliseconds(20),
parentSpanId: "1-2"),
// Siblings of the matching chain do not match the filter and stay hidden.
CreateSpan(traceId: "1", spanId: "1-4",
startTime: s_testTime.AddMilliseconds(8),
endTime: s_testTime.AddMilliseconds(9),
parentSpanId: "1-1"),
CreateSpan(traceId: "1", spanId: "1-5",
startTime: s_testTime.AddMilliseconds(10),
endTime: s_testTime.AddMilliseconds(14),
parentSpanId: "1-1")
}
}
}
}
});
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
cut.WaitForAssertion(() => Assert.NotNull(cut.Instance.PageViewModel.SpanWaterfallViewModels));
var unfilteredData = await cut.Instance.GetData(new GridItemsProviderRequest<SpanWaterfallViewModel>());
// Duration >= 10ms only matches 1-3. Its parent chain (1-1, 1-2) stays visible
// as ancestors so the matching span remains navigable in the waterfall, even
// though they don't themselves satisfy the duration filter.
var durationMatches = await GetMatchingSpanIdsAsync(
telemetryRepository,
traceId,
[
new FieldTelemetryFilter
{
Field = KnownTraceFields.DurationField,
Condition = FilterCondition.GreaterThanOrEqual,
Value = "10"
}
]);
var filteredItems = TraceDetail.TraceDetailPageViewModel.ApplySpanFilters(
unfilteredData.Items.ToList(),
contextFilterMatches: null,
durationMatches).ToList();
Assert.Collection(filteredItems,
item => Assert.Equal("Test span. Id: 1-1", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-2", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-3", item.Span.Name));
// Duration matches are per-span, so a visible ancestor does not automatically
// include its descendants. With Duration >= 2ms, only 1-3 (18ms) and 1-5 (4ms)
// match. 1-4 (1ms) is hidden even though its parent 1-1 is visible as an
// ancestor of 1-3 and 1-5.
// This is the per-span behavior expected for a "min duration" filter; otherwise
// a long root span would expose every short descendant in the waterfall.
durationMatches = await GetMatchingSpanIdsAsync(
telemetryRepository,
traceId,
[
new FieldTelemetryFilter
{
Field = KnownTraceFields.DurationField,
Condition = FilterCondition.GreaterThanOrEqual,
Value = "2"
}
]);
var rootMatchFilteredItems = TraceDetail.TraceDetailPageViewModel.ApplySpanFilters(
unfilteredData.Items.ToList(),
contextFilterMatches: null,
durationMatches).ToList();
Assert.Collection(rootMatchFilteredItems,
item => Assert.Equal("Test span. Id: 1-1", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-2", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-3", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-5", item.Span.Name));
Assert.Collection(unfilteredData.Items,
item => Assert.Equal("Test span. Id: 1-1", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-2", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-3", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-4", item.Span.Name),
item => Assert.Equal("Test span. Id: 1-5", item.Span.Name));
}
[Fact]
public async Task Render_DurationFilter_LongRoot_DoesNotExposeShortChildren()
{
// Regression test for the long-root / short-child case. Other structured filters
// expand context by walking the matched span's full subtree (so children stay
// visible when their parent matches). For "min duration" that expansion would
// make the filter useless: a long root span would unconditionally expose every
// short descendant. This test pins down the per-span behavior: when the root
// is long enough to satisfy the duration filter on its own, its short children
// must still be hidden unless they independently match.
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(),
new RepeatedField<ResourceSpans>
{
new ResourceSpans
{
Resource = CreateResource(),
ScopeSpans =
{
new ScopeSpans
{
Scope = CreateScope(),
Spans =
{
// Long root (100ms) would auto-include every descendant
// under tree-aware context expansion.
CreateSpan(traceId: "2", spanId: "2-1",
startTime: s_testTime,
endTime: s_testTime.AddMilliseconds(100)),
// Short children that don't satisfy the filter on their own.
CreateSpan(traceId: "2", spanId: "2-2",
startTime: s_testTime.AddMilliseconds(1),
endTime: s_testTime.AddMilliseconds(3),
parentSpanId: "2-1"),
CreateSpan(traceId: "2", spanId: "2-3",
startTime: s_testTime.AddMilliseconds(5),
endTime: s_testTime.AddMilliseconds(8),
parentSpanId: "2-1"),
// A long descendant that independently matches the filter,
// verifying duration filtering still surfaces deep matches.
CreateSpan(traceId: "2", spanId: "2-4",
startTime: s_testTime.AddMilliseconds(10),
endTime: s_testTime.AddMilliseconds(90),
parentSpanId: "2-3")
}
}
}
}
});
var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("2"));
var cut = RenderComponent<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
cut.WaitForAssertion(() => Assert.NotNull(cut.Instance.PageViewModel.SpanWaterfallViewModels));
var unfilteredData = await cut.Instance.GetData(new GridItemsProviderRequest<SpanWaterfallViewModel>());
var durationMatches = await GetMatchingSpanIdsAsync(
telemetryRepository,
traceId,
[
new FieldTelemetryFilter
{
Field = KnownTraceFields.DurationField,
Condition = FilterCondition.GreaterThanOrEqual,
Value = "50"
}
]);
var filteredItems = TraceDetail.TraceDetailPageViewModel.ApplySpanFilters(
unfilteredData.Items.ToList(),
contextFilterMatches: null,
durationMatches).ToList();
// Direct matches: 2-1 (100ms) and 2-4 (80ms). 2-3 (3ms) is kept as an ancestor
// of 2-4 so the waterfall stays navigable. 2-2 (2ms) has no matching descendant
// and does not satisfy the filter itself, so it remains hidden even though its
// long parent 2-1 matches.
Assert.Collection(filteredItems,
item => Assert.Equal("Test span. Id: 2-1", item.Span.Name),
item => Assert.Equal("Test span. Id: 2-3", item.Span.Name),
item => Assert.Equal("Test span. Id: 2-4", item.Span.Name));
}
[Fact]
public async Task ToggleCollapse_SpanStateChanges()
{
// Arrange
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(),
new RepeatedField<ResourceSpans>
{
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<TraceDetail>(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")));
});
}
[Fact]
public async Task CollapseAllSpans_CollapsesAllSpans()
{
// Arrange
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(),
new RepeatedField<ResourceSpans>
{
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<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".main-grid-expand-button").Count));
// Act - Find the dropdown menu and click Collapse All
var menuButton = cut.FindComponent<AspireMenuButton>();
var collapseAllMenuItem = menuButton.Instance.ItemsProvider().FirstOrDefault(item => item.Text == "Collapse all"); // Locate by text since ID was removed
Assert.NotNull(collapseAllMenuItem);
await cut.InvokeAsync(() => collapseAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask);
// Assert
cut.WaitForAssertion(() =>
{
var expandContainers = cut
.FindAll(".main-grid-expand-container")
.Where(c => c.ParentElement?.QuerySelector(".main-grid-expand-button") != null)
.ToList();
for (var i = 0; i < expandContainers.Count; i++)
{
// The first container should be expanded
// All other containers should be collapsed
var expectedClass = (i == 0)
? "main-grid-expanded"
: "main-grid-collapsed";
Assert.True(expandContainers[i].ClassList.Contains(expectedClass));
}
});
}
[Fact]
public async Task ExpandAllSpans_ExpandsAllSpans()
{
// Arrange
SetupTraceDetailsServices();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
var dimensionManager = Services.GetRequiredService<DimensionManager>();
dimensionManager.InvokeOnViewportInformationChanged(viewport);
var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
await telemetryRepository.AddTracesAsync(new AddContext(),
new RepeatedField<ResourceSpans>
{
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<TraceDetail>(builder =>
{
builder.Add(p => p.TraceId, traceId);
builder.AddCascadingValue(viewport);
});
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".main-grid-expand-button").Count));
// First click "Collapse All" to collapse everything
var menuButton = cut.FindComponent<AspireMenuButton>();
var collapseAllMenuItem = menuButton.Instance.ItemsProvider().FirstOrDefault(item => item.Text == "Collapse all"); // Locate by text since ID was removed
Assert.NotNull(collapseAllMenuItem);
await cut.InvokeAsync(() => collapseAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask);
// Wait for spans to collapse
cut.WaitForAssertion(() =>
{
var expandContainers = cut.FindAll(".main-grid-expand-container");
// At least one span should be collapsed
Assert.Contains(expandContainers, container => container.ClassList.Contains("main-grid-collapsed"));
});
// Act - Click "Expand All"
var expandAllMenuItem = menuButton.Instance.ItemsProvider().FirstOrDefault(item => item.Text == "Expand all"); // Locate by text since ID was removed
Assert.NotNull(expandAllMenuItem);
await cut.InvokeAsync(() => expandAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask);
// Assert
cut.WaitForAssertion(() =>
{
var expandContainers = cut.FindAll(".main-grid-expand-container");
// All containers should now be expanded
foreach (var container in expandContainers)
{
Assert.True(container.ClassList.Contains("main-grid-expanded"));
}
});
}
private static async Task<HashSet<string>> GetMatchingSpanIdsAsync(
SqliteTelemetryRepository repository,
string traceId,
List<TelemetryFilter> filters)
{
var response = await repository.GetSpansAsync(new GetSpansRequest
{
ResourceKeys = [],
StartIndex = 0,
Count = int.MaxValue,
Filters = filters,
TraceId = traceId
}, CancellationToken.None);
return response.PagedResult.Items.Select(span => span.SpanId).ToHashSet(StringComparer.Ordinal);
}
private void SetupTraceDetailsServices(ILoggerFactory? loggerFactory = null)
{
FluentUISetupHelpers.SetupFluentOverflow(this);
FluentUISetupHelpers.SetupFluentDivider(this);
FluentUISetupHelpers.SetupFluentInputLabel(this);
FluentUISetupHelpers.SetupFluentDataGrid(this);
FluentUISetupHelpers.SetupFluentAnchor(this);
FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
FluentUISetupHelpers.SetupFluentList(this);
FluentUISetupHelpers.SetupFluentSearch(this);
FluentUISetupHelpers.SetupFluentTextField(this);
FluentUISetupHelpers.SetupFluentKeyCode(this);
FluentUISetupHelpers.SetupFluentToolbar(this);
FluentUISetupHelpers.SetupFluentMenu(this);
JSInterop.SetupVoid("initializeContinuousScroll").SetVoidResult();
JSInterop.SetupVoid("focusElement", _ => true);
loggerFactory ??= NullLoggerFactory.Instance;
FluentUISetupHelpers.AddCommonDashboardServices(this);
Services.AddSingleton<ILoggerFactory>(loggerFactory);
}
}