Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 9 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ protected override void OnParametersSet()
},
};

if (_viewModel.Span.GetDestination() is { } destination)
{
_valueComponents[KnownTraceFields.DestinationField] = new ComponentMetadata
{
Type = typeof(ResourceNameButtonValue),
Parameters = { ["Resource"] = destination }
};
}

UpdateSpanActionsMenu();
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ private void UpdateDetailViewData()
var result = TelemetryRepository.GetLogs(logsContext);

Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", _trace.TraceId, _trace.Spans.Count);
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result.Items, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds));
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result.Items, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds, _resources));
_maxDepth = _spanWaterfallViewModels.Max(s => s.Depth);

var apps = new HashSet<OtlpResource>();
Expand Down
23 changes: 22 additions & 1 deletion src/Aspire.Dashboard/Extensions/CollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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.Extensions;
Expand All @@ -14,4 +14,25 @@ public static bool Equivalent<T>(this T[] array, T[] other)

return !array.Where((t, i) => !Equals(t, other[i])).Any();
}

public static T? SingleOrNull<T>(this IEnumerable<T> source)
{
ArgumentNullException.ThrowIfNull(source);

using var enumerator = source.GetEnumerator();

if (!enumerator.MoveNext())
{
return default; // no items
}

var first = enumerator.Current;

if (enumerator.MoveNext())
{
return default; // more than one
}

return first; // exactly one
}
}
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Model/Otlp/KnownTraceFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public static class KnownTraceFields
// Not used in search.
public const string StatusMessageField = "trace.statusmessage";
public const string ParentIdField = "trace.parentid";
public const string DestinationField = "trace.destination";

public static readonly List<string> AllFields = [
NameField,
Expand Down
10 changes: 5 additions & 5 deletions src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ private void UpdateHidden(bool isParentCollapsed = false)

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

public sealed record TraceDetailState(IOutgoingPeerResolver[] OutgoingPeerResolvers, List<string> CollapsedSpanIds);
public sealed record TraceDetailState(IOutgoingPeerResolver[] OutgoingPeerResolvers, List<string> CollapsedSpanIds, List<OtlpResource> AllResources);

public static string GetTitle(OtlpSpan span, List<OtlpResource> allResources)
{
Expand Down Expand Up @@ -160,7 +160,7 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid
// 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 uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, state.OutgoingPeerResolvers, state.AllResources) : null;

var spanLogVms = new List<SpanLogEntryViewModel>();
if (spanLogs != null)
Expand Down Expand Up @@ -213,12 +213,12 @@ static double CalculatePercent(double value, double total)
}
}

private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers)
private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers, List<OtlpResource> allResources)
{
if (span.UninstrumentedPeer?.ResourceName is { } peerName)
if (span.UninstrumentedPeer != null)
{
// If the span has a peer name, use it.
return peerName;
return OtlpResource.GetResourceName(span.UninstrumentedPeer, allResources);
}

// Attempt to resolve uninstrumented peer to a friendly name from the span.
Expand Down
19 changes: 16 additions & 3 deletions src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ public static SpanDetailsViewModel Create(OtlpSpan span, TelemetryRepository tel
ArgumentNullException.ThrowIfNull(telemetryRepository);
ArgumentNullException.ThrowIfNull(resources);

var entryProperties = span.AllProperties()
.Select(f => new TelemetryPropertyViewModel { Name = f.DisplayName, Key = f.Key, Value = f.Value })
.ToList();
var entryProperties = span.GetKnownProperties().Select(CreateTelemetryProperty).ToList();
if (span.GetDestination() is { } destination)
{
entryProperties.Add(new TelemetryPropertyViewModel
{
Name = "Destination",
Key = KnownTraceFields.DestinationField,
Value = OtlpResource.GetResourceName(destination, resources)
});
}
entryProperties.AddRange(span.GetAttributeProperties().Select(CreateTelemetryProperty));

var traceCache = new Dictionary<string, OtlpTrace>(StringComparer.Ordinal);

Expand All @@ -44,6 +52,11 @@ public static SpanDetailsViewModel Create(OtlpSpan span, TelemetryRepository tel
Backlinks = backlinks,
};
return spanDetailsViewModel;

static TelemetryPropertyViewModel CreateTelemetryProperty(OtlpDisplayField f)
{
return new TelemetryPropertyViewModel { Name = f.DisplayName, Key = f.Key, Value = f.Value };
}
}

private static SpanLinkViewModel CreateLinkViewModel(string traceId, string spanId, KeyValuePair<string, string>[] attributes, TelemetryRepository telemetryRepository, Dictionary<string, OtlpTrace> traceCache)
Expand Down
33 changes: 32 additions & 1 deletion src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model.Otlp;
using Grpc.Core;

Expand Down Expand Up @@ -98,7 +99,7 @@ public static OtlpSpan Clone(OtlpSpan item, OtlpTrace trace)
};
}

public List<OtlpDisplayField> AllProperties()
public List<OtlpDisplayField> GetKnownProperties()
{
var props = new List<OtlpDisplayField>
{
Expand All @@ -117,6 +118,13 @@ public List<OtlpDisplayField> AllProperties()
props.Add(new OtlpDisplayField { DisplayName = "StatusMessage", Key = KnownTraceFields.StatusMessageField, Value = StatusMessage });
}

return props;
}

public List<OtlpDisplayField> GetAttributeProperties()
{
var props = new List<OtlpDisplayField>();

foreach (var kv in Attributes.OrderBy(a => a.Key))
{
props.Add(new OtlpDisplayField { DisplayName = kv.Key, Key = $"unknown-{kv.Key}", Value = kv.Value });
Expand All @@ -125,6 +133,29 @@ public List<OtlpDisplayField> AllProperties()
return props;
}

public OtlpResource? GetDestination()
{
// Calculate destination. The destination could either be resolved from:
// - An uninstrumented peer, or
// - From single child span when the child span has a different resources and is a server/consumer.
if (UninstrumentedPeer is { } peer)
{
return peer;
}
else
{
if (GetChildSpans().SingleOrNull() is { } childSpan)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's an example of this?

Copy link
Member Author

@JamesNK JamesNK Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for when a client calling a server, and the server is configured with OTEL.

For example, frontend calls basketservice and they are both .NET projects with telemetry enabled. The client span has one server span child and so a destination is added:

image

{
if (childSpan.Source.ResourceKey != Source.ResourceKey && childSpan.Kind is OtlpSpanKind.Server or OtlpSpanKind.Consumer)
{
return childSpan.Source.Resource;
}
}
}

return null;
}

private string DebuggerToString()
{
return $@"SpanId = {SpanId}, StartTime = {StartTime.ToLocalTime():h:mm:ss.fff tt}, ParentSpanId = {ParentSpanId}, Resource = {Source.ResourceKey}, UninstrumentedPeerResource = {UninstrumentedPeer?.ResourceKey}, TraceId = {Trace.TraceId}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void Create_HasChildren_ChildrenPopulated()
trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(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([], []));
var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], []));

// Assert
Assert.Collection(vm,
Expand Down Expand Up @@ -60,7 +60,7 @@ public void Create_RootSpanZeroDuration_ZeroPercentage()
var log = new OtlpLogEntry(TelemetryTestHelpers.CreateLogRecord(traceId: trace.TraceId, spanId: "1"), app1View, scope, context);

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

// Assert
Assert.Collection(vm,
Expand Down Expand Up @@ -89,7 +89,7 @@ public void Create_OutgoingPeers_BrowserLink()
trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "2", parentSpanId: null, startDate: new DateTime(2001, 2, 1, 1, 1, 2, DateTimeKind.Utc), kind: OtlpSpanKind.Client));

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

// Assert
Assert.Collection(vm,
Expand Down Expand Up @@ -142,7 +142,7 @@ public void MatchesFilter_VariousCases_ReturnsExpected(string filter, bool expec
var vm = SpanWaterfallViewModel.Create(
trace,
[],
new SpanWaterfallViewModel.TraceDetailState([], [])).First();
new SpanWaterfallViewModel.TraceDetailState([], [], [])).First();

// Act
var result = vm.MatchesFilter(filter, typeFilter: null, a => a.Resource.ResourceName, out _);
Expand Down Expand Up @@ -202,7 +202,7 @@ public void MatchesFilter_SpanType_ReturnsExpected(string spanTypeName, string?
var vm = SpanWaterfallViewModel.Create(
trace,
[],
new SpanWaterfallViewModel.TraceDetailState([], [])).First();
new SpanWaterfallViewModel.TraceDetailState([], [], [])).First();

// Act 1
var result1 = vm.MatchesFilter(string.Empty, typeFilter: spanType.Id?.Filter, a => a.Resource.ResourceName, out _);
Expand Down Expand Up @@ -234,7 +234,7 @@ public void MatchesFilter_ParentSpanIncludedWhenChildMatched()
trace.AddSpan(parentSpan);
trace.AddSpan(childSpan);

var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], []));
var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], []));
var parent = vms[0];
var child = vms[1];

Expand All @@ -256,7 +256,7 @@ public void MatchesFilter_ChildSpanIncludedWhenParentMatched()
trace.AddSpan(parentSpan);
trace.AddSpan(childSpan);

var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], []));
var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], []));
var parent = vms[0];
var child = vms[1];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ public void AllProperties()
statusCode: OtlpSpanStatusCode.Ok, statusMessage: "Status message!", attributes: [new KeyValuePair<string, string>(KnownTraceFields.StatusMessageField, "value")]);

// Act
var properties = span.AllProperties();
var knownProperties = span.GetKnownProperties();
var attributeProperties = span.GetAttributeProperties();

// Assert
Assert.Collection(properties,
Assert.Collection(knownProperties,
a =>
{
Assert.Equal("trace.spanid", a.Key);
Expand All @@ -54,11 +55,75 @@ public void AllProperties()
{
Assert.Equal("trace.statusmessage", a.Key);
Assert.Equal("Status message!", a.Value);
},
});
Assert.Collection(attributeProperties,
a =>
{
Assert.Equal("unknown-trace.statusmessage", a.Key);
Assert.Equal("value", a.Value);
});
}

[Fact]
public void GetDestination_NoChildOrPeer_Null()
{
// Arrange
var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() };
var app1 = new OtlpResource("app1", "instance", uninstrumentedPeer: false, context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue);
var scope = TelemetryTestHelpers.CreateOtlpScope(context);

var span = TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime);

// Act
var destination = span.GetDestination();

// Assert
Assert.Null(destination);
}

[Fact]
public void GetDestination_HasPeer_ReturnPeerResource()
{
// Arrange
var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() };
var app1 = new OtlpResource("app1", "instance", uninstrumentedPeer: false, context);
var app2 = new OtlpResource("app2", "instance", uninstrumentedPeer: true, context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue);
var scope = TelemetryTestHelpers.CreateOtlpScope(context);

var span = TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime,
kind: OtlpSpanKind.Client, uninstrumentedPeer: app2);

// Act
var destination = span.GetDestination();

// Assert
Assert.Equal(app2, destination);
}

[Fact]
public void GetDestination_HasSingleChild_ReturnChildResource()
{
// Arrange
var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() };
var app1 = new OtlpResource("app1", "instance", uninstrumentedPeer: false, context);
var app2 = new OtlpResource("app2", "instance", uninstrumentedPeer: true, context);
var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue);
var scope = TelemetryTestHelpers.CreateOtlpScope(context);

var parentSpan = TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime,
kind: OtlpSpanKind.Client);
var childSpan = TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "abc-2", parentSpanId: "abc", startDate: s_testTime,
kind: OtlpSpanKind.Server);

trace.AddSpan(parentSpan);
trace.AddSpan(childSpan);

// Act
var destination = parentSpan.GetDestination();

// Assert
Assert.Equal(app2, destination);
}
}
Loading