File: Components\Pages\TraceDetail.razor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// 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 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 List<IDisposable> _peerChangesSubscriptions = new();
    private OtlpTrace? _trace;
    private Subscription? _tracesSubscription;
    private List<SpanWaterfallViewModel>? _spanWaterfallViewModels;
    private int _maxDepth;
    private int _resourceCount;
    private List<OtlpResource> _resources = default!;
    private readonly List<string> _collapsedSpanIds = [];
    private string? _elementIdBeforeDetailsViewOpened;
    private FluentDataGrid<SpanWaterfallViewModel> _dataGrid = null!;
    private GridColumnManager _manager = null!;
    private IList<GridColumn> _gridColumns = null!;
    private string _filter = string.Empty;
    private readonly List<MenuButtonItem> _traceActionsMenuItems = [];
    private AspirePageContentLayout? _layout;
    private List<SelectViewModel<SpanType>> _spanTypes = default!;
    private SelectViewModel<SpanType> _selectedSpanType = default!;
 
    [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 TelemetryRepository TelemetryRepository { get; init; }
 
    [Inject]
    public required IEnumerable<IOutgoingPeerResolver> OutgoingPeerResolvers { get; init; }
 
    [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 IStringLocalizer<Aspire.Dashboard.Resources.Dialogs> DialogsLoc { get; init; }
 
    [Inject]
    public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }
 
    [Inject]
    public required IDialogService DialogService { 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)
        ];
 
        foreach (var resolver in OutgoingPeerResolvers)
        {
            _peerChangesSubscriptions.Add(resolver.OnPeerChanges(async () =>
            {
                UpdateDetailViewData();
                await InvokeAsync(StateHasChanged);
                await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
            }));
        }
 
        UpdateTraceActionsMenu();
 
        _spanTypes = SpanType.CreateKnownSpanTypes(ControlStringsLoc);
        _selectedSpanType = _spanTypes[0];
    }
 
    private void UpdateTraceActionsMenu()
    {
        _traceActionsMenuItems.Clear();
 
        // Add "View structured logs" at the top
        _traceActionsMenuItems.Add(new MenuButtonItem
        {
            Text = ControlStringsLoc[nameof(ControlsStrings.ViewStructuredLogsText)],
            Icon = new Icons.Regular.Size16.SlideTextSparkle(),
            OnClick = () =>
            {
                NavigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(traceId: _trace?.TraceId));
                return Task.CompletedTask;
            }
        });
 
        // Add divider
        _traceActionsMenuItems.Add(new MenuButtonItem
        {
            IsDivider = true
        });
 
        // 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()
        });
    }
 
    // Internal to be used in unit tests
    internal ValueTask<GridItemsProviderResult<SpanWaterfallViewModel>> GetData(GridItemsProviderRequest<SpanWaterfallViewModel> request)
    {
        var page = GetVisibleSpanViewModels();
        var totalItemCount = page.Count();
        if (request.StartIndex > 0)
        {
            page = page.Skip(request.StartIndex);
        }
        page = page.Take(request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount);
 
        return ValueTask.FromResult(new GridItemsProviderResult<SpanWaterfallViewModel>
        {
            Items = page.ToList(),
            TotalItemCount = totalItemCount
        });
    }
 
    private IEnumerable<SpanWaterfallViewModel> GetVisibleSpanViewModels()
    {
        Debug.Assert(_spanWaterfallViewModels != null);
 
        var visibleViewModels = new HashSet<SpanWaterfallViewModel>();
        foreach (var viewModel in _spanWaterfallViewModels)
        {
            if (viewModel.IsHidden || visibleViewModels.Contains(viewModel))
            {
                continue;
            }
 
            if (viewModel.MatchesFilter(_filter, _selectedSpanType.Id?.Filter, GetResourceName, out var matchedDescendents))
            {
                visibleViewModels.Add(viewModel);
                foreach (var descendent in matchedDescendents.Where(d => !d.IsHidden))
                {
                    visibleViewModels.Add(descendent);
                }
            }
        }
 
        return _spanWaterfallViewModels.Where(visibleViewModels.Contains);
    }
 
    private 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)
        {
            UpdateDetailViewData();
            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 && _spanWaterfallViewModels is not null)
        {
            var spanVm = _spanWaterfallViewModels.SingleOrDefault(vm => vm.Span.SpanId == SpanId);
            if (spanVm != null)
            {
                await OnShowPropertiesAsync(spanVm, buttonId: null);
            }
 
            // Navigate to remove ?spanId=xxx in the URL.
            NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(TraceId), new NavigationOptions { ReplaceHistoryEntry = true });
        }
    }
 
    protected override void OnAfterRender(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();
        }
    }
 
    private void UpdateDetailViewData()
    {
        _resources = TelemetryRepository.GetResources();
 
        Logger.LogInformation("Getting trace '{TraceId}'.", TraceId);
        _trace = (TraceId != null) ? TelemetryRepository.GetTrace(TraceId) : null;
 
        if (_trace == null)
        {
            Logger.LogInformation("Couldn't find trace '{TraceId}'.", TraceId);
            _spanWaterfallViewModels = 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 logsContext = new GetLogsContext
        {
            ResourceKey = null,
            Count = int.MaxValue,
            StartIndex = 0,
            Filters = [new FieldTelemetryFilter
            {
                Field = KnownStructuredLogFields.TraceIdField,
                Condition = FilterCondition.Equals,
                Value = _trace.TraceId
            }]
        };
        var result = TelemetryRepository.GetLogs(logsContext);
 
        Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", _trace.TraceId, _trace.Spans.Count);
        _spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result.Items, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds));
        _maxDepth = _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()
    {
        SelectedData = null;
        await InvokeAsync(StateHasChanged);
 
        await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
    }
 
    private async Task HandleSelectedSpanTypeChangedAsync()
    {
        SelectedData = null;
        await InvokeAsync(StateHasChanged);
 
        await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
    }
 
    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))
                {
                    UpdateDetailViewData();
                    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 (SelectedData?.SpanViewModel is { } selectedSpan && selectedSpan.Span.SpanId == viewModel.Span.SpanId)
        {
            return "selected-row";
        }
        else if (SelectedData?.LogEntryViewModel is { } selectedLog && viewModel.SpanLogs.Any(l => l.LogEntry.InternalId == selectedLog.LogEntry.InternalId))
        {
            return "selected-row";
        }
 
        return string.Empty;
    }
 
    public TraceDetailSelectedDataViewModel? SelectedData { get; set; }
 
    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()
    {
        UpdateDetailViewData();
        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? buttonId)
    {
        _elementIdBeforeDetailsViewOpened = buttonId;
 
        if (SelectedData?.SpanViewModel?.Span.SpanId == viewModel.Span.SpanId)
        {
            await ClearSelectedSpanAsync();
        }
        else
        {
            var spanDetailsViewModel = SpanDetailsViewModel.Create(viewModel.Span, TelemetryRepository, _resources);
 
            SelectedData = new TraceDetailSelectedDataViewModel
            {
                SpanViewModel = spanDetailsViewModel
            };
        }
    }
 
    private async Task ClearSelectedSpanAsync(bool causedByUserAction = false)
    {
        SelectedData = null;
 
        if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
        {
            await JS.InvokeVoidAsync("focusElement", _elementIdBeforeDetailsViewOpened);
        }
 
        _elementIdBeforeDetailsViewOpened = null;
    }
 
    private bool HasCollapsedSpans()
    {
        if (_spanWaterfallViewModels is null)
        {
            return false;
        }
 
        return _spanWaterfallViewModels.Any(vm => vm.IsCollapsed);
    }
 
    private bool HasExpandedSpans()
    {
        if (_spanWaterfallViewModels is null)
        {
            return false;
        }
 
        // Don't consider root spans (depth 0) when determining if collapse all should be enabled
        return _spanWaterfallViewModels.Any(vm => vm.Depth > RootSpanDepth && !vm.IsCollapsed && vm.Children.Count > 0);
    }
 
    private async Task CollapseAllSpansAsync()
    {
        if (_spanWaterfallViewModels is null)
        {
            return;
        }
 
        foreach (var viewModel in _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 (_spanWaterfallViewModels is null)
        {
            return;
        }
 
        foreach (var viewModel in _spanWaterfallViewModels)
        {
            if (viewModel.IsCollapsed)
            {
                SetSpanCollapsedState(viewModel, false);
            }
        }
 
        await RefreshSpanViewAsync();
    }
 
    private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app, _resources);
 
    private async Task ToggleSpanLogsAsync(OtlpLogEntry logEntry)
    {
        if (SelectedData?.LogEntryViewModel?.LogEntry.InternalId == logEntry.InternalId)
        {
            await ClearSelectedSpanAsync();
        }
        else
        {
            SelectedData = new TraceDetailSelectedDataViewModel
            {
                LogEntryViewModel = new StructureLogsDetailsViewModel { LogEntry = logEntry }
            };
        }
    }
 
    private static bool IsGenAISpan(SpanWaterfallViewModel spanViewModel)
    {
        return GenAIHelpers.IsGenAISpan(spanViewModel.Span.Attributes);
    }
 
    private async Task OnGenAIClickedAsync(SpanWaterfallViewModel spanViewModel)
    {
        await GenAIVisualizerDialog.OpenDialogAsync(
            ViewportInformation,
            DialogService,
            DialogsLoc,
            spanViewModel.Span,
            selectedLogEntryId: null,
            TelemetryRepository,
            _resources,
            () =>
            {
                var genAISpans = new List<OtlpSpan>();
                var visibleSpanViewModels = GetVisibleSpanViewModels();
                foreach (var vm in visibleSpanViewModels.Where(IsGenAISpan))
                {
                    genAISpans.Add(vm.Span);
                }
                return genAISpans;
            });
    }
 
    public void Dispose()
    {
        foreach (var subscription in _peerChangesSubscriptions)
        {
            subscription.Dispose();
        }
        _tracesSubscription?.Dispose();
        TelemetryContext.Dispose();
    }
 
    // IComponentWithTelemetry impl
    public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.TraceDetail);
}