Skip to content

Commit de35684

Browse files
authored
Add support for exemplars in metrics UI (#4629)
1 parent 43e1cf4 commit de35684

65 files changed

Lines changed: 2042 additions & 144 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs

Lines changed: 108 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Aspire.Dashboard.Model;
99
using Aspire.Dashboard.Otlp.Model;
1010
using Aspire.Dashboard.Otlp.Model.MetricValues;
11+
using Aspire.Dashboard.Otlp.Storage;
1112
using Aspire.Dashboard.Resources;
1213
using Aspire.Dashboard.Utils;
1314
using Microsoft.AspNetCore.Components;
@@ -30,18 +31,34 @@ public abstract class ChartBase : ComponentBase
3031
[Inject]
3132
public required IStringLocalizer<ControlsStrings> Loc { get; init; }
3233

34+
[Inject]
35+
public required IStringLocalizer<Resources.Dialogs> DialogsLoc { get; init; }
36+
3337
[Inject]
3438
public required IInstrumentUnitResolver InstrumentUnitResolver { get; init; }
3539

3640
[Inject]
3741
public required BrowserTimeProvider TimeProvider { get; init; }
3842

43+
[Inject]
44+
public required TelemetryRepository TelemetryRepository { get; init; }
45+
3946
[Parameter, EditorRequired]
4047
public required InstrumentViewModel InstrumentViewModel { get; set; }
4148

4249
[Parameter, EditorRequired]
4350
public required TimeSpan Duration { get; set; }
4451

52+
[Parameter]
53+
public required List<OtlpApplication> Applications { get; set; }
54+
55+
// Stores a cache of the last set of spans returned as exemplars.
56+
// This dictionary is replaced each time the chart is updated.
57+
private Dictionary<SpanKey, OtlpSpan> _currentCache = new Dictionary<SpanKey, OtlpSpan>();
58+
private Dictionary<SpanKey, OtlpSpan> _newCache = new Dictionary<SpanKey, OtlpSpan>();
59+
60+
private readonly record struct SpanKey(string TraceId, string SpanId);
61+
4562
protected override void OnInitialized()
4663
{
4764
_currentDataStartTime = GetCurrentDataTime();
@@ -93,7 +110,7 @@ private Task OnInstrumentDataUpdate()
93110
return InvokeAsync(StateHasChanged);
94111
}
95112

96-
private (List<ChartTrace> Y, List<DateTimeOffset> X) CalculateHistogramValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
113+
private (List<ChartTrace> Y, List<DateTimeOffset> X, List<ChartExemplar> Exemplars) CalculateHistogramValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
97114
{
98115
var pointDuration = Duration / pointCount;
99116
var traces = new Dictionary<int, ChartTrace>
@@ -103,8 +120,10 @@ private Task OnInstrumentDataUpdate()
103120
[99] = new() { Name = $"P99 {yLabel}", Percentile = 99 }
104121
};
105122
var xValues = new List<DateTimeOffset>();
123+
var exemplars = new List<ChartExemplar>();
106124
var startDate = _currentDataStartTime;
107125
DateTimeOffset? firstPointEndTime = null;
126+
DateTimeOffset? lastPointStartTime = null;
108127

109128
// Generate the points in reverse order so that the chart is drawn from right to left.
110129
// Add a couple of extra points to the end so that the chart is drawn all the way to the right edge.
@@ -113,10 +132,11 @@ private Task OnInstrumentDataUpdate()
113132
var start = CalcOffset(pointIndex, startDate, pointDuration);
114133
var end = CalcOffset(pointIndex - 1, startDate, pointDuration);
115134
firstPointEndTime ??= end;
135+
lastPointStartTime = start;
116136

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

119-
if (!TryCalculateHistogramPoints(dimensions, start, end, traces))
139+
if (!TryCalculateHistogramPoints(dimensions, start, end, traces, exemplars))
120140
{
121141
foreach (var trace in traces)
122142
{
@@ -131,7 +151,7 @@ private Task OnInstrumentDataUpdate()
131151
}
132152
xValues.Reverse();
133153

134-
if (tickUpdate && TryCalculateHistogramPoints(dimensions, firstPointEndTime!.Value, inProgressDataTime, traces))
154+
if (tickUpdate && TryCalculateHistogramPoints(dimensions, firstPointEndTime!.Value, inProgressDataTime, traces, exemplars))
135155
{
136156
xValues.Add(TimeProvider.ToLocalDateTimeOffset(inProgressDataTime));
137157
}
@@ -161,12 +181,15 @@ private Task OnInstrumentDataUpdate()
161181

162182
previousValues = currentTrace;
163183
}
164-
return (traces.Select(kvp => kvp.Value).ToList(), xValues);
184+
185+
exemplars = exemplars.Where(p => p.Start <= startDate && p.Start >= lastPointStartTime!.Value).OrderBy(p => p.Start).ToList();
186+
187+
return (traces.Select(kvp => kvp.Value).ToList(), xValues, exemplars);
165188
}
166189

167190
private string FormatTooltip(string name, double yValue, DateTimeOffset xValue)
168191
{
169-
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))}";
192+
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))}";
170193
}
171194

172195
private static HistogramValue GetHistogramValue(MetricValueBase metric)
@@ -179,7 +202,7 @@ private static HistogramValue GetHistogramValue(MetricValueBase metric)
179202
throw new InvalidOperationException("Unexpected metric type: " + metric.GetType());
180203
}
181204

182-
internal static bool TryCalculateHistogramPoints(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary<int, ChartTrace> traces)
205+
internal bool TryCalculateHistogramPoints(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary<int, ChartTrace> traces, List<ChartExemplar> exemplars)
183206
{
184207
var hasValue = false;
185208

@@ -199,6 +222,8 @@ internal static bool TryCalculateHistogramPoints(List<DimensionScope> dimensions
199222
{
200223
var histogramValue = GetHistogramValue(metric);
201224

225+
AddExemplars(exemplars, metric);
226+
202227
// Only use the first recorded entry if it is the beginning of data.
203228
// We can verify the first entry is the beginning of data by checking if the number of buckets equals the total count.
204229
if (i == 0 && CountBuckets(histogramValue) != histogramValue.Count)
@@ -247,6 +272,57 @@ internal static bool TryCalculateHistogramPoints(List<DimensionScope> dimensions
247272
return hasValue;
248273
}
249274

275+
private void AddExemplars(List<ChartExemplar> exemplars, MetricValueBase metric)
276+
{
277+
if (metric.HasExemplars)
278+
{
279+
foreach (var exemplar in metric.Exemplars)
280+
{
281+
// TODO: Exemplars are duplicated on metrics in some scenarios.
282+
// This is a quick fix to ensure a distinct collection of metrics are displayed in the UI.
283+
// Investigation is needed into why there are duplicates.
284+
var exists = false;
285+
foreach (var existingExemplar in exemplars)
286+
{
287+
if (exemplar.Start == existingExemplar.Start &&
288+
exemplar.Value == existingExemplar.Value &&
289+
exemplar.SpanId == existingExemplar.SpanId &&
290+
exemplar.TraceId == existingExemplar.TraceId)
291+
{
292+
exists = true;
293+
break;
294+
}
295+
}
296+
if (exists)
297+
{
298+
continue;
299+
}
300+
301+
// Try to find span the the local cache first.
302+
// This is done to avoid scanning a potentially large trace collection in repository.
303+
var key = new SpanKey(exemplar.TraceId, exemplar.SpanId);
304+
if (!_currentCache.TryGetValue(key, out var span))
305+
{
306+
span = GetSpan(exemplar.TraceId, exemplar.SpanId);
307+
}
308+
if (span != null)
309+
{
310+
_newCache[key] = span;
311+
}
312+
313+
var exemplarStart = TimeProvider.ToLocalDateTimeOffset(exemplar.Start);
314+
exemplars.Add(new ChartExemplar
315+
{
316+
Start = exemplarStart,
317+
Value = exemplar.Value,
318+
TraceId = exemplar.TraceId,
319+
SpanId = exemplar.SpanId,
320+
Span = span
321+
});
322+
}
323+
}
324+
}
325+
250326
private static ulong CountBuckets(HistogramValue histogramValue)
251327
{
252328
ulong value = 0ul;
@@ -287,11 +363,12 @@ private static ulong CountBuckets(HistogramValue histogramValue)
287363
return explicitBounds[explicitBounds.Length - 1];
288364
}
289365

290-
private (List<ChartTrace> Y, List<DateTimeOffset> X) CalculateChartValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
366+
private (List<ChartTrace> Y, List<DateTimeOffset> X, List<ChartExemplar> Exemplars) CalculateChartValues(List<DimensionScope> dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel)
291367
{
292368
var pointDuration = Duration / pointCount;
293369
var yValues = new List<double?>();
294370
var xValues = new List<DateTimeOffset>();
371+
var exemplars = new List<ChartExemplar>();
295372
var startDate = _currentDataStartTime;
296373
DateTimeOffset? firstPointEndTime = null;
297374

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

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

308-
if (TryCalculatePoint(dimensions, start, end, out var tickPointValue))
385+
if (TryCalculatePoint(dimensions, start, end, exemplars, out var tickPointValue))
309386
{
310387
yValues.Add(tickPointValue);
311388
}
@@ -318,7 +395,7 @@ private static ulong CountBuckets(HistogramValue histogramValue)
318395
yValues.Reverse();
319396
xValues.Reverse();
320397

321-
if (tickUpdate && TryCalculatePoint(dimensions, firstPointEndTime!.Value, inProgressDataTime, out var inProgressPointValue))
398+
if (tickUpdate && TryCalculatePoint(dimensions, firstPointEndTime!.Value, inProgressDataTime, exemplars, out var inProgressPointValue))
322399
{
323400
yValues.Add(inProgressPointValue);
324401
xValues.Add(TimeProvider.ToLocalDateTimeOffset(inProgressDataTime));
@@ -343,10 +420,10 @@ private static ulong CountBuckets(HistogramValue histogramValue)
343420
}
344421
}
345422

346-
return ([trace], xValues);
423+
return ([trace], xValues, exemplars);
347424
}
348425

349-
private static bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, out double pointValue)
426+
private bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeOffset start, DateTimeOffset end, List<ChartExemplar> exemplars, out double pointValue)
350427
{
351428
var hasValue = false;
352429
pointValue = 0d;
@@ -371,6 +448,8 @@ private static bool TryCalculatePoint(List<DimensionScope> dimensions, DateTimeO
371448
dimensionValue = Math.Max(value, dimensionValue);
372449
hasValue = true;
373450
}
451+
452+
AddExemplars(exemplars, metric);
374453
}
375454

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

407486
List<ChartTrace> traces;
408487
List<DateTimeOffset> xValues;
488+
List<ChartExemplar> exemplars;
409489
if (InstrumentViewModel.Instrument?.Type != OtlpInstrumentType.Histogram || InstrumentViewModel.ShowCount)
410490
{
411-
(traces, xValues) = CalculateChartValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
491+
(traces, xValues, exemplars) = CalculateChartValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
492+
493+
// TODO: Exemplars on non-histogram charts doesn't work well. Don't display for now.
494+
exemplars.Clear();
412495
}
413496
else
414497
{
415-
(traces, xValues) = CalculateHistogramValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
498+
(traces, xValues, exemplars) = CalculateHistogramValues(InstrumentViewModel.MatchedDimensions, GraphPointCount, tickUpdate, inProgressDataTime, unit);
416499
}
417500

418-
await OnChartUpdated(traces, xValues, tickUpdate, inProgressDataTime);
501+
// Replace cache for next update.
502+
_currentCache = _newCache;
503+
_newCache = new Dictionary<SpanKey, OtlpSpan>();
504+
505+
await OnChartUpdated(traces, xValues, exemplars, tickUpdate, inProgressDataTime);
506+
}
507+
508+
protected OtlpSpan? GetSpan(string traceId, string spanId)
509+
{
510+
return MetricsHelpers.GetSpan(TelemetryRepository, traceId, spanId);
419511
}
420512

421513
private DateTimeOffset GetCurrentDataTime()
@@ -425,8 +517,8 @@ private DateTimeOffset GetCurrentDataTime()
425517

426518
private string GetDisplayedUnit(OtlpInstrument instrument)
427519
{
428-
return InstrumentUnitResolver.ResolveDisplayedUnit(instrument);
520+
return InstrumentUnitResolver.ResolveDisplayedUnit(instrument, titleCase: true, pluralize: true);
429521
}
430522

431-
protected abstract Task OnChartUpdated(List<ChartTrace> traces, List<DateTimeOffset> xValues, bool tickUpdate, DateTimeOffset inProgressDataTime);
523+
protected abstract Task OnChartUpdated(List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime);
432524
}

src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ else
2525
Label="@Loc[nameof(ControlsStrings.ChartContainerGraphTab)]"
2626
Icon="@(new Icons.Regular.Size24.DataArea())">
2727
<div class="metrics-chart-container metric-tab">
28-
<PlotlyChart InstrumentViewModel="_instrumentViewModel" Duration="Duration"/>
28+
<PlotlyChart InstrumentViewModel="_instrumentViewModel" Duration="Duration" Applications="Applications"/>
2929
<ChartFilters InstrumentViewModel="_instrumentViewModel" Instrument="_instrument" ViewModel="_viewModel"/>
3030
</div>
3131
</FluentTab>
@@ -34,7 +34,7 @@ else
3434
Label="@Loc[nameof(ControlsStrings.ChartContainerTableTab)]"
3535
Icon="@(new Icons.Regular.Size24.Table())">
3636
<div class="metric-tab">
37-
<MetricTable InstrumentViewModel="_instrumentViewModel" Duration="Duration"/>
37+
<MetricTable InstrumentViewModel="_instrumentViewModel" Duration="Duration" Applications="Applications" />
3838
<ChartFilters InstrumentViewModel="_instrumentViewModel" Instrument="_instrument" ViewModel="_viewModel"/>
3939
</div>
4040
</FluentTab>
@@ -48,4 +48,7 @@ else
4848

4949
[Parameter, EditorRequired]
5050
public required Func<Metrics.MetricViewKind, Task> OnViewChangedAsync { get; set; }
51+
52+
[Parameter]
53+
public required List<OtlpApplication> Applications { get; set; }
5154
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using Aspire.Dashboard.Otlp.Model;
6+
7+
namespace Aspire.Dashboard.Components.Controls.Chart;
8+
9+
[DebuggerDisplay("Start = {Start}, Value = {Value}, TraceId = {TraceId}, SpanId = {SpanId}")]
10+
public class ChartExemplar
11+
{
12+
public required DateTimeOffset Start { get; init; }
13+
public required double Value { get; init; }
14+
public required string TraceId { get; init; }
15+
public required string SpanId { get; init; }
16+
public required OtlpSpan? Span { get; init; }
17+
}

src/Aspire.Dashboard/Components/Controls/Chart/ChartTrace.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
namespace Aspire.Dashboard.Components.Controls.Chart;

src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@
1111
// these colors line up with P50/P90/P99 colors for the plotly graph
1212
var percentileColumns = new List<(int Percentile, string UnderlineColor)> { (50, "#89B5D3"), (90, "#F9B980"), (99, "#8FC98F") };
1313
var columnCount = ShowPercentiles() ? percentileColumns.Count + 1 : 2;
14+
if (_exemplars.Count > 0)
15+
{
16+
columnCount++;
17+
}
1418
}
1519

1620
<div id="metric-table-container" style="height: 40vh; overflow-y: auto; margin-bottom: 20px; max-width:1200px;">
1721
@* ItemKey is to preserve row focus by associating rows with their associated time *@
1822
<FluentDataGrid
1923
Items="@_metricsView"
20-
ItemSize="35"
24+
ItemSize="46"
2125
Virtualize="true"
2226
GridTemplateColumns="@string.Join(" ", Enumerable.Repeat("1fr", columnCount))"
2327
ItemKey="@(item => item.DateTime)">
@@ -30,7 +34,7 @@
3034
{
3135
foreach (var (percentile, underlineColor) in percentileColumns)
3236
{
33-
<TemplateColumn Title="@((_metricsView.FirstOrDefault() as HistogramMetricView)?.Percentiles[percentile].Name ?? (_instrument is not null ? $"P{percentile} {InstrumentUnitResolver.ResolveDisplayedUnit(_instrument)}" : $"P{percentile}"))">
37+
<TemplateColumn Title="@((_metricsView.FirstOrDefault() as HistogramMetricView)?.Percentiles[percentile].Name ?? (_instrument is not null ? $"P{percentile} {InstrumentUnitResolver.ResolveDisplayedUnit(_instrument, titleCase: true, pluralize: true)}" : $"P{percentile}"))">
3438
@if (context is HistogramMetricView histogramMetric)
3539
{
3640
var percentileData = histogramMetric.Percentiles[percentile];
@@ -73,6 +77,23 @@
7377
}
7478
</TemplateColumn>
7579
}
80+
@if (_exemplars.Count > 0)
81+
{
82+
<TemplateColumn Title="@Loc[nameof(ControlsStrings.MetricTableExemplarsColumnHeader)]">
83+
@if (context.Exemplars.Count > 0)
84+
{
85+
@* min-width ensures a consistent button width up to 999 metrics *@
86+
<FluentButton Appearance="Appearance.Accent"
87+
aria-label="@Loc[nameof(ControlsStrings.MetricTableViewExemplarsLabel)]"
88+
@onclick="() => OpenExemplarsDialogAsync(context)"
89+
Style="min-width: 45px">@context.Exemplars.Count</FluentButton>
90+
}
91+
else
92+
{
93+
<span>0</span>
94+
}
95+
</TemplateColumn>
96+
}
7697
</ChildContent>
7798
<EmptyContent>
7899
<FluentIcon Icon="Icons.Regular.Size24.ChartMultiple" />&nbsp;@Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)]

0 commit comments

Comments
 (0)