From dfcd72ac28d854cc3c8f8f33a6198d811f5da6aa Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 24 Jun 2024 13:58:53 +0800 Subject: [PATCH] Add support for exemplars in metrics UI --- .../Components/Controls/Chart/ChartBase.cs | 124 +++++++++++-- .../Controls/Chart/ChartContainer.razor | 7 +- .../Controls/Chart/ChartExemplar.cs | 17 ++ .../Components/Controls/Chart/ChartTrace.cs | 2 +- .../Controls/Chart/MetricTable.razor | 25 ++- .../Controls/Chart/MetricTable.razor.cs | 64 +++++-- .../Controls/Chart/PlotlyChart.razor.cs | 169 +++++++++++++++++- .../Components/Dialogs/ExemplarsDialog.razor | 39 ++++ .../Dialogs/ExemplarsDialog.razor.cs | 84 +++++++++ .../Components/Pages/Metrics.razor | 6 +- .../Components/Pages/Metrics.razor.cs | 10 +- .../Components/Pages/TraceDetail.razor | 6 +- .../Components/Pages/TraceDetail.razor.cs | 24 ++- .../Model/DefaultInstrumentUnitResolver.cs | 30 +++- .../Model/ExemplarsDialogViewModel.cs | 14 ++ .../Model/IInstrumentUnitResolver.cs | 2 +- src/Aspire.Dashboard/Model/MetricsHelpers.cs | 92 ++++++++++ .../Model/Otlp/SpanWaterfallViewModel.cs | 47 ++--- src/Aspire.Dashboard/Model/PlotlyTrace.cs | 4 +- .../Otlp/Model/MetricValues/DimensionScope.cs | 57 +++++- .../Otlp/Model/MetricValues/HistogramValue.cs | 7 +- .../Otlp/Model/MetricValues/MetricValue.cs | 9 +- .../Model/MetricValues/MetricValueBase.cs | 16 +- .../Otlp/Model/OtlpInstrument.cs | 6 +- .../Resources/ControlsStrings.Designer.cs | 45 +++++ .../Resources/ControlsStrings.resx | 15 ++ .../Resources/Dialogs.Designer.cs | 82 +++++++++ src/Aspire.Dashboard/Resources/Dialogs.resx | 84 ++++++--- .../Resources/xlf/ControlsStrings.cs.xlf | 25 +++ .../Resources/xlf/ControlsStrings.de.xlf | 25 +++ .../Resources/xlf/ControlsStrings.es.xlf | 25 +++ .../Resources/xlf/ControlsStrings.fr.xlf | 25 +++ .../Resources/xlf/ControlsStrings.it.xlf | 25 +++ .../Resources/xlf/ControlsStrings.ja.xlf | 25 +++ .../Resources/xlf/ControlsStrings.ko.xlf | 25 +++ .../Resources/xlf/ControlsStrings.pl.xlf | 25 +++ .../Resources/xlf/ControlsStrings.pt-BR.xlf | 25 +++ .../Resources/xlf/ControlsStrings.ru.xlf | 25 +++ .../Resources/xlf/ControlsStrings.tr.xlf | 25 +++ .../Resources/xlf/ControlsStrings.zh-Hans.xlf | 25 +++ .../Resources/xlf/ControlsStrings.zh-Hant.xlf | 25 +++ .../Resources/xlf/Dialogs.cs.xlf | 45 +++++ .../Resources/xlf/Dialogs.de.xlf | 45 +++++ .../Resources/xlf/Dialogs.es.xlf | 45 +++++ .../Resources/xlf/Dialogs.fr.xlf | 45 +++++ .../Resources/xlf/Dialogs.it.xlf | 45 +++++ .../Resources/xlf/Dialogs.ja.xlf | 45 +++++ .../Resources/xlf/Dialogs.ko.xlf | 45 +++++ .../Resources/xlf/Dialogs.pl.xlf | 45 +++++ .../Resources/xlf/Dialogs.pt-BR.xlf | 45 +++++ .../Resources/xlf/Dialogs.ru.xlf | 45 +++++ .../Resources/xlf/Dialogs.tr.xlf | 45 +++++ .../Resources/xlf/Dialogs.zh-Hans.xlf | 45 +++++ .../Resources/xlf/Dialogs.zh-Hant.xlf | 45 +++++ src/Aspire.Dashboard/Utils/DashboardUrls.cs | 10 +- src/Aspire.Dashboard/Utils/FormatHelpers.cs | 14 +- src/Aspire.Dashboard/wwwroot/js/app.js | 89 ++++++++- .../OtlpConfigurationExtensions.cs | 2 + .../Aspire.Dashboard.Components.Tests.csproj | 4 + .../Controls/PlotlyChartTests.cs | 10 +- .../Aspire.Dashboard.Tests.csproj | 4 + .../FormatHelpersTests.cs | 4 +- .../TelemetryRepositoryTests/MetricsTests.cs | 34 +++- .../TelemetryRepositoryTests/TestHelpers.cs | 13 +- .../ProjectResourceTests.cs | 5 + 65 files changed, 2042 insertions(+), 144 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/Chart/ChartExemplar.cs create mode 100644 src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs create mode 100644 src/Aspire.Dashboard/Model/ExemplarsDialogViewModel.cs create mode 100644 src/Aspire.Dashboard/Model/MetricsHelpers.cs diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 0c8e5d9213..cedac63aec 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -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; @@ -30,18 +31,34 @@ public abstract class ChartBase : ComponentBase [Inject] public required IStringLocalizer Loc { get; init; } + [Inject] + public required IStringLocalizer 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 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 _currentCache = new Dictionary(); + private Dictionary _newCache = new Dictionary(); + + private readonly record struct SpanKey(string TraceId, string SpanId); + protected override void OnInitialized() { _currentDataStartTime = GetCurrentDataTime(); @@ -93,7 +110,7 @@ private Task OnInstrumentDataUpdate() return InvokeAsync(StateHasChanged); } - private (List Y, List X) CalculateHistogramValues(List dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel) + private (List Y, List X, List Exemplars) CalculateHistogramValues(List dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel) { var pointDuration = Duration / pointCount; var traces = new Dictionary @@ -103,8 +120,10 @@ private Task OnInstrumentDataUpdate() [99] = new() { Name = $"P99 {yLabel}", Percentile = 99 } }; var xValues = new List(); + var exemplars = new List(); 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. @@ -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) { @@ -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)); } @@ -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 $"{HttpUtility.HtmlEncode(InstrumentViewModel.Instrument?.Name)}
{HttpUtility.HtmlEncode(name)}: {FormatHelpers.FormatNumberWithOptionalDecimalPlaces(yValue, CultureInfo.CurrentCulture)}
Time: {FormatHelpers.FormatTime(TimeProvider, TimeProvider.ToLocal(xValue))}"; + return $"{HttpUtility.HtmlEncode(InstrumentViewModel.Instrument?.Name)}
{HttpUtility.HtmlEncode(name)}: {FormatHelpers.FormatNumberWithOptionalDecimalPlaces(yValue, maxDecimalPlaces: 6, CultureInfo.CurrentCulture)}
Time: {FormatHelpers.FormatTime(TimeProvider, TimeProvider.ToLocal(xValue))}"; } private static HistogramValue GetHistogramValue(MetricValueBase metric) @@ -179,7 +202,7 @@ private static HistogramValue GetHistogramValue(MetricValueBase metric) throw new InvalidOperationException("Unexpected metric type: " + metric.GetType()); } - internal static bool TryCalculateHistogramPoints(List dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary traces) + internal bool TryCalculateHistogramPoints(List dimensions, DateTimeOffset start, DateTimeOffset end, Dictionary traces, List exemplars) { var hasValue = false; @@ -199,6 +222,8 @@ internal static bool TryCalculateHistogramPoints(List 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) @@ -247,6 +272,57 @@ internal static bool TryCalculateHistogramPoints(List dimensions return hasValue; } + private void AddExemplars(List 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; @@ -287,11 +363,12 @@ private static ulong CountBuckets(HistogramValue histogramValue) return explicitBounds[explicitBounds.Length - 1]; } - private (List Y, List X) CalculateChartValues(List dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel) + private (List Y, List X, List Exemplars) CalculateChartValues(List dimensions, int pointCount, bool tickUpdate, DateTimeOffset inProgressDataTime, string yLabel) { var pointDuration = Duration / pointCount; var yValues = new List(); var xValues = new List(); + var exemplars = new List(); var startDate = _currentDataStartTime; DateTimeOffset? firstPointEndTime = null; @@ -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); } @@ -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)); @@ -343,10 +420,10 @@ private static ulong CountBuckets(HistogramValue histogramValue) } } - return ([trace], xValues); + return ([trace], xValues, exemplars); } - private static bool TryCalculatePoint(List dimensions, DateTimeOffset start, DateTimeOffset end, out double pointValue) + private bool TryCalculatePoint(List dimensions, DateTimeOffset start, DateTimeOffset end, List exemplars, out double pointValue) { var hasValue = false; pointValue = 0d; @@ -371,6 +448,8 @@ private static bool TryCalculatePoint(List dimensions, DateTimeO dimensionValue = Math.Max(value, dimensionValue); hasValue = true; } + + AddExemplars(exemplars, metric); } pointValue += dimensionValue; @@ -406,16 +485,29 @@ private async Task UpdateChart(bool tickUpdate, DateTimeOffset inProgressDataTim List traces; List xValues; + List 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(); + + await OnChartUpdated(traces, xValues, exemplars, tickUpdate, inProgressDataTime); + } + + protected OtlpSpan? GetSpan(string traceId, string spanId) + { + return MetricsHelpers.GetSpan(TelemetryRepository, traceId, spanId); } private DateTimeOffset GetCurrentDataTime() @@ -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 traces, List xValues, bool tickUpdate, DateTimeOffset inProgressDataTime); + protected abstract Task OnChartUpdated(List traces, List xValues, List exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime); } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index 9e1e83559e..9665a1226c 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -25,7 +25,7 @@ else Label="@Loc[nameof(ControlsStrings.ChartContainerGraphTab)]" Icon="@(new Icons.Regular.Size24.DataArea())">
- +
@@ -34,7 +34,7 @@ else Label="@Loc[nameof(ControlsStrings.ChartContainerTableTab)]" Icon="@(new Icons.Regular.Size24.Table())">
- +
@@ -48,4 +48,7 @@ else [Parameter, EditorRequired] public required Func OnViewChangedAsync { get; set; } + + [Parameter] + public required List Applications { get; set; } } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartExemplar.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartExemplar.cs new file mode 100644 index 0000000000..6398e00940 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartExemplar.cs @@ -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; } +} diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartTrace.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartTrace.cs index 23992a672c..9bf2c33482 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartTrace.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartTrace.cs @@ -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; diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor index 3d7c2eecbd..4469a69858 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor @@ -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++; + } }
@* ItemKey is to preserve row focus by associating rows with their associated time *@ @@ -30,7 +34,7 @@ { foreach (var (percentile, underlineColor) in percentileColumns) { - + @if (context is HistogramMetricView histogramMetric) { var percentileData = histogramMetric.Percentiles[percentile]; @@ -73,6 +77,23 @@ } } + @if (_exemplars.Count > 0) + { + + @if (context.Exemplars.Count > 0) + { + @* min-width ensures a consistent button width up to 999 metrics *@ + @context.Exemplars.Count + } + else + { + 0 + } + + }  @Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)] diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs index ff96a01a33..69d69db1c3 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor.cs @@ -1,8 +1,11 @@ // 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 Aspire.Dashboard.Components.Controls.Chart; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; @@ -15,6 +18,7 @@ namespace Aspire.Dashboard.Components.Controls; public partial class MetricTable : ChartBase { private SortedList _metrics = []; + private List _exemplars = []; private string _unitColumnHeader = string.Empty; private IJSObjectReference? _jsModule; @@ -30,7 +34,10 @@ public partial class MetricTable : ChartBase [Inject] public required IJSRuntime JS { get; init; } - protected override async Task OnChartUpdated(List traces, List xValues, bool tickUpdate, DateTimeOffset inProgressDataTime) + [Inject] + public required IDialogService DialogService { get; init; } + + protected override async Task OnChartUpdated(List traces, List xValues, List exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime) { // Only update the data grid once per second to avoid additional DOM re-renders. if (inProgressDataTime - _lastUpdate < TimeSpan.FromSeconds(1)) @@ -50,7 +57,8 @@ protected override async Task OnChartUpdated(List traces, List traces, List UpdateMetrics(out ISet addedXValues, List traces, List xValues) + private async Task OpenExemplarsDialogAsync(MetricViewBase metric) + { + var vm = new ExemplarsDialogViewModel + { + Exemplars = metric.Exemplars, + Applications = Applications, + Instrument = InstrumentViewModel.Instrument! + }; + var parameters = new DialogParameters + { + Title = DialogsLoc[nameof(Resources.Dialogs.ExemplarsDialogTitle)], + PrimaryAction = DialogsLoc[nameof(Resources.Dialogs.ExemplarsDialogCloseButtonText)], + SecondaryAction = string.Empty, + Width = "800px", + Height = "auto" + }; + await DialogService.ShowDialogAsync(vm, parameters); + } + + private SortedList UpdateMetrics(out ISet addedXValues, List traces, List xValues, List exemplars) { var newMetrics = new SortedList(); @@ -88,8 +115,7 @@ private SortedList UpdateMetrics(out ISet? previousMetric = newMetrics.LastOrDefault(dt => dt.Key < xValue); + var previousMetric = newMetrics.LastOrDefault(dt => dt.Key < xValue).Value; if (IsHistogramInstrument() && !_showCount) { @@ -99,7 +125,7 @@ private SortedList UpdateMetrics(out ISet newKey > latestCurrentMetric).ToHashSet(); + // Associate exemplars with rows. Need to happen after rows are calculated because they could be skipped (e.g. unchanged data) + for (var i = newMetrics.Count - 1; i >= 0; i--) + { + var current = newMetrics.GetValueAtIndex(i); + var endTime = (i != newMetrics.Count - 1) ? current.DateTime : (DateTimeOffset?)null; + var startTime = (i > 0) ? newMetrics.GetKeyAtIndex(i - 1) : (DateTimeOffset?)null; + + var currentExemplars = exemplars.Where(e => (e.Start >= startTime || startTime == null) && (e.Start < endTime || endTime == null)).ToList(); + current.Exemplars.AddRange(currentExemplars); + } + + Debug.Assert(exemplars.Count == newMetrics.Sum(m => m.Value.Exemplars.Count), $"Expected {exemplars.Count} exemplars but got {newMetrics.Sum(m => m.Value.Exemplars.Count)} exemplars."); + + var latestCurrentMetric = _metrics.Keys.OfType().LastOrDefault(); + addedXValues = newMetrics.Keys.Where(newKey => newKey > latestCurrentMetric || latestCurrentMetric == null).ToHashSet(); return newMetrics; } @@ -228,6 +269,7 @@ public async ValueTask DisposeAsync() public abstract record MetricViewBase { public required DateTimeOffset DateTime { get; set; } + public required List Exemplars { get; set; } } public record MetricValueView : MetricViewBase diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs index 2b81454d02..c51abb8522 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs @@ -2,28 +2,60 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Web; using Aspire.Dashboard.Components.Controls.Chart; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Resources; +using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components; -public partial class PlotlyChart : ChartBase +public partial class PlotlyChart : ChartBase, IDisposable { [Inject] public required IJSRuntime JS { get; init; } - protected override async Task OnChartUpdated(List traces, List xValues, bool tickUpdate, DateTimeOffset inProgressDataTime) + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required IDialogService DialogService { get; init; } + + private DotNetObjectReference? _chartInteropReference; + + private string FormatTooltip(string title, double yValue, DateTimeOffset xValue) { - var traceDtos = traces.Select(y => new PlotlyTrace + var formattedValue = FormatHelpers.FormatNumberWithOptionalDecimalPlaces(yValue, maxDecimalPlaces: 3, CultureInfo.CurrentCulture); + if (InstrumentViewModel?.Instrument is { } instrument) { - Name = y.Name, - Values = y.DiffValues, - Tooltips = y.Tooltips + formattedValue += " " + InstrumentUnitResolver.ResolveDisplayedUnit(instrument, titleCase: false, pluralize: yValue != 1); + } + return $""" + {HttpUtility.HtmlEncode(title)}
+ {Loc[nameof(ControlsStrings.PlotlyChartValue)]}: {formattedValue}
+ {Loc[nameof(ControlsStrings.PlotlyChartTime)]}: {FormatHelpers.FormatTime(TimeProvider, TimeProvider.ToLocal(xValue))} + """; + } + + protected override async Task OnChartUpdated(List traces, List xValues, List exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime) + { + var traceDtos = traces.Select(t => new PlotlyTrace + { + Name = t.Name, + Y = t.DiffValues, + X = xValues, + Tooltips = t.Tooltips, + TraceData = new List() }).ToArray(); + var exemplarTraceDto = CalculateExemplarsTrace(xValues, exemplars); + if (!tickUpdate) { // The chart mostly shows numbers but some localization is needed for displaying time ticks. @@ -36,22 +68,141 @@ protected override async Task OnChartUpdated(List traces, List xValues, List exemplars) + { + // In local development there is no sampling of traces. There could be a very high number. + // Too many points on the graph will impact browser performance, and is not useful anyway as they will + // draw on top of each other and can't be used. Fix both of these problems by enforcing a maximum limit. + // + // Displaying up to a maximum number of exemplars per tick will display a continuous number of ticks across the graph. + const int MaxExemplarsPerTick = 20; + + // Group exemplars into ticks based on xValues. + var exemplarGroups = new Dictionary>(); + for (var i = 0; i <= xValues.Count; i++) + { + var start = i > 0 ? xValues[i - 1] : (DateTimeOffset?)null; + var end = i < xValues.Count ? xValues[i] : (DateTimeOffset?)null; + var g = new ExemplarGroupKey(start, end); + + var groupExemplars = exemplars.Where(e => (e.Start >= g.Start || g.Start == null) && (e.Start < g.End || g.End == null)).ToList(); + + // When exemplars exceeds the limit then sample the exemplars to reduce data to the limit. + if (groupExemplars.Count > MaxExemplarsPerTick) + { + var step = (double)groupExemplars.Count / MaxExemplarsPerTick; + + var sampledList = new List(MaxExemplarsPerTick); + for (var j = 0; j < MaxExemplarsPerTick; j++) + { + // Calculate the index to take from the original list + var index = (int)Math.Floor(j * step); + sampledList.Add(groupExemplars[index]); + } + + groupExemplars = sampledList; + } + + exemplarGroups.Add(g, groupExemplars); + } + + var exemplarTraceDto = new PlotlyTrace + { + Name = Loc[nameof(ControlsStrings.PlotlyChartExemplars)], + Y = new List(), + X = new List(), + Tooltips = new List(), + TraceData = new List() + }; + + foreach (var exemplar in exemplarGroups.SelectMany(g => g.Value)) + { + var title = exemplar.Span != null + ? SpanWaterfallViewModel.GetTitle(exemplar.Span, Applications) + : $"{Loc[nameof(ControlsStrings.PlotlyChartTrace)]}: {OtlpHelpers.ToShortenedId(exemplar.TraceId)}"; + var tooltip = FormatTooltip(title, exemplar.Value, exemplar.Start); + + exemplarTraceDto.X.Add(exemplar.Start); + exemplarTraceDto.Y.Add(exemplar.Value); + exemplarTraceDto.Tooltips.Add(tooltip); + exemplarTraceDto.TraceData.Add(new { TraceId = exemplar.TraceId, SpanId = exemplar.SpanId }); + } + + return exemplarTraceDto; + } + + public void Dispose() + { + if (_chartInteropReference != null) + { + _chartInteropReference.Value.Dispose(); + _chartInteropReference.Dispose(); + } + } + + /// + /// Handle user clicking on a trace point in the browser. + /// + private sealed class ChartInterop : IDisposable + { + private readonly PlotlyChart _plotlyChart; + private readonly CancellationTokenSource _cts; + + public ChartInterop(PlotlyChart plotlyChart) + { + _plotlyChart = plotlyChart; + _cts = new CancellationTokenSource(); + } + + public void Dispose() + { + _cts.Cancel(); + } + + [JSInvokable] + public async Task ViewSpan(string traceId, string spanId) + { + var available = await MetricsHelpers.WaitForSpanToBeAvailableAsync( + traceId, + spanId, + _plotlyChart.GetSpan, + _plotlyChart.DialogService, + _plotlyChart.InvokeAsync, + _plotlyChart.DialogsLoc, + _cts.Token).ConfigureAwait(false); + + if (available) + { + await _plotlyChart.InvokeAsync(() => + { + _plotlyChart.NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(traceId, spanId)); + }); + } + } + } + + private readonly record struct ExemplarGroupKey(DateTimeOffset? Start, DateTimeOffset? End); } diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor new file mode 100644 index 0000000000..2bf0d7f922 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor @@ -0,0 +1,39 @@ +@using Aspire.Dashboard.Components.Controls.Chart +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Model.Otlp +@using Aspire.Dashboard.Otlp.Model +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils +@using Aspire.Dashboard.Extensions +@using System.Globalization +@using Dialogs = Aspire.Dashboard.Resources.Dialogs +@implements IDialogContentComponent + +@inject IStringLocalizer Loc + +
+ + + + + @GetTitle(context) + + + + @FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, TimeProvider.ToLocal(context.Start), MillisecondsDisplay.Truncated) + + + @FormatMetricValue(context.Value) + + + View + + + +  @Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)] + + +
diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs new file mode 100644 index 0000000000..1b0a0c661c --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs @@ -0,0 +1,84 @@ +// 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.Model.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class ExemplarsDialog : IDisposable +{ + [CascadingParameter] + public FluentDialog Dialog { get; set; } = default!; + + [Parameter] + public ExemplarsDialogViewModel Content { get; set; } = default!; + + [Inject] + public required BrowserTimeProvider TimeProvider { get; init; } + + [Inject] + public required IDialogService DialogService { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required TelemetryRepository TelemetryRepository { get; init; } + + public IQueryable MetricView => Content.Exemplars.AsQueryable(); + + private readonly CancellationTokenSource _cts = new(); + + public async Task OnViewDetailsAsync(ChartExemplar exemplar) + { + var available = await MetricsHelpers.WaitForSpanToBeAvailableAsync( + traceId: exemplar.TraceId, + spanId: exemplar.SpanId, + getSpan: (traceId, spanId) => MetricsHelpers.GetSpan(TelemetryRepository, traceId, spanId), + DialogService, + InvokeAsync, + Loc, + _cts.Token).ConfigureAwait(false); + + if (available) + { + NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(exemplar.TraceId, spanId: exemplar.SpanId)); + } + } + + private string GetTitle(ChartExemplar exemplar) + { + return (exemplar.Span != null) + ? SpanWaterfallViewModel.GetTitle(exemplar.Span, Content.Applications) + : $"{Loc[nameof(Resources.Dialogs.ExemplarsDialogTrace)]}: {OtlpHelpers.ToShortenedId(exemplar.TraceId)}"; + } + + private string FormatMetricValue(double? value) + { + if (value is null) + { + return string.Empty; + } + + var formattedValue = value.Value.ToString("F3", CultureInfo.CurrentCulture); + if (!string.IsNullOrEmpty(Content.Instrument.Unit)) + { + formattedValue += Content.Instrument.Unit.TrimStart('{').TrimEnd('}'); + } + + return formattedValue; + } + + public void Dispose() + { + _cts.Dispose(); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor b/src/Aspire.Dashboard/Components/Pages/Metrics.razor index ae275aa132..76c8d30deb 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor @@ -15,7 +15,7 @@

@Loc[nameof(Dashboard.Resources.Metrics.MetricsHeader)]

- @@ -56,7 +56,9 @@ InstrumentName="@(PageViewModel.SelectedInstrument.Name)" Duration="PageViewModel.SelectedDuration.Id" ActiveView="@(PageViewModel.SelectedViewKind ?? MetricViewKind.Graph)" - OnViewChangedAsync="@OnViewChangedAsync"/> + OnViewChangedAsync="@OnViewChangedAsync" + Applications="_applications" + /> } else if (PageViewModel.SelectedMeter != null) { diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index d8131fb59a..c94088a430 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -19,7 +19,8 @@ public partial class Metrics : IDisposable, IPageWithSessionAndUrlState> _durations = null!; private static readonly TimeSpan s_defaultDuration = TimeSpan.FromMinutes(5); - private List> _applications = default!; + private List _applications = default!; + private List> _applicationViewModels = default!; private Subscription? _applicationsSubscription; private Subscription? _metricsSubscription; @@ -119,7 +120,7 @@ public MetricsPageState ConvertViewModelToSerializable() public void UpdateViewModelFromQuery(MetricsViewModel viewModel) { viewModel.SelectedDuration = _durations.SingleOrDefault(d => (int)d.Id.TotalMinutes == DurationMinutes) ?? _durations.Single(d => d.Id == s_defaultDuration); - viewModel.SelectedApplication = _applications.GetApplication(Logger, ApplicationName, _selectApplication); + viewModel.SelectedApplication = _applicationViewModels.GetApplication(Logger, ApplicationName, _selectApplication); var selectedInstance = viewModel.SelectedApplication.Id?.GetApplicationKey(); viewModel.Instruments = selectedInstance != null ? TelemetryRepository.GetInstrumentsSummary(selectedInstance.Value) : null; @@ -144,8 +145,9 @@ public void UpdateViewModelFromQuery(MetricsViewModel viewModel) private void UpdateApplications() { - _applications = ApplicationsSelectHelpers.CreateApplications(TelemetryRepository.GetApplications()); - _applications.Insert(0, _selectApplication); + _applications = TelemetryRepository.GetApplications(); + _applicationViewModels = ApplicationsSelectHelpers.CreateApplications(_applications); + _applicationViewModels.Insert(0, _selectApplication); UpdateSubscription(); } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor index f680ad2287..ae5b0876f3 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -55,7 +55,7 @@ -
+
@{ var isServerOrConsumer = context.Span.Kind == OtlpSpanKind.Server || context.Span.Kind == OtlpSpanKind.Consumer; // Indent the span name based on the depth of the span. @@ -127,7 +127,7 @@ @context.UninstrumentedPeer } - @context.GetDisplaySummary() + @SpanWaterfallViewModel.GetDisplaySummary(context.Span)
@@ -156,7 +156,7 @@
- @GetResourceName(context.Span.Source): @context.GetDisplaySummary() + @SpanWaterfallViewModel.GetTitle(context.Span, _applications) @DurationFormatter.FormatDuration(context.Span.Duration)
diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 6ea622fa8d..f76163733e 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -6,6 +6,7 @@ using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; @@ -26,6 +27,10 @@ public partial class TraceDetail : ComponentBase [Parameter] public required string TraceId { get; set; } + [Parameter] + [SupplyParameterFromQuery] + public required string? SpanId { get; set; } + [Inject] public required TelemetryRepository TelemetryRepository { get; init; } @@ -38,6 +43,9 @@ public partial class TraceDetail : ComponentBase [Inject] public required IJSRuntime JS { get; init; } + [Inject] + public required NavigationManager NavigationManager { get; init; } + protected override void OnInitialized() { foreach (var resolver in OutgoingPeerResolvers) @@ -182,9 +190,21 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid return OtlpHelpers.GetPeerAddress(span.Attributes); } - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { UpdateDetailViewData(); + + if (SpanId is not null && _spanWaterfallViewModels is not null) + { + var spanVm = _spanWaterfallViewModels.SingleOrDefault(vm => vm.Span.SpanId == SpanId); + if (spanVm != null) + { + await OnShowPropertiesAsync(spanVm, buttonId: null); + } + + // Navigate to remove ?spanId=xxx in the URL. + NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(TraceId), new NavigationOptions { ReplaceHistoryEntry = true }); + } } private void UpdateDetailViewData() @@ -262,7 +282,7 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin { Span = viewModel.Span, Properties = entryProperties, - Title = $"{GetResourceName(viewModel.Span.Source)}: {viewModel.GetDisplaySummary()}" + Title = SpanWaterfallViewModel.GetTitle(viewModel.Span, _applications) }; SelectedSpan = spanDetailsViewModel; diff --git a/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs b/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs index 9e4dfe3125..30c761593b 100644 --- a/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs +++ b/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs @@ -1,6 +1,7 @@ -// 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. +using System.Globalization; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Resources; using Humanizer; @@ -10,27 +11,44 @@ namespace Aspire.Dashboard.Model; public sealed class DefaultInstrumentUnitResolver(IStringLocalizer loc) : IInstrumentUnitResolver { - public string ResolveDisplayedUnit(OtlpInstrument instrument) + public string ResolveDisplayedUnit(OtlpInstrument instrument, bool titleCase, bool pluralize) { if (!string.IsNullOrEmpty(instrument.Unit)) { var unit = OtlpUnits.GetUnit(instrument.Unit.TrimStart('{').TrimEnd('}')); - return unit.Pluralize().Titleize(); + if (pluralize) + { + unit = unit.Pluralize(); + } + if (titleCase) + { + unit = unit.Titleize(); + } + return unit; } // Hard code for instrument names that don't have units // but have a descriptive name that lets us infer the unit. if (instrument.Name.EndsWith(".count")) { - return loc[nameof(ControlsStrings.PlotlyChartCount)]; + return UntitleCase(loc[nameof(ControlsStrings.PlotlyChartCount)], titleCase); } else if (instrument.Name.EndsWith(".length")) { - return loc[nameof(ControlsStrings.PlotlyChartLength)]; + return UntitleCase(loc[nameof(ControlsStrings.PlotlyChartLength)], titleCase); } else { - return loc[nameof(ControlsStrings.PlotlyChartValue)]; + return UntitleCase(loc[nameof(ControlsStrings.PlotlyChartValue)], titleCase); + } + + static string UntitleCase(string value, bool titleCase) + { + if (!titleCase) + { + value = value.ToLower(CultureInfo.CurrentCulture); + } + return value; } } } diff --git a/src/Aspire.Dashboard/Model/ExemplarsDialogViewModel.cs b/src/Aspire.Dashboard/Model/ExemplarsDialogViewModel.cs new file mode 100644 index 0000000000..eb9da28e9e --- /dev/null +++ b/src/Aspire.Dashboard/Model/ExemplarsDialogViewModel.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Components.Controls.Chart; +using Aspire.Dashboard.Otlp.Model; + +namespace Aspire.Dashboard.Model; + +public sealed class ExemplarsDialogViewModel +{ + public required List Exemplars { get; init; } + public required List Applications { get; init; } + public required OtlpInstrument Instrument { get; init; } +} diff --git a/src/Aspire.Dashboard/Model/IInstrumentUnitResolver.cs b/src/Aspire.Dashboard/Model/IInstrumentUnitResolver.cs index cf19c770ff..6797a1183f 100644 --- a/src/Aspire.Dashboard/Model/IInstrumentUnitResolver.cs +++ b/src/Aspire.Dashboard/Model/IInstrumentUnitResolver.cs @@ -7,5 +7,5 @@ namespace Aspire.Dashboard.Model; public interface IInstrumentUnitResolver { - string ResolveDisplayedUnit(OtlpInstrument instrument); + string ResolveDisplayedUnit(OtlpInstrument instrument, bool titleCase, bool pluralize); } diff --git a/src/Aspire.Dashboard/Model/MetricsHelpers.cs b/src/Aspire.Dashboard/Model/MetricsHelpers.cs new file mode 100644 index 0000000000..e16457b75f --- /dev/null +++ b/src/Aspire.Dashboard/Model/MetricsHelpers.cs @@ -0,0 +1,92 @@ +// 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.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.Resources; +using Aspire.Dashboard.Utils; +using Microsoft.Extensions.Localization; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Model; + +public static class MetricsHelpers +{ + public static OtlpSpan? GetSpan(TelemetryRepository telemetryRepository, string traceId, string spanId) + { + var trace = telemetryRepository.GetTrace(traceId); + if (trace == null) + { + return null; + } + + return trace.Spans.FirstOrDefault(s => s.SpanId == spanId); + } + + public static async Task WaitForSpanToBeAvailableAsync( + string traceId, + string spanId, + Func getSpan, + IDialogService dialogService, + Func, Task> dispatcher, + IStringLocalizer loc, + CancellationToken cancellationToken) + { + var span = getSpan(traceId, spanId); + + // Exemplar span isn't loaded yet. Display a dialog until the data is ready or the user cancels the dialog. + if (span == null) + { + using var cts = new CancellationTokenSource(); + using var registration = cancellationToken.Register(cts.Cancel); + + var reference = await dialogService.ShowMessageBoxAsync(new DialogParameters() + { + Content = new MessageBoxContent + { + Intent = MessageBoxIntent.Info, + Icon = new Icons.Filled.Size24.Info(), + IconColor = Color.Info, + Message = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dialogs.OpenTraceDialogMessage)], OtlpHelpers.ToShortenedId(traceId)), + }, + DialogType = DialogType.MessageBox, + PrimaryAction = string.Empty, + SecondaryAction = loc[nameof(Dialogs.OpenTraceDialogCancelButtonText)] + }).ConfigureAwait(false); + + // Task that polls for the span to be available. + var waitForTraceTask = Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + span = getSpan(traceId, spanId); + if (span != null) + { + await dispatcher(async () => + { + await reference.CloseAsync(DialogResult.Ok(true)).ConfigureAwait(false); + }).ConfigureAwait(false); + } + else + { + await Task.Delay(TimeSpan.FromSeconds(0.5), cts.Token).ConfigureAwait(false); + } + } + }, cts.Token); + + var result = await reference.Result.ConfigureAwait(false); + cts.Cancel(); + + await TaskHelpers.WaitIgnoreCancelAsync(waitForTraceTask).ConfigureAwait(false); + + if (result.Cancelled) + { + // Dialog was canceled before span was ready. Exit without navigating. + return false; + } + } + + return true; + } +} diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 09d8b660f0..90dae3e93a 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -32,9 +32,9 @@ public bool IsCollapsed } } - public string GetTooltip() + public string GetTooltip(List allApplications) { - var tooltip = $"{Span.Source.ApplicationName}: {GetDisplaySummary()}"; + var tooltip = GetTitle(Span, allApplications); if (IsError) { tooltip += Environment.NewLine + "Status = Error"; @@ -47,35 +47,40 @@ public string GetTooltip() return tooltip; } - public string GetDisplaySummary() + public static string GetTitle(OtlpSpan span, List allApplications) + { + return $"{OtlpApplication.GetResourceName(span.Source, allApplications)}: {GetDisplaySummary(span)}"; + } + + public static string GetDisplaySummary(OtlpSpan span) { // Use attributes on the span to calculate a friendly summary. // Optimize for common cases: HTTP, RPC, DATA, etc. // Fall back to the span name if we can't find anything. - if (Span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer or OtlpSpanKind.Consumer) + if (span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer or OtlpSpanKind.Consumer) { - if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(Span.Attributes, "http.method"))) + if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(span.Attributes, "http.method"))) { - var httpMethod = OtlpHelpers.GetValue(Span.Attributes, "http.method"); - var statusCode = OtlpHelpers.GetValue(Span.Attributes, "http.status_code"); + var httpMethod = OtlpHelpers.GetValue(span.Attributes, "http.method"); + var statusCode = OtlpHelpers.GetValue(span.Attributes, "http.status_code"); return $"HTTP {httpMethod?.ToUpperInvariant()} {statusCode}"; } - else if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(Span.Attributes, "db.system"))) + else if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(span.Attributes, "db.system"))) { - var dbSystem = OtlpHelpers.GetValue(Span.Attributes, "db.system"); + var dbSystem = OtlpHelpers.GetValue(span.Attributes, "db.system"); - return $"DATA {dbSystem} {Span.Name}"; + return $"DATA {dbSystem} {span.Name}"; } - else if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(Span.Attributes, "rpc.system"))) + else if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(span.Attributes, "rpc.system"))) { - var rpcSystem = OtlpHelpers.GetValue(Span.Attributes, "rpc.system"); - var rpcService = OtlpHelpers.GetValue(Span.Attributes, "rpc.service"); - var rpcMethod = OtlpHelpers.GetValue(Span.Attributes, "rpc.method"); + var rpcSystem = OtlpHelpers.GetValue(span.Attributes, "rpc.system"); + var rpcService = OtlpHelpers.GetValue(span.Attributes, "rpc.service"); + var rpcMethod = OtlpHelpers.GetValue(span.Attributes, "rpc.method"); if (string.Equals(rpcSystem, "grpc", StringComparison.OrdinalIgnoreCase)) { - var grpcStatusCode = OtlpHelpers.GetValue(Span.Attributes, "rpc.grpc.status_code"); + var grpcStatusCode = OtlpHelpers.GetValue(span.Attributes, "rpc.grpc.status_code"); var summary = $"RPC {rpcService}/{rpcMethod}"; if (!string.IsNullOrEmpty(grpcStatusCode) && Enum.TryParse(grpcStatusCode, out var statusCode)) @@ -85,19 +90,19 @@ public string GetDisplaySummary() return summary; } - return $"RPC {rpcSystem} {rpcService}/{rpcMethod}"; + return $"RPC {rpcService}/{rpcMethod}"; } - else if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(Span.Attributes, "messaging.system"))) + else if (!string.IsNullOrEmpty(OtlpHelpers.GetValue(span.Attributes, "messaging.system"))) { - var messagingSystem = OtlpHelpers.GetValue(Span.Attributes, "messaging.system"); - var messagingOperation = OtlpHelpers.GetValue(Span.Attributes, "messaging.operation"); - var destinationName = OtlpHelpers.GetValue(Span.Attributes, "messaging.destination.name"); + var messagingSystem = OtlpHelpers.GetValue(span.Attributes, "messaging.system"); + var messagingOperation = OtlpHelpers.GetValue(span.Attributes, "messaging.operation"); + var destinationName = OtlpHelpers.GetValue(span.Attributes, "messaging.destination.name"); return $"MSG {messagingSystem} {messagingOperation} {destinationName}"; } } - return Span.Name; + return span.Name; } private void UpdateHidden(bool isParentCollapsed = false) diff --git a/src/Aspire.Dashboard/Model/PlotlyTrace.cs b/src/Aspire.Dashboard/Model/PlotlyTrace.cs index ea67227f13..4d15f80d3d 100644 --- a/src/Aspire.Dashboard/Model/PlotlyTrace.cs +++ b/src/Aspire.Dashboard/Model/PlotlyTrace.cs @@ -6,8 +6,10 @@ namespace Aspire.Dashboard.Model; public class PlotlyTrace { public required string Name { get; init; } - public required List Values { get; init; } + public required List X { get; init; } + public required List Y { get; init; } public required List Tooltips { get; init; } + public required List TraceData { get; init; } } public class PlotlyUserLocale diff --git a/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs b/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs index 6f0f2c02e8..524d0fba79 100644 --- a/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs +++ b/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Otlp.Storage; +using Google.Protobuf.Collections; using OpenTelemetry.Proto.Metrics.V1; namespace Aspire.Dashboard.Otlp.Model.MetricValues; @@ -29,11 +31,7 @@ public DimensionScope(int capacity, KeyValuePair[] attributes) _values = new(capacity); } - /// - /// Compares and updates the timespan for metrics if they are unchanged. - /// - /// Metric value to merge - public void AddPointValue(NumberDataPoint d) + public void AddPointValue(NumberDataPoint d, TelemetryLimitOptions options) { var start = OtlpHelpers.UnixNanoSecondsToDateTime(d.StartTimeUnixNano); var end = OtlpHelpers.UnixNanoSecondsToDateTime(d.TimeUnixNano); @@ -45,6 +43,7 @@ public void AddPointValue(NumberDataPoint d) if (lastLongValue is not null && lastLongValue.Value == value) { lastLongValue.End = end; + AddExemplars(lastLongValue, d.Exemplars, options); Interlocked.Increment(ref lastLongValue.Count); } else @@ -54,6 +53,7 @@ public void AddPointValue(NumberDataPoint d) start = lastLongValue.End; } _lastValue = new MetricValue(d.AsInt, start, end); + AddExemplars(_lastValue, d.Exemplars, options); _values.Add(_lastValue); } } @@ -63,6 +63,7 @@ public void AddPointValue(NumberDataPoint d) if (lastDoubleValue is not null && lastDoubleValue.Value == d.AsDouble) { lastDoubleValue.End = end; + AddExemplars(lastDoubleValue, d.Exemplars, options); Interlocked.Increment(ref lastDoubleValue.Count); } else @@ -72,12 +73,13 @@ public void AddPointValue(NumberDataPoint d) start = lastDoubleValue.End; } _lastValue = new MetricValue(d.AsDouble, start, end); + AddExemplars(_lastValue, d.Exemplars, options); _values.Add(_lastValue); } } } - public void AddHistogramValue(HistogramDataPoint h) + public void AddHistogramValue(HistogramDataPoint h, TelemetryLimitOptions options) { var start = OtlpHelpers.UnixNanoSecondsToDateTime(h.StartTimeUnixNano); var end = OtlpHelpers.UnixNanoSecondsToDateTime(h.TimeUnixNano); @@ -86,6 +88,7 @@ public void AddHistogramValue(HistogramDataPoint h) if (lastHistogramValue is not null && lastHistogramValue.Count == h.Count) { lastHistogramValue.End = end; + AddExemplars(lastHistogramValue, h.Exemplars, options); } else { @@ -103,10 +106,52 @@ public void AddHistogramValue(HistogramDataPoint h) explicitBounds = h.ExplicitBounds.ToArray(); } _lastValue = new HistogramValue(h.BucketCounts.ToArray(), h.Sum, h.Count, start, end, explicitBounds); + AddExemplars(_lastValue, h.Exemplars, options); _values.Add(_lastValue); } } + private static void AddExemplars(MetricValueBase value, RepeatedField exemplars, TelemetryLimitOptions options) + { + if (exemplars.Count > 0) + { + foreach (var exemplar in exemplars) + { + // Can't do anything useful with exemplars without a linked trace. Filter them out. + if (exemplar.TraceId == null || exemplar.SpanId == null) + { + continue; + } + + var start = OtlpHelpers.UnixNanoSecondsToDateTime(exemplar.TimeUnixNano); + var exemplarValue = exemplar.HasAsDouble ? exemplar.AsDouble : exemplar.AsInt; + + var exists = false; + foreach (var existingExemplar in value.Exemplars) + { + if (start == existingExemplar.Start && exemplarValue == existingExemplar.Value) + { + exists = true; + break; + } + } + if (exists) + { + continue; + } + + value.Exemplars.Add(new MetricsExemplar + { + Start = start, + Value = exemplarValue, + Attributes = exemplar.FilteredAttributes.ToKeyValuePairs(options), + SpanId = exemplar.SpanId.ToHexString(), + TraceId = exemplar.TraceId.ToHexString() + }); + } + } + } + internal static DimensionScope Clone(DimensionScope value, DateTime? valuesStart, DateTime? valuesEnd) { var newDimensionScope = new DimensionScope(value.Capacity, value.Attributes); diff --git a/src/Aspire.Dashboard/Otlp/Model/MetricValues/HistogramValue.cs b/src/Aspire.Dashboard/Otlp/Model/MetricValues/HistogramValue.cs index 62cc1a2a71..3565481ed7 100644 --- a/src/Aspire.Dashboard/Otlp/Model/MetricValues/HistogramValue.cs +++ b/src/Aspire.Dashboard/Otlp/Model/MetricValues/HistogramValue.cs @@ -46,7 +46,12 @@ internal override bool TryCompare(MetricValueBase other, out int comparisonResul protected override MetricValueBase Clone() { - return new HistogramValue(Values, Sum, Count, Start, End, ExplicitBounds); + var value = new HistogramValue(Values, Sum, Count, Start, End, ExplicitBounds); + if (HasExemplars) + { + value.Exemplars.AddRange(Exemplars); + } + return value; } public override bool Equals(object? obj) diff --git a/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValue.cs b/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValue.cs index 3936c6c9bf..bde8f97cec 100644 --- a/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValue.cs +++ b/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValue.cs @@ -5,7 +5,7 @@ namespace Aspire.Dashboard.Otlp.Model.MetricValues; -[DebuggerDisplay("Start = {Start}, End = {End}, Value = {Value}")] +[DebuggerDisplay("Start = {Start}, End = {End}, Value = {Value}, Exemplars = {Exemplars.Count}")] public class MetricValue : MetricValueBase where T : struct { public readonly T Value; @@ -19,7 +19,12 @@ public MetricValue(T value, DateTime start, DateTime end) : base(start, end) protected override MetricValueBase Clone() { - return new MetricValue(Value, Start, End); + var value = new MetricValue(Value, Start, End); + if (HasExemplars) + { + value.Exemplars.AddRange(Exemplars); + } + return value; } internal override bool TryCompare(MetricValueBase obj, out int comparisonResult) diff --git a/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValueBase.cs b/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValueBase.cs index c33afedc06..770db85101 100644 --- a/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValueBase.cs +++ b/src/Aspire.Dashboard/Otlp/Model/MetricValues/MetricValueBase.cs @@ -5,9 +5,13 @@ namespace Aspire.Dashboard.Otlp.Model.MetricValues; -[DebuggerDisplay("Start = {Start}, End = {End}")] +[DebuggerDisplay("Start = {Start}, End = {End}, Exemplars = {Exemplars.Count}")] public abstract class MetricValueBase { + private List? _exemplars; + + public List Exemplars => _exemplars ??= new List(); + public bool HasExemplars => _exemplars != null && _exemplars.Count > 0; public DateTime Start { get; set; } public DateTime End { get; set; } public ulong Count = 1; @@ -27,3 +31,13 @@ internal static MetricValueBase Clone(MetricValueBase item) protected abstract MetricValueBase Clone(); } + +[DebuggerDisplay("Start = {Start}, Value = {Value}, SpanId = {SpanId}, TraceId = {TraceId}, Attributes = {Attributes.Count}")] +public sealed class MetricsExemplar +{ + public required DateTime Start { get; init; } + public required double Value { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + public required KeyValuePair[] Attributes { get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs index 2c1697d304..44d2558165 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs @@ -31,19 +31,19 @@ public void AddMetrics(Metric metric, ref KeyValuePair[]? tempAt case Metric.DataOneofCase.Gauge: foreach (var d in metric.Gauge.DataPoints) { - FindScope(d.Attributes, ref tempAttributes).AddPointValue(d); + FindScope(d.Attributes, ref tempAttributes).AddPointValue(d, Options); } break; case Metric.DataOneofCase.Sum: foreach (var d in metric.Sum.DataPoints) { - FindScope(d.Attributes, ref tempAttributes).AddPointValue(d); + FindScope(d.Attributes, ref tempAttributes).AddPointValue(d, Options); } break; case Metric.DataOneofCase.Histogram: foreach (var d in metric.Histogram.DataPoints) { - FindScope(d.Attributes, ref tempAttributes).AddHistogramValue(d); + FindScope(d.Attributes, ref tempAttributes).AddHistogramValue(d, Options); } break; } diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 99cb8be58d..7636c347c3 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -285,6 +285,15 @@ public static string Loading { } } + /// + /// Looks up a localized string similar to Exemplars. + /// + public static string MetricTableExemplarsColumnHeader { + get { + return ResourceManager.GetString("MetricTableExemplarsColumnHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to No metrics data found. /// @@ -348,6 +357,15 @@ public static string MetricTableValueNoChange { } } + /// + /// Looks up a localized string similar to View exemplars. + /// + public static string MetricTableViewExemplarsLabel { + get { + return ResourceManager.GetString("MetricTableViewExemplarsLabel", resourceCulture); + } + } + /// /// Looks up a localized string similar to Name. /// @@ -366,6 +384,15 @@ public static string PlotlyChartCount { } } + /// + /// Looks up a localized string similar to Exemplars. + /// + public static string PlotlyChartExemplars { + get { + return ResourceManager.GetString("PlotlyChartExemplars", resourceCulture); + } + } + /// /// Looks up a localized string similar to Length. /// @@ -375,6 +402,24 @@ public static string PlotlyChartLength { } } + /// + /// Looks up a localized string similar to Time. + /// + public static string PlotlyChartTime { + get { + return ResourceManager.GetString("PlotlyChartTime", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Trace. + /// + public static string PlotlyChartTrace { + get { + return ResourceManager.GetString("PlotlyChartTrace", resourceCulture); + } + } + /// /// Looks up a localized string similar to Value. /// diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 6fa81c763f..8b56f90f7e 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -326,4 +326,19 @@ Context + + Exemplars + + + View exemplars + + + Time + + + Exemplars + + + Trace + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index f0811eab48..e06f38aba6 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -59,6 +60,69 @@ internal Dialogs() { } } + /// + /// Looks up a localized string similar to Close. + /// + public static string ExemplarsDialogCloseButtonText { + get { + return ResourceManager.GetString("ExemplarsDialogCloseButtonText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Details. + /// + public static string ExemplarsDialogDetailsColumnHeader { + get { + return ResourceManager.GetString("ExemplarsDialogDetailsColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Timestamp. + /// + public static string ExemplarsDialogTimestampColumnHeader { + get { + return ResourceManager.GetString("ExemplarsDialogTimestampColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Exemplars. + /// + public static string ExemplarsDialogTitle { + get { + return ResourceManager.GetString("ExemplarsDialogTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Trace. + /// + public static string ExemplarsDialogTrace { + get { + return ResourceManager.GetString("ExemplarsDialogTrace", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Trace. + /// + public static string ExemplarsDialogTraceColumnHeader { + get { + return ResourceManager.GetString("ExemplarsDialogTraceColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Value. + /// + public static string ExemplarsDialogValueColumnHeader { + get { + return ResourceManager.GetString("ExemplarsDialogValueColumnHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Apply filter. /// @@ -266,6 +330,24 @@ public static string HelpDialogTogglePanelOrientation { } } + /// + /// Looks up a localized string similar to Cancel. + /// + public static string OpenTraceDialogCancelButtonText { + get { + return ResourceManager.GetString("OpenTraceDialogCancelButtonText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Waiting for trace {0} to load.... + /// + public static string OpenTraceDialogMessage { + get { + return ResourceManager.GetString("OpenTraceDialogMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Dark. /// diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index 8413bc42ee..3aee27774b 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -1,17 +1,17 @@  - @@ -202,4 +202,32 @@ Keyboard Shortcuts - + + Close + + + Exemplars + + + Trace + + + Timestamp + + + Value + + + Details + + + Trace + + + Waiting for trace {0} to load... + {0} is a trace ID + + + Cancel + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf index bfb459a71b..55649046cc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -127,6 +127,11 @@ Načítání… + + Exemplars + Exemplars + + No metrics data found Nenašla se žádná data metrik. @@ -162,6 +167,11 @@ Hodnota se nezměnila. + + View exemplars + View exemplars + + Name Název @@ -172,11 +182,26 @@ Počet Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Délka Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Hodnota diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index 672a87e2b8..d9383223b3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -127,6 +127,11 @@ Wird geladen... + + Exemplars + Exemplars + + No metrics data found Keine Metrikdaten gefunden. @@ -162,6 +167,11 @@ Wert wurde nicht geändert + + View exemplars + View exemplars + + Name Name @@ -172,11 +182,26 @@ Anzahl Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Länge Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Wert diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index d7030c10f5..d782d3afad 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -127,6 +127,11 @@ Cargando... + + Exemplars + Exemplars + + No metrics data found No se encontraron datos de métricas @@ -162,6 +167,11 @@ El valor no cambió + + View exemplars + View exemplars + + Name Nombre @@ -172,11 +182,26 @@ Recuento Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Longitud Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Valor diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index 5b55cb3a8c..ddf4f6c0e7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -127,6 +127,11 @@ Chargement... + + Exemplars + Exemplars + + No metrics data found Données de métriques introuvables @@ -162,6 +167,11 @@ La valeur n’a pas changé + + View exemplars + View exemplars + + Name Nom @@ -172,11 +182,26 @@ Nombre Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Longueur Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Valeur diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index 8a7b91a6dd..22b5a63849 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -127,6 +127,11 @@ Caricamento in corso... + + Exemplars + Exemplars + + No metrics data found Nessun dato delle metriche trovato @@ -162,6 +167,11 @@ Il valore non è stato modificato + + View exemplars + View exemplars + + Name Nome @@ -172,11 +182,26 @@ Numero Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Lunghezza Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Valore diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index 23fe9fe083..32db1c8d6f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -127,6 +127,11 @@ 読み込み中... + + Exemplars + Exemplars + + No metrics data found メトリック データが見つかりません @@ -162,6 +167,11 @@ 値は変更されませんでした + + View exemplars + View exemplars + + Name 名前 @@ -172,11 +182,26 @@ カウント Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length 長さ Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index f3a1e264a7..f9039fdf04 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -127,6 +127,11 @@ 로드 중... + + Exemplars + Exemplars + + No metrics data found 메트릭 데이터를 찾을 수 없음 @@ -162,6 +167,11 @@ 값이 변경되지 않음 + + View exemplars + View exemplars + + Name 이름 @@ -172,11 +182,26 @@ 개수 Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length 길이 Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index bdfc288305..4b3586ac03 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -127,6 +127,11 @@ Trwa ładowanie... + + Exemplars + Exemplars + + No metrics data found Nie znaleziono danych metryk @@ -162,6 +167,11 @@ Wartość nie została zmieniona + + View exemplars + View exemplars + + Name Nazwa @@ -172,11 +182,26 @@ Liczba Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Długość Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Wartość diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index 61fbf7c63a..433c8e7d67 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -127,6 +127,11 @@ Carregando... + + Exemplars + Exemplars + + No metrics data found Nenhum dado de métrica encontrado @@ -162,6 +167,11 @@ O valor não foi alterado + + View exemplars + View exemplars + + Name Nome @@ -172,11 +182,26 @@ Número Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Comprimento Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Valor diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index bb2408a1bc..12ce8f3a40 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -127,6 +127,11 @@ Идет загрузка... + + Exemplars + Exemplars + + No metrics data found Данные метрик не найдены @@ -162,6 +167,11 @@ Значение не изменилось + + View exemplars + View exemplars + + Name Имя @@ -172,11 +182,26 @@ Количество Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Длина Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Значение diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index a89d752e5e..1625de5fde 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -127,6 +127,11 @@ Yükleniyor... + + Exemplars + Exemplars + + No metrics data found Ölçüm verisi bulunamadı @@ -162,6 +167,11 @@ Değer değişmedi + + View exemplars + View exemplars + + Name Ad @@ -172,11 +182,26 @@ Sayı Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length Uzunluk Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value Değer diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index 987068d895..5309367b62 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -127,6 +127,11 @@ 正在加载... + + Exemplars + Exemplars + + No metrics data found 未找到指标数据 @@ -162,6 +167,11 @@ 值未更改 + + View exemplars + View exemplars + + Name 名称 @@ -172,11 +182,26 @@ 计数 Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length 长度 Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index 0a27e00021..e001dae8bd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -127,6 +127,11 @@ 正在載入... + + Exemplars + Exemplars + + No metrics data found 未找到計量資料 @@ -162,6 +167,11 @@ 值未變更 + + View exemplars + View exemplars + + Name 名稱 @@ -172,11 +182,26 @@ 計數 Count is the name of a plot y-axis label + + Exemplars + Exemplars + + Length 長度 Length is the name of a plot y-axis label + + Time + Time + + + + Trace + Trace + + Value diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index 6177d18476..5abc00668f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Použít filtr @@ -117,6 +152,16 @@ Přepnout orientaci panelu + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Tmavý diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 9d05395d5f..95d7603bc2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Filter anwenden @@ -117,6 +152,16 @@ Bereichsausrichtung umschalten + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Dunkel diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index 944a526702..2a7e0053f4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Aplicar filtro @@ -117,6 +152,16 @@ Alternar orientación del panel + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Oscuro diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index 55bed021ab..f191d0be8e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Appliquer le filtre @@ -117,6 +152,16 @@ Activer/désactiver l’orientation du panneau + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Sombre diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index 6881fd5147..1bec0d5e59 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Applica filtro @@ -117,6 +152,16 @@ Attiva/Disattiva orientamento del pannello + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Scuro diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index 689fa453aa..98ac103e98 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter フィルターの適用 @@ -117,6 +152,16 @@ パネルの向きを切り替える + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark ダーク diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index fc1037b4e5..8648776cd8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter 필터 적용 @@ -117,6 +152,16 @@ 패널 방향 전환 + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark 어두움 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index 4513218589..c2fbb020df 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Zastosuj filtr @@ -117,6 +152,16 @@ Przełącz orientację panelu + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Ciemny diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index 80b63421fa..c028903d01 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Aplicar filtro @@ -117,6 +152,16 @@ Alternar orientação do painel + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Escuro diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index c0125d4a83..345a4f246c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Применить фильтр @@ -117,6 +152,16 @@ Переключить ориентацию панели + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Темная diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index d4593a23dd..703a81b63a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter Filtre uygula @@ -117,6 +152,16 @@ Panelin yönünü değiştir + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark Koyu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index c0e48435d0..a933749ee1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter 应用筛选器 @@ -117,6 +152,16 @@ 切换面板方向 + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark 深色 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index 22d404faa9..0ad0fec179 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -2,6 +2,41 @@ + + Close + Close + + + + Details + Details + + + + Timestamp + Timestamp + + + + Exemplars + Exemplars + + + + Trace + Trace + + + + Trace + Trace + + + + Value + Value + + Apply filter 套用篩選 @@ -117,6 +152,16 @@ 切換面板方向 + + Cancel + Cancel + + + + Waiting for trace {0} to load... + Waiting for trace {0} to load... + {0} is a trace ID + Dark 深色 diff --git a/src/Aspire.Dashboard/Utils/DashboardUrls.cs b/src/Aspire.Dashboard/Utils/DashboardUrls.cs index b51655dca8..d6bfed8b44 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUrls.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUrls.cs @@ -96,9 +96,15 @@ public static string TracesUrl(string? resource = null) return url; } - public static string TraceDetailUrl(string traceId) + public static string TraceDetailUrl(string traceId, string? spanId = null) { - return $"/{TracesBasePath}/detail/{Uri.EscapeDataString(traceId)}"; + var url = $"/{TracesBasePath}/detail/{Uri.EscapeDataString(traceId)}"; + if (spanId != null) + { + url = QueryHelpers.AddQueryString(url, "spanId", spanId); + } + + return url; } public static string LoginUrl(string? returnUrl = null, string? token = null) diff --git a/src/Aspire.Dashboard/Utils/FormatHelpers.cs b/src/Aspire.Dashboard/Utils/FormatHelpers.cs index 7e63090efe..1f04ab39c8 100644 --- a/src/Aspire.Dashboard/Utils/FormatHelpers.cs +++ b/src/Aspire.Dashboard/Utils/FormatHelpers.cs @@ -119,8 +119,18 @@ public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider } } - public static string FormatNumberWithOptionalDecimalPlaces(double value, CultureInfo? provider = null) + public static string FormatNumberWithOptionalDecimalPlaces(double value, int maxDecimalPlaces, CultureInfo? provider = null) { - return value.ToString("##,0.######", provider ?? CultureInfo.CurrentCulture); + var formatString = maxDecimalPlaces switch + { + 1 => "##,0.#", + 2 => "##,0.##", + 3 => "##,0.###", + 4 => "##,0.####", + 5 => "##,0.#####", + 6 => "##,0.######", + _ => throw new ArgumentException("Unexpected value.", nameof(maxDecimalPlaces)) + }; + return value.ToString(formatString, provider ?? CultureInfo.CurrentCulture); } } diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index b2121ed8ff..17e9fda9ce 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -146,7 +146,8 @@ function getThemeColors() { var style = getComputedStyle(document.body); return { backgroundColor: style.getPropertyValue("--fill-color"), - textColor: style.getPropertyValue("--neutral-foreground-rest") + textColor: style.getPropertyValue("--neutral-foreground-rest"), + pointColor: style.getPropertyValue("--accent-fill-rest") }; } @@ -163,12 +164,22 @@ function fixTraceLineRendering(chartDiv) { if (parent.childNodes.length > 0) { for (var i = 1; i < parent.childNodes.length; i++) { - parent.insertBefore(parent.childNodes[i], parent.firstChild); + var child = parent.childNodes[i]; + parent.insertBefore(child, parent.firstChild); + } + + // Check if there is a trace with points. It should be top most. + for (var i = 0; i < parent.childNodes.length; i++) { + var child = parent.childNodes[i]; + if (child.querySelector(".point")) { + // Append trace to parent to move to the last in the collection. + parent.appendChild(child); + } } } } -window.updateChart = function (id, traces, xValues, rangeStartTime, rangeEndTime) { +window.updateChart = function (id, traces, exemplarTrace, rangeStartTime, rangeEndTime) { var chartContainerDiv = document.getElementById(id); var chartDiv = chartContainerDiv.firstChild; @@ -177,16 +188,24 @@ window.updateChart = function (id, traces, xValues, rangeStartTime, rangeEndTime var xUpdate = []; var yUpdate = []; var tooltipsUpdate = []; + var traceData = []; for (var i = 0; i < traces.length; i++) { - xUpdate.push(xValues); - yUpdate.push(traces[i].values); + xUpdate.push(traces[i].x); + yUpdate.push(traces[i].y); tooltipsUpdate.push(traces[i].tooltips); + traceData.push(traces.traceData); } + xUpdate.push(exemplarTrace.x); + yUpdate.push(exemplarTrace.y); + tooltipsUpdate.push(exemplarTrace.tooltips); + traceData.push(exemplarTrace.traceData); + var data = { x: xUpdate, y: yUpdate, text: tooltipsUpdate, + traceData: traceData }; var layout = { @@ -204,7 +223,7 @@ window.updateChart = function (id, traces, xValues, rangeStartTime, rangeEndTime fixTraceLineRendering(chartDiv); }; -window.initializeChart = function (id, traces, xValues, rangeStartTime, rangeEndTime, serverLocale) { +window.initializeChart = function (id, traces, exemplarTrace, rangeStartTime, rangeEndTime, serverLocale, chartInterop) { registerLocale(serverLocale); var chartContainerDiv = document.getElementById(id); @@ -220,8 +239,8 @@ window.initializeChart = function (id, traces, xValues, rangeStartTime, rangeEnd for (var i = 0; i < traces.length; i++) { var name = traces[i].name || "Value"; var t = { - x: xValues, - y: traces[i].values, + x: traces[i].x, + y: traces[i].y, name: name, text: traces[i].tooltips, hoverinfo: 'text', @@ -230,6 +249,26 @@ window.initializeChart = function (id, traces, xValues, rangeStartTime, rangeEnd data.push(t); } + var points = { + x: exemplarTrace.x, + y: exemplarTrace.y, + name: exemplarTrace.name, + text: exemplarTrace.tooltips, + hoverinfo: 'text', + traceData: exemplarTrace.traceData, + mode: 'markers', + type: 'scatter', + marker: { + size: 16, + color: themeColors.pointColor, + line: { + color: themeColors.backgroundColor, + width: 1 + } + } + }; + data.push(points); + // Explicitly set the width and height based on the container div. // If there is no explicit width and height, Plotly will use the rendered container size. // However, if the container isn't visible then it uses a default size. @@ -255,7 +294,7 @@ window.initializeChart = function (id, traces, xValues, rangeStartTime, rangeEnd fixedrange: true, color: themeColors.textColor }, - hovermode: "x", + hovermode: "closest", showlegend: true, legend: { orientation: "h", @@ -273,6 +312,38 @@ window.initializeChart = function (id, traces, xValues, rangeStartTime, rangeEnd Plotly.newPlot(chartDiv, data, layout, options); fixTraceLineRendering(chartDiv); + + // We only want a pointer cursor when the mouse is hovering over an exemplar point. + // Set the drag layer cursor back to the default and then use plotly_hover/ploty_unhover events to set to pointer. + dragLayer = document.getElementsByClassName('nsewdrag')[0]; + dragLayer.style.cursor = 'default'; + + // Use mousedown instead of plotly_click event because plotly_click has issues with updating charts. + // The current point is tracked by setting it with hover/unhover events and then mousedown uses the current value. + var currentPoint = null; + chartDiv.on('plotly_hover', function (data) { + var point = data.points[0]; + if (point.fullData.name == exemplarTrace.name) { + currentPoint = point; + var pointTraceData = point.data.traceData[point.pointIndex]; + dragLayer.style.cursor = 'pointer'; + } + }); + chartDiv.on('plotly_unhover', function (data) { + var point = data.points[0]; + if (point.fullData.name == exemplarTrace.name) { + currentPoint = null; + dragLayer.style.cursor = 'default'; + } + }); + chartDiv.addEventListener("mousedown", (e) => { + if (currentPoint) { + var point = currentPoint; + var pointTraceData = point.data.traceData[point.pointIndex]; + + chartInterop.invokeMethodAsync('ViewSpan', pointTraceData.traceId, pointTraceData.spanId); + } + }); }; function registerLocale(serverLocale) { diff --git a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs index 8e0bdcbe15..e08c74f94a 100644 --- a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs +++ b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs @@ -89,6 +89,8 @@ public static void AddOtlpEnvironment(IResource resource, IConfiguration configu // Configure trace sampler to send all traces to the dashboard. context.EnvironmentVariables["OTEL_TRACES_SAMPLER"] = "always_on"; + // Configure metrics to include exemplars. + context.EnvironmentVariables["OTEL_METRICS_EXEMPLAR_FILTER"] = "trace_based"; } })); diff --git a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj index 0b19d42404..60b78defb1 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj +++ b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj @@ -2,6 +2,10 @@ $(NetCurrent) + + $(NoWarn);CS8002 diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs index 86ab0d1c7c..ead36db1a8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs @@ -5,9 +5,11 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Otlp.Storage; using Bunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.FluentUI.AspNetCore.Components; using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Metrics.V1; using Xunit; @@ -26,6 +28,8 @@ public void Render_NoInstrument_NoPlotlyInvocations() Services.AddLocalization(); Services.AddSingleton(); Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); var model = new InstrumentViewModel(); @@ -50,6 +54,8 @@ public async Task Render_HasInstrument_InitializeChartInvocation() Services.AddLocalization(); Services.AddSingleton(); Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); var options = new TelemetryLimitOptions(); var instrument = new OtlpInstrument @@ -72,7 +78,7 @@ public async Task Render_HasInstrument_InitializeChartInvocation() AsInt = 1, StartTimeUnixNano = 0, TimeUnixNano = long.MaxValue - }); + }, options); await model.UpdateDataAsync(instrument, new List { @@ -101,7 +107,7 @@ public async Task Render_HasInstrument_InitializeChartInvocation() private sealed class TestInstrumentUnitResolver : IInstrumentUnitResolver { - public string ResolveDisplayedUnit(OtlpInstrument instrument) + public string ResolveDisplayedUnit(OtlpInstrument instrument, bool titleCase, bool pluralize) { return instrument.Unit; } diff --git a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj index cd0baa3ef6..001879a954 100644 --- a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj +++ b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj @@ -2,6 +2,10 @@ $(NetCurrent) + + $(NoWarn);CS8002 true true diff --git a/tests/Aspire.Dashboard.Tests/FormatHelpersTests.cs b/tests/Aspire.Dashboard.Tests/FormatHelpersTests.cs index 6e3741e4fc..1dc9b2fb3b 100644 --- a/tests/Aspire.Dashboard.Tests/FormatHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/FormatHelpersTests.cs @@ -19,7 +19,7 @@ public class FormatHelpersTests [InlineData("1.234568", 1.23456789d)] public void FormatNumberWithOptionalDecimalPlaces_InvariantCulture(string expected, double value) { - Assert.Equal(expected, FormatHelpers.FormatNumberWithOptionalDecimalPlaces(value, CultureInfo.InvariantCulture)); + Assert.Equal(expected, FormatHelpers.FormatNumberWithOptionalDecimalPlaces(value, maxDecimalPlaces: 6, CultureInfo.InvariantCulture)); } [Theory] @@ -30,7 +30,7 @@ public void FormatNumberWithOptionalDecimalPlaces_InvariantCulture(string expect [InlineData("1,234568", 1.23456789d)] public void FormatNumberWithOptionalDecimalPlaces_GermanCulture(string expected, double value) { - Assert.Equal(expected, FormatHelpers.FormatNumberWithOptionalDecimalPlaces(value, CultureInfo.GetCultureInfo("de-DE"))); + Assert.Equal(expected, FormatHelpers.FormatNumberWithOptionalDecimalPlaces(value, maxDecimalPlaces: 6, CultureInfo.GetCultureInfo("de-DE"))); } [Theory] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 3f3fb1ab5b..a1006360e1 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.InteropServices; +using System.Text; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; +using Google.Protobuf; using Google.Protobuf.Collections; +using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Metrics.V1; using Xunit; using static Aspire.Dashboard.Tests.TelemetryRepositoryTests.TestHelpers; @@ -238,7 +241,7 @@ public void GetInstrument() Scope = CreateScope(name: "test-meter"), Metrics = { - CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1), exemplars: new List { CreateExemplar(startTime: s_testTime.AddMinutes(1), value: 2, attributes: [KeyValuePair.Create("key1", "value1")]) }), CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(2)), CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1), attributes: [KeyValuePair.Create("key1", "value1")]), CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1), attributes: [KeyValuePair.Create("key1", "value2")]), @@ -279,7 +282,7 @@ public void GetInstrument() e => { Assert.Equal("key1", e.Key); - Assert.Equal(new [] { "", "value1", "value2" }, e.Value); + Assert.Equal(new[] { "", "value1", "value2" }, e.Value); }, e => { @@ -290,11 +293,38 @@ public void GetInstrument() Assert.Equal(4, instrument.Dimensions.Count); AssertDimensionValues(instrument.Dimensions, Array.Empty>(), valueCount: 1); + var dimension = instrument.Dimensions[Array.Empty>()]; + var exemplar = Assert.Single(dimension.Values[0].Exemplars); + + Assert.Equal("key1", exemplar.Attributes[0].Key); + Assert.Equal("value1", exemplar.Attributes[0].Value); + AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value1") }, valueCount: 1); AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value2") }, valueCount: 1); AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value1"), KeyValuePair.Create("key2", "value1") }, valueCount: 1); } + private static Exemplar CreateExemplar(DateTime startTime, double value, IEnumerable>? attributes = null) + { + var exemplar = new Exemplar + { + TimeUnixNano = DateTimeToUnixNanoseconds(startTime), + AsDouble = value, + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("span-id")), + TraceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("trace-id")) + }; + + if (attributes != null) + { + foreach (var attribute in attributes) + { + exemplar.FilteredAttributes.Add(new KeyValue { Key = attribute.Key, Value = new AnyValue { StringValue = attribute.Value } }); + } + } + + return exemplar; + } + [Fact] public void AddMetrics_Capacity_ValuesRemoved() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs index d041b66915..df03834351 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs @@ -80,7 +80,7 @@ public static Metric CreateHistogramMetric(string metricName, DateTime startTime }; } - public static Metric CreateSumMetric(string metricName, DateTime startTime, IEnumerable>? attributes = null, int? value = null) + public static Metric CreateSumMetric(string metricName, DateTime startTime, IEnumerable>? attributes = null, IEnumerable? exemplars = null, int? value = null) { return new Metric { @@ -93,13 +93,13 @@ public static Metric CreateSumMetric(string metricName, DateTime startTime, IEnu IsMonotonic = true, DataPoints = { - CreateNumberPoint(startTime, value ?? 1, attributes) + CreateNumberPoint(startTime, value ?? 1, attributes, exemplars) } } }; } - private static NumberDataPoint CreateNumberPoint(DateTime startTime, int value, IEnumerable>? attributes = null) + private static NumberDataPoint CreateNumberPoint(DateTime startTime, int value, IEnumerable>? attributes = null, IEnumerable? exemplars = null) { var point = new NumberDataPoint { @@ -114,6 +114,13 @@ private static NumberDataPoint CreateNumberPoint(DateTime startTime, int value, point.Attributes.Add(new KeyValue { Key = attribute.Key, Value = new AnyValue { StringValue = attribute.Value } }); } } + if (exemplars != null) + { + foreach (var exemplar in exemplars) + { + point.Exemplars.Add(exemplar); + } + } return point; } diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 7bc44a9062..69125bdb14 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -109,6 +109,11 @@ public async Task AddProjectAddsEnvironmentVariablesAndServiceMetadata() Assert.Equal("always_on", env.Value); }, env => + { + Assert.Equal("OTEL_METRICS_EXEMPLAR_FILTER", env.Key); + Assert.Equal("trace_based", env.Value); + }, + env => { Assert.Equal("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", env.Key); Assert.Equal("true", env.Value);