| 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.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; private DateTimeOffset? _previousDataEndTime; // Full updates always run. This interval only throttles recurring tick updates. protected virtual TimeSpan UpdateInterval => TimeSpan.FromSeconds(0.2); [Inject] public required IStringLocalizer<ControlsStrings> Loc { get; init; } [Inject] public required IInstrumentUnitResolver InstrumentUnitResolver { get; init; } [Inject] public required BrowserTimeProvider TimeProvider { get; init; } [Inject] public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required PauseManager PauseManager { get; init; } [Parameter, EditorRequired] public required InstrumentViewModel InstrumentViewModel { get; set; } [Parameter, EditorRequired] public required TimeSpan Duration { get; set; } [Parameter] public required List<OtlpResource> Resources { get; set; } [Parameter] public DateTimeOffset? DataEndTime { 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>(); 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(out _); InstrumentViewModel.AddDataUpdateSubscription(OnInstrumentDataUpdate); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (CancellationToken.IsCancellationRequested || InstrumentViewModel.Instrument is null || InstrumentViewModel.MatchedDimensions is null || !ReadyForData()) { return; } var inProgressDataTime = GetCurrentDataTime(out var isTimeFrozen); // Keep the time axis stable for historical data and while live data is paused. if (!isTimeFrozen) { 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); _lastUpdateTime = TimeProvider.GetUtcNow(); // Derived components can update Blazor-rendered state during OnAfterRenderAsync. await InvokeAsync(StateHasChanged); } else if (_lastUpdateTime.Add(UpdateInterval) < 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; if (_previousDataEndTime != DataEndTime) { _currentDataStartTime = GetCurrentDataTime(out _); _previousDataEndTime = DataEndTime; } } private Task OnInstrumentDataUpdate() { return CancellationToken.IsCancellationRequested ? Task.CompletedTask : InvokeAsync(StateHasChanged); } 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 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; var calculator = new ChartDataCalculator(GraphPointCount, Duration); ChartData data; if (InstrumentViewModel.Instrument?.Type != OtlpInstrumentType.Histogram || InstrumentViewModel.ShowCount) { data = calculator.CalculateChartValues(InstrumentViewModel.MatchedDimensions, _currentDataStartTime, TimeProvider.ToLocalDateTimeOffset, unit); // TODO: Exemplars on non-histogram charts doesn't work well. Don't display for now. data.Exemplars.Clear(); } else { data = calculator.CalculateHistogramValues(InstrumentViewModel.MatchedDimensions, _currentDataStartTime, TimeProvider.ToLocalDateTimeOffset, unit); } // Add tooltips to traces. The calculator produces values and diff values // but omits tooltips to keep it free of UI/localization concerns. // Tooltips are added before encoding trace names because FormatTooltip // encodes the name parameter internally. foreach (var trace in data.Traces) { for (var i = 0; i < data.XValues.Count; i++) { if (trace.Percentile is not null) { // Histogram: show tooltip only when diff is positive. if (trace.DiffValues[i] > 0) { trace.Tooltips.Add(FormatTooltip(trace.Name, trace.Values[i].GetValueOrDefault(), data.XValues[i])); } else { trace.Tooltips.Add(null); } } else { // Non-histogram: show tooltip when value exists. if (trace.Values[i] is not null) { trace.Tooltips.Add(FormatTooltip(trace.Name, trace.Values[i]!.Value, data.XValues[i])); } else { trace.Tooltips.Add(null); } } } // HTML-encode trace names because Plotly renders HTML in legend text. trace.Name = HttpUtility.HtmlEncode(trace.Name); } // Resolve exemplar spans using the telemetry repository and span cache. ResolveExemplarSpans(data.Exemplars); // Replace cache for next update. _currentCache = _newCache; _newCache = new Dictionary<SpanKey, OtlpSpan>(); await OnChartUpdatedAsync(data.Traces, data.XValues, data.Exemplars, tickUpdate, inProgressDataTime, CancellationToken); } private void ResolveExemplarSpans(List<ChartExemplar> exemplars) { for (var i = 0; i < exemplars.Count; i++) { var exemplar = exemplars[i]; var key = new SpanKey(exemplar.TraceId, exemplar.SpanId); // Try to find span in the local cache first. // This is done to avoid scanning a potentially large trace collection in repository. if (!_currentCache.TryGetValue(key, out var span)) { span = TelemetryRepository.GetSpan(exemplar.TraceId, exemplar.SpanId); } if (span != null) { _newCache[key] = span; // ChartExemplar properties are init-only, so replace with a new instance. exemplars[i] = new ChartExemplar { Start = exemplar.Start, Value = exemplar.Value, TraceId = exemplar.TraceId, SpanId = exemplar.SpanId, Span = span }; } } } private DateTimeOffset GetCurrentDataTime(out bool isTimeFrozen) { if (DataEndTime is { } dataEndTime) { isTimeFrozen = true; return dataEndTime; } if (PauseManager.AreMetricsPaused(out var pausedAt)) { isTimeFrozen = true; return pausedAt.Value; } isTimeFrozen = false; // Compensate for delay in receiving metrics from services. return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); } 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(); InstrumentViewModel.RemoveDataUpdateSubscription(OnInstrumentDataUpdate); _cts.Dispose(); return ValueTask.CompletedTask; } }