// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.GenAI;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
namespace Aspire.Dashboard.Components.Pages;
public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisposable
{
private const string ScrollContainerId = "traceDetailScrollContainer";
private const string NameColumn = nameof(NameColumn);
private const string ResourceColumn = nameof(ResourceColumn);
private const string TicksColumn = nameof(TicksColumn);
private const string ActionsColumn = nameof(ActionsColumn);
private const int RootSpanDepth = 1;
private readonly CancellationTokenSource _cts = new();
private OtlpTrace? _trace;
private long _detailViewUpdateVersion;
private Subscription? _tracesSubscription;
private int _maxDepth;
private int _resourceCount;
private List<OtlpResource> _resources = default!;
private readonly List<string> _collapsedSpanIds = [];
private string? _elementIdBeforeDetailsViewOpened;
private string? _pendingFocusElementId;
private FluentDataGrid<SpanWaterfallViewModel> _dataGrid = null!;
private GridColumnManager _manager = null!;
private IList<GridColumn> _gridColumns = null!;
private readonly List<MenuButtonItem> _traceActionsMenuItems = [];
private AspirePageContentLayout? _layout;
private List<SelectViewModel<SpanType>> _spanTypes = default!;
public TraceDetailPageViewModel PageViewModel { get; set; } = new();
[Parameter]
public required string TraceId { get; set; }
[Parameter]
[SupplyParameterFromQuery]
public string? SpanId { get; set; }
[Inject]
public required ILogger<TraceDetail> Logger { get; init; }
[Inject]
public required ITelemetryErrorRecorder ErrorRecorder { get; init; }
[Inject]
public required DashboardDataSource DataSource { get; init; }
public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
[Inject]
public required BrowserTimeProvider TimeProvider { get; init; }
[Inject]
public required IJSRuntime JS { get; init; }
[Inject]
public required NavigationManager NavigationManager { get; init; }
[Inject]
public required IStringLocalizer<Dashboard.Resources.TraceDetail> Loc { get; init; }
[Inject]
public required IStringLocalizer<Dashboard.Resources.StructuredLogs> StructuredLogsLoc { get; init; }
[Inject]
public required IStringLocalizer<ControlsStrings> ControlStringsLoc { get; init; }
[Inject]
public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }
[Inject]
public required DashboardDialogService DialogService { get; init; }
[Inject]
public required TraceMenuBuilder TraceMenuBuilder { get; init; }
[CascadingParameter]
public required ViewportInformation ViewportInformation { get; set; }
protected override void OnInitialized()
{
TelemetryContextProvider.Initialize(TelemetryContext);
_gridColumns = [
new GridColumn(Name: NameColumn, DesktopWidth: "7fr", MobileWidth: "7fr"),
new GridColumn(Name: ResourceColumn, DesktopWidth: "4fr", MobileWidth: null),
new GridColumn(Name: TicksColumn, DesktopWidth: "12fr", MobileWidth: "12fr"),
new GridColumn(Name: ActionsColumn, DesktopWidth: "100px", MobileWidth: null)
];
UpdateTraceActionsMenu();
_spanTypes = SpanType.CreateKnownSpanTypes(ControlStringsLoc);
PageViewModel.SelectedSpanType = _spanTypes[0];
}
private void UpdateTraceActionsMenu()
{
if (_trace is null)
{
return;
}
_traceActionsMenuItems.Clear();
// Add expand/collapse options
_traceActionsMenuItems.Add(new MenuButtonItem
{
Text = ControlStringsLoc[nameof(ControlsStrings.ExpandAllSpansText)],
Icon = new Icons.Regular.Size16.ArrowExpandAll(),
OnClick = ExpandAllSpansAsync,
IsDisabled = !HasCollapsedSpans()
});
_traceActionsMenuItems.Add(new MenuButtonItem
{
Text = ControlStringsLoc[nameof(ControlsStrings.CollapseAllSpansText)],
Icon = new Icons.Regular.Size16.ArrowCollapseAll(),
OnClick = CollapseAllSpansAsync,
IsDisabled = !HasExpandedSpans()
});
// Add divider
_traceActionsMenuItems.Add(new MenuButtonItem
{
IsDivider = true
});
TraceMenuBuilder.AddMenuItems(_traceActionsMenuItems, _trace, showViewDetails: false);
}
// Internal to be used in unit tests
internal ValueTask<GridItemsProviderResult<SpanWaterfallViewModel>> GetData(GridItemsProviderRequest<SpanWaterfallViewModel> request)
{
var visibleItems = GetVisibleSpanViewModels().ToList();
var totalItemCount = visibleItems.Count;
var startIndex = Math.Min(request.StartIndex, visibleItems.Count);
var count = Math.Min(request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount, visibleItems.Count - startIndex);
var page = count > 0
? visibleItems.GetRange(startIndex, count)
: [];
return ValueTask.FromResult(new GridItemsProviderResult<SpanWaterfallViewModel>
{
Items = page,
TotalItemCount = totalItemCount
});
}
private IEnumerable<SpanWaterfallViewModel> GetVisibleSpanViewModels()
{
if (PageViewModel.SpanWaterfallViewModels is null)
{
return [];
}
return TraceDetailPageViewModel.ApplySpanFilters(PageViewModel.SpanWaterfallViewModels, PageViewModel.ContextFilterMatches, PageViewModel.DurationFilterMatches);
}
internal string? GetPageTitle()
{
if (_trace is null)
{
return null;
}
var headerSpan = _trace.RootOrFirstSpan;
return $"{GetResourceName(headerSpan.Source)}: {headerSpan.Name}";
}
protected override async Task OnParametersSetAsync()
{
if (TraceId != _trace?.TraceId)
{
// Close the span detail pane when navigating between traces (e.g. browser back/forward).
// If the new trace has a SpanId query parameter, it will be re-opened below.
PageViewModel.SelectedData = null;
await UpdateDetailViewDataAsync();
UpdateSubscription();
// If parameters change after render then the grid is automatically updated.
// Explicitly update data grid to support navigating between traces via span links.
await _dataGrid.SafeRefreshDataAsync();
}
if (SpanId is not null && PageViewModel.SpanWaterfallViewModels is not null)
{
var spanVm = PageViewModel.SpanWaterfallViewModels.SingleOrDefault(vm => vm.Span.SpanId == SpanId);
if (spanVm != null)
{
await OnShowPropertiesAsync(spanVm, focusElementId: null);
}
// Navigate to remove ?spanId=xxx in the URL. A small delay is required here, otherwise the page rendering breaks.
await Task.Delay(200, _cts.Token);
NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(TraceId), new NavigationOptions { ReplaceHistoryEntry = true });
}
}
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<SpanWaterfallViewModel>.TrySetMaxItemCount(_dataGrid, 10_000))
{
StateHasChanged();
}
if (firstRender)
{
// Focus the scroll container without showing the focus ring. The container is a large
// content area where a visible focus indicator would be visually noisy on initial load.
await JS.InvokeVoidAsync("focusElement", ScrollContainerId, true);
}
if (_pendingFocusElementId is { } pendingFocusElementId)
{
_pendingFocusElementId = null;
await JS.InvokeVoidAsync("focusElement", pendingFocusElementId);
}
}
private async Task UpdateDetailViewDataAsync()
{
var updateVersion = Interlocked.Increment(ref _detailViewUpdateVersion);
var traceId = TraceId;
var resources = TelemetryRepository.GetResources();
var trace = _trace;
// Copying a large trace can be expensive so only do this if required.
if (trace == null || trace.TraceId != traceId || TelemetryRepository.HasUpdatedTrace(trace))
{
Logger.LogInformation("Getting trace '{TraceId}'.", traceId);
trace = TelemetryRepository.GetTrace(traceId);
// The asynchronous log query below allows an intermediate render. Publish resources before the trace so
// page title rendering never observes a trace without the resource list used to format its source name.
_resources = resources;
_trace = trace;
}
if (trace == null)
{
Logger.LogInformation("Couldn't find trace '{TraceId}'.", traceId);
_resources = resources;
PageViewModel.SpanWaterfallViewModels = null;
PageViewModel.ContextFilterMatches = null;
PageViewModel.DurationFilterMatches = null;
_maxDepth = 0;
_resourceCount = 0;
UpdateTraceActionsMenu();
return;
}
// Get logs for the trace. Note that there isn't a limit on this query so all logs are returned.
// There is a limit on the number of logs stored by the dashboard so this is implicitly limited.
// If there are performance issues with displaying all logs then consider adding a limit to this query.
var result = (await TelemetryRepository.GetLogSummariesAsync(new GetLogsContext
{
ResourceKeys = [],
StartIndex = 0,
Count = int.MaxValue,
Filters =
[
new FieldTelemetryFilter
{
Field = KnownStructuredLogFields.TraceIdField,
Condition = FilterCondition.Equals,
Value = trace.TraceId
}
]
}, _cts.Token)).Items;
if (updateVersion != Volatile.Read(ref _detailViewUpdateVersion))
{
return;
}
_trace = trace;
_resources = resources;
Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", trace.TraceId, trace.Spans.Count);
PageViewModel.SpanWaterfallViewModels = SpanWaterfallViewModel.Create(trace, result, new SpanWaterfallViewModel.TraceDetailState(_collapsedSpanIds, resources));
await UpdateFilterMatchesAsync();
if (updateVersion != Volatile.Read(ref _detailViewUpdateVersion))
{
return;
}
_maxDepth = PageViewModel.SpanWaterfallViewModels.Max(s => s.Depth);
var apps = new HashSet<OtlpResource>();
foreach (var span in trace.Spans)
{
apps.Add(span.Source.Resource);
if (span.UninstrumentedPeer != null)
{
apps.Add(span.UninstrumentedPeer);
}
}
_resourceCount = apps.Count;
UpdateTraceActionsMenu();
}
private async Task HandleAfterFilterBindAsync()
{
await RefreshAfterFilterChangeAsync();
}
private async Task HandleSelectedSpanTypeChangedAsync()
{
await RefreshAfterFilterChangeAsync();
}
private void UpdateSubscription()
{
if (_trace == null)
{
_tracesSubscription?.Dispose();
return;
}
if (_tracesSubscription is null || _tracesSubscription.ResourceKey != _trace.FirstSpan.Source.ResourceKey)
{
_tracesSubscription?.Dispose();
_tracesSubscription = TelemetryRepository.OnNewTraces(_trace.FirstSpan.Source.ResourceKey, SubscriptionType.Read, () => InvokeAsync(async () =>
{
if (_trace == null)
{
return;
}
// Only update trace if required.
if (TelemetryRepository.HasUpdatedTrace(_trace))
{
await UpdateDetailViewDataAsync();
StateHasChanged();
await _dataGrid.SafeRefreshDataAsync();
}
else
{
Logger.LogTrace("Trace '{TraceId}' is unchanged.", TraceId);
}
}));
}
}
private string GetRowClass(SpanWaterfallViewModel viewModel)
{
// Test with id rather than the object reference because the data and view model objects are recreated on trace updates.
if (PageViewModel.SelectedData?.SpanViewModel is { } selectedSpan && selectedSpan.Span.SpanId == viewModel.Span.SpanId)
{
return "selected-row";
}
else if (PageViewModel.SelectedData?.LogEntryViewModel is { } selectedLog && viewModel.SpanLogs.Any(l => l.LogEntry.InternalId == selectedLog.LogEntry.InternalId))
{
return "selected-row";
}
return string.Empty;
}
public TraceDetailSelectedDataViewModel? SelectedData => PageViewModel.SelectedData;
private async Task OnToggleCollapse(SpanWaterfallViewModel viewModel)
{
SetSpanCollapsedState(viewModel, !viewModel.IsCollapsed);
await RefreshSpanViewAsync();
}
private void SetSpanCollapsedState(SpanWaterfallViewModel viewModel, bool isCollapsed)
{
// View model data is recreated if the trace updates.
// Persist the collapsed state in a separate list.
viewModel.IsCollapsed = isCollapsed;
if (isCollapsed)
{
_collapsedSpanIds.Add(viewModel.Span.SpanId);
}
else
{
_collapsedSpanIds.Remove(viewModel.Span.SpanId);
}
}
private async Task RefreshSpanViewAsync()
{
await UpdateDetailViewDataAsync();
UpdateTraceActionsMenu();
await _dataGrid.SafeRefreshDataAsync();
await InvokeAsync(StateHasChanged);
// Close mobile toolbar if open, as the content has changed.
Debug.Assert(_layout is not null);
await _layout.CloseMobileToolbarAsync();
}
private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, string? focusElementId)
{
_elementIdBeforeDetailsViewOpened = focusElementId;
if (PageViewModel.SelectedData?.SpanViewModel?.Span.SpanId == viewModel.Span.SpanId)
{
await ClearSelectedSpanAsync();
}
else
{
var spanDetailsViewModel = SpanDetailsViewModel.Create(viewModel.Span, TelemetryRepository, _resources);
PageViewModel.SelectedData = new TraceDetailSelectedDataViewModel
{
SpanViewModel = spanDetailsViewModel
};
}
}
private Task ClearSelectedSpanAsync(bool causedByUserAction = false)
{
PageViewModel.SelectedData = null;
if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
{
_pendingFocusElementId = _elementIdBeforeDetailsViewOpened;
}
_elementIdBeforeDetailsViewOpened = null;
return Task.CompletedTask;
}
private bool HasCollapsedSpans()
{
if (PageViewModel.SpanWaterfallViewModels is null)
{
return false;
}
return PageViewModel.SpanWaterfallViewModels.Any(vm => vm.IsCollapsed);
}
private bool HasExpandedSpans()
{
if (PageViewModel.SpanWaterfallViewModels is null)
{
return false;
}
// Don't consider root spans (depth 0) when determining if collapse all should be enabled
return PageViewModel.SpanWaterfallViewModels.Any(vm => vm.Depth > RootSpanDepth && !vm.IsCollapsed && vm.Children.Count > 0);
}
private async Task CollapseAllSpansAsync()
{
if (PageViewModel.SpanWaterfallViewModels is null)
{
return;
}
foreach (var viewModel in PageViewModel.SpanWaterfallViewModels)
{
// Don't collapse root spans.
if (viewModel.Depth > RootSpanDepth && viewModel.Children.Count > 0 && !viewModel.IsCollapsed)
{
SetSpanCollapsedState(viewModel, true);
}
}
await RefreshSpanViewAsync();
}
private async Task ExpandAllSpansAsync()
{
if (PageViewModel.SpanWaterfallViewModels is null)
{
return;
}
foreach (var viewModel in PageViewModel.SpanWaterfallViewModels)
{
if (viewModel.IsCollapsed)
{
SetSpanCollapsedState(viewModel, false);
}
}
await RefreshSpanViewAsync();
}
private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app, _resources);
private async Task ToggleSpanLogsAsync(LogSummary logEntry)
{
if (PageViewModel.SelectedData?.LogEntryViewModel?.LogEntry.InternalId == logEntry.InternalId)
{
await ClearSelectedSpanAsync();
}
else
{
if (TelemetryRepository.GetLog(logEntry.InternalId) is { } fullLogEntry)
{
PageViewModel.SelectedData = new TraceDetailSelectedDataViewModel
{
LogEntryViewModel = new StructureLogsDetailsViewModel { LogEntry = fullLogEntry }
};
}
}
}
private static bool IsGenAISpan(SpanWaterfallViewModel spanViewModel)
{
return GenAIHelpers.HasGenAIAttribute(spanViewModel.Span.Attributes);
}
private async Task OnGenAIClickedAsync(OtlpSpan span)
{
await GenAIVisualizerDialog.OpenDialogAsync(
DialogService,
span,
selectedLogEntryId: null,
TelemetryRepository,
ErrorRecorder,
_resources,
() =>
{
var genAISpans = new List<OtlpSpan>();
var visibleSpanViewModels = GetVisibleSpanViewModels();
foreach (var vm in visibleSpanViewModels.Where(IsGenAISpan))
{
genAISpans.Add(vm.Span);
}
return genAISpans;
},
_cts.Token);
}
private async Task OpenFilterAsync(FieldTelemetryFilter? entry)
{
if (_layout is not null)
{
await _layout.CloseMobileToolbarAsync();
}
await FilterHelpers.OpenFilterAsync(
entry,
DialogService,
DialogService.CreateDialogCallback(this, HandleFilterDialog),
getPropertyKeysAsync: GetTraceSpanPropertyKeysAsync,
knownKeys: KnownTraceFields.AllFields,
getFieldValuesAsync: GetTraceSpanFieldValuesAsync,
FilterLoc);
}
private async Task HandleFilterDialog(DialogResult result)
{
if (result.Data is FilterDialogResult filterResult && filterResult.Filter is FieldTelemetryFilter filter)
{
if (filterResult.Delete)
{
PageViewModel.Filters.Remove(filter);
}
else if (filterResult.Add)
{
PageViewModel.Filters.Add(filter);
}
else if (filterResult.Enable)
{
filter.Enabled = true;
}
else if (filterResult.Disable)
{
filter.Enabled = false;
}
}
await RefreshAfterFilterChangeAsync();
}
private async Task RefreshAfterFilterChangeAsync()
{
await UpdateFilterMatchesAsync();
ClearSelectedDataIfNotVisible();
await InvokeAsync(StateHasChanged);
await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
}
private async Task UpdateFilterMatchesAsync()
{
var traceId = _trace?.TraceId;
if (traceId is null)
{
PageViewModel.ContextFilterMatches = null;
PageViewModel.DurationFilterMatches = null;
return;
}
var contextFilters = PageViewModel.Filters
.Where(filter => filter.Enabled && filter.Field != KnownTraceFields.DurationField)
.Cast<TelemetryFilter>()
.ToList();
var durationFilters = PageViewModel.Filters
.Where(filter => filter.Enabled && filter.Field == KnownTraceFields.DurationField)
.Cast<TelemetryFilter>()
.ToList();
if (PageViewModel.SelectedSpanType.Id?.Filter is { } typeFilter)
{
contextFilters.Add(typeFilter);
}
// An older filter query could finish after a newer query and apply stale matches. Each query is
// constrained to the current trace and filter changes are user-driven, so this is unlikely and not
// worth the additional state and coordination required to guard against it.
var hasTextFilter = !string.IsNullOrWhiteSpace(PageViewModel.Filter);
var contextMatches = contextFilters.Count > 0 || hasTextFilter
? await GetMatchingSpanIdsAsync(contextFilters, hasTextFilter ? [PageViewModel.Filter] : null)
: null;
var durationMatches = durationFilters.Count > 0
? await GetMatchingSpanIdsAsync(durationFilters, textFragments: null)
: null;
PageViewModel.ContextFilterMatches = contextMatches;
PageViewModel.DurationFilterMatches = durationMatches;
async Task<HashSet<string>> GetMatchingSpanIdsAsync(List<TelemetryFilter> filters, string[]? textFragments)
{
// This intentionally uses GetSpansAsync instead of adding an identity-only repository query. The
// request is constrained to one trace, and GetSpansAsync materializes each distinct matching trace
// once, so each debounced filter update materializes at most one trace regardless of match count.
// That bounded work doesn't justify a separate repository API used only by this page.
var response = await TelemetryRepository.GetSpansAsync(new GetSpansRequest
{
ResourceKeys = [],
StartIndex = 0,
Count = int.MaxValue,
Filters = filters,
TraceId = traceId,
TextFragments = textFragments
}, _cts.Token);
return response.PagedResult.Items.Select(span => span.SpanId).ToHashSet(StringComparer.Ordinal);
}
}
/// <summary>
/// Clears the selected span or log entry only if it is no longer visible after a filter change.
/// Preserving the selection when the entry is still in the filtered results avoids losing
/// user context when filters are relaxed or when restrictive filters still include the entry.
/// </summary>
private void ClearSelectedDataIfNotVisible()
{
if (PageViewModel.SpanWaterfallViewModels is null)
{
return;
}
if (PageViewModel.SelectedData is null)
{
return;
}
if (PageViewModel.IsSelectedDataExcludedByFilters(GetVisibleSpanViewModels()))
{
PageViewModel.SelectedData = null;
}
}
// Computed fresh on each dialog open. A single trace typically has a small number of spans,
// so caching is unnecessary and avoids stale data if the trace is updated while the page is open.
private Task<List<string>> GetTraceSpanPropertyKeysAsync(CancellationToken cancellationToken)
{
if (_trace is null)
{
return Task.FromResult<List<string>>([]);
}
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var span in _trace.Spans)
{
foreach (var attribute in span.Attributes)
{
keys.Add(attribute.Key);
}
}
return Task.FromResult(keys.OrderBy(k => k).ToList());
}
// Computed fresh on each dialog open for the same reason as GetTraceSpanPropertyKeysAsync.
private Task<Dictionary<string, int>> GetTraceSpanFieldValuesAsync(string attributeName, CancellationToken cancellationToken)
{
if (_trace is null)
{
return Task.FromResult(new Dictionary<string, int>(StringComparers.OtlpAttribute));
}
var fieldValues = OtlpSpan.GetFieldValuesFromTraces([_trace], attributeName);
return Task.FromResult(fieldValues);
}
private List<MenuButtonItem> GetFilterMenuItems()
{
return FilterHelpers.GetFilterMenuItems(
PageViewModel.Filters,
clearFilters: PageViewModel.Filters.Clear,
openFilterAsync: OpenFilterAsync,
afterChangeAsync: RefreshAfterFilterChangeAsync,
filterLoc: FilterLoc,
dialogsLoc: DialogsLoc);
}
public void Dispose()
{
Interlocked.Increment(ref _detailViewUpdateVersion);
_cts.Cancel();
_tracesSubscription?.Dispose();
TelemetryContext.Dispose();
}
// IComponentWithTelemetry impl
public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.TraceDetail);
public class TraceDetailPageViewModel
{
public TraceDetailSelectedDataViewModel? SelectedData { get; set; }
public List<SpanWaterfallViewModel>? SpanWaterfallViewModels { get; set; }
public string Filter { get; set; } = string.Empty;
public List<FieldTelemetryFilter> Filters { get; } = [];
public SelectViewModel<SpanType> SelectedSpanType { get; set; } = default!;
public HashSet<string>? ContextFilterMatches { get; set; }
public HashSet<string>? DurationFilterMatches { get; set; }
/// <summary>
/// Returns true when the selected span or log entry is not present in the visible span set.
/// </summary>
public bool IsSelectedDataExcludedByFilters(IEnumerable<SpanWaterfallViewModel> visibleSpans)
{
if (SelectedData is null)
{
return false;
}
if (SelectedData.SpanViewModel is { } spanVm)
{
return !visibleSpans.Any(vm => vm.Span.SpanId == spanVm.Span.SpanId);
}
if (SelectedData.LogEntryViewModel is { } logVm)
{
return !visibleSpans.Any(vm => vm.SpanLogs.Any(l => l.LogEntry.InternalId == logVm.LogEntry.InternalId));
}
return false;
}
internal static IEnumerable<SpanWaterfallViewModel> ApplySpanFilters(
IReadOnlyList<SpanWaterfallViewModel> spanWaterfallViewModels,
HashSet<string>? contextFilterMatches,
HashSet<string>? durationFilterMatches)
{
// Trace Detail has two different filter semantics; the agreed behavior is:
//
// 1. Context filters (name, kind, status, resource, attributes, etc.):
// Preserve surrounding context. When a span matches, both its ancestors AND
// its descendants stay visible so the waterfall remains navigable around the
// interesting span. This is the existing tree-aware behavior implemented in
// SpanWaterfallViewModel.MatchesFilter.
//
// 2. Duration filters are per-span "noise" filters. They hide spans below a
// duration threshold so slow work stands out. The asymmetric rule is:
//
// - Every span must INDIVIDUALLY satisfy the duration filter to be shown.
// - A matching ancestor (e.g. a long root span) does NOT automatically
// retain its short descendants; a descendant is only kept if it
// independently meets the threshold. Otherwise "min duration" would be
// a no-op for descendants whenever any ancestor was long enough.
// - Ancestors of a matching descendant ARE retained for context so the
// matching span has a navigable path back to the root, even when those
// ancestors don't themselves satisfy the duration filter.
Dictionary<SpanWaterfallViewModel, SpanWaterfallViewModel>? parentMap = null;
if (contextFilterMatches is null && durationFilterMatches is null)
{
return spanWaterfallViewModels.Where(vm => !vm.IsHidden);
}
var visibleViewModels = contextFilterMatches is not null
? BuildContextVisibleSet(spanWaterfallViewModels, contextFilterMatches, GetParentMap())
: [.. spanWaterfallViewModels.Where(vm => !vm.IsHidden)];
if (durationFilterMatches is null)
{
return spanWaterfallViewModels.Where(visibleViewModels.Contains);
}
// Per-span noise filter pass. Narrow the context-visible set down to spans that
// INDIVIDUALLY satisfy every duration filter — short descendants of a long
// matching ancestor drop out here. Then walk parents of each direct match to
// keep the ancestor chain visible for navigation, but only ancestors that were
// already in the context-visible set, so the duration pass never widens what
// the context filters allowed.
var directMatches = visibleViewModels
.Where(vm => durationFilterMatches.Contains(vm.Span.SpanId))
.ToList();
var finalVisible = new HashSet<SpanWaterfallViewModel>(directMatches);
foreach (var match in directMatches)
{
var current = match;
while (GetParentMap().TryGetValue(current, out var parent) && visibleViewModels.Contains(parent) && finalVisible.Add(parent))
{
current = parent;
}
}
return spanWaterfallViewModels.Where(finalVisible.Contains);
Dictionary<SpanWaterfallViewModel, SpanWaterfallViewModel> GetParentMap()
{
return parentMap ??= BuildParentMap(spanWaterfallViewModels);
}
}
private static HashSet<SpanWaterfallViewModel> BuildContextVisibleSet(
IReadOnlyList<SpanWaterfallViewModel> spanWaterfallViewModels,
HashSet<string> directMatchSpanIds,
Dictionary<SpanWaterfallViewModel, SpanWaterfallViewModel> parentMap)
{
var visibleViewModels = new HashSet<SpanWaterfallViewModel>();
foreach (var viewModel in spanWaterfallViewModels)
{
if (visibleViewModels.Contains(viewModel))
{
continue;
}
// Only context filters participate in tree-aware expansion. Direct matches
// keep their descendants visible and matching descendants keep their
// ancestors visible, but the scan itself stays linear instead of recursively
// walking each subtree from every flat-list row.
if (directMatchSpanIds.Contains(viewModel.Span.SpanId))
{
AddContextMatch(viewModel, visibleViewModels, parentMap);
}
}
return visibleViewModels;
}
private static Dictionary<SpanWaterfallViewModel, SpanWaterfallViewModel> BuildParentMap(IReadOnlyList<SpanWaterfallViewModel> spans)
{
var map = new Dictionary<SpanWaterfallViewModel, SpanWaterfallViewModel>();
foreach (var parent in spans)
{
foreach (var child in parent.Children)
{
map[child] = parent;
}
}
return map;
}
private static void AddContextMatch(
SpanWaterfallViewModel match,
HashSet<SpanWaterfallViewModel> visibleViewModels,
Dictionary<SpanWaterfallViewModel, SpanWaterfallViewModel> parentMap)
{
if (!match.IsHidden)
{
visibleViewModels.Add(match);
AddVisibleDescendents(match, visibleViewModels);
}
var current = match;
while (parentMap.TryGetValue(current, out var parent))
{
if (!parent.IsHidden)
{
visibleViewModels.Add(parent);
}
current = parent;
}
}
private static void AddVisibleDescendents(SpanWaterfallViewModel match, HashSet<SpanWaterfallViewModel> visibleViewModels)
{
var stack = new Stack<SpanWaterfallViewModel>();
foreach (var child in match.Children)
{
stack.Push(child);
}
while (stack.Count > 0)
{
var current = stack.Pop();
if (!current.IsHidden)
{
visibleViewModels.Add(current);
}
foreach (var child in current.Children)
{
stack.Push(child);
}
}
}
}
}