Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
84 changes: 46 additions & 38 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -68,54 +68,62 @@
// - 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 = $"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));
}
}