Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for exemplars in metrics UI #4629

Merged
merged 1 commit into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 108 additions & 16 deletions src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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;
Expand All @@ -30,18 +31,34 @@ public abstract class ChartBase : ComponentBase
[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()
{
_currentDataStartTime = GetCurrentDataTime();
Expand Down Expand Up @@ -93,7 +110,7 @@ private Task OnInstrumentDataUpdate()
return InvokeAsync(StateHasChanged);
}

private (List<ChartTrace> Y, List<DateTimeOffset> X) CalculateHistogramValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
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>
Expand All @@ -103,8 +120,10 @@ private Task OnInstrumentDataUpdate()
[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.
Expand All @@ -113,10 +132,11 @@ private Task OnInstrumentDataUpdate()
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))
if (!TryCalculateHistogramPoints(dimensions, start, end, traces, exemplars))
{
foreach (var trace in traces)
{
Expand All @@ -131,7 +151,7 @@ private Task OnInstrumentDataUpdate()
}
xValues.Reverse();

if (tickUpdate && TryCalculateHistogramPoints(dimensions, firstPointEndTime!.Value, inProgressDataTime, traces))
if (tickUpdate && TryCalculateHistogramPoints(dimensions, firstPointEndTime!.Value, inProgressDataTime, traces, exemplars))
{
xValues.Add(TimeProvider.ToLocalDateTimeOffset(inProgressDataTime));
}
Expand Down Expand Up @@ -161,12 +181,15 @@ private Task OnInstrumentDataUpdate()

previousValues = currentTrace;
}
return (traces.Select(kvp => kvp.Value).ToList(), xValues);

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, CultureInfo.CurrentCulture)}<br />Time: {FormatHelpers.FormatTime(TimeProvider, TimeProvider.ToLocal(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)
Expand All @@ -179,7 +202,7 @@ private static HistogramValue GetHistogramValue(MetricValueBase metric)
throw new InvalidOperationException("Unexpected metric type: " + metric.GetType());
}

internal static bool TryCalculateHistogramPoints(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary<int, ChartTrace> traces)
internal bool TryCalculateHistogramPoints(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary<int, ChartTrace> traces, List<ChartExemplar> exemplars)
{
var hasValue = false;

Expand All @@ -199,6 +222,8 @@ internal static bool TryCalculateHistogramPoints(List<DimensionScope> dimensions
{
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)
Expand Down Expand Up @@ -247,6 +272,57 @@ internal static bool TryCalculateHistogramPoints(List<DimensionScope> dimensions
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 = 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;
Expand Down Expand Up @@ -287,11 +363,12 @@ private static ulong CountBuckets(HistogramValue histogramValue)
return explicitBounds[explicitBounds.Length - 1];
}

private (List<ChartTrace> Y, List<DateTimeOffset> X) CalculateChartValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
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;

Expand All @@ -305,7 +382,7 @@ private static ulong CountBuckets(HistogramValue histogramValue)

xValues.Add(TimeProvider.ToLocalDateTimeOffset(end));

if (TryCalculatePoint(dimensions, start, end, out var tickPointValue))
if (TryCalculatePoint(dimensions, start, end, exemplars, out var tickPointValue))
{
yValues.Add(tickPointValue);
}
Expand All @@ -318,7 +395,7 @@ private static ulong CountBuckets(HistogramValue histogramValue)
yValues.Reverse();
xValues.Reverse();

if (tickUpdate && TryCalculatePoint(dimensions, firstPointEndTime!.Value, inProgressDataTime, out var inProgressPointValue))
if (tickUpdate && TryCalculatePoint(dimensions, firstPointEndTime!.Value, inProgressDataTime, exemplars, out var inProgressPointValue))
{
yValues.Add(inProgressPointValue);
xValues.Add(TimeProvider.ToLocalDateTimeOffset(inProgressDataTime));
Expand All @@ -343,10 +420,10 @@ private static ulong CountBuckets(HistogramValue histogramValue)
}
}

return ([trace], xValues);
return ([trace], xValues, exemplars);
}

private static bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, out double pointValue)
private bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, List<ChartExemplar> exemplars, out double pointValue)
{
var hasValue = false;
pointValue = 0d;
Expand All @@ -371,6 +448,8 @@ private static bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeO
dimensionValue = Math.Max(value, dimensionValue);
hasValue = true;
}

AddExemplars(exemplars, metric);
}

pointValue += dimensionValue;
Expand Down Expand Up @@ -406,16 +485,29 @@ private async Task UpdateChart(bool tickUpdate, DateTimeOffset inProgressDataTim

List<ChartTrace> traces;
List<DateTimeOffset> xValues;
List<ChartExemplar> exemplars;
if (InstrumentViewModel.Instrument?.Type != OtlpInstrumentType.Histogram || InstrumentViewModel.ShowCount)
{
(traces, xValues) = CalculateChartValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
(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) = CalculateHistogramValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
(traces, xValues, exemplars) = CalculateHistogramValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
}

await OnChartUpdated(traces, xValues, tickUpdate, inProgressDataTime);
// Replace cache for next update.
_currentCache = _newCache;
_newCache = new Dictionary<SpanKey, OtlpSpan>();

await OnChartUpdated(traces, xValues, exemplars, tickUpdate, inProgressDataTime);
}

protected OtlpSpan? GetSpan(string traceId, string spanId)
{
return MetricsHelpers.GetSpan(TelemetryRepository, traceId, spanId);
}

private DateTimeOffset GetCurrentDataTime()
Expand All @@ -425,8 +517,8 @@ private DateTimeOffset GetCurrentDataTime()

private string GetDisplayedUnit(OtlpInstrument instrument)
{
return InstrumentUnitResolver.ResolveDisplayedUnit(instrument);
return InstrumentUnitResolver.ResolveDisplayedUnit(instrument, titleCase: true, pluralize: true);
}

protected abstract Task OnChartUpdated(List<ChartTrace> traces, List<DateTimeOffset> xValues, bool tickUpdate, DateTimeOffset inProgressDataTime);
protected abstract Task OnChartUpdated(List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ else
Label="@Loc[nameof(ControlsStrings.ChartContainerGraphTab)]"
Icon="@(new Icons.Regular.Size24.DataArea())">
<div class="metrics-chart-container metric-tab">
<PlotlyChart InstrumentViewModel="_instrumentViewModel" Duration="Duration"/>
<PlotlyChart InstrumentViewModel="_instrumentViewModel" Duration="Duration" Applications="Applications"/>
<ChartFilters InstrumentViewModel="_instrumentViewModel" Instrument="_instrument" ViewModel="_viewModel"/>
</div>
</FluentTab>
Expand All @@ -34,7 +34,7 @@ else
Label="@Loc[nameof(ControlsStrings.ChartContainerTableTab)]"
Icon="@(new Icons.Regular.Size24.Table())">
<div class="metric-tab">
<MetricTable InstrumentViewModel="_instrumentViewModel" Duration="Duration"/>
<MetricTable InstrumentViewModel="_instrumentViewModel" Duration="Duration" Applications="Applications" />
<ChartFilters InstrumentViewModel="_instrumentViewModel" Instrument="_instrument" ViewModel="_viewModel"/>
</div>
</FluentTab>
Expand All @@ -48,4 +48,7 @@ else

[Parameter, EditorRequired]
public required Func<Metrics.MetricViewKind, Task> OnViewChangedAsync { get; set; }

[Parameter]
public required List<OtlpApplication> Applications { get; set; }
}
17 changes: 17 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/Chart/ChartExemplar.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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 Aspire.Dashboard.Otlp.Model;

namespace Aspire.Dashboard.Components.Controls.Chart;

[DebuggerDisplay("Start = {Start}, Value = {Value}, TraceId = {TraceId}, SpanId = {SpanId}")]
public class ChartExemplar
{
public required DateTimeOffset Start { get; init; }
public required double Value { get; init; }
public required string TraceId { get; init; }
public required string SpanId { get; init; }
public required OtlpSpan? Span { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Dashboard.Components.Controls.Chart;
Expand Down
25 changes: 23 additions & 2 deletions src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
// these colors line up with P50/P90/P99 colors for the plotly graph
var percentileColumns = new List<(int Percentile, string UnderlineColor)> { (50, "#89B5D3"), (90, "#F9B980"), (99, "#8FC98F") };
var columnCount = ShowPercentiles() ? percentileColumns.Count + 1 : 2;
if (_exemplars.Count > 0)
{
columnCount++;
}
}

<div id="metric-table-container" style="height: 40vh; overflow-y: auto; margin-bottom: 20px; max-width:1200px;">
@* ItemKey is to preserve row focus by associating rows with their associated time *@
<FluentDataGrid
Items="@_metricsView"
ItemSize="35"
ItemSize="46"
Virtualize="true"
GridTemplateColumns="@string.Join(" ", Enumerable.Repeat("1fr", columnCount))"
ItemKey="@(item => item.DateTime)">
Expand All @@ -30,7 +34,7 @@
{
foreach (var (percentile, underlineColor) in percentileColumns)
{
<TemplateColumn Title="@((_metricsView.FirstOrDefault() as HistogramMetricView)?.Percentiles[percentile].Name ?? (_instrument is not null ? $"P{percentile} {InstrumentUnitResolver.ResolveDisplayedUnit(_instrument)}" : $"P{percentile}"))">
<TemplateColumn Title="@((_metricsView.FirstOrDefault() as HistogramMetricView)?.Percentiles[percentile].Name ?? (_instrument is not null ? $"P{percentile} {InstrumentUnitResolver.ResolveDisplayedUnit(_instrument, titleCase: true, pluralize: true)}" : $"P{percentile}"))">
@if (context is HistogramMetricView histogramMetric)
{
var percentileData = histogramMetric.Percentiles[percentile];
Expand Down Expand Up @@ -73,6 +77,23 @@
}
</TemplateColumn>
}
@if (_exemplars.Count > 0)
{
<TemplateColumn Title="@Loc[nameof(ControlsStrings.MetricTableExemplarsColumnHeader)]">
@if (context.Exemplars.Count > 0)
{
@* min-width ensures a consistent button width up to 999 metrics *@
<FluentButton Appearance="Appearance.Accent"
aria-label="@Loc[nameof(ControlsStrings.MetricTableViewExemplarsLabel)]"
@onclick="() => OpenExemplarsDialogAsync(context)"
Style="min-width: 45px">@context.Exemplars.Count</FluentButton>
}
else
{
<span>0</span>
}
</TemplateColumn>
}
</ChildContent>
<EmptyContent>
<FluentIcon Icon="Icons.Regular.Size24.ChartMultiple" />&nbsp;@Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)]
Expand Down
Loading