Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spelling.dic
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ rediscommander
runtimeconfig
sqlserver
trce
uninstrumented
upsert
uris
urls
87 changes: 47 additions & 40 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -64,58 +64,65 @@
if (!isServerKind)
{
// Client span has 19px extra content:
// - 5px extra margin-left
// - 5px border
// - 9px padding-left
// - 5px extra margin-left
marginLeft += 5;
spanNameContainerStyle = $"margin-left: {marginLeft}px; border-left-color: {ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source))}; border-left-width: 5px; border-left-style: solid; padding-left: 9px;";
spanNameContainerStyle = $"margin-left: 5px; border-left-color: {ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source))}; border-left-width: 5px; border-left-style: solid; padding-left: 9px;";
}
else
{
// Server span has 19px extra content:
// - 16px icon
// - 3px padding-left
spanNameContainerStyle = $"margin-left: {marginLeft}px;";
spanNameContainerStyle = string.Empty;
}
}
<span class="span-name-container" style="@spanNameContainerStyle">
@if (isServerKind)
{
<FluentIcon
Class="server-request-icon"
Color="Color.Custom"
CustomColor="@ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source))"
Icon="Icons.Filled.Size16.Server"/>
}

@if (context.IsError)
{
<FluentIcon Icon="Icons.Filled.Size12.ErrorCircle" Color="Color.Error" Class="trace-tag-icon"/>
}
@GetResourceName(context.Span.Source)
@if (context.HasUninstrumentedPeer)
{
<span class="uninstrumented-peer">
@{
Icon icon;
if (context.Span.Attributes.HasKey("db.system"))
{
icon = new Icons.Filled.Size16.Database();
}
else
{
// Everything else.
icon = new Icons.Filled.Size16.ArrowCircleRight();
<span style="margin-left: @(marginLeft)px;">
<span class="span-collapse-symbol" @onclick="() => OnToggleCollapse(context)" @onclick:stopPropagation="true">
@if (context.Children.Count > 0)
{
@(context.IsCollapsed ? '+' : '-')
}
</span>
<span class="span-name-container" style="@spanNameContainerStyle">
@if (isServerKind)
{
<FluentIcon Class="server-request-icon"
Color="Color.Custom"
CustomColor="@ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source))"
Icon="Icons.Filled.Size16.Server" />
}

@if (context.IsError)
{
<FluentIcon Icon="Icons.Filled.Size12.ErrorCircle" Color="Color.Error" Class="trace-tag-icon" />
}
@GetResourceName(context.Span.Source)
@if (context.HasUninstrumentedPeer)
{
<span class="uninstrumented-peer">
@{
Icon icon;
if (context.Span.Attributes.HasKey("db.system"))
{
icon = new Icons.Filled.Size16.Database();
}
else
{
// Everything else.
icon = new Icons.Filled.Size16.ArrowCircleRight();
}
}
}
<FluentIcon
Style="@($"fill: {ColorGenerator.Instance.GetColorHexByKey(context.UninstrumentedPeer)};")"
Value="icon"
Class="uninstrumented-peer-icon"/>
@context.UninstrumentedPeer
</span>
}
<span class="span-row-name">@context.GetDisplaySummary()</span>
<FluentIcon
Style="@($"fill: {ColorGenerator.Instance.GetColorHexByKey(context.UninstrumentedPeer)};")"
Value="icon"
Class="uninstrumented-peer-icon" />
@context.UninstrumentedPeer
</span>
}
<span class="span-row-name">@context.GetDisplaySummary()</span>
</span>
</span>
</div>
</TemplateColumn>
Expand Down
56 changes: 46 additions & 10 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public partial class TraceDetail : ComponentBase
private List<SpanWaterfallViewModel>? _spanWaterfallViewModels;
private int _maxDepth;
private List<OtlpApplication> _applications = default!;
private readonly List<string> _collapsedSpanIds = [];

[Parameter]
public required string TraceId { get; set; }
Expand Down Expand Up @@ -49,41 +50,46 @@ private ValueTask<GridItemsProviderResult<SpanWaterfallViewModel>> GetData(GridI
{
Debug.Assert(_spanWaterfallViewModels != null);

var visibleSpanWaterfallViewModels = _spanWaterfallViewModels.Where(viewModel => !viewModel.IsHidden).ToList();
return ValueTask.FromResult(new GridItemsProviderResult<SpanWaterfallViewModel>
{
Items = _spanWaterfallViewModels,
Items = visibleSpanWaterfallViewModels,
TotalItemCount = _spanWaterfallViewModels.Count
});
}

private static List<SpanWaterfallViewModel> CreateSpanWaterfallViewModels(OtlpTrace trace, IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers)
private static List<SpanWaterfallViewModel> CreateSpanWaterfallViewModels(OtlpTrace trace, TraceDetailState state)
{
var orderedSpans = new List<SpanWaterfallViewModel>();
// There should be one root span but just in case, we'll add them all.
foreach (var rootSpan in trace.Spans.Where(s => string.IsNullOrEmpty(s.ParentSpanId)).OrderBy(s => s.StartTime))
{
AddSelfAndChildren(orderedSpans, rootSpan, depth: 1, outgoingPeerResolvers, CreateViewModel);
AddSelfAndChildren(orderedSpans, rootSpan, depth: 1, hidden: false, state, CreateViewModel);
}
// Unparented spans.
foreach (var unparentedSpan in trace.Spans.Where(s => !string.IsNullOrEmpty(s.ParentSpanId) && s.GetParentSpan() == null).OrderBy(s => s.StartTime))
{
AddSelfAndChildren(orderedSpans, unparentedSpan, depth: 1, outgoingPeerResolvers, CreateViewModel);
AddSelfAndChildren(orderedSpans, unparentedSpan, depth: 1, hidden: false, state, CreateViewModel);
}

return orderedSpans;

static void AddSelfAndChildren(List<SpanWaterfallViewModel> orderedSpans, OtlpSpan span, int depth, IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers, Func<OtlpSpan, int, IEnumerable<IOutgoingPeerResolver>, SpanWaterfallViewModel> createViewModel)
static SpanWaterfallViewModel AddSelfAndChildren(List<SpanWaterfallViewModel> orderedSpans, OtlpSpan span, int depth, bool hidden, TraceDetailState state, Func<OtlpSpan, int, bool, TraceDetailState, SpanWaterfallViewModel> createViewModel)
{
orderedSpans.Add(createViewModel(span, depth, outgoingPeerResolvers));
var viewModel = createViewModel(span, depth, hidden, state);
orderedSpans.Add(viewModel);
depth++;

foreach (var child in span.GetChildSpans().OrderBy(s => s.StartTime))
{
AddSelfAndChildren(orderedSpans, child, depth, outgoingPeerResolvers, createViewModel);
var childViewModel = AddSelfAndChildren(orderedSpans, child, depth, viewModel.IsHidden || viewModel.IsCollapsed, state, createViewModel);
viewModel.Children.Add(childViewModel);
}

return viewModel;
}

static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers)
static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hidden, TraceDetailState state)
{
var traceStart = span.Trace.FirstSpan.StartTime;
var relativeStart = span.StartTime - traceStart;
Expand All @@ -99,17 +105,29 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, IEnumera
// A span may indicate a call to another service but the service isn't instrumented.
var hasPeerService = span.Attributes.Any(a => a.Key == OtlpSpan.PeerServiceAttributeKey);
var isUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any();
var uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, outgoingPeerResolvers) : null;
var uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, state.OutgoingPeerResolvers) : null;

var viewModel = new SpanWaterfallViewModel
{
Children = [],
Span = span,
LeftOffset = leftOffset,
Width = width,
Depth = depth,
LabelIsRight = labelIsRight,
UninstrumentedPeer = uninstrumentedPeer
};

// Restore hidden/collapsed state to new view model.
if (state.CollapsedSpanIds.Contains(span.SpanId))
{
viewModel.IsCollapsed = true;
}
if (hidden)
{
viewModel.IsHidden = true;
}

return viewModel;
}
}
Expand Down Expand Up @@ -146,7 +164,7 @@ private void UpdateDetailViewData()
_trace = TelemetryRepository.GetTrace(TraceId);
if (_trace != null)
{
_spanWaterfallViewModels = CreateSpanWaterfallViewModels(_trace, OutgoingPeerResolvers);
_spanWaterfallViewModels = CreateSpanWaterfallViewModels(_trace, new TraceDetailState(OutgoingPeerResolvers, _collapsedSpanIds));
_maxDepth = _spanWaterfallViewModels.Max(s => s.Depth);

if (_tracesSubscription is null || _tracesSubscription.ApplicationId != _trace.FirstSpan.Source.InstanceId)
Expand Down Expand Up @@ -181,6 +199,22 @@ private string GetRowClass(SpanWaterfallViewModel viewModel)

public SpanDetailsViewModel? SelectedSpan { get; set; }

private void OnToggleCollapse(SpanWaterfallViewModel viewModel)
{
// View model data is recreated if the trace updates.
// Persist the collapsed state in a separate list.
if (viewModel.IsCollapsed)
{
viewModel.IsCollapsed = false;
_collapsedSpanIds.Remove(viewModel.Span.SpanId);
}
else
{
viewModel.IsCollapsed = true;
_collapsedSpanIds.Add(viewModel.Span.SpanId);
}
}

private void OnShowProperties(SpanWaterfallViewModel viewModel)
{
if (SelectedSpan?.Span == viewModel.Span)
Expand Down Expand Up @@ -219,4 +253,6 @@ public void Dispose()
}
_tracesSubscription?.Dispose();
}

private sealed record TraceDetailState(IEnumerable<IOutgoingPeerResolver> OutgoingPeerResolvers, List<string> CollapsedSpanIds);
}
10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,17 @@
grid-row: 1;
}

::deep .span-collapse-symbol {
display: inline-block;
width: 20px;
text-align: center;
padding: 0px 5px;
line-height: 28px;
user-select: none;
}

::deep .span-name-container {
margin-left: -3px;
line-height: 28px;
}

Expand Down
19 changes: 19 additions & 0 deletions src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,29 @@ namespace Aspire.Dashboard.Model.Otlp;

public sealed class SpanWaterfallViewModel
{
public required List<SpanWaterfallViewModel> Children { get; init; }
public required OtlpSpan Span { get; init; }
public required double LeftOffset { get; init; }
public required double Width { get; init; }
public required int Depth { get; init; }
public required bool LabelIsRight { get; init; }
public required string? UninstrumentedPeer { get; init; }
public bool IsHidden { get; set; }
[MemberNotNullWhen(true, nameof(UninstrumentedPeer))]
public bool HasUninstrumentedPeer => !string.IsNullOrEmpty(UninstrumentedPeer);
public bool IsError => Span.Status == OtlpSpanStatusCode.Error;

private bool _isCollapsed;
public bool IsCollapsed
{
get => _isCollapsed;
set
{
_isCollapsed = value;
UpdateHidden();
}
}

public string GetTooltip()
{
var tooltip = $"{Span.Source.ApplicationName}: {GetDisplaySummary()}";
Expand Down Expand Up @@ -86,4 +99,10 @@ public string GetDisplaySummary()

return Span.Name;
}

private void UpdateHidden(bool isParentCollapsed = false)
{
IsHidden = isParentCollapsed;
Children.ForEach(child => child.UpdateHidden(isParentCollapsed || IsCollapsed));
}
}