File: Components\Pages\StructuredLogs.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.Globalization;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Configuration;
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.Options;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Pages;
 
public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionAndUrlState<StructuredLogs.StructuredLogsPageViewModel, StructuredLogs.StructuredLogsPageState>
{
    private const string ScrollContainerId = "structuredLogsScrollContainer";
    private const string ResourceColumn = nameof(ResourceColumn);
    private const string LogLevelColumn = nameof(LogLevelColumn);
    private const string TimestampColumn = nameof(TimestampColumn);
    private const string MessageColumn = nameof(MessageColumn);
    private const string TraceColumn = nameof(TraceColumn);
    private const string ActionsColumn = nameof(ActionsColumn);
 
    private SelectViewModel<ResourceTypeDetails> _allResource = default!;
 
    private TotalItemsFooter _totalItemsFooter = default!;
    private int _totalItemsCount;
    private List<OtlpResource> _resources = default!;
    private List<SelectViewModel<ResourceTypeDetails>> _resourceViewModels = default!;
    private List<SelectViewModel<LogLevel?>> _logLevels = default!;
    private readonly CancellationTokenSource _cts = new();
    private Subscription? _resourcesSubscription;
    private Subscription? _logsSubscription;
    private int? _displayedItemCount;
    private bool _resourceChanged;
    private string? _elementIdBeforeDetailsViewOpened;
    private string? _pendingFocusElementId;
    private AspirePageContentLayout? _contentLayout;
    private string _filter = string.Empty;
    private FluentDataGrid<LogSummary>? _dataGrid;
    private GridColumnManager _manager = null!;
    private IList<GridColumn> _gridColumns = null!;
 
    private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default;
    private ColumnSortLabels _sortLabels = ColumnSortLabels.Default;
 
    public string BasePath => DashboardUrls.StructuredLogsBasePath;
    public string SessionStorageKey => BrowserStorageKeys.StructuredLogsPageState;
    public StructuredLogsPageViewModel PageViewModel { get; set; } = null!;
 
    [Inject]
    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; }
 
    [Inject]
    public required DashboardDialogService DialogService { get; init; }
 
    [Inject]
    public required ISessionStorage SessionStorage { get; init; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; init; }
 
    [Inject]
    public required BrowserTimeProvider TimeProvider { get; init; }
 
    [Inject]
    public required ILogger<StructuredLogs> Logger { get; init; }
 
    [Inject]
    public required ITelemetryErrorRecorder ErrorRecorder { get; init; }
 
    [Inject]
    public required DimensionManager DimensionManager { get; init; }
 
    [Inject]
    public required IOptions<DashboardOptions> DashboardOptions { get; init; }
 
    [Inject]
    public required IMessageService MessageService { get; init; }
 
    [Inject]
    public required PauseManager PauseManager { get; init; }
 
    [Inject]
    public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }
 
    [CascadingParameter]
    public required ViewportInformation ViewportInformation { get; set; }
 
    [Parameter]
    public string? ResourceName { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery]
    public string? TraceId { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery]
    public string? SpanId { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery(Name = "logLevel")]
    public string? LogLevelText { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery(Name = "filters")]
    public string? SerializedFilters { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery]
    public long? LogEntryId { get; set; }
 
    private async ValueTask<GridItemsProviderResult<LogSummary>> GetData(GridItemsProviderRequest<LogSummary> request)
    {
        ViewModel.StartIndex = request.StartIndex;
        ViewModel.Count = request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount;
 
        var logs = await ViewModel.GetLogsAsync(request.CancellationToken);
 
        if (!TelemetryRepository.IsReadOnly)
        {
            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();
            }
        }
 
        var virtualizedLogCount = DashboardUIHelpers.GetVirtualizedItemCount(logs.TotalItemCount);
        _displayedItemCount = virtualizedLogCount < logs.TotalItemCount ? virtualizedLogCount : null;
 
        // Updating the total item count as a field doesn't work because it isn't updated with the grid.
        // The workaround is to explicitly update and refresh the control.
        _totalItemsCount = logs.TotalItemCount;
        _totalItemsFooter?.UpdateDisplayedCount(_totalItemsCount, _displayedItemCount);
 
        TelemetryRepository.MarkViewedErrorLogs(ViewModel.ResourceKey);
 
        return GridItemsProviderResult.From(logs.Items, virtualizedLogCount);
    }
 
    protected override void OnInitialized()
    {
        TelemetryContextProvider.Initialize(TelemetryContext);
 
        (_resizeLabels, _sortLabels) = DashboardUIHelpers.CreateGridLabels(ControlsStringsLoc);
 
        _gridColumns = [
            new GridColumn(Name: ResourceColumn, DesktopWidth: "2fr", MobileWidth: "1fr"),
            new GridColumn(Name: LogLevelColumn, DesktopWidth: "1fr"),
            new GridColumn(Name: TimestampColumn, DesktopWidth: "1.5fr"),
            new GridColumn(Name: MessageColumn, DesktopWidth: "5fr", "2.5fr"),
            new GridColumn(Name: TraceColumn, DesktopWidth: "1fr"),
            new GridColumn(Name: ActionsColumn, DesktopWidth: "1fr", MobileWidth: "0.8fr")
        ];
 
        if (!string.IsNullOrEmpty(TraceId))
        {
            ViewModel.AddFilter(new FieldTelemetryFilter
            {
                Field = KnownStructuredLogFields.TraceIdField, Condition = FilterCondition.Equals, Value = TraceId
            });
        }
        if (!string.IsNullOrEmpty(SpanId))
        {
            ViewModel.AddFilter(new FieldTelemetryFilter
            {
                Field = KnownStructuredLogFields.SpanIdField, Condition = FilterCondition.Equals, Value = SpanId
            });
        }
 
        _allResource = new()
        {
            Id = null,
            Name = ControlsStringsLoc[nameof(ControlsStrings.LabelAll)]
        };
 
        _logLevels =
        [
            new() { Id = null, Name = ControlsStringsLoc[nameof(ControlsStrings.LabelAll)] },
            new() { Id = LogLevel.Trace, Name = "Trace" },
            new() { Id = LogLevel.Debug, Name = "Debug" },
            new() { Id = LogLevel.Information, Name = "Information" },
            new() { Id = LogLevel.Warning, Name = "Warning" },
            new() { Id = LogLevel.Error, Name = "Error" },
            new() { Id = LogLevel.Critical, Name = "Critical" },
        ];
 
        PageViewModel = new StructuredLogsPageViewModel
        {
            SelectedLogLevel = _logLevels[0],
            SelectedResource = _allResource
        };
 
        UpdateResources();
        _resourcesSubscription = TelemetryRepository.OnNewResources(() => InvokeAsync(() =>
        {
            UpdateResources();
            StateHasChanged();
        }));
    }
 
    protected override async Task OnParametersSetAsync()
    {
        if (await this.InitializeViewModelAsync())
        {
            return;
        }
 
        UpdateSubscription();
        UpdateTelemetryProperties();
 
        if (LogEntryId is not null)
        {
            var logEntryId = TelemetryRepository.GetLog(LogEntryId.Value);
            if (logEntryId != null)
            {
                await OnShowPropertiesAsync(logEntryId, focusElementId: null);
            }
 
            // Navigate to remove ?logEntryId=xxx in the URL. A small delay is required here, otherwise the page rendering breaks.
            await Task.Delay(200, _cts.Token);
 
            NavigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(), new NavigationOptions { ReplaceHistoryEntry = true });
        }
    }
 
    private void UpdateResources()
    {
        _resources = TelemetryRepository.GetResources();
        _resourceViewModels = ResourcesSelectHelpers.CreateResources(_resources);
        _resourceViewModels.Insert(0, _allResource);
    }
 
    private Task HandleSelectedResourceChangedAsync()
    {
        _resourceChanged = true;
 
        return this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: true);
    }
 
    private async Task HandleSelectedLogLevelChangedAsync()
    {
        _resourceChanged = true;
 
        await ClearSelectedLogEntryIfExcludedAsync(_filter, ViewModel.Filters);
 
        await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: true);
    }
 
    private void UpdateSubscription()
    {
        // Subscribe to updates.
        if (_logsSubscription is null || _logsSubscription.ResourceKey != PageViewModel.SelectedResource.Id?.GetResourceKey())
        {
            _logsSubscription?.Dispose();
            _logsSubscription = TelemetryRepository.OnNewLogs(PageViewModel.SelectedResource.Id?.GetResourceKey(), SubscriptionType.Read, async () =>
            {
                ViewModel.ClearData();
                await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
            });
        }
    }
 
    private async Task OnShowPropertiesAsync(OtlpLogEntry entry, string? focusElementId)
    {
        _elementIdBeforeDetailsViewOpened = focusElementId;
 
        if (PageViewModel.SelectedLogEntry?.LogEntry.InternalId == entry.InternalId)
        {
            await ClearSelectedLogEntryAsync();
        }
        else
        {
            var logEntryViewModel = new StructureLogsDetailsViewModel
            {
                LogEntry = entry
            };
 
            PageViewModel.SelectedLogEntry = logEntryViewModel;
        }
    }
 
    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;
 
        if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
        {
            _pendingFocusElementId = _elementIdBeforeDetailsViewOpened;
        }
 
        _elementIdBeforeDetailsViewOpened = null;
 
        return Task.CompletedTask;
    }
 
    private async Task OpenFilterAsync(FieldTelemetryFilter? entry)
    {
        if (_contentLayout is not null)
        {
            await _contentLayout.CloseMobileToolbarAsync();
        }
 
        var resourceKey = PageViewModel.SelectedResource.Id?.GetResourceKey();
        await FilterHelpers.OpenFilterAsync(
            entry,
            DialogService,
            DialogService.CreateDialogCallback(this, HandleFilterDialog),
            getPropertyKeysAsync: cancellationToken => TelemetryRepository.GetLogPropertyKeysAsync(resourceKey, cancellationToken),
            knownKeys: KnownStructuredLogFields.AllFields,
            getFieldValuesAsync: TelemetryRepository.GetLogsFieldValuesAsync,
            FilterLoc);
    }
 
    private async Task HandleFilterDialog(DialogResult result)
    {
        if (result.Data is FilterDialogResult filterResult && filterResult.Filter is FieldTelemetryFilter filter)
        {
            if (filterResult.Delete)
            {
                ViewModel.RemoveFilter(filter);
            }
            else if (filterResult.Add)
            {
                ViewModel.AddFilter(filter);
            }
            else if (filterResult.Enable)
            {
                filter.Enabled = true;
            }
            else if (filterResult.Disable)
            {
                filter.Enabled = false;
            }
        }
 
        await ClearSelectedLogEntryIfExcludedAsync(_filter, ViewModel.Filters);
 
        await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false);
    }
 
    private async Task HandleAfterFilterBindAsync()
    {
        ViewModel.FilterText = _filter;
        await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
 
        await ClearSelectedLogEntryIfExcludedAsync(_filter, ViewModel.Filters);
    }
 
    internal async Task ClearSelectedLogEntryIfExcludedAsync(string textFilter, IReadOnlyList<FieldTelemetryFilter> fieldFilters)
    {
        if (PageViewModel.SelectedLogEntry is null)
        {
            return;
        }
 
        // An older filter query could finish after a newer query and clear the selection using stale filters.
        // The query is constrained to one log and filter changes are user-driven, so this is unlikely and not
        // worth the additional state and coordination required to guard against it.
        var isExcluded = await PageViewModel.IsSelectedLogEntryExcludedByFiltersAsync(
            TelemetryRepository,
            textFilter,
            fieldFilters,
            _cts.Token);
        if (isExcluded)
        {
            await ClearSelectedLogEntryAsync();
        }
    }
 
    private string GetResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, _resources);
 
    private string GetRowClass(LogSummary entry)
    {
        if (entry.InternalId == PageViewModel.SelectedLogEntry?.LogEntry.InternalId)
        {
            return "selected-row";
        }
        else
        {
            return $"log-row-{entry.Severity.ToString().ToLowerInvariant()}";
        }
    }
 
    private List<MenuButtonItem> GetFilterMenuItems()
    {
        return FilterHelpers.GetFilterMenuItems(
            ViewModel.Filters,
            ViewModel.ClearFilters,
            OpenFilterAsync,
            afterChangeAsync: () => this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false),
            FilterLoc,
            DialogsLoc);
    }
 
    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<LogSummary>.TrySetMaxItemCount(_dataGrid, 10_000))
        {
            StateHasChanged();
        }
 
        if (_resourceChanged)
        {
            await JS.InvokeVoidAsync("resetContinuousScrollPosition");
            _resourceChanged = false;
        }
        if (firstRender)
        {
            await JS.InvokeVoidAsync("initializeContinuousScroll");
            // 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);
            DimensionManager.OnViewportInformationChanged += OnBrowserResize;
        }
 
        if (_pendingFocusElementId is { } pendingFocusElementId)
        {
            _pendingFocusElementId = null;
            await JS.InvokeVoidAsync("focusElement", pendingFocusElementId);
        }
    }
 
    private void OnBrowserResize(object? o, EventArgs args)
    {
        InvokeAsync(async () =>
        {
            await JS.InvokeVoidAsync("resetContinuousScrollPosition");
            await JS.InvokeVoidAsync("initializeContinuousScroll");
        });
    }
 
    private string? PauseText => PauseManager.AreStructuredLogsPaused(out var startTime)
        ? string.Format(
            CultureInfo.CurrentCulture,
            Loc[nameof(Dashboard.Resources.StructuredLogs.PauseInProgressText)],
            FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, startTime.Value))
        : null;
 
    public void Dispose()
    {
        _cts.Cancel();
        _resourcesSubscription?.Dispose();
        _logsSubscription?.Dispose();
        DimensionManager.OnViewportInformationChanged -= OnBrowserResize;
        TelemetryContext.Dispose();
    }
 
    public string GetUrlFromSerializableViewModel(StructuredLogsPageState serializable)
    {
        var filters = (serializable.Filters.Count > 0) ? TelemetryFilterFormatter.SerializeFiltersToString(serializable.Filters) : null;
 
        var url = DashboardUrls.StructuredLogsUrl(
            resource: serializable.SelectedResource,
            logLevel: serializable.LogLevelText,
            filters: filters);
 
        return url;
    }
 
    public StructuredLogsPageState ConvertViewModelToSerializable()
    {
        return new StructuredLogsPageState
        {
            LogLevelText = PageViewModel.SelectedLogLevel.Id?.ToString().ToLowerInvariant(),
            SelectedResource = PageViewModel.SelectedResource.Id is not null ? PageViewModel.SelectedResource.Name : null,
            Filters = ViewModel.Filters
        };
    }
 
    public async Task UpdateViewModelFromQueryAsync(StructuredLogsPageViewModel viewModel)
    {
        viewModel.SelectedResource = _resourceViewModels.GetResource(Logger, ResourceName, canSelectGrouping: true, _allResource);
        ViewModel.ResourceKey = PageViewModel.SelectedResource.Id?.GetResourceKey();
 
        if (LogLevelText is not null && Enum.TryParse<LogLevel>(LogLevelText, ignoreCase: true, out var logLevel))
        {
            PageViewModel.SelectedLogLevel = _logLevels.SingleOrDefault(e => e.Id == logLevel) ?? _logLevels[0];
        }
        else
        {
            PageViewModel.SelectedLogLevel = _logLevels[0];
        }
 
        ViewModel.LogLevel = PageViewModel.SelectedLogLevel.Id;
 
        if (SerializedFilters is not null)
        {
            var filters = TelemetryFilterFormatter.DeserializeFiltersFromString(SerializedFilters);
 
            if (filters.Count > 0)
            {
                ViewModel.ClearFilters();
                foreach (var filter in filters)
                {
                    ViewModel.AddFilter(filter);
                }
            }
        }
 
        await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
    }
 
    private async Task LaunchGenAIVisualizerAsync(LogSummary logEntry)
    {
        var available = await TraceLinkHelpers.WaitForSpanToBeAvailableAsync(
            logEntry.TraceId,
            logEntry.SpanId,
            TelemetryRepository.GetSpan,
            DialogService,
            InvokeAsync,
            DialogsLoc,
            _cts.Token).ConfigureAwait(false);
 
        if (available)
        {
            var span = TelemetryRepository.GetSpan(logEntry.TraceId, logEntry.SpanId)!;
 
            await GenAIVisualizerDialog.OpenDialogAsync(
                DialogService,
                span,
                logEntry.InternalId,
                TelemetryRepository,
                ErrorRecorder,
                _resources,
                () =>
                {
                    var trace = TelemetryRepository.GetTrace(logEntry.TraceId);
                    if (trace is null)
                    {
                        return [];
                    }
 
                    var genAISpans = new List<OtlpSpan>();
                    foreach (var s in trace.Spans)
                    {
                        if (GenAIHelpers.HasGenAIAttribute(s.Attributes))
                        {
                            genAISpans.Add(s);
                        }
                    }
 
                    return genAISpans;
                },
                _cts.Token);
        }
    }
 
    private Task ClearStructureLogs(ResourceKey? key)
    {
        DataSource.EnsureWritable();
        return TelemetryRepositoryWriter.ClearStructuredLogsAsync(key);
    }
 
    public class StructuredLogsPageViewModel
    {
        public required SelectViewModel<ResourceTypeDetails> SelectedResource { get; set; }
        public SelectViewModel<LogLevel?> SelectedLogLevel { get; set; } = default!;
        public StructureLogsDetailsViewModel? SelectedLogEntry { get; set; }
 
        /// <summary>
        /// Returns <see langword="true"/> when the selected log entry is excluded by the active filters.
        /// </summary>
        public async Task<bool> IsSelectedLogEntryExcludedByFiltersAsync(
            ITelemetryRepository telemetryRepository,
            string textFilter,
            IReadOnlyList<FieldTelemetryFilter> fieldFilters,
            CancellationToken cancellationToken)
        {
            if (SelectedLogEntry is null)
            {
                return false;
            }
 
            var entry = SelectedLogEntry.LogEntry;
            var filters = StructuredLogsViewModel.BuildFilters(fieldFilters, textFilter, SelectedLogLevel.Id);
            var matchingLogs = await telemetryRepository.GetLogsAsync(new GetLogsContext
            {
                ResourceKeys = [],
                StartIndex = 0,
                Count = 1,
                Filters = filters,
                LogIds = [entry.InternalId]
            }, cancellationToken).ConfigureAwait(false);
            return matchingLogs.Items.Count == 0;
        }
    }
 
    public class StructuredLogsPageState
    {
        public string? SelectedResource { get; set; }
        public string? LogLevelText { get; set; }
        public required IReadOnlyCollection<FieldTelemetryFilter> Filters { get; set; }
    }
 
    // IComponentWithTelemetry impl
    public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.StructuredLogs);
 
    public void UpdateTelemetryProperties()
    {
        TelemetryContext.UpdateTelemetryProperties([
            new ComponentTelemetryProperty(TelemetryPropertyKeys.StructuredLogsSelectedLogLevel, new AspireTelemetryProperty(PageViewModel.SelectedLogLevel.Id?.ToString() ?? string.Empty, AspireTelemetryPropertyType.UserSetting)),
            new ComponentTelemetryProperty(TelemetryPropertyKeys.StructuredLogsFilterCount, new AspireTelemetryProperty(ViewModel.Filters.Count.ToString(CultureInfo.InvariantCulture), AspireTelemetryPropertyType.Metric))
        ], Logger);
    }
}