File: Components\Controls\Chart\ChartContainer.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.Collections.Immutable;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
 
namespace Aspire.Dashboard.Components;
 
public partial class ChartContainer : ComponentBase, IAsyncDisposable
{
    private static readonly TimeSpan s_chartUpdateInterval = TimeSpan.FromSeconds(0.2);
    private static readonly TimeSpan s_dataFetchInterval = TimeSpan.FromSeconds(1);
 
    private readonly object _instrumentUpdateLock = new();
    private OtlpInstrumentData? _instrument;
    private readonly CancellationTokenSource _disposeCts = new();
    private Task? _tickTask;
    private IDisposable? _themeChangedSubscription;
    private readonly InstrumentViewModel _instrumentViewModel = new InstrumentViewModel();
    private (ResourceKey ResourceKey, string MeterName, string InstrumentName)? _dataEndTimeKey;
    private (ResourceKey ResourceKey, string MeterName, string InstrumentName, TimeSpan Duration)? _instrumentRequestKey;
    private DateTimeOffset? _dataEndTime;
    private long _lastDataFetchTimestamp = -1;
    private long _instrumentUpdateVersion;
    private int _disposed;
 
    [Parameter, EditorRequired]
    public required ResourceKey ResourceKey { get; set; }
 
    [Parameter, EditorRequired]
    public required string MeterName { get; set; }
 
    [Parameter, EditorRequired]
    public required string InstrumentName { get; set; }
 
    [Parameter, EditorRequired]
    public required TimeSpan Duration { get; set; }
 
    [Parameter, EditorRequired]
    public required Pages.Metrics.MetricViewKind ActiveView { get; set; }
 
    [Parameter, EditorRequired]
    public required Func<Pages.Metrics.MetricViewKind, Task> OnViewChangedAsync { get; set; }
 
    [Parameter, EditorRequired]
    public required List<OtlpResource> Resources { get; set; }
 
    [Inject]
    public required DashboardDataSource DataSource { get; init; }
 
    public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
 
    [Inject]
    public required ILogger<ChartContainer> Logger { get; init; }
 
    [Inject]
    public required ThemeManager ThemeManager { get; init; }
 
    [Inject]
    public required PauseManager PauseManager { get; init; }
 
    [Inject]
    public required DashboardActivitySource DashboardActivitySource { get; init; }
 
    [Inject]
    public required TimeProvider TimeProvider { get; init; }
 
    public ImmutableList<DimensionFilterViewModel> DimensionFilters { get; set; } = [];
    public string? PreviousMeterName { get; set; }
    public string? PreviousInstrumentName { get; set; }
 
    protected override async Task OnInitializedAsync()
    {
        await ThemeManager.EnsureInitializedAsync();
 
        if (!TelemetryRepository.IsReadOnly)
        {
            // Update the graph every 200ms. This displays the latest data and moves time forward.
            var cancellationToken = _disposeCts.Token;
            // Don't suppress the execution context because graph updates use the current culture for formatting.
            _tickTask = Task.Run(() => UpdateDataAsync(cancellationToken));
        }
        _themeChangedSubscription = ThemeManager.OnThemeChanged(async () =>
        {
            _instrumentViewModel.Theme = ThemeManager.EffectiveTheme;
            await InvokeAsync(StateHasChanged);
        });
    }
 
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }
 
        lock (_instrumentUpdateLock)
        {
            _instrumentUpdateVersion++;
        }
 
        _themeChangedSubscription?.Dispose();
        _disposeCts.Cancel();
 
        // Wait for UpdateData to complete.
        if (_tickTask is { } t)
        {
            await t;
        }
 
        _disposeCts.Dispose();
    }
 
    private async Task UpdateDataAsync(CancellationToken cancellationToken)
    {
        using var timer = new PeriodicTimer(s_chartUpdateInterval, TimeProvider);
        try
        {
            while (await timer.WaitForNextTickAsync(cancellationToken))
            {
                var lastDataFetchTimestamp = Volatile.Read(ref _lastDataFetchTimestamp);
                if (lastDataFetchTimestamp < 0 || TimeProvider.GetElapsedTime(lastDataFetchTimestamp) >= s_dataFetchInterval)
                {
                    using var activity = DashboardActivitySource.ActivitySource.StartActivity("Update metric chart data from tick");
 
                    var result = await GetInstrumentAsync(useIncrementalCache: true, cancellationToken).ConfigureAwait(false);
                    if (!TryPublishInstrument(result))
                    {
                        continue;
                    }
 
                    if (_instrument is not null && HaveDimensionFilterValuesChanged(_instrument))
                    {
                        await InvokeAsync(() =>
                        {
                            UpdateDimensionFilters(hasInstrumentChanged: false);
                            StateHasChanged();
                        });
                    }
                }
 
                if (_instrument == null || PauseManager.AreMetricsPaused(out _))
                {
                    continue;
                }
 
                await UpdateInstrumentDataAsync(_instrument);
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Unexpected error in UpdateDataAsync");
        }
    }
 
    public async Task DimensionValuesChangedAsync(DimensionFilterViewModel dimensionViewModel)
    {
        var result = await GetInstrumentAsync(useIncrementalCache: false, _disposeCts.Token).ConfigureAwait(false);
        if (!TryPublishInstrument(result))
        {
            return;
        }
 
        if (_instrument is null)
        {
            return;
        }
 
        await UpdateInstrumentDataAsync(_instrument);
    }
 
    private async Task UpdateInstrumentDataAsync(OtlpInstrumentData instrument)
    {
        // Only update data in plotly
        await _instrumentViewModel.UpdateDataAsync(instrument.Summary, instrument.Dimensions);
    }
 
    private async Task ShowCountChangedAsync(bool showCount)
    {
        if (_instrumentViewModel.ShowCount == showCount)
        {
            return;
        }
 
        _instrumentViewModel.ShowCount = showCount;
        if (_instrument is not null)
        {
            await UpdateInstrumentDataAsync(_instrument);
        }
    }
 
    protected override async Task OnParametersSetAsync()
    {
        var requestKey = (ResourceKey, MeterName, InstrumentName, Duration);
        if (_instrumentRequestKey != requestKey)
        {
            _instrumentRequestKey = requestKey;
            await RefreshChartAsync();
        }
    }
 
    private async Task RefreshChartAsync()
    {
        // Track the selection change before awaiting. hasInstrumentChanged describes the selected
        // parameters, not the fetched data, so it must not be lost when a concurrent tick wins the
        // version race below.
        var hasInstrumentChanged = PreviousMeterName != MeterName || PreviousInstrumentName != InstrumentName;
        PreviousMeterName = MeterName;
        PreviousInstrumentName = InstrumentName;
 
        var result = await GetInstrumentAsync(useIncrementalCache: false, _disposeCts.Token).ConfigureAwait(false);
        if (!TryPublishInstrument(result))
        {
            // A newer fetch owns _instrument now, but the dimension filters still belong to this
            // selection. The tick loop only ever calls UpdateDimensionFilters with
            // hasInstrumentChanged: false, which carries the previous instrument's selections forward by
            // attribute name, so returning here would leave the new instrument filtered by the old one.
            if (hasInstrumentChanged)
            {
                UpdateDimensionFilters(hasInstrumentChanged: true);
            }
 
            return;
        }
 
        if (_instrument == null)
        {
            return;
        }
 
        UpdateDimensionFilters(hasInstrumentChanged);
 
        await UpdateInstrumentDataAsync(_instrument);
    }
 
    private async Task<(long UpdateVersion, OtlpInstrumentData? Instrument)> GetInstrumentAsync(bool useIncrementalCache, CancellationToken cancellationToken)
    {
        long updateVersion;
        OtlpInstrumentData? baseInstrument;
        lock (_instrumentUpdateLock)
        {
            updateVersion = ++_instrumentUpdateVersion;
 
            // Capture the base instrument under the same lock that stamps the version. TryPublishInstrument
            // writes _instrument under this lock, so reading it outside would let an incremental refresh
            // merge the previously selected instrument's series into the newly selected one: the guard below
            // only proves no newer call started, not that the base snapshot still matches the selection.
            baseInstrument = _instrument;
        }
 
        var resourceKey = ResourceKey;
        var meterName = MeterName;
        var instrumentName = InstrumentName;
        var duration = Duration;
        var dimensionFilters = DimensionFilters
            .Where(filter => filter.AreAllValuesSelected is not true)
            .ToDictionary(
                filter => filter.Name,
                filter => (IReadOnlyList<string?>)filter.SelectedValues.Select(value => value.Value).ToArray());
 
        var instrumentSummary = TelemetryRepository.GetInstrumentSummary(resourceKey, meterName, instrumentName);
        if (instrumentSummary is null)
        {
            Logger.LogDebug(
                "Unable to find instrument. ResourceKey: {ResourceKey}, MeterName: {MeterName}, InstrumentName: {InstrumentName}",
                resourceKey,
                meterName,
                instrumentName);
            return (updateVersion, Instrument: null);
        }
 
        DateTime endDate;
        if (TelemetryRepository.IsReadOnly)
        {
            EnsureDataEndTime(resourceKey, meterName, instrumentName);
            endDate = _dataEndTime?.UtcDateTime ?? DateTime.UtcNow;
        }
        else
        {
            // When paused, use the paused time to keep the data window stable.
            // This ensures filter changes while paused still show the same data.
            endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow;
        }
 
        var dataPointInterval = MetricDataPointInterval.Get(duration);
        var includeExemplars = instrumentSummary.Type == OtlpInstrumentType.Histogram;
 
        // Histogram graphs need one preceding rollup to calculate bucket count changes at the beginning of the chart.
        var historyDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, dataPointInterval.Ticks));
        var startDate = endDate.Subtract(duration + historyDuration);
        var cursors = useIncrementalCache && baseInstrument is not null
            ? MetricInstrumentDataCache.CreateCursors(baseInstrument, historyDuration, dataPointInterval)
            : [];
 
        var refreshedInstrument = await TelemetryRepository.GetInstrumentAsync(new GetInstrumentRequest
        {
            ResourceKey = resourceKey,
            MeterName = meterName,
            InstrumentName = instrumentName,
            StartTime = startDate,
            EndTime = endDate,
            DataPointInterval = dataPointInterval,
            IncludeExemplars = includeExemplars,
            PopulateExemplarAttributes = false,
            DimensionCursors = cursors,
            DimensionFilters = dimensionFilters
        }, cancellationToken).ConfigureAwait(false);
        Debug.Assert(refreshedInstrument is not null);
 
        lock (_instrumentUpdateLock)
        {
            if (updateVersion != _instrumentUpdateVersion)
            {
                return (updateVersion, Instrument: null);
            }
 
            Volatile.Write(ref _lastDataFetchTimestamp, TimeProvider.GetTimestamp());
        }
 
        var instrument = baseInstrument is not null && cursors.Count > 0
            ? MetricInstrumentDataCache.Merge(baseInstrument, refreshedInstrument, cursors, startDate)
            : refreshedInstrument;
        return (updateVersion, instrument);
    }
 
    private bool TryPublishInstrument((long UpdateVersion, OtlpInstrumentData? Instrument) result)
    {
        lock (_instrumentUpdateLock)
        {
            if (result.UpdateVersion != _instrumentUpdateVersion)
            {
                return false;
            }
 
            _instrument = result.Instrument;
            return true;
        }
    }
 
    private void EnsureDataEndTime(ResourceKey resourceKey, string meterName, string instrumentName)
    {
        var key = (resourceKey, meterName, instrumentName);
        if (_dataEndTimeKey == key)
        {
            return;
        }
 
        var latestEndTime = TelemetryRepository.GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName);
        _dataEndTime = latestEndTime is not null ? new DateTimeOffset(latestEndTime.Value) : null;
        _dataEndTimeKey = key;
    }
 
    private List<DimensionFilterViewModel> CreateUpdatedFilters(bool hasInstrumentChanged)
    {
        var filters = new List<DimensionFilterViewModel>();
        if (_instrument != null)
        {
            foreach (var item in _instrument.KnownAttributeValues.OrderBy(kvp => kvp.Key))
            {
                var dimensionModel = new DimensionFilterViewModel
                {
                    Name = item.Key
                };
 
                dimensionModel.Values.AddRange(item.Value.Select(v =>
                {
                    var text = v switch
                    {
                        null => Loc[nameof(ControlsStrings.LabelValueUnset)],
                        { Length: 0 } => Loc[nameof(ControlsStrings.LabelEmpty)],
                        _ => v
                    };
                    return new DimensionValueViewModel
                    {
                        Text = text,
                        Value = v,
                    };
                }));
 
                filters.Add(dimensionModel);
            }
 
            foreach (var item in filters)
            {
                if (hasInstrumentChanged)
                {
                    // Select all by default.
                    item.SetSelectedValues(item.Values);
                }
                else
                {
                    var existing = DimensionFilters.SingleOrDefault(m => m.Name == item.Name);
                    if (existing != null)
                    {
                        // Select previously selected.
                        // Automatically select new incoming values if existing values are all selected.
                        var newSelectedValues = (existing.AreAllValuesSelected ?? false)
                            ? item.Values
                            : item.Values.Where(newValue => existing.SelectedValues.Any(existingValue => existingValue.Value == newValue.Value));
 
                        item.SetSelectedValues(newSelectedValues);
                    }
                    else
                    {
                        // New filter. Select all by default.
                        item.SetSelectedValues(item.Values);
                    }
                }
            }
        }
 
        return filters;
    }
 
    private bool UpdateDimensionFilters(bool hasInstrumentChanged)
    {
        var updatedFilters = ImmutableList.Create(CollectionsMarshal.AsSpan(CreateUpdatedFilters(hasInstrumentChanged)));
        if (HaveSameDimensionFilterContent(DimensionFilters, updatedFilters))
        {
            return false;
        }
 
        // Filters can be accessed from a background task, so replace the immutable collection atomically.
        DimensionFilters = updatedFilters;
        return true;
    }
 
    private bool HaveDimensionFilterValuesChanged(OtlpInstrumentData instrument)
    {
        if (instrument.KnownAttributeValues.Count != DimensionFilters.Count)
        {
            return true;
        }
 
        var index = 0;
        foreach (var attribute in instrument.KnownAttributeValues.OrderBy(attribute => attribute.Key))
        {
            var filter = DimensionFilters[index++];
            if (filter.Name != attribute.Key ||
                !filter.Values.Select(value => value.Value).SequenceEqual(attribute.Value))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static bool HaveSameDimensionFilterContent(
        ImmutableList<DimensionFilterViewModel> currentFilters,
        ImmutableList<DimensionFilterViewModel> updatedFilters)
    {
        if (currentFilters.Count != updatedFilters.Count)
        {
            return false;
        }
 
        for (var filterIndex = 0; filterIndex < currentFilters.Count; filterIndex++)
        {
            var currentFilter = currentFilters[filterIndex];
            var updatedFilter = updatedFilters[filterIndex];
            if (currentFilter.Name != updatedFilter.Name || currentFilter.Values.Count != updatedFilter.Values.Count)
            {
                return false;
            }
 
            for (var valueIndex = 0; valueIndex < currentFilter.Values.Count; valueIndex++)
            {
                var currentValue = currentFilter.Values[valueIndex];
                var updatedValue = updatedFilter.Values[valueIndex];
                if (currentValue.Text != updatedValue.Text ||
                    currentValue.Value != updatedValue.Value ||
                    currentFilter.SelectedValues.Contains(currentValue) != updatedFilter.SelectedValues.Contains(updatedValue))
                {
                    return false;
                }
            }
        }
 
        return true;
    }
 
    private Task OnTabChangeAsync(FluentTab newTab)
    {
        var id = newTab.Id?.Substring("tab-".Length);
 
        if (id is null
            || !Enum.TryParse(typeof(Pages.Metrics.MetricViewKind), id, out var o)
            || o is not Pages.Metrics.MetricViewKind viewKind)
        {
            return Task.CompletedTask;
        }
 
        return OnViewChangedAsync(viewKind);
    }
}