Skip to content

Commit

Permalink
Add support for exemplars in metrics UI
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK committed Jul 11, 2024
1 parent 20e5fb0 commit dfcd72a
Show file tree
Hide file tree
Showing 65 changed files with 2,042 additions and 144 deletions.
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

0 comments on commit dfcd72a

Please sign in to comment.