File: Components\Controls\Chart\ChartBase.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 System.Globalization;
using System.Web;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Model.MetricValues;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
 
namespace Aspire.Dashboard.Components.Controls.Chart;
 
public abstract class ChartBase : ComponentBase, IAsyncDisposable
{
    private const int GraphPointCount = 30;
 
    private readonly CancellationTokenSource _cts = new();
    protected CancellationToken CancellationToken { get; private set; }
 
    private TimeSpan _tickDuration;
    private DateTimeOffset _lastUpdateTime;
    private DateTimeOffset _currentDataStartTime;
    private List<KeyValuePair<string, string>[]>? _renderedDimensionAttributes;
    private OtlpInstrumentKey? _renderedInstrument;
    private string? _renderedTheme;
    private bool _renderedShowCount;
 
    [Inject]
    public required IStringLocalizer<ControlsStrings> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.Dialogs> DialogsLoc { get; init; }
 
    [Inject]
    public required IInstrumentUnitResolver InstrumentUnitResolver { get; init; }
 
    [Inject]
    public required BrowserTimeProvider TimeProvider { get; init; }
 
    [Inject]
    public required TelemetryRepository TelemetryRepository { get; init; }
 
    [Parameter, EditorRequired]
    public required InstrumentViewModel InstrumentViewModel { get; set; }
 
    [Parameter, EditorRequired]
    public required TimeSpan Duration { get; set; }
 
    [Parameter]
    public required List<OtlpApplication> Applications { get; set; }
 
    // Stores a cache of the last set of spans returned as exemplars.
    // This dictionary is replaced each time the chart is updated.
    private Dictionary<SpanKey, OtlpSpan> _currentCache = new Dictionary<SpanKey, OtlpSpan>();
    private Dictionary<SpanKey, OtlpSpan> _newCache = new Dictionary<SpanKey, OtlpSpan>();
 
    private readonly record struct SpanKey(string TraceId, string SpanId);
 
    protected override void OnInitialized()
    {
        // Copy the token so there is no chance it is accessed on CTS after it is disposed.
        CancellationToken = _cts.Token;
        _currentDataStartTime = GetCurrentDataTime();
        InstrumentViewModel.DataUpdateSubscriptions.Add(OnInstrumentDataUpdate);
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (CancellationToken.IsCancellationRequested ||
            InstrumentViewModel.Instrument is null ||
            InstrumentViewModel.MatchedDimensions is null ||
            !ReadyForData())
        {
            return;
        }
 
        var inProgressDataTime = GetCurrentDataTime();
 
        while (_currentDataStartTime.Add(_tickDuration) < inProgressDataTime)
        {
            _currentDataStartTime = _currentDataStartTime.Add(_tickDuration);
        }
 
        var dimensionAttributes = InstrumentViewModel.MatchedDimensions.Select(d => d.Attributes).ToList();
        if (_renderedInstrument is null || _renderedInstrument != InstrumentViewModel.Instrument.GetKey() ||
            _renderedDimensionAttributes is null || !_renderedDimensionAttributes.SequenceEqual(dimensionAttributes) ||
            _renderedTheme != InstrumentViewModel.Theme ||
            _renderedShowCount != InstrumentViewModel.ShowCount)
        {
            // Dimensions (or entire chart) has changed. Re-render the entire chart.
            _renderedInstrument = InstrumentViewModel.Instrument.GetKey();
            _renderedDimensionAttributes = dimensionAttributes;
            _renderedTheme = InstrumentViewModel.Theme;
            _renderedShowCount = InstrumentViewModel.ShowCount;
            await UpdateChartAsync(tickUpdate: false, inProgressDataTime).ConfigureAwait(false);
        }
        else if (_lastUpdateTime.Add(TimeSpan.FromSeconds(0.2)) < TimeProvider.GetUtcNow())
        {
            // Throttle how often the chart is updated.
            _lastUpdateTime = TimeProvider.GetUtcNow();
            await UpdateChartAsync(tickUpdate: true, inProgressDataTime).ConfigureAwait(false);
        }
    }
 
    protected override void OnParametersSet()
    {
        _tickDuration = Duration / GraphPointCount;
    }
 
    private Task OnInstrumentDataUpdate()
    {
        return InvokeAsync(StateHasChanged);
    }
 
    private (List<ChartTrace> Y, List<DateTimeOffset> X, List<ChartExemplar> Exemplars) CalculateHistogramValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
    {
        var pointDuration = Duration / pointCount;
        var traces = new Dictionary<int, ChartTrace>
        {
            [50] = new() { Name = $"P50 {yLabel}", Percentile = 50 },
            [90] = new() { Name = $"P90 {yLabel}", Percentile = 90 },
            [99] = new() { Name = $"P99 {yLabel}", Percentile = 99 }
        };
        var xValues = new List<DateTimeOffset>();
        var exemplars = new List<ChartExemplar>();
        var startDate = _currentDataStartTime;
        DateTimeOffset? firstPointEndTime = null;
        DateTimeOffset? lastPointStartTime = null;
 
        // Generate the points in reverse order so that the chart is drawn from right to left.
        // Add a couple of extra points to the end so that the chart is drawn all the way to the right edge.
        for (var pointIndex = 0; pointIndex < (pointCount + 2); pointIndex++)
        {
            var start = CalcOffset(pointIndex, startDate, pointDuration);
            var end = CalcOffset(pointIndex - 1, startDate, pointDuration);
            firstPointEndTime ??= end;
            lastPointStartTime = start;
 
            xValues.Add(TimeProvider.ToLocalDateTimeOffset(end));
 
            if (!TryCalculateHistogramPoints(dimensions, start, end, traces, exemplars))
            {
                foreach (var trace in traces)
                {
                    trace.Value.Values.Add(null);
                }
            }
        }
 
        foreach (var item in traces)
        {
            item.Value.Values.Reverse();
        }
        xValues.Reverse();
 
        if (tickUpdate && TryCalculateHistogramPoints(dimensions, firstPointEndTime!.Value, inProgressDataTime, traces, exemplars))
        {
            xValues.Add(TimeProvider.ToLocalDateTimeOffset(inProgressDataTime));
        }
 
        ChartTrace? previousValues = null;
        foreach (var trace in traces.OrderBy(kvp => kvp.Key))
        {
            var currentTrace = trace.Value;
 
            for (var i = 0; i < currentTrace.Values.Count; i++)
            {
                double? diffValue = (previousValues != null)
                    ? currentTrace.Values[i] - previousValues.Values[i] ?? 0
                    : currentTrace.Values[i];
 
                if (diffValue > 0)
                {
                    currentTrace.Tooltips.Add(FormatTooltip(currentTrace.Name, currentTrace.Values[i].GetValueOrDefault(), xValues[i]));
                }
                else
                {
                    currentTrace.Tooltips.Add(null);
                }
 
                currentTrace.DiffValues.Add(diffValue);
            }
 
            previousValues = currentTrace;
        }
 
        exemplars = exemplars.Where(p => p.Start <= startDate && p.Start >= lastPointStartTime!.Value).OrderBy(p => p.Start).ToList();
 
        return (traces.Select(kvp => kvp.Value).ToList(), xValues, exemplars);
    }
 
    private string FormatTooltip(string name, double yValue, DateTimeOffset xValue)
    {
        return $"<b>{HttpUtility.HtmlEncode(InstrumentViewModel.Instrument?.Name)}</b><br />{HttpUtility.HtmlEncode(name)}: {FormatHelpers.FormatNumberWithOptionalDecimalPlaces(yValue, maxDecimalPlaces: 6, CultureInfo.CurrentCulture)}<br />Time: {FormatHelpers.FormatTime(TimeProvider, TimeProvider.ToLocal(xValue))}";
    }
 
    private static HistogramValue GetHistogramValue(MetricValueBase metric)
    {
        if (metric is HistogramValue histogramValue)
        {
            return histogramValue;
        }
 
        throw new InvalidOperationException("Unexpected metric type: " + metric.GetType());
    }
 
    internal bool TryCalculateHistogramPoints(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary<int, ChartTrace> traces, List<ChartExemplar> exemplars)
    {
        var hasValue = false;
 
        ulong[]? currentBucketCounts = null;
        double[]? explicitBounds = null;
 
        start = start.Subtract(TimeSpan.FromSeconds(1));
        end = end.Add(TimeSpan.FromSeconds(1));
 
        foreach (var dimension in dimensions)
        {
            var dimensionValues = dimension.Values;
            for (var i = dimensionValues.Count - 1; i >= 0; i--)
            {
                var metric = dimensionValues[i];
                if (metric.Start >= start && metric.Start <= end)
                {
                    var histogramValue = GetHistogramValue(metric);
 
                    AddExemplars(exemplars, metric);
 
                    // Only use the first recorded entry if it is the beginning of data.
                    // We can verify the first entry is the beginning of data by checking if the number of buckets equals the total count.
                    if (i == 0 && CountBuckets(histogramValue) != histogramValue.Count)
                    {
                        continue;
                    }
 
                    explicitBounds ??= histogramValue.ExplicitBounds;
 
                    var previousHistogramValues = i > 0 ? GetHistogramValue(dimensionValues[i - 1]).Values : null;
 
                    if (currentBucketCounts is null)
                    {
                        currentBucketCounts = new ulong[histogramValue.Values.Length];
                    }
                    else if (currentBucketCounts.Length != histogramValue.Values.Length)
                    {
                        throw new InvalidOperationException("Histogram values changed size");
                    }
 
                    for (var valuesIndex = 0; valuesIndex < histogramValue.Values.Length; valuesIndex++)
                    {
                        var newValue = histogramValue.Values[valuesIndex];
 
                        if (previousHistogramValues != null)
                        {
                            // Histogram values are cumulative, so subtract the previous value to get the diff.
                            newValue -= previousHistogramValues[valuesIndex];
                        }
 
                        currentBucketCounts[valuesIndex] += newValue;
                    }
 
                    hasValue = true;
                }
            }
        }
        if (hasValue)
        {
            foreach (var percentileValues in traces)
            {
                var percentileValue = CalculatePercentile(percentileValues.Key, currentBucketCounts!, explicitBounds!);
                percentileValues.Value.Values.Add(percentileValue);
            }
        }
        return hasValue;
    }
 
    private void AddExemplars(List<ChartExemplar> exemplars, MetricValueBase metric)
    {
        if (metric.HasExemplars)
        {
            foreach (var exemplar in metric.Exemplars)
            {
                // TODO: Exemplars are duplicated on metrics in some scenarios.
                // This is a quick fix to ensure a distinct collection of metrics are displayed in the UI.
                // Investigation is needed into why there are duplicates.
                var exists = false;
                foreach (var existingExemplar in exemplars)
                {
                    if (exemplar.Start == existingExemplar.Start &&
                        exemplar.Value == existingExemplar.Value &&
                        exemplar.SpanId == existingExemplar.SpanId &&
                        exemplar.TraceId == existingExemplar.TraceId)
                    {
                        exists = true;
                        break;
                    }
                }
                if (exists)
                {
                    continue;
                }
 
                // Try to find span the the local cache first.
                // This is done to avoid scanning a potentially large trace collection in repository.
                var key = new SpanKey(exemplar.TraceId, exemplar.SpanId);
                if (!_currentCache.TryGetValue(key, out var span))
                {
                    span = TelemetryRepository.GetSpan(exemplar.TraceId, exemplar.SpanId);
                }
                if (span != null)
                {
                    _newCache[key] = span;
                }
 
                var exemplarStart = TimeProvider.ToLocalDateTimeOffset(exemplar.Start);
                exemplars.Add(new ChartExemplar
                {
                    Start = exemplarStart,
                    Value = exemplar.Value,
                    TraceId = exemplar.TraceId,
                    SpanId = exemplar.SpanId,
                    Span = span
                });
            }
        }
    }
 
    private static ulong CountBuckets(HistogramValue histogramValue)
    {
        ulong value = 0ul;
        for (var i = 0; i < histogramValue.Values.Length; i++)
        {
            value += histogramValue.Values[i];
        }
        return value;
    }
 
    internal static double? CalculatePercentile(int percentile, ulong[] counts, double[] explicitBounds)
    {
        if (percentile < 0 || percentile > 100)
        {
            throw new ArgumentOutOfRangeException(nameof(percentile), percentile, "Percentile must be between 0 and 100.");
        }
 
        var totalCount = 0ul;
        foreach (var count in counts)
        {
            totalCount += count;
        }
 
        var targetCount = (percentile / 100.0) * totalCount;
        var accumulatedCount = 0ul;
 
        for (var i = 0; i < explicitBounds.Length; i++)
        {
            accumulatedCount += counts[i];
 
            if (accumulatedCount >= targetCount)
            {
                return explicitBounds[i];
            }
        }
 
        // If the percentile is larger than any bucket value, return the last value
        return explicitBounds[explicitBounds.Length - 1];
    }
 
    private (List<ChartTrace> Y, List<DateTimeOffset> X, List<ChartExemplar> Exemplars) CalculateChartValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
    {
        var pointDuration = Duration / pointCount;
        var yValues = new List<double?>();
        var xValues = new List<DateTimeOffset>();
        var exemplars = new List<ChartExemplar>();
        var startDate = _currentDataStartTime;
        DateTimeOffset? firstPointEndTime = null;
 
        // Generate the points in reverse order so that the chart is drawn from right to left.
        // Add a couple of extra points to the end so that the chart is drawn all the way to the right edge.
        for (var pointIndex = 0; pointIndex < (pointCount + 2); pointIndex++)
        {
            var start = CalcOffset(pointIndex, startDate, pointDuration);
            var end = CalcOffset(pointIndex - 1, startDate, pointDuration);
            firstPointEndTime ??= end;
 
            xValues.Add(TimeProvider.ToLocalDateTimeOffset(end));
 
            if (TryCalculatePoint(dimensions, start, end, exemplars, out var tickPointValue))
            {
                yValues.Add(tickPointValue);
            }
            else
            {
                yValues.Add(null);
            }
        }
 
        yValues.Reverse();
        xValues.Reverse();
 
        if (tickUpdate && TryCalculatePoint(dimensions, firstPointEndTime!.Value, inProgressDataTime, exemplars, out var inProgressPointValue))
        {
            yValues.Add(inProgressPointValue);
            xValues.Add(TimeProvider.ToLocalDateTimeOffset(inProgressDataTime));
        }
 
        var trace = new ChartTrace
        {
            Name = HttpUtility.HtmlEncode(yLabel)
        };
 
        for (var i = 0; i < xValues.Count; i++)
        {
            trace.Values.AddRange(yValues);
            trace.DiffValues.AddRange(yValues);
            if (yValues[i] is not null)
            {
                trace.Tooltips.Add(FormatTooltip(yLabel, yValues[i]!.Value, xValues[i]));
            }
            else
            {
                trace.Tooltips.Add(null);
            }
        }
 
        return ([trace], xValues, exemplars);
    }
 
    private bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, List<ChartExemplar> exemplars, out double pointValue)
    {
        var hasValue = false;
        pointValue = 0d;
 
        foreach (var dimension in dimensions)
        {
            var dimensionValues = dimension.Values;
            var dimensionValue = 0d;
            for (var i = dimensionValues.Count - 1; i >= 0; i--)
            {
                var metric = dimensionValues[i];
                if ((metric.Start <= end && metric.End >= start) || (metric.Start >= start && metric.End <= end))
                {
                    var value = metric switch
                    {
                        MetricValue<long> longMetric => longMetric.Value,
                        MetricValue<double> doubleMetric => doubleMetric.Value,
                        HistogramValue histogramValue => histogramValue.Count,
                        _ => 0// throw new InvalidOperationException("Unexpected metric type: " + metric.GetType())
                    };
 
                    dimensionValue = Math.Max(value, dimensionValue);
                    hasValue = true;
                }
 
                AddExemplars(exemplars, metric);
            }
 
            pointValue += dimensionValue;
        }
 
        // JS interop doesn't support serializing NaN values.
        if (double.IsNaN(pointValue))
        {
            pointValue = default;
            return false;
        }
 
        return hasValue;
    }
 
    private static DateTimeOffset CalcOffset(int pointIndex, DateTimeOffset now, TimeSpan pointDuration)
    {
        return now.Subtract(pointDuration * pointIndex);
    }
 
    private async Task UpdateChartAsync(bool tickUpdate, DateTimeOffset inProgressDataTime)
    {
        // Unit comes from the instrument and they're not localized.
        // The hardcoded "Count" label isn't localized for consistency.
        const string CountUnit = "Count";
 
        Debug.Assert(InstrumentViewModel.MatchedDimensions != null);
        Debug.Assert(InstrumentViewModel.Instrument != null);
 
        var unit = !InstrumentViewModel.ShowCount
            ? GetDisplayedUnit(InstrumentViewModel.Instrument)
            : CountUnit;
 
        List<ChartTrace> traces;
        List<DateTimeOffset> xValues;
        List<ChartExemplar> exemplars;
        if (InstrumentViewModel.Instrument?.Type != OtlpInstrumentType.Histogram || InstrumentViewModel.ShowCount)
        {
            (traces, xValues, exemplars) = CalculateChartValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
 
            // TODO: Exemplars on non-histogram charts doesn't work well. Don't display for now.
            exemplars.Clear();
        }
        else
        {
            (traces, xValues, exemplars) = CalculateHistogramValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
        }
 
        // Replace cache for next update.
        _currentCache = _newCache;
        _newCache = new Dictionary<SpanKey, OtlpSpan>();
 
        await OnChartUpdatedAsync(traces, xValues, exemplars, tickUpdate, inProgressDataTime, CancellationToken);
    }
 
    private DateTimeOffset GetCurrentDataTime()
    {
        return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); // Compensate for delay in receiving metrics from services.
    }
 
    private string GetDisplayedUnit(OtlpInstrumentSummary instrument)
    {
        return InstrumentUnitResolver.ResolveDisplayedUnit(instrument, titleCase: true, pluralize: true);
    }
 
    protected abstract Task OnChartUpdatedAsync(List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime, CancellationToken cancellationToken);
 
    protected abstract bool ReadyForData();
 
    public ValueTask DisposeAsync() => DisposeAsync(disposing: true);
 
    protected virtual ValueTask DisposeAsync(bool disposing)
    {
        _cts.Cancel();
        _cts.Dispose();
        return ValueTask.CompletedTask;
    }
}