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
79 changes: 1 addition & 78 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,81 +116,6 @@ private static Icon GetSpanIcon(OtlpSpan span)
}
}

private readonly record struct SpanWaterfallViewModelState(SpanWaterfallViewModel? Parent, int Depth, bool Hidden);

private static List<SpanWaterfallViewModel> CreateSpanWaterfallViewModels(OtlpTrace trace, TraceDetailState state)
{
var orderedSpans = new List<SpanWaterfallViewModel>();

TraceHelpers.VisitSpans(trace, (OtlpSpan span, SpanWaterfallViewModelState s) =>
{
var viewModel = CreateViewModel(span, s.Depth, s.Hidden, state);
var peers = s.Parent?.Children ?? orderedSpans;
peers.Add(viewModel);

return s with { Depth = s.Depth + 1, Hidden = viewModel.IsHidden || viewModel.IsCollapsed };
}, new SpanWaterfallViewModelState(Parent: null, Depth: 1, Hidden: false));

return orderedSpans;

static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hidden, TraceDetailState state)
{
var traceStart = span.Trace.FirstSpan.StartTime;
var relativeStart = span.StartTime - traceStart;
var rootDuration = span.Trace.Duration.TotalMilliseconds;

var leftOffset = relativeStart.TotalMilliseconds / rootDuration * 100;
var width = span.Duration.TotalMilliseconds / rootDuration * 100;

// Figure out if the label is displayed to the left or right of the span.
// If the label position is based on whether more than half of the span is on the left or right side of the trace.
var labelIsRight = (relativeStart + span.Duration / 2) < (span.Trace.Duration / 2);

// A span may indicate a call to another service but the service isn't instrumented.
var hasPeerService = OtlpHelpers.GetPeerAddress(span.Attributes) != null;
var isUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any();
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;
}
}

private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers)
{
// Attempt to resolve uninstrumented peer to a friendly name from the span.
foreach (var resolver in outgoingPeerResolvers)
{
if (resolver.TryResolvePeerName(span.Attributes, out var name))
{
return name;
}
}

// Fallback to the peer address.
return OtlpHelpers.GetPeerAddress(span.Attributes);
}

protected override async Task OnParametersSetAsync()
{
UpdateDetailViewData();
Expand Down Expand Up @@ -219,7 +144,7 @@ private void UpdateDetailViewData()
_trace = TelemetryRepository.GetTrace(TraceId);
if (_trace is { } trace)
{
_spanWaterfallViewModels = CreateSpanWaterfallViewModels(trace, new TraceDetailState(OutgoingPeerResolvers, _collapsedSpanIds));
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(trace, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers, _collapsedSpanIds));
_maxDepth = _spanWaterfallViewModels.Max(s => s.Depth);

if (_tracesSubscription is null || _tracesSubscription.ApplicationKey != trace.FirstSpan.Source.ApplicationKey)
Expand Down Expand Up @@ -338,6 +263,4 @@ public void Dispose()
}
_tracesSubscription?.Dispose();
}

private sealed record TraceDetailState(IEnumerable<IOutgoingPeerResolver> OutgoingPeerResolvers, List<string> CollapsedSpanIds);
}
78 changes: 78 additions & 0 deletions src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,82 @@ private void UpdateHidden(bool isParentCollapsed = false)
child.UpdateHidden(isParentCollapsed || IsCollapsed);
}
}

private readonly record struct SpanWaterfallViewModelState(SpanWaterfallViewModel? Parent, int Depth, bool Hidden);

public sealed record TraceDetailState(IEnumerable<IOutgoingPeerResolver> OutgoingPeerResolvers, List<string> CollapsedSpanIds);

public static List<SpanWaterfallViewModel> Create(OtlpTrace trace, TraceDetailState state)
{
var orderedSpans = new List<SpanWaterfallViewModel>();

TraceHelpers.VisitSpans(trace, (OtlpSpan span, SpanWaterfallViewModelState s) =>
{
var viewModel = CreateViewModel(span, s.Depth, s.Hidden, state);
orderedSpans.Add(viewModel);

s.Parent?.Children.Add(viewModel);

return s with { Parent = viewModel, Depth = s.Depth + 1, Hidden = viewModel.IsHidden || viewModel.IsCollapsed };
}, new SpanWaterfallViewModelState(Parent: null, Depth: 1, Hidden: false));

return orderedSpans;

static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hidden, TraceDetailState state)
{
var traceStart = span.Trace.FirstSpan.StartTime;
var relativeStart = span.StartTime - traceStart;
var rootDuration = span.Trace.Duration.TotalMilliseconds;

var leftOffset = relativeStart.TotalMilliseconds / rootDuration * 100;
var width = span.Duration.TotalMilliseconds / rootDuration * 100;

// Figure out if the label is displayed to the left or right of the span.
// If the label position is based on whether more than half of the span is on the left or right side of the trace.
var labelIsRight = (relativeStart + span.Duration / 2) < (span.Trace.Duration / 2);

// A span may indicate a call to another service but the service isn't instrumented.
var hasPeerService = OtlpHelpers.GetPeerAddress(span.Attributes) != null;
var isUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any();
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;
}
}

private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers)
{
// Attempt to resolve uninstrumented peer to a friendly name from the span.
foreach (var resolver in outgoingPeerResolvers)
{
if (resolver.TryResolvePeerName(span.Attributes, out var name))
{
return name;
}
}

// Fallback to the peer address.
return OtlpHelpers.GetPeerAddress(span.Attributes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using DiagnosticsHealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;

namespace Aspire.Dashboard.Tests.Model;

public sealed class ResourceViewModelTests
Expand Down
43 changes: 43 additions & 0 deletions tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Tests.Shared.Telemetry;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace Aspire.Dashboard.Tests.Model;

public sealed class SpanWaterfallViewModelTests
{
[Fact]
public void Create_HasChildren_ChildrenPopulated()
{
// Arrange
var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() };
var app1 = new OtlpApplication("app1", "instance", context);
var app2 = new OtlpApplication("app2", "instance", context);

var trace = new OtlpTrace(new byte[] { 1, 2, 3 });
var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context);
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 3, DateTimeKind.Utc)));

// Act
var vm = SpanWaterfallViewModel.Create(trace, new SpanWaterfallViewModel.TraceDetailState([], []));

// Assert
Assert.Collection(vm,
e =>
{
Assert.Equal("1", e.Span.SpanId);
Assert.Equal("1-1", Assert.Single(e.Children).Span.SpanId);
},
e =>
{
Assert.Equal("1-1", e.Span.SpanId);
Assert.Empty(e.Children);
});
}
}
38 changes: 9 additions & 29 deletions tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public void GetOrderedApplications_SingleSpan_GroupedResult()
var app1 = new OtlpApplication("app1", "instance", context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 });
var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context);
trace.AddSpan(CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc)));

// Act
var results = TraceHelpers.GetOrderedApplications(trace);
Expand All @@ -41,8 +41,8 @@ public void GetOrderedApplications_MultipleUnparentedSpans_GroupedResult()
var app2 = new OtlpApplication("app2", "instance", context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 });
var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context);
trace.AddSpan(CreateSpan(app2, trace, scope, spanId: "1-2", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(CreateSpan(app1, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app2, trace, scope, spanId: "1-2", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app1, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc)));

// Act
var results = TraceHelpers.GetOrderedApplications(trace);
Expand All @@ -68,8 +68,8 @@ public void GetOrderedApplications_ChildSpanAfterParentSpan_GroupedResult()
var app2 = new OtlpApplication("app2", "instance", context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 });
var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context);
trace.AddSpan(CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(CreateSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc)));

// Act
var results = TraceHelpers.GetOrderedApplications(trace);
Expand All @@ -96,10 +96,10 @@ public void GetOrderedApplications_ChildSpanDifferentStartTime_GroupedResult()
var app3 = new OtlpApplication("app3", "instance", context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 });
var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context);
trace.AddSpan(CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(CreateSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 3, DateTimeKind.Utc)));
trace.AddSpan(CreateSpan(app3, trace, scope, spanId: "1-1-1", parentSpanId: "1-1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(CreateSpan(app3, trace, scope, spanId: "1-2", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 3, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app3, trace, scope, spanId: "1-1-1", parentSpanId: "1-1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));
trace.AddSpan(TelemetryTestHelpers.CreateSpan(app3, trace, scope, spanId: "1-2", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)));

// Act
var results = TraceHelpers.GetOrderedApplications(trace);
Expand All @@ -119,24 +119,4 @@ public void GetOrderedApplications_ChildSpanDifferentStartTime_GroupedResult()
Assert.Equal(app2, g.Application);
});
}

private static OtlpSpan CreateSpan(OtlpApplication app, OtlpTrace trace, OtlpScope scope, string spanId, string? parentSpanId, DateTime startDate)
{
return new OtlpSpan(app.GetView([]), trace, scope)
{
Attributes = [],
BackLinks = [],
EndTime = DateTime.MaxValue,
Events = [],
Kind = OtlpSpanKind.Unspecified,
Links = [],
Name = "Test",
ParentSpanId = parentSpanId,
SpanId = spanId,
StartTime = startDate,
State = null,
Status = OtlpSpanStatusCode.Unset,
StatusMessage = null
};
}
}
20 changes: 20 additions & 0 deletions tests/Shared/Telemetry/TelemetryTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,24 @@ public static OtlpContext CreateContext(TelemetryLimitOptions? options = null, I
Logger = logger ?? NullLogger.Instance
};
}

public static OtlpSpan CreateSpan(OtlpApplication app, OtlpTrace trace, OtlpScope scope, string spanId, string? parentSpanId, DateTime startDate)
{
return new OtlpSpan(app.GetView([]), trace, scope)
{
Attributes = [],
BackLinks = [],
EndTime = DateTime.MaxValue,
Events = [],
Kind = OtlpSpanKind.Unspecified,
Links = [],
Name = "Test",
ParentSpanId = parentSpanId,
SpanId = spanId,
StartTime = startDate,
State = null,
Status = OtlpSpanStatusCode.Unset,
StatusMessage = null
};
}
}
Loading