From a3be023f73808ea3e8ec353d7adddd4555b5d430 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 26 Apr 2024 07:22:13 +0800 Subject: [PATCH 1/2] Span links --- .../Stress/Stress.ApiService/TraceCreator.cs | 81 ++++++- .../Components/Controls/Chart/ChartBase.cs | 7 +- .../Controls/Chart/PlotlyChart.razor.cs | 3 +- .../Components/Controls/SpanDetails.razor | 72 ++++++- .../Components/Controls/SpanDetails.razor.cs | 43 +++- .../Components/Controls/SpanDetails.razor.css | 13 +- .../Dialogs/ExemplarsDialog.razor.cs | 3 +- .../Components/Pages/TraceDetail.razor.cs | 32 ++- src/Aspire.Dashboard/Model/MetricsHelpers.cs | 12 -- .../Model/SpanDetailsViewModel.cs | 3 + .../Model/SpanLinkViewModel.cs | 14 ++ src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs | 6 +- .../Otlp/Model/OtlpSpanLink.cs | 17 ++ .../Otlp/Storage/TelemetryRepository.cs | 138 +++++++++++- .../Resources/ControlsStrings.Designer.cs | 45 ++++ .../Resources/ControlsStrings.resx | 15 ++ .../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 +++ src/Shared/CircularBuffer.cs | 13 ++ .../CircularBufferTests.cs | 33 +++ .../TelemetryRepositoryTests/TestHelpers.cs | 6 +- .../TelemetryRepositoryTests/TraceTests.cs | 200 +++++++++++++++++- 33 files changed, 1041 insertions(+), 40 deletions(-) create mode 100644 src/Aspire.Dashboard/Model/SpanLinkViewModel.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpSpanLink.cs diff --git a/playground/Stress/Stress.ApiService/TraceCreator.cs b/playground/Stress/Stress.ApiService/TraceCreator.cs index 7477eb1426..5a7accc699 100644 --- a/playground/Stress/Stress.ApiService/TraceCreator.cs +++ b/playground/Stress/Stress.ApiService/TraceCreator.cs @@ -11,9 +11,13 @@ public class TraceCreator private static readonly ActivitySource s_activitySource = new(ActivitySourceName); + private readonly List _allActivities = new List(); + public async Task CreateTraceAsync(int count, bool createChildren) { - for (var i = 0; i < count; i++) + var activityStack = new Stack(); + + for (var i = 0; i < 10; i++) { if (i > 0) { @@ -27,19 +31,39 @@ public async Task CreateTraceAsync(int count, bool createChildren) continue; } + _allActivities.Add(activity); + if (createChildren) { await CreateChildActivityAsync(name); } + + await Task.Delay(Random.Shared.Next(10, 50)); + } + + while (activityStack.Count > 0) + { + activityStack.Pop().Stop(); } } - private static async Task CreateChildActivityAsync(string parentName) + private async Task CreateChildActivityAsync(string parentName) { if (Random.Shared.NextDouble() > 0.05) { var name = parentName + "-0"; - using var activity = s_activitySource.StartActivity(name, ActivityKind.Client); + + var links = CreateLinks(); + + using var activity = s_activitySource.StartActivity(ActivityKind.Client, name: name, links: links.DistinctBy(l => l.Context.SpanId)); + if (activity == null) + { + return; + } + + AddEvents(activity); + + _allActivities.Add(activity); await Task.Delay(Random.Shared.Next(10, 50)); @@ -48,4 +72,55 @@ private static async Task CreateChildActivityAsync(string parentName) await Task.Delay(Random.Shared.Next(10, 50)); } } + + private static void AddEvents(Activity activity) + { + var eventCount = Random.Shared.Next(0, 5); + for (var i = 0; i < eventCount; i++) + { + var activityTags = new ActivityTagsCollection(); + var tagsCount = Random.Shared.Next(0, 3); + for (var j = 0; j < tagsCount; j++) + { + activityTags.Add($"key-{j}", "Value!"); + } + + activity.AddEvent(new ActivityEvent($"event-{i}", DateTimeOffset.UtcNow.AddMilliseconds(1), activityTags)); + } + } + + private ActivityLink[] CreateLinks() + { + var activityLinkCount = Random.Shared.Next(0, Math.Min(5, _allActivities.Count)); + var links = new ActivityLink[activityLinkCount]; + for (var i = 0; i < links.Length; i++) + { + // Randomly create some tags. + var activityTags = new ActivityTagsCollection(); + var tagsCount = Random.Shared.Next(0, 3); + for (var j = 0; j < tagsCount; j++) + { + activityTags.Add($"key-{j}", "Value!"); + } + + // Create the activity link. There is a 50% chance the activity link goes to an activity + // that doesn't exist. This logic is here to ensure incomplete links are handled correctly. + ActivityContext activityContext; + if (Random.Shared.Next() % 2 == 0) + { + var a = _allActivities[Random.Shared.Next(0, _allActivities.Count)]; + activityContext = a.Context; + } + else + { + activityContext = new ActivityContext( + ActivityTraceId.CreateRandom(), + ActivitySpanId.CreateRandom(), + ActivityTraceFlags.None); + } + links[i] = new ActivityLink(activityContext, activityTags); + } + + return links; + } } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index e994a989e5..7c1b9087d4 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -303,7 +303,7 @@ private void AddExemplars(List exemplars, MetricValueBase metric) var key = new SpanKey(exemplar.TraceId, exemplar.SpanId); if (!_currentCache.TryGetValue(key, out var span)) { - span = GetSpan(exemplar.TraceId, exemplar.SpanId); + span = TelemetryRepository.GetSpan(exemplar.TraceId, exemplar.SpanId); } if (span != null) { @@ -505,11 +505,6 @@ private async Task UpdateChart(bool tickUpdate, DateTimeOffset inProgressDataTim await OnChartUpdated(traces, xValues, exemplars, tickUpdate, inProgressDataTime); } - protected OtlpSpan? GetSpan(string traceId, string spanId) - { - return MetricsHelpers.GetSpan(TelemetryRepository, traceId, spanId); - } - private DateTimeOffset GetCurrentDataTime() { return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); // Compensate for delay in receiving metrics from services. diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs index a713a4182b..ccc6ff6a33 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs @@ -199,6 +199,7 @@ public ChartInterop(PlotlyChart plotlyChart) public void Dispose() { _cts.Cancel(); + _cts.Dispose(); } [JSInvokable] @@ -207,7 +208,7 @@ public async Task ViewSpan(string traceId, string spanId) var available = await MetricsHelpers.WaitForSpanToBeAvailableAsync( traceId, spanId, - _plotlyChart.GetSpan, + _plotlyChart.TelemetryRepository.GetSpan, _plotlyChart.DialogService, _plotlyChart.InvokeAsync, _plotlyChart.DialogsLoc, diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor index 761d3561f3..893f07172c 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor @@ -1,8 +1,10 @@ @using Aspire.Dashboard.Model +@using Aspire.Dashboard.Model.Otlp @using Aspire.Dashboard.Otlp.Model @using Aspire.Dashboard.Resources @using Aspire.Dashboard.Utils @inject IStringLocalizer Loc +@inject IStringLocalizer DialogsLoc
@@ -91,8 +93,6 @@ @if (context.Attributes.Length > 0) { -
@Loc[nameof(ControlsStrings.TraceDetailAttributesHeader)]
- /* There's a weird bug where trying to render a nested FluentDataGrid here with a non-primitive TItem leads to the click event not being raised *only in the value (not header) rows*. A workaround is to use the attribute index as the item and query the attributes list for its @@ -109,6 +109,74 @@
+ +
+ + @FilteredSpanLinks.Count() + +
+ + + @WriteSpanLink(context) + + + View + + +
+ +
+ + @FilteredSpanBacklinks.Count() + +
+ + + @WriteSpanLink(context) + + + View + + +
+ +@code { + RenderFragment WriteSpanLink(SpanLinkViewModel context) + { + var content = context.Span != null + ? SpanWaterfallViewModel.GetTitle(context.Span, ViewModel.Applications) + : $"{Loc[nameof(ControlsStrings.SpanDetailsSpanPrefix)]}: {OtlpHelpers.ToShortenedId(context.SpanId)}"; + var color = context.Span != null + ? ColorGenerator.Instance.GetColorHexByKey(OtlpApplication.GetResourceName(context.Span.Source, ViewModel.Applications)) + : "transparent"; + + return @
+ + @if (context.Attributes.Length > 0) + { + + } +
; + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs index a0c1a3fa1d..aa66bc740f 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs @@ -3,16 +3,27 @@ using Aspire.Dashboard.Model; 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.Controls; -public partial class SpanDetails +public partial class SpanDetails : IDisposable { [Parameter, EditorRequired] public required SpanDetailsViewModel ViewModel { get; set; } + [Inject] + public required IDialogService DialogService { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required TelemetryRepository TelemetryRepository { get; init; } + private IQueryable FilteredItems => ViewModel.Properties.Where(ApplyFilter).AsQueryable(); @@ -27,8 +38,15 @@ public partial class SpanDetails private IQueryable FilteredSpanEvents => ViewModel.Span.Events.Where(e => e.Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase)).OrderBy(e => e.Time).AsQueryable(); + private IQueryable FilteredSpanLinks => + ViewModel.Links.Where(e => e.SpanId.Contains(_filter, StringComparison.CurrentCultureIgnoreCase)).AsQueryable(); + + private IQueryable FilteredSpanBacklinks => + ViewModel.Backlinks.Where(e => e.SpanId.Contains(_filter, StringComparison.CurrentCultureIgnoreCase)).AsQueryable(); + private string _filter = ""; private List> _contextAttributes = null!; + private readonly CancellationTokenSource _cts = new(); private readonly GridSort _nameSort = GridSort.ByAscending(vm => vm.Name); private readonly GridSort _valueSort = GridSort.ByAscending(vm => vm.Value); @@ -58,4 +76,27 @@ protected override void OnParametersSet() _contextAttributes.Add(new KeyValuePair("TraceId", ViewModel.Span.TraceId)); } } + + public async Task OnViewDetailsAsync(SpanLinkViewModel linkVM) + { + var available = await MetricsHelpers.WaitForSpanToBeAvailableAsync( + traceId: linkVM.TraceId, + spanId: linkVM.SpanId, + getSpan: TelemetryRepository.GetSpan, + DialogService, + InvokeAsync, + DialogsLoc, + _cts.Token).ConfigureAwait(false); + + if (available) + { + NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(linkVM.TraceId, spanId: linkVM.SpanId)); + } + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } } diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.css b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.css index b503e0380b..c1cf968dc7 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.css @@ -18,8 +18,15 @@ margin-bottom: 0; } -::deep .event-attributes-header { - font-weight: bold; - margin: 5px 0; +::deep .spanlink-container { + display: grid; + grid-template-columns: 1fr auto; + gap: calc((6 + (var(--design-unit) * var(--density))) * 1px); + align-items: center; } +::deep .spanlink-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs index 1b0a0c661c..a8cc72113a 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs @@ -42,7 +42,7 @@ 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), + getSpan: TelemetryRepository.GetSpan, DialogService, InvokeAsync, Loc, @@ -79,6 +79,7 @@ private string FormatMetricValue(double? value) public void Dispose() { + _cts.Cancel(); _cts.Dispose(); } } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index f76163733e..a393f3f9f1 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -278,17 +278,47 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin .Select(kvp => new SpanPropertyViewModel { Name = kvp.Key, Value = kvp.Value }) .ToList(); + var traceCache = new Dictionary(StringComparer.Ordinal); + + var links = viewModel.Span.Links.Select(l => CreateLinkViewModel(l.TraceId, l.SpanId, l.Attributes, traceCache)).ToList(); + var backlinks = viewModel.Span.BackLinks.Select(l => CreateLinkViewModel(l.SourceTraceId, l.SourceSpanId, l.Attributes, traceCache)).ToList(); + var spanDetailsViewModel = new SpanDetailsViewModel { Span = viewModel.Span, + Applications = _applications, Properties = entryProperties, - Title = SpanWaterfallViewModel.GetTitle(viewModel.Span, _applications) + Title = SpanWaterfallViewModel.GetTitle(viewModel.Span, _applications), + Links = links, + Backlinks = backlinks, }; SelectedSpan = spanDetailsViewModel; } } + private SpanLinkViewModel CreateLinkViewModel(string traceId, string spanId, KeyValuePair[] attributes, Dictionary traceCache) + { + if (!traceCache.TryGetValue(traceId, out var trace)) + { + trace = TelemetryRepository.GetTrace(traceId); + if (trace != null) + { + traceCache[traceId] = trace; + } + } + + var linkSpan = trace?.Spans.FirstOrDefault(s => s.SpanId == spanId); + + return new SpanLinkViewModel + { + TraceId = traceId, + SpanId = spanId, + Attributes = attributes, + Span = linkSpan, + }; + } + private async Task ClearSelectedSpanAsync(bool causedByUserAction = false) { SelectedSpan = null; diff --git a/src/Aspire.Dashboard/Model/MetricsHelpers.cs b/src/Aspire.Dashboard/Model/MetricsHelpers.cs index e16457b75f..592f434132 100644 --- a/src/Aspire.Dashboard/Model/MetricsHelpers.cs +++ b/src/Aspire.Dashboard/Model/MetricsHelpers.cs @@ -3,7 +3,6 @@ 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; @@ -13,17 +12,6 @@ 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, diff --git a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs index 31bcd2ea3b..706eee05a4 100644 --- a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs +++ b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs @@ -9,5 +9,8 @@ public sealed class SpanDetailsViewModel { public required OtlpSpan Span { get; init; } public required List Properties { get; init; } + public required List Links { get; init; } + public required List Backlinks { get; init; } public required string Title { get; init; } + public required List Applications { get; init; } } diff --git a/src/Aspire.Dashboard/Model/SpanLinkViewModel.cs b/src/Aspire.Dashboard/Model/SpanLinkViewModel.cs new file mode 100644 index 0000000000..4f841951a4 --- /dev/null +++ b/src/Aspire.Dashboard/Model/SpanLinkViewModel.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.Otlp.Model; + +namespace Aspire.Dashboard.Model; + +public sealed class SpanLinkViewModel +{ + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public required KeyValuePair[] Attributes { get; init; } + public required OtlpSpan? Span { get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs index 7aeb623553..c3486040ce 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs @@ -34,6 +34,8 @@ public class OtlpSpan public required string? State { get; init; } public required KeyValuePair[] Attributes { get; init; } public required List Events { get; init; } + public required List Links { get; init; } + public required List BackLinks { get; init; } public OtlpScope Scope { get; } public TimeSpan Duration => EndTime - StartTime; @@ -62,7 +64,9 @@ public static OtlpSpan Clone(OtlpSpan item, OtlpTrace trace) StatusMessage = item.StatusMessage, State = item.State, Attributes = item.Attributes, - Events = item.Events + Events = item.Events, + Links = item.Links, + BackLinks = item.BackLinks, }; } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanLink.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanLink.cs new file mode 100644 index 0000000000..7c161285fe --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanLink.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; + +namespace Aspire.Dashboard.Otlp.Model; + +[DebuggerDisplay("TraceId = {TraceId}, SpanId = {SpanId}, SourceTraceId = {SourceTraceId}, SourceSpanId = {SourceSpanId}")] +public class OtlpSpanLink +{ + public required string SourceTraceId { get; init; } + public required string SourceSpanId { get; init; } + public required string TraceState { 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/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 52a1380140..03a23f515a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -4,6 +4,8 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Otlp.Model; using Google.Protobuf.Collections; @@ -38,11 +40,15 @@ public sealed class TelemetryRepository private readonly ReaderWriterLockSlim _tracesLock = new(); private readonly Dictionary _traceScopes = new(); private readonly CircularBuffer _traces; + private readonly List _spanLinks = new(); private readonly DashboardOptions _dashboardOptions; public bool HasDisplayedMaxLogLimitMessage { get; set; } public bool HasDisplayedMaxTraceLimitMessage { get; set; } + // For testing. + internal List SpanLinks => _spanLinks; + public TelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions) { _logger = loggerFactory.CreateLogger(typeof(TelemetryRepository)); @@ -50,6 +56,19 @@ public TelemetryRepository(ILoggerFactory loggerFactory, IOptions GetApplications() @@ -413,6 +432,20 @@ public GetTracesResponse GetTraces(GetTracesRequest context) { _tracesLock.EnterReadLock(); + try + { + return GetTraceUnsynchronized(traceId); + } + finally + { + _tracesLock.ExitReadLock(); + } + } + + private OtlpTrace? GetTraceUnsynchronized(string traceId) + { + Debug.Assert(_tracesLock.IsReadLockHeld || _tracesLock.IsWriteLockHeld, $"Must get lock before calling {nameof(GetTraceUnsynchronized)}."); + try { var results = _traces.Where(t => t.TraceId.StartsWith(traceId, StringComparison.Ordinal)); @@ -423,6 +456,35 @@ public GetTracesResponse GetTraces(GetTracesRequest context) { throw new InvalidOperationException($"Multiple traces found with trace id '{traceId}'.", ex); } + } + + private OtlpSpan? GetSpanUnsynchronized(string traceId, string spanId) + { + Debug.Assert(_tracesLock.IsReadLockHeld || _tracesLock.IsWriteLockHeld, $"Must get lock before calling {nameof(GetSpanUnsynchronized)}."); + + var trace = GetTraceUnsynchronized(traceId); + if (trace != null) + { + foreach (var span in trace.Spans) + { + if (span.SpanId == spanId) + { + return span; + } + } + } + + return null; + } + + public OtlpSpan? GetSpan(string traceId, string spanId) + { + _tracesLock.EnterReadLock(); + + try + { + return GetSpanUnsynchronized(traceId, spanId); + } finally { _tracesLock.ExitReadLock(); @@ -563,6 +625,25 @@ internal void AddTracesCore(AddContext context, OtlpApplication application, Rep var newSpan = CreateSpan(application, span, trace, scope, _dashboardOptions.TelemetryLimits); trace.AddSpan(newSpan); + // The new span might be linked to by an existing span. + // Check current links to see if a backlink should be created. + foreach (var existingLink in _spanLinks) + { + if (existingLink.SpanId == newSpan.SpanId && existingLink.TraceId == newSpan.TraceId) + { + newSpan.BackLinks.Add(existingLink); + } + } + + // Add links to central collection. Add backlinks to existing spans. + foreach (var link in newSpan.Links) + { + _spanLinks.Add(link); + + var linkedSpan = GetSpanUnsynchronized(link.TraceId, link.SpanId); + linkedSpan?.BackLinks.Add(link); + } + // Traces are sorted by the start time of the first span. // We need to ensure traces are in the correct order if we're: // 1. Adding a new trace. @@ -627,6 +708,7 @@ internal void AddTracesCore(AddContext context, OtlpApplication application, Rep } AssertTraceOrder(); + AssertSpanLinks(); } } @@ -669,6 +751,42 @@ private void AssertTraceOrder() } } + [Conditional("DEBUG")] + private void AssertSpanLinks() + { + // Create a local copy of span links. + var currentSpanLinks = _spanLinks.ToList(); + + // Remove span links that match span links on spans. + // Throw an error if an expected span link doesn't exist. + foreach (var trace in _traces) + { + foreach (var span in trace.Spans) + { + foreach (var link in span.Links) + { + if (!currentSpanLinks.Remove(link)) + { + throw new InvalidOperationException($"Couldn't find expected link from span {span.SpanId} to span {link.SpanId}."); + } + } + } + } + + // Throw error if there are orphaned span links. + if (currentSpanLinks.Count > 0) + { + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"There are {currentSpanLinks.Count} orphaned span links."); + foreach (var link in currentSpanLinks) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"\tSource span ID: {link.SourceSpanId}, Target span ID: {link.SpanId}"); + } + + throw new InvalidOperationException(sb.ToString()); + } + } + private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpTrace trace, OtlpScope scope, TelemetryLimitOptions options) { var id = span.SpanId?.ToHexString(); @@ -680,7 +798,7 @@ private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpT var events = new List(); foreach (var e in span.Events.OrderBy(e => e.TimeUnixNano)) { - events.Add(new OtlpSpanEvent() + events.Add(new OtlpSpanEvent { Name = e.Name, Time = OtlpHelpers.UnixNanoSecondsToDateTime(e.TimeUnixNano), @@ -693,6 +811,20 @@ private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpT } } + var links = new List(); + foreach (var e in span.Links) + { + links.Add(new OtlpSpanLink + { + SourceSpanId = id, + SourceTraceId = trace.TraceId, + TraceState = e.TraceState, + SpanId = e.SpanId.ToHexString(), + TraceId = e.TraceId.ToHexString(), + Attributes = e.Attributes.ToKeyValuePairs(options) + }); + } + var newSpan = new OtlpSpan(application, trace, scope) { SpanId = id, @@ -705,7 +837,9 @@ private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpT StatusMessage = span.Status?.Message, Attributes = span.Attributes.ToKeyValuePairs(options), State = span.TraceState, - Events = events + Events = events, + Links = links, + BackLinks = new() }; return newSpan; } diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 7636c347c3..8c51ec6dcb 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -501,6 +501,15 @@ public static string SelectAResource { } } + /// + /// Looks up a localized string similar to Backlinks. + /// + public static string SpanDetailsBacklinksHeader { + get { + return ResourceManager.GetString("SpanDetailsBacklinksHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Context. /// @@ -510,6 +519,15 @@ public static string SpanDetailsContextHeader { } } + /// + /// Looks up a localized string similar to Details. + /// + public static string SpanDetailsDetailsColumnHeader { + get { + return ResourceManager.GetString("SpanDetailsDetailsColumnHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Duration <strong>{0}</strong>. /// @@ -528,6 +546,15 @@ public static string SpanDetailsEventsHeader { } } + /// + /// Looks up a localized string similar to Links. + /// + public static string SpanDetailsLinksHeader { + get { + return ResourceManager.GetString("SpanDetailsLinksHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resource <strong>{0}</strong>. /// @@ -546,6 +573,15 @@ public static string SpanDetailsResourceHeader { } } + /// + /// Looks up a localized string similar to Span. + /// + public static string SpanDetailsSpanColumnHeader { + get { + return ResourceManager.GetString("SpanDetailsSpanColumnHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Span. /// @@ -555,6 +591,15 @@ public static string SpanDetailsSpanHeader { } } + /// + /// Looks up a localized string similar to Span. + /// + public static string SpanDetailsSpanPrefix { + get { + return ResourceManager.GetString("SpanDetailsSpanPrefix", resourceCulture); + } + } + /// /// Looks up a localized string similar to Start time <strong>{0}</strong>. /// diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 8b56f90f7e..bba7ae6f87 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -341,4 +341,19 @@ Trace + + Links + + + Span + + + Span + + + Details + + + Backlinks + \ 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 55649046cc..ee38fd1e5d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -247,11 +247,21 @@ Vybrat aplikaci + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Doba trvání: <strong>{0}</strong> @@ -262,6 +272,11 @@ Události + + Links + Links + + Resource <strong>{0}</strong> Prostředek <strong>{0}</strong> @@ -272,11 +287,21 @@ Prostředek + + Span + Span + + Span Rozpětí + + Span + Span + + Start time <strong>{0}</strong> Čas spuštění: <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index d9383223b3..28962af2a4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -247,11 +247,21 @@ Anwendung auswählen + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Dauer <strong>{0}</strong> @@ -262,6 +272,11 @@ Ereignisse + + Links + Links + + Resource <strong>{0}</strong> Ressource <strong>{0}</strong> @@ -272,11 +287,21 @@ Ressource + + Span + Span + + Span Span + + Span + Span + + Start time <strong>{0}</strong> Startzeit <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index d782d3afad..f61a2566ec 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -247,11 +247,21 @@ Seleccionar una aplicación + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Duración <strong>{0}</strong> @@ -262,6 +272,11 @@ Eventos + + Links + Links + + Resource <strong>{0}</strong> Recurso <strong>{0}</strong> @@ -272,11 +287,21 @@ Recurso + + Span + Span + + Span Span + + Span + Span + + Start time <strong>{0}</strong> Hora de inicio <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index ddf4f6c0e7..e313610ab9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -247,11 +247,21 @@ Sélectionner une application + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Durée <strong>{0}</strong> @@ -262,6 +272,11 @@ Événements + + Links + Links + + Resource <strong>{0}</strong> Ressource <strong>{0}</strong> @@ -272,11 +287,21 @@ Ressource + + Span + Span + + Span Étendue + + Span + Span + + Start time <strong>{0}</strong> <strong>Heure de début : </strong> {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index 22b5a63849..188a8992b0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -247,11 +247,21 @@ Seleziona un'applicazione + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Durata <strong>{0}</strong> @@ -262,6 +272,11 @@ Eventi + + Links + Links + + Resource <strong>{0}</strong> Risorsa <strong>{0}</strong> @@ -272,11 +287,21 @@ Risorsa + + Span + Span + + Span Span + + Span + Span + + Start time <strong>{0}</strong> Ora di inizio <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index 32db1c8d6f..2cf4484b3d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -247,11 +247,21 @@ アプリケーションを選択する + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> 期間 <strong>{0}</strong> @@ -262,6 +272,11 @@ イベント + + Links + Links + + Resource <strong>{0}</strong> リソース <strong>{0}</strong> @@ -272,11 +287,21 @@ リソース + + Span + Span + + Span スパン + + Span + Span + + Start time <strong>{0}</strong> 開始時刻 <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index f9039fdf04..bc706a184e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -247,11 +247,21 @@ 애플리케이션 선택 + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> 기간 <strong>{0}</strong> @@ -262,6 +272,11 @@ 이벤트 + + Links + Links + + Resource <strong>{0}</strong> 리소스 <strong>{0}</strong> @@ -272,11 +287,21 @@ 리소스 + + Span + Span + + Span 범위 + + Span + Span + + Start time <strong>{0}</strong> 시작 시간 <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index 4b3586ac03..0d7fb60b76 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -247,11 +247,21 @@ Wybierz aplikację + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Czas trwania: <strong>{0}</strong> @@ -262,6 +272,11 @@ Zdarzenia + + Links + Links + + Resource <strong>{0}</strong> Zasób <strong>{0}</strong> @@ -272,11 +287,21 @@ Zasób + + Span + Span + + Span Zakres + + Span + Span + + Start time <strong>{0}</strong> Czas rozpoczęcia: <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index 433c8e7d67..31fdcea6a6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -247,11 +247,21 @@ Selecionar um aplicativo + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Duração <strong>{0}</strong> @@ -262,6 +272,11 @@ Eventos + + Links + Links + + Resource <strong>{0}</strong> Recurso <strong>{0}</strong> @@ -272,11 +287,21 @@ Recurso + + Span + Span + + Span Extensão + + Span + Span + + Start time <strong>{0}</strong> Horário de início <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index 12ce8f3a40..904b86dd0a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -247,11 +247,21 @@ Выберите приложение + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Длительность: <strong>{0}</strong> @@ -262,6 +272,11 @@ События + + Links + Links + + Resource <strong>{0}</strong> Ресурс <strong>{0}</strong> @@ -272,11 +287,21 @@ Ресурс + + Span + Span + + Span Диапазон + + Span + Span + + Start time <strong>{0}</strong> <strong>Время начала: </strong> {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index 1625de5fde..5123f1c687 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -247,11 +247,21 @@ Uygulama seçin + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> Süre <strong>{0}</strong> @@ -262,6 +272,11 @@ Etkinlikler + + Links + Links + + Resource <strong>{0}</strong> Kaynak <strong>{0}</strong> @@ -272,11 +287,21 @@ Kaynak + + Span + Span + + Span Yayılma + + Span + Span + + Start time <strong>{0}</strong> Başlangıç zamanı <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index 5309367b62..65ae819bf9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -247,11 +247,21 @@ 选择应用程序 + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> 持续时间 <strong>{0}</strong> @@ -262,6 +272,11 @@ 事件 + + Links + Links + + Resource <strong>{0}</strong> 资源 <strong>{0}</strong> @@ -272,11 +287,21 @@ 资源 + + Span + Span + + Span 范围 + + Span + Span + + Start time <strong>{0}</strong> 开始时间 <strong>{0}</strong> diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index e001dae8bd..36e09002e2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -247,11 +247,21 @@ 選取應用程式 + + Backlinks + Backlinks + + Context Context + + Details + Details + + Duration <strong>{0}</strong> 期間 <strong>{0}</strong> @@ -262,6 +272,11 @@ 事件 + + Links + Links + + Resource <strong>{0}</strong> 資源 <strong>{0}</strong> @@ -272,11 +287,21 @@ 資源 + + Span + Span + + Span 跨時 + + Span + Span + + Start time <strong>{0}</strong> 開始時間 <strong>{0}</strong> diff --git a/src/Shared/CircularBuffer.cs b/src/Shared/CircularBuffer.cs index ce731bb5a3..35535af710 100644 --- a/src/Shared/CircularBuffer.cs +++ b/src/Shared/CircularBuffer.cs @@ -20,6 +20,8 @@ internal sealed class CircularBuffer : IList, ICollection, IEnumerable< internal int _start; internal int _end; + public event Action? ItemRemovedForCapacity; + public CircularBuffer(int capacity) : this(new List(), capacity, start: 0, end: 0) { } @@ -81,8 +83,13 @@ public void Insert(int index, T item) if (index == 0) { // When full, the item inserted at 0 is always the "last" in the buffer and is removed. + ItemRemovedForCapacity?.Invoke(item); return; } + else + { + ItemRemovedForCapacity?.Invoke(this[0]); + } var internalIndex = InternalIndex(index); @@ -106,6 +113,10 @@ public void Insert(int index, T item) } data[0] = overflowItem; } + else if (internalIndex < _end && _end < _buffer.Count - 1) + { + data.Slice(internalIndex, _end - internalIndex).CopyTo(data.Slice(_end)); + } else { data.Slice(internalIndex, data.Length - internalIndex - 1).CopyTo(data.Slice(internalIndex + 1)); @@ -181,6 +192,8 @@ public void Add(T item) { if (IsFull) { + ItemRemovedForCapacity?.Invoke(this[0]); + _buffer[_end] = item; Increment(ref _end); _start = _end; diff --git a/tests/Aspire.Dashboard.Tests/CircularBufferTests.cs b/tests/Aspire.Dashboard.Tests/CircularBufferTests.cs index 135bdfc3d5..b3e323c2cd 100644 --- a/tests/Aspire.Dashboard.Tests/CircularBufferTests.cs +++ b/tests/Aspire.Dashboard.Tests/CircularBufferTests.cs @@ -590,4 +590,37 @@ public void RemoveAtEndToZero() b.RemoveAt(0); Assert.Empty(b); } + + [Fact] + public void Insert_BeforeEnd_EndInMiddle() + { + var values = new List + { + "10", + "12", + "0", + "2", + "2", + "4", + "4", + "6", + "6", + "8", + }; + + var buffer = new CircularBuffer(values, capacity: 10, start: 2, end: 2); + buffer.Insert(9, "11"); + + Assert.Collection(buffer, + i => Assert.Equal("2", i), + i => Assert.Equal("2", i), + i => Assert.Equal("4", i), + i => Assert.Equal("4", i), + i => Assert.Equal("6", i), + i => Assert.Equal("6", i), + i => Assert.Equal("8", i), + i => Assert.Equal("10", i), + i => Assert.Equal("11", i), + i => Assert.Equal("12", i)); + } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs index df03834351..3683f6bdfd 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TestHelpers.cs @@ -143,7 +143,7 @@ public static Span.Types.Event CreateSpanEvent(string name, int startTime, IEnum return e; } - public static Span CreateSpan(string traceId, string spanId, DateTime startTime, DateTime endTime, string? parentSpanId = null, List? events = null, IEnumerable>? attributes = null) + public static Span CreateSpan(string traceId, string spanId, DateTime startTime, DateTime endTime, string? parentSpanId = null, List? events = null, List? links = null, IEnumerable>? attributes = null) { var span = new Span { @@ -158,6 +158,10 @@ public static Span CreateSpan(string traceId, string spanId, DateTime startTime, { span.Events.AddRange(events); } + if (links != null) + { + span.Links.AddRange(links); + } if (attributes != null) { foreach (var attribute in attributes) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 59bf908022..2d7bb08d2d 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Text; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Google.Protobuf; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Trace.V1; @@ -411,6 +413,114 @@ public void AddTraces_SpanEvents_ReturnData() }); } + [Fact] + public void AddTraces_SpanLinks_ReturnData() + { + // Arrange + var repository = CreateRepository(); + + // Act + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), links: new List + { + new Span.Types.Link + { + TraceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("1")), + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("1-1")), + Attributes = + { + new KeyValue { Key = "key2", Value = new AnyValue { StringValue = "Value!" } } + } + }, + new Span.Types.Link + { + TraceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("2")), + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("2-1")), + Attributes = + { + new KeyValue { Key = "key1", Value = new AnyValue { StringValue = "Value!" } } + } + } + }) + } + } + } + } + }); + + var traces = repository.GetTraces(new GetTracesRequest + { + ApplicationKey = null, + FilterText = string.Empty, + StartIndex = 0, + Count = 10 + }); + Assert.Collection(traces.PagedResult.Items, + trace => + { + AssertId("1", trace.TraceId); + AssertId("1-1", trace.FirstSpan.SpanId); + Assert.Collection(trace.FirstSpan.Links, + l => + { + AssertId("1", l.TraceId); + AssertId("1-1", l.SpanId); + Assert.Collection(l.Attributes, + a => + { + Assert.Equal("key2", a.Key); + Assert.Equal("Value!", a.Value); + }); + }, + l => + { + AssertId("2", l.TraceId); + AssertId("2-1", l.SpanId); + Assert.Collection(l.Attributes, + a => + { + Assert.Equal("key1", a.Key); + Assert.Equal("Value!", a.Value); + }); + }); + }); + + Assert.Collection(repository.SpanLinks, + l => + { + AssertId("1", l.TraceId); + AssertId("1-1", l.SpanId); + Assert.Collection(l.Attributes, + a => + { + Assert.Equal("key2", a.Key); + Assert.Equal("Value!", a.Value); + }); + }, + l => + { + AssertId("2", l.TraceId); + AssertId("2-1", l.SpanId); + Assert.Collection(l.Attributes, + a => + { + Assert.Equal("key1", a.Key); + Assert.Equal("Value!", a.Value); + }); + }); + } + [Fact] public void GetTraces_ReturnCopies() { @@ -563,23 +673,74 @@ public void AddTraces_AttributeAndEventLimits_LimitsApplied() Assert.Equal(5, trace.FirstSpan.Events[0].Attributes.Length); } + [Fact] + public void AddTraces_Links_BacklinksPopulated() + { + // Arrange + var repository = CreateRepository(); + + // Act + AddTrace(repository, "1", s_testTime); + var traces = repository.GetTraces(new GetTracesRequest + { + ApplicationKey = null, + FilterText = string.Empty, + StartIndex = 0, + Count = 10 + }); + + // Assert + var trace = Assert.Single(traces.PagedResult.Items); + + Assert.Collection(trace.Spans, + s => + { + var link = Assert.Single(s.Links); + AssertId("1-2", link.SpanId); + AssertId("1-1", link.SourceSpanId); + + var backLink = Assert.Single(s.BackLinks); + AssertId("1-1", backLink.SpanId); + AssertId("1-2", backLink.SourceSpanId); + }, + s => + { + var link = Assert.Single(s.Links); + AssertId("1-1", link.SpanId); + AssertId("1-2", link.SourceSpanId); + + var backLink = Assert.Single(s.BackLinks); + AssertId("1-2", backLink.SpanId); + AssertId("1-1", backLink.SourceSpanId); + }); + } + [Fact] public void AddTraces_ExceedLimit_FirstInFirstOut() { // Arrange - var repository = CreateRepository(maxTraceCount: 10); + const int MaxTraceCount = 10; + var repository = CreateRepository(maxTraceCount: MaxTraceCount); var testTime = s_testTime.AddDays(1); // Act for (var i = 0; i < 2000; i++) { - var traceId = (i + 1).ToString(CultureInfo.InvariantCulture); + var traceNumber = i + 1; + var traceId = traceNumber.ToString(CultureInfo.InvariantCulture); // Insert traces out of order to stress the circular buffer type. var startTime = testTime.AddMinutes(i + (i % 2 == 0 ? -5 : 0)); - AddTrace(repository, traceId, startTime); + try + { + AddTrace(repository, traceId, startTime); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error adding trace number {i}.", ex); + } } // Assert @@ -602,19 +763,40 @@ public void AddTraces_ExceedLimit_FirstInFirstOut() // Most recent traces are returned. var first = GetStringId(traces.PagedResult.Items.First().TraceId); var last = GetStringId(traces.PagedResult.Items.Last().TraceId); - Assert.Equal("1984", first); + Assert.Equal("1988", first); Assert.Equal("2000", last); // Traces returned are ordered by start time. var actualOrder = traces.PagedResult.Items.Select(t => t.TraceId).ToList(); var expectedOrder = traces.PagedResult.Items.OrderBy(t => t.FirstSpan.StartTime).Select(t => t.TraceId).ToList(); Assert.Equal(expectedOrder, actualOrder); + + Assert.Equal(MaxTraceCount * 2, repository.SpanLinks.Count); } private static void AddTrace(TelemetryRepository repository, string traceId, DateTime startTime) { var addContext = new AddContext(); + var link1 = new Span.Types.Link + { + TraceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes(traceId)), + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes($"{traceId}-2")), + Attributes = + { + new KeyValue { Key = "key2", Value = new AnyValue { StringValue = "Value!" } } + } + }; + var link2 = new Span.Types.Link + { + TraceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes(traceId)), + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes($"{traceId}-1")), + Attributes = + { + new KeyValue { Key = "key2", Value = new AnyValue { StringValue = "Value!" } } + } + }; + repository.AddTraces(addContext, new RepeatedField() { new ResourceSpans @@ -627,8 +809,14 @@ private static void AddTrace(TelemetryRepository repository, string traceId, Dat Scope = CreateScope(), Spans = { - CreateSpan(traceId: traceId, spanId: $"{traceId}-2", startTime: startTime.AddMinutes(5), endTime: startTime.AddMinutes(1), parentSpanId: $"{traceId}-1"), - CreateSpan(traceId: traceId, spanId: $"{traceId}-1", startTime: startTime.AddMinutes(1), endTime: startTime.AddMinutes(10)) + CreateSpan(traceId: traceId, spanId: $"{traceId}-2", startTime: startTime.AddMinutes(5), endTime: startTime.AddMinutes(1), parentSpanId: $"{traceId}-1", links: new List + { + link2 + }), + CreateSpan(traceId: traceId, spanId: $"{traceId}-1", startTime: startTime.AddMinutes(1), endTime: startTime.AddMinutes(10), links: new List + { + link1 + }) } } } From edd8ed3564cc236f18fa31bdac70f2138bf8e573 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 17 Jul 2024 07:32:00 +0800 Subject: [PATCH 2/2] PR feedback --- src/Aspire.Dashboard/Components/Controls/SpanDetails.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor index 893f07172c..a5bc8137e3 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor @@ -124,7 +124,7 @@ @WriteSpanLink(context) - View + @Loc[nameof(ControlsStrings.ViewAction)] @@ -143,7 +143,7 @@ @WriteSpanLink(context) - View + @Loc[nameof(ControlsStrings.ViewAction)]