// 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.Controls.Chart;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
namespace Aspire.Dashboard.Components;
/// <summary>
/// Loads a live sparkline and the latest value for each instrument of a list, and keeps them up to date.
/// Derived components only decide how to present them, e.g. the metrics page's cards or the resource overview's tiles.
/// </summary>
public abstract class LiveSparklinesBase : ComponentBase, IAsyncDisposable
{
// Sparklines show the trend shape, not detail, so fewer points than the full chart is enough.
private const int SparklinePointCount = 30;
internal const double SparklineWidth = 100;
internal const double SparklineHeight = 32;
// Sparklines refresh less often than the main chart: a meter can have dozens of instruments
// and each refresh queries the telemetry store once per instrument.
private static readonly TimeSpan s_refreshInterval = TimeSpan.FromSeconds(3);
private readonly CancellationTokenSource _disposeCts = new();
// Copied from the CTS so it stays readable after the CTS is disposed. A parameter update can
// race with disposal when the user switches resource, and CancellationTokenSource.Token throws
// ObjectDisposedException once disposed.
private readonly CancellationToken _disposeToken;
private Dictionary<(ResourceKey, OtlpInstrumentKey), SparklineData> _sparklines = new();
private Task? _refreshTask;
private (ResourceKey?, TimeSpan, string)? _loadedParameters;
// Refreshes run one at a time: the periodic refresh and a refresh of cards scrolled into view each replace the
// whole dictionary, and running them together would drop one of their updates.
// It isn't disposed: a refresh started by a parameter change can still hold it when the component is disposed, and
// a SemaphoreSlim that never created a wait handle has nothing to release.
private readonly SemaphoreSlim _refreshLock = new(1, 1);
// The instruments shown near the viewport, or null when the derived component doesn't report them, in which case
// every instrument is refreshed. A long list refreshes only these: a query per instrument every few seconds for
// hundreds of cards keeps the server busy for cards nobody can see.
private HashSet<(ResourceKey, OtlpInstrumentKey)>? _visibleInstruments;
// Instruments a periodic refresh skipped because they were off screen. They're refreshed as soon as they're shown.
private readonly HashSet<(ResourceKey, OtlpInstrumentKey)> _staleInstruments = new();
/// <summary>
/// Gets or sets the resource the instruments belong to. When null, each instrument is read from the resource it
/// came from, so one list can show the instruments of several resources.
/// </summary>
[Parameter]
public ResourceKey? ResourceKey { get; set; }
[Parameter, EditorRequired]
public required IReadOnlyList<OtlpInstrumentSummary> Instruments { get; set; }
[Parameter, EditorRequired]
public required TimeSpan Duration { get; set; }
[Inject]
public required DashboardDataSource DataSource { get; init; }
[Inject]
public required IInstrumentUnitResolver InstrumentUnitResolver { get; init; }
[Inject]
public required PauseManager PauseManager { get; init; }
[Inject]
public required ILogger<LiveSparklinesBase> Logger { get; init; }
[Inject]
public required BrowserTimeProvider TimeProvider { get; init; }
private ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
protected LiveSparklinesBase()
{
_disposeToken = _disposeCts.Token;
}
protected override async Task OnParametersSetAsync()
{
if (_disposeToken.IsCancellationRequested)
{
return;
}
// Compare the instruments themselves, not just their count: unpinning one instrument and pinning another
// keeps the pinned group's count but changes which sparklines need data.
var parameters = (ResourceKey, Duration, string.Join('\n', Instruments.Select(i => $"{GetResourceKey(i)}\t{i.Parent.Name}\t{i.Name}")));
if (_loadedParameters != parameters)
{
_loadedParameters = parameters;
try
{
// Every instrument is loaded once, even off screen: the list is ordered by the instruments' values.
await RefreshAsync(onlyVisible: false, _disposeToken);
}
catch (OperationCanceledException) when (_disposeToken.IsCancellationRequested)
{
return;
}
}
_refreshTask ??= RefreshPeriodicallyAsync(_disposeToken);
}
protected ResourceKey GetResourceKey(OtlpInstrumentSummary instrument) => ResourceKey ?? instrument.ResourceView.ResourceKey;
/// <summary>
/// Gets a key for the instrument that's unique in the list, for a derived component to identify its cards.
/// </summary>
protected string GetInstrumentId(OtlpInstrumentSummary instrument)
{
var resourceKey = GetResourceKey(instrument);
return $"{resourceKey.Name}\n{resourceKey.InstanceId}\n{instrument.Parent.Name}\n{instrument.Name}";
}
/// <summary>
/// Sets the instruments shown near the viewport, identified by <see cref="GetInstrumentId"/>. Later periodic
/// refreshes skip the others, and shown instruments that missed a refresh are refreshed now.
/// </summary>
protected async Task SetVisibleInstrumentsAsync(IEnumerable<string> instrumentIds)
{
var ids = instrumentIds.ToHashSet(StringComparer.Ordinal);
var visible = Instruments
.Where(i => ids.Contains(GetInstrumentId(i)))
.Select(i => (GetResourceKey(i), i.GetKey()))
.ToHashSet();
_visibleInstruments = visible;
var toRefresh = Instruments.Where(i => _staleInstruments.Contains((GetResourceKey(i), i.GetKey()))).Where(i => visible.Contains((GetResourceKey(i), i.GetKey()))).ToList();
if (toRefresh.Count == 0 || _disposeToken.IsCancellationRequested)
{
return;
}
try
{
await RefreshInstrumentsAsync(toRefresh, replaceAll: false, _disposeToken);
}
catch (OperationCanceledException) when (_disposeToken.IsCancellationRequested)
{
return;
}
await InvokeAsync(StateHasChanged);
}
protected SparklineData GetSparkline(OtlpInstrumentSummary instrument)
{
return _sparklines.TryGetValue((GetResourceKey(instrument), instrument.GetKey()), out var data) ? data : SparklineData.Empty;
}
protected string FormatUnit(OtlpInstrumentSummary instrument, double value)
{
return InstrumentUnitResolver.ResolveDisplayedUnit(instrument, titleCase: false, pluralize: value != 1);
}
private async Task RefreshPeriodicallyAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(s_refreshInterval, TimeProvider);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
// Read-only telemetry doesn't change, and paused metrics should stay frozen.
if (TelemetryRepository.IsReadOnly || PauseManager.AreMetricsPaused(out _))
{
continue;
}
await RefreshAsync(onlyVisible: true, cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
// Keep a background failure from surfacing as an unobserved error during disposal.
Logger.LogError(ex, "Unexpected error refreshing metric sparklines.");
}
}
private Task RefreshAsync(bool onlyVisible, CancellationToken cancellationToken)
{
if (!onlyVisible || _visibleInstruments is not { } visible)
{
return RefreshInstrumentsAsync(Instruments, replaceAll: true, cancellationToken);
}
var shown = new List<OtlpInstrumentSummary>();
foreach (var instrument in Instruments)
{
var key = (GetResourceKey(instrument), instrument.GetKey());
if (visible.Contains(key))
{
shown.Add(instrument);
}
else
{
_staleInstruments.Add(key);
}
}
return RefreshInstrumentsAsync(shown, replaceAll: false, cancellationToken);
}
/// <summary>
/// Loads the sparklines of the given instruments. <paramref name="replaceAll"/> drops the sparklines of every other
/// instrument, for when the given instruments are the whole list.
/// </summary>
private async Task RefreshInstrumentsAsync(IReadOnlyList<OtlpInstrumentSummary> instruments, bool replaceAll, CancellationToken cancellationToken)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
var refreshed = new List<((ResourceKey, OtlpInstrumentKey) Key, SparklineData Data)>(instruments.Count);
foreach (var instrument in instruments)
{
refreshed.Add(((GetResourceKey(instrument), instrument.GetKey()), await GetSparklineDataAsync(instrument, cancellationToken)));
}
// Swap the whole dictionary so a render never sees a partially refreshed set of sparklines.
var sparklines = replaceAll ? new Dictionary<(ResourceKey, OtlpInstrumentKey), SparklineData>() : new Dictionary<(ResourceKey, OtlpInstrumentKey), SparklineData>(_sparklines);
if (replaceAll)
{
_staleInstruments.Clear();
}
foreach (var (key, data) in refreshed)
{
sparklines[key] = data;
_staleInstruments.Remove(key);
}
_sparklines = sparklines;
}
finally
{
_refreshLock.Release();
}
}
private async Task<SparklineData> GetSparklineDataAsync(OtlpInstrumentSummary instrument, CancellationToken cancellationToken)
{
DateTime endTime;
if (TelemetryRepository.IsReadOnly)
{
// Imported telemetry is historical, so anchor each sparkline to that instrument's last data.
var latestEndTime = TelemetryRepository.GetInstrumentLatestEndTime(GetResourceKey(instrument), instrument.Parent.Name, instrument.Name);
if (latestEndTime is null)
{
return SparklineData.Empty;
}
endTime = latestEndTime.Value;
}
else
{
endTime = PauseManager.AreMetricsPaused(out var pausedAt)
? pausedAt.Value
// Compensate for delay in receiving metrics from services, matching the main chart.
: TimeProvider.GetUtcNow().UtcDateTime.Subtract(TimeSpan.FromSeconds(1));
}
var dataPointInterval = MetricDataPointInterval.Get(Duration);
// Histograms need one preceding rollup to calculate bucket count changes at the start of the range.
var historyDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, dataPointInterval.Ticks));
var data = await TelemetryRepository.GetInstrumentAsync(new GetInstrumentRequest
{
ResourceKey = GetResourceKey(instrument),
MeterName = instrument.Parent.Name,
InstrumentName = instrument.Name,
StartTime = endTime.Subtract(Duration + historyDuration),
EndTime = endTime,
DataPointInterval = dataPointInterval,
IncludeExemplars = false,
PopulateExemplarAttributes = false
}, cancellationToken).ConfigureAwait(false);
if (data is null)
{
return SparklineData.Empty;
}
var calculator = new ChartDataCalculator(SparklinePointCount, Duration);
var endTimeOffset = new DateTimeOffset(endTime, TimeSpan.Zero);
var unit = InstrumentUnitResolver.ResolveDisplayedUnit(instrument, titleCase: true, pluralize: true);
// Histograms show their median. It's the most representative single value for a
// distribution, and the full chart is one click away for P90/P99.
var chartData = instrument.Type == OtlpInstrumentType.Histogram
? calculator.CalculateHistogramValues(data.Dimensions, endTimeOffset, static t => t, unit)
: calculator.CalculateChartValues(data.Dimensions, endTimeOffset, static t => t, unit);
var trace = chartData.Traces.Single(t => t.Percentile is null or 50);
// The calculator pads points past the end time so the full chart's line reaches the right
// edge. Those buckets are still filling up (or empty), which shows as a misleading drop at
// the end of a sparkline, so only keep points that end by the end time.
var values = new List<double?>(trace.Values.Count);
for (var i = 0; i < trace.Values.Count; i++)
{
if (chartData.XValues[i] <= endTimeOffset)
{
values.Add(trace.Values[i]);
}
}
var latestValue = values.LastOrDefault(v => v is not null);
if (latestValue is null)
{
return SparklineData.Empty;
}
// The form follows what the reader wants from each instrument type:
// - Counters only ever grow, so a line of the running total says little. Bars of the
// increase per interval show the actual activity, e.g. requests per interval.
// - Histograms are often sparse (no requests means no data), and a line drawn between rare
// points implies values that were never measured. A bar per interval with data doesn't.
// - Gauges and up-down counters are levels, which is what a line shows best.
if (ChartDataCalculator.IsMonotonicCounter(instrument))
{
// The increases share the chart's intervals, so the same trailing points are dropped.
var increases = calculator.CalculateCounterIncreaseValues(data.Dimensions, endTimeOffset, instrument.AggregationTemporality)
.Where((_, i) => chartData.XValues[i] <= endTimeOffset)
.ToList();
return new SparklineData(latestValue, SparklineKind.Bars, SparklinePath.BuildBars(increases, SparklineWidth, SparklineHeight), string.Empty);
}
if (instrument.Type == OtlpInstrumentType.Histogram)
{
return new SparklineData(latestValue, SparklineKind.Bars, SparklinePath.BuildBars(values, SparklineWidth, SparklineHeight), string.Empty);
}
var (line, area) = SparklinePath.Build(values, SparklineWidth, SparklineHeight);
return new SparklineData(latestValue, SparklineKind.Line, line, area);
}
internal static string FormatCompactValue(double value, CultureInfo culture)
{
// Cards have limited room, so large values use a short suffix, e.g. 125300000 -> "125.3M".
// Small values keep their decimals because durations such as 0.004 seconds are meaningful.
var abs = Math.Abs(value);
var (scaled, suffix) = abs switch
{
>= 1e12 => (value / 1e12, "T"),
>= 1e9 => (value / 1e9, "B"),
>= 1e6 => (value / 1e6, "M"),
>= 1e4 => (value / 1e3, "K"),
_ => (value, string.Empty)
};
return suffix.Length > 0
? FormatHelpers.FormatNumberWithOptionalDecimalPlaces(scaled, maxDecimalPlaces: 1, culture) + suffix
: FormatHelpers.FormatNumberWithOptionalDecimalPlaces(scaled, maxDecimalPlaces: 3, culture);
}
/// <summary>
/// Releases the derived component's resources, after the refreshes stopped.
/// </summary>
protected virtual ValueTask DisposeCoreAsync() => ValueTask.CompletedTask;
public async ValueTask DisposeAsync()
{
await _disposeCts.CancelAsync();
if (_refreshTask is { } task)
{
await task;
}
_disposeCts.Dispose();
await DisposeCoreAsync();
}
}
public enum SparklineKind
{
Line,
Bars
}
/// <summary>
/// A sparkline's SVG paths and the instrument's latest value. For lines, <see cref="Path"/> is the stroke and
/// <see cref="AreaPath"/> the fill below it. For bars, <see cref="Path"/> holds all bars.
/// </summary>
public sealed record SparklineData(double? LatestValue, SparklineKind Kind, string Path, string AreaPath)
{
public static readonly SparklineData Empty = new(null, SparklineKind.Line, string.Empty, string.Empty);
}