diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor index 87cad650696..650de980375 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor @@ -51,7 +51,7 @@ !Resource.Environment.All(i => !i.FromSpec) && !GetResourceProperties(ordered: false).Any(static vm => vm.KnownProperty is null); // NOTE Excludes URLs as they don't expose sensitive items (and enumerating URLs is non-trivial) - private IEnumerable SensitiveGridItems => Resource.Environment.Cast().Concat(Resource.Properties.Values).Where(static vm => vm.IsValueSensitive); + private IEnumerable SensitiveGridItems => Resource.Environment.Cast().Concat(_displayedResourcePropertyViewModels).Where(static vm => vm.IsValueSensitive); private bool _showAll; private ResourceViewModel? _resource; + private readonly List _displayedResourcePropertyViewModels = new(); private readonly HashSet _unmaskedItemNames = new(); private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; @@ -71,7 +75,7 @@ public partial class ResourceDetails .Where(vm => vm.MatchesFilter(_filter)) .AsQueryable(); - internal IQueryable FilteredResourceProperties => + internal IQueryable FilteredResourceProperties => GetResourceProperties(ordered: true) .Where(vm => (_showAll || vm.KnownProperty != null) && vm.MatchesFilter(_filter)) .AsQueryable(); @@ -108,6 +112,8 @@ protected override void OnParametersSet() } _resource = Resource; + _displayedResourcePropertyViewModels.Clear(); + _displayedResourcePropertyViewModels.AddRange(_resource.Properties.Select(p => new DisplayedResourcePropertyViewModel(p.Value, Loc, TimeProvider))); // Collapse details sections when they have no data. _isUrlsExpanded = GetUrls().Count > 0; @@ -216,13 +222,13 @@ private List GetUrls() return ResourceUrlHelpers.GetUrls(Resource, includeInternalUrls: true, includeNonEndpointUrls: true); } - private IEnumerable GetResourceProperties(bool ordered) + private IEnumerable GetResourceProperties(bool ordered) { - var vms = Resource.Properties.Values + var vms = _displayedResourcePropertyViewModels .Where(vm => vm.Value is { HasNullValue: false } and not { KindCase: Value.KindOneofCase.ListValue, ListValue.Values.Count: 0 }); return ordered - ? vms.OrderBy(vm => vm.Priority).ThenBy(vm => vm.Name) + ? vms.OrderBy(vm => vm.Priority).ThenBy(vm => vm.DisplayName) : vms; } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 5767e289e53..d11e0a4ef94 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -182,7 +182,7 @@ private void UpdateDetailViewData() } Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", _trace.TraceId, _trace.Spans.Count); - _spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers, _collapsedSpanIds)); + _spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, new SpanWaterfallViewModel.TraceDetailState(_collapsedSpanIds)); _maxDepth = _spanWaterfallViewModels.Max(s => s.Depth); } diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index 3607edf6dda..1e5073ab6ad 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -187,7 +187,7 @@ protected override async Task OnParametersSetAsync() private void UpdateApplications() { - _applications = TelemetryRepository.GetApplications(); + _applications = TelemetryRepository.GetApplications(includeUninstrumentedPeers: true); _applicationViewModels = ApplicationsSelectHelpers.CreateApplications(_applications); _applicationViewModels.Insert(0, _allApplication); UpdateSubscription(); diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 796a18a363c..3439d3bf130 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -228,7 +228,7 @@ public DashboardWebApplication( } // Data from the server. - builder.Services.TryAddScoped(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddScoped(); @@ -244,8 +244,8 @@ public DashboardWebApplication( builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped()); - builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.AddFluentUIComponents(); @@ -261,7 +261,7 @@ public DashboardWebApplication( builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/src/Aspire.Dashboard/Model/BrowserLinkOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/BrowserLinkOutgoingPeerResolver.cs index 56d9404a9b8..fd684a7995e 100644 --- a/src/Aspire.Dashboard/Model/BrowserLinkOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/BrowserLinkOutgoingPeerResolver.cs @@ -1,7 +1,6 @@ // 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.CodeAnalysis; using Aspire.Dashboard.Otlp.Model; namespace Aspire.Dashboard.Model; @@ -20,7 +19,7 @@ public void Dispose() } } - public bool TryResolvePeerName(KeyValuePair[] attributes, [NotNullWhen(true)] out string? name) + public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource) { // There isn't a good way to identify the HTTP request the BrowserLink middleware makes to // the IDE to get the script tag. The logic below looks at the host and URL and identifies @@ -48,6 +47,7 @@ public bool TryResolvePeerName(KeyValuePair[] attributes, [NotNu if (Guid.TryParse(parts[0], out _) && string.Equals(parts[1], lastSegment, StringComparisons.UrlPath)) { name = "Browser Link"; + matchedResource = null; return true; } } @@ -55,6 +55,7 @@ public bool TryResolvePeerName(KeyValuePair[] attributes, [NotNu } name = null; + matchedResource = null; return false; } } diff --git a/src/Aspire.Dashboard/Model/IOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/IOutgoingPeerResolver.cs index ad1077ef59d..810ef65a6a9 100644 --- a/src/Aspire.Dashboard/Model/IOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/IOutgoingPeerResolver.cs @@ -5,6 +5,6 @@ namespace Aspire.Dashboard.Model; public interface IOutgoingPeerResolver { - bool TryResolvePeerName(KeyValuePair[] attributes, out string? name); + bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResourced); IDisposable OnPeerChanges(Func callback); } diff --git a/src/Aspire.Dashboard/Model/KnownPropertyLookup.cs b/src/Aspire.Dashboard/Model/KnownPropertyLookup.cs index ec6aa936084..4be7c69b4a2 100644 --- a/src/Aspire.Dashboard/Model/KnownPropertyLookup.cs +++ b/src/Aspire.Dashboard/Model/KnownPropertyLookup.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Localization; using static Aspire.Dashboard.Resources.Resources; namespace Aspire.Dashboard.Model; @@ -18,43 +17,43 @@ public sealed class KnownPropertyLookup : IKnownPropertyLookup private readonly List _executableProperties; private readonly List _containerProperties; - public KnownPropertyLookup(IStringLocalizer loc) + public KnownPropertyLookup() { _resourceProperties = [ - new(KnownProperties.Resource.DisplayName, loc[nameof(ResourcesDetailsDisplayNameProperty)]), - new(KnownProperties.Resource.State, loc[nameof(ResourcesDetailsStateProperty)]), - new(KnownProperties.Resource.StartTime, loc[nameof(ResourcesDetailsStartTimeProperty)]), - new(KnownProperties.Resource.StopTime, loc[nameof(ResourcesDetailsStopTimeProperty)]), - new(KnownProperties.Resource.ExitCode, loc[nameof(ResourcesDetailsExitCodeProperty)]), - new(KnownProperties.Resource.HealthState, loc[nameof(ResourcesDetailsHealthStateProperty)]) + new(KnownProperties.Resource.DisplayName, loc => loc[nameof(ResourcesDetailsDisplayNameProperty)]), + new(KnownProperties.Resource.State, loc => loc[nameof(ResourcesDetailsStateProperty)]), + new(KnownProperties.Resource.StartTime, loc => loc[nameof(ResourcesDetailsStartTimeProperty)]), + new(KnownProperties.Resource.StopTime, loc => loc[nameof(ResourcesDetailsStopTimeProperty)]), + new(KnownProperties.Resource.ExitCode, loc => loc[nameof(ResourcesDetailsExitCodeProperty)]), + new(KnownProperties.Resource.HealthState, loc => loc[nameof(ResourcesDetailsHealthStateProperty)]) ]; _projectProperties = [ .. _resourceProperties, - new(KnownProperties.Project.Path, loc[nameof(ResourcesDetailsProjectPathProperty)]), - new(KnownProperties.Executable.Pid, loc[nameof(ResourcesDetailsExecutableProcessIdProperty)]), + new(KnownProperties.Project.Path, loc => loc[nameof(ResourcesDetailsProjectPathProperty)]), + new(KnownProperties.Executable.Pid, loc => loc[nameof(ResourcesDetailsExecutableProcessIdProperty)]), ]; _executableProperties = [ .. _resourceProperties, - new(KnownProperties.Executable.Path, loc[nameof(ResourcesDetailsExecutablePathProperty)]), - new(KnownProperties.Executable.WorkDir, loc[nameof(ResourcesDetailsExecutableWorkingDirectoryProperty)]), - new(KnownProperties.Executable.Args, loc[nameof(ResourcesDetailsExecutableArgumentsProperty)]), - new(KnownProperties.Executable.Pid, loc[nameof(ResourcesDetailsExecutableProcessIdProperty)]), + new(KnownProperties.Executable.Path, loc => loc[nameof(ResourcesDetailsExecutablePathProperty)]), + new(KnownProperties.Executable.WorkDir, loc => loc[nameof(ResourcesDetailsExecutableWorkingDirectoryProperty)]), + new(KnownProperties.Executable.Args, loc => loc[nameof(ResourcesDetailsExecutableArgumentsProperty)]), + new(KnownProperties.Executable.Pid, loc => loc[nameof(ResourcesDetailsExecutableProcessIdProperty)]), ]; _containerProperties = [ .. _resourceProperties, - new(KnownProperties.Container.Image, loc[nameof(ResourcesDetailsContainerImageProperty)]), - new(KnownProperties.Container.Id, loc[nameof(ResourcesDetailsContainerIdProperty)]), - new(KnownProperties.Container.Command, loc[nameof(ResourcesDetailsContainerCommandProperty)]), - new(KnownProperties.Container.Args, loc[nameof(ResourcesDetailsContainerArgumentsProperty)]), - new(KnownProperties.Container.Ports, loc[nameof(ResourcesDetailsContainerPortsProperty)]), - new(KnownProperties.Container.Lifetime, loc[nameof(ResourcesDetailsContainerLifetimeProperty)]), + new(KnownProperties.Container.Image, loc => loc[nameof(ResourcesDetailsContainerImageProperty)]), + new(KnownProperties.Container.Id, loc => loc[nameof(ResourcesDetailsContainerIdProperty)]), + new(KnownProperties.Container.Command, loc => loc[nameof(ResourcesDetailsContainerCommandProperty)]), + new(KnownProperties.Container.Args, loc => loc[nameof(ResourcesDetailsContainerArgumentsProperty)]), + new(KnownProperties.Container.Ports, loc => loc[nameof(ResourcesDetailsContainerPortsProperty)]), + new(KnownProperties.Container.Lifetime, loc => loc[nameof(ResourcesDetailsContainerLifetimeProperty)]), ]; } diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 9aa81b49108..905330c49ed 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -107,7 +107,7 @@ private void UpdateHidden(bool isParentCollapsed = false) private readonly record struct SpanWaterfallViewModelState(SpanWaterfallViewModel? Parent, int Depth, bool Hidden); - public sealed record TraceDetailState(IEnumerable OutgoingPeerResolvers, List CollapsedSpanIds); + public sealed record TraceDetailState(List CollapsedSpanIds); public static string GetTitle(OtlpSpan span, List allApplications) { @@ -146,7 +146,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) : null; var viewModel = new SpanWaterfallViewModel { @@ -173,15 +173,12 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid } } - private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IEnumerable outgoingPeerResolvers) + private static string? ResolveUninstrumentedPeerName(OtlpSpan span) { - // Attempt to resolve uninstrumented peer to a friendly name from the span. - foreach (var resolver in outgoingPeerResolvers) + if (span.UninstrumentedPeer?.ApplicationName is { } peerName) { - if (resolver.TryResolvePeerName(span.Attributes, out var name)) - { - return name; - } + // If the span has a peer name, use it. + return peerName; } // Fallback to the peer address. diff --git a/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs b/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs index caad0a65694..5ec71da1a96 100644 --- a/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs +++ b/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs @@ -132,8 +132,39 @@ public IEnumerable Apply(IEnumerable input) public bool Apply(OtlpSpan span) { var fieldValue = OtlpSpan.GetFieldValue(span, Field); - var func = ConditionToFuncString(Condition); - return func(fieldValue, Value); + var isNot = Condition is FilterCondition.NotEqual or FilterCondition.NotContains; + + if (!isNot) + { + // Or + if (fieldValue.Value1 != null && IsMatch(fieldValue.Value1, Value, Condition)) + { + return true; + } + if (fieldValue.Value2 != null && IsMatch(fieldValue.Value2, Value, Condition)) + { + return true; + } + } + else + { + // And + if (fieldValue.Value1 != null && IsMatch(fieldValue.Value1, Value, Condition)) + { + if (fieldValue.Value2 != null && IsMatch(fieldValue.Value2, Value, Condition)) + { + return true; + } + } + } + + return false; + + static bool IsMatch(string fieldValue, string filterValue, FilterCondition condition) + { + var func = ConditionToFuncString(condition); + return func(fieldValue, filterValue); + } } public bool Equals(TelemetryFilter? other) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 49497697fb5..c72aaeb5989 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Dashboard.Otlp.Model; @@ -41,36 +42,70 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) await foreach (var changes in subscription.WithCancellation(_watchContainersTokenSource.Token).ConfigureAwait(false)) { + var hasUrlChanges = false; + foreach (var (changeType, resource) in changes) { if (changeType == ResourceViewModelChangeType.Upsert) { + if (!_resourceByName.TryGetValue(resource.Name, out var existingResource) || !AreEquivalent(resource.Urls, existingResource.Urls)) + { + hasUrlChanges = true; + } + _resourceByName[resource.Name] = resource; } else if (changeType == ResourceViewModelChangeType.Delete) { + hasUrlChanges = true; + var removed = _resourceByName.TryRemove(resource.Name, out _); Debug.Assert(removed, "Cannot remove unknown resource."); } } - await RaisePeerChangesAsync().ConfigureAwait(false); + if (hasUrlChanges) + { + await RaisePeerChangesAsync().ConfigureAwait(false); + } } }); } - public bool TryResolvePeerName(KeyValuePair[] attributes, [NotNullWhen(true)] out string? name) + private static bool AreEquivalent(ImmutableArray urls1, ImmutableArray urls2) + { + // Compare if the two sets of URLs are equivalent. + if (urls1.Length != urls2.Length) + { + return false; + } + + for (var i = 0; i < urls1.Length; i++) + { + var url1 = urls1[i].Url; + var url2 = urls2[i].Url; + + if (!url1.Equals(url2)) + { + return false; + } + } + + return true; + } + + public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource) { - return TryResolvePeerNameCore(_resourceByName, attributes, out name); + return TryResolvePeerNameCore(_resourceByName, attributes, out name, out matchedResource); } - internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, out string? name) + internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) { var address = OtlpHelpers.GetPeerAddress(attributes); if (address != null) { // Match exact value. - if (TryMatchResourceAddress(address, out name)) + if (TryMatchResourceAddress(address, out name, out resourceMatch)) { return true; } @@ -82,7 +117,7 @@ internal static bool TryResolvePeerNameCore(IDictionary _displayValue; private readonly Lazy _tooltip; - public string Name { get; } - public Value Value { get; } - public KnownProperty? KnownProperty { get; } - public string ToolTip => _tooltip.Value; - public bool IsValueSensitive { get; } - public bool IsValueMasked { get; set; } - internal int Priority { get; } private readonly string _key; + private readonly ResourcePropertyViewModel _propertyViewModel; + private readonly IStringLocalizer _loc; + private readonly BrowserTimeProvider _browserTimeProvider; - string IPropertyGridItem.Name => KnownProperty?.DisplayName ?? Name; - string? IPropertyGridItem.Value => _displayValue.Value; + public string ToolTip => _tooltip.Value; + public KnownProperty? KnownProperty => _propertyViewModel.KnownProperty; + public int Priority => _propertyViewModel.Priority; + public Value Value => _propertyViewModel.Value; + public string DisplayName => _propertyViewModel.KnownProperty?.GetDisplayName(_loc) ?? _propertyViewModel.Name; + string IPropertyGridItem.Name => DisplayName; + string? IPropertyGridItem.Value => _displayValue.Value; object IPropertyGridItem.Key => _key; - public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive, KnownProperty? knownProperty, int priority, BrowserTimeProvider timeProvider) + public DisplayedResourcePropertyViewModel(ResourcePropertyViewModel propertyViewModel, IStringLocalizer loc, BrowserTimeProvider browserTimeProvider) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - - Name = name; - Value = value; - IsValueSensitive = isValueSensitive; - KnownProperty = knownProperty; - Priority = priority; - IsValueMasked = isValueSensitive; + _propertyViewModel = propertyViewModel; + _loc = loc; + _browserTimeProvider = browserTimeProvider; // Known and unknown properties are displayed together. Avoid any duplicate keys. - _key = KnownProperty != null ? KnownProperty.Key : $"unknown-{Name}"; + _key = propertyViewModel.KnownProperty != null ? propertyViewModel.KnownProperty.Key : $"unknown-{propertyViewModel.Name}"; - _tooltip = new(() => value.HasStringValue ? value.StringValue : value.ToString()); + _tooltip = new(() => propertyViewModel.Value.HasStringValue ? propertyViewModel.Value.StringValue : propertyViewModel.Value.ToString()); _displayValue = new(() => { - var value = Value is { HasStringValue: true, StringValue: var stringValue } + var value = propertyViewModel.Value is { HasStringValue: true, StringValue: var stringValue } ? stringValue // Complex values such as arrays and objects will be output as JSON. // Consider how complex values are rendered in the future. - : Value.ToString(); + : propertyViewModel.Value.ToString(); - if (Name == KnownProperties.Container.Id) + if (propertyViewModel.Name == KnownProperties.Container.Id) { // Container images have a short ID of 12 characters if (value.Length > 12) @@ -276,7 +273,7 @@ public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive // Dates are returned as ISO 8601 text. Try to parse. If successful, format with the current culture. if (DateTime.TryParseExact(value, "o", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { - value = FormatHelpers.FormatDateTime(timeProvider, date, cultureInfo: CultureInfo.CurrentCulture); + value = FormatHelpers.FormatDateTime(_browserTimeProvider, date, cultureInfo: CultureInfo.CurrentCulture); } } @@ -285,11 +282,34 @@ public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive } public bool MatchesFilter(string filter) => - Name.Contains(filter, StringComparison.CurrentCultureIgnoreCase) || + _propertyViewModel.Name.Contains(filter, StringComparison.CurrentCultureIgnoreCase) || ToolTip.Contains(filter, StringComparison.CurrentCultureIgnoreCase); } -public sealed record KnownProperty(string Key, string DisplayName); +[DebuggerDisplay("Name = {Name}, Value = {Value}, IsValueSensitive = {IsValueSensitive}, IsValueMasked = {IsValueMasked}")] +public sealed class ResourcePropertyViewModel +{ + public string Name { get; } + public Value Value { get; } + public KnownProperty? KnownProperty { get; } + public bool IsValueSensitive { get; } + public bool IsValueMasked { get; set; } + public int Priority { get; } + + public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive, KnownProperty? knownProperty, int priority) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + Value = value; + IsValueSensitive = isValueSensitive; + KnownProperty = knownProperty; + Priority = priority; + IsValueMasked = isValueSensitive; + } +} + +public sealed record KnownProperty(string Key, Func, string> GetDisplayName); [DebuggerDisplay("EndpointName = {EndpointName}, Url = {Url}, IsInternal = {IsInternal}")] public sealed class UrlViewModel diff --git a/src/Aspire.Dashboard/Model/TraceHelpers.cs b/src/Aspire.Dashboard/Model/TraceHelpers.cs index ebb5522b275..98714ebcd2d 100644 --- a/src/Aspire.Dashboard/Model/TraceHelpers.cs +++ b/src/Aspire.Dashboard/Model/TraceHelpers.cs @@ -51,27 +51,12 @@ public static IEnumerable GetOrderedApplications(OtlpTrace t { var currentMinDate = (state.CurrentMinDate == null || state.CurrentMinDate < span.StartTime) ? span.StartTime - : state.CurrentMinDate; + : state.CurrentMinDate.Value; - if (appFirstTimes.TryGetValue(span.Source.Application, out var orderedApp)) + ProcessSpanApp(span, span.Source.Application, appFirstTimes, currentMinDate); + if (span.UninstrumentedPeer is { } peer) { - if (currentMinDate < orderedApp.FirstDateTime) - { - orderedApp.FirstDateTime = currentMinDate.Value; - } - - if (span.Status == OtlpSpanStatusCode.Error) - { - orderedApp.ErroredSpans++; - } - - orderedApp.TotalSpans++; - } - else - { - appFirstTimes.Add( - span.Source.Application, - new OrderedApplication(span.Source.Application, appFirstTimes.Count, currentMinDate.Value, totalSpans: 1, erroredSpans: span.Status == OtlpSpanStatusCode.Error ? 1 : 0)); + ProcessSpanApp(span, peer, appFirstTimes, currentMinDate); } return new OrderedApplicationsState(currentMinDate); @@ -81,6 +66,30 @@ public static IEnumerable GetOrderedApplications(OtlpTrace t .OrderBy(s => s.FirstDateTime) .ThenBy(s => s.Index); } + + private static void ProcessSpanApp(OtlpSpan span, OtlpApplication application, Dictionary appFirstTimes, DateTime currentMinDate) + { + if (appFirstTimes.TryGetValue(application, out var orderedApp)) + { + if (currentMinDate < orderedApp.FirstDateTime) + { + orderedApp.FirstDateTime = currentMinDate; + } + + if (span.Status == OtlpSpanStatusCode.Error) + { + orderedApp.ErroredSpans++; + } + + orderedApp.TotalSpans++; + } + else + { + appFirstTimes.Add( + application, + new OrderedApplication(application, appFirstTimes.Count, currentMinDate, totalSpans: 1, erroredSpans: span.Status == OtlpSpanStatusCode.Error ? 1 : 0)); + } + } } public sealed class OrderedApplication(OtlpApplication application, int index, DateTime firstDateTime, int totalSpans, int erroredSpans) diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs index 8d95deec4ac..4a4c833b2b4 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs @@ -22,6 +22,10 @@ public class OtlpApplication public string ApplicationName { get; } public string InstanceId { get; } public OtlpContext Context { get; } + // This flag indicates whether the app was created for an uninstrumented peer. + // It's used to hide the app on pages that don't use uninstrumented peers. + // Traces uses uninstrumented peers, structured logs and metrics don't. + public bool UninstrumentedPeer { get; private set; } public ApplicationKey ApplicationKey => new ApplicationKey(ApplicationName, InstanceId); @@ -30,10 +34,11 @@ public class OtlpApplication private readonly Dictionary _instruments = new(); private readonly ConcurrentDictionary[], OtlpApplicationView> _applicationViews = new(ApplicationViewKeyComparer.Instance); - public OtlpApplication(string name, string instanceId, OtlpContext context) + public OtlpApplication(string name, string instanceId, bool uninstrumentedPeer, OtlpContext context) { ApplicationName = name; InstanceId = instanceId; + UninstrumentedPeer = uninstrumentedPeer; Context = context; } @@ -294,6 +299,16 @@ internal OtlpApplicationView GetView(RepeatedField attributes) return _applicationViews.GetOrAdd(view.Properties, view); } + internal void SetUninstrumentedPeer(bool uninstrumentedPeer) + { + // An app could initially be created for an uninstrumented peer and then telemetry is received from it. + // This method "upgrades" the resource to not be for an uninstrumented peer when appropriate. + if (UninstrumentedPeer && !uninstrumentedPeer) + { + UninstrumentedPeer = uninstrumentedPeer; + } + } + /// /// Application views are equal when all properties are equal. /// diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs index b00ff75592b..8a763483e4e 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs @@ -42,6 +42,8 @@ public class OtlpSpan public OtlpScope Scope { get; } public TimeSpan Duration => EndTime - StartTime; + public OtlpApplication? UninstrumentedPeer { get; internal set; } + public IEnumerable GetChildSpans() => GetChildSpans(this, Trace.Spans); public static IEnumerable GetChildSpans(OtlpSpan parentSpan, OtlpSpanCollection spans) => spans.Where(s => s.ParentSpanId == parentSpan.SpanId); @@ -86,6 +88,7 @@ public static OtlpSpan Clone(OtlpSpan item, OtlpTrace trace) Events = item.Events, Links = item.Links, BackLinks = item.BackLinks, + UninstrumentedPeer = item.UninstrumentedPeer }; } @@ -118,7 +121,7 @@ public List AllProperties() private string DebuggerToString() { - return $@"SpanId = {SpanId}, StartTime = {StartTime.ToLocalTime():h:mm:ss.fff tt}, ParentSpanId = {ParentSpanId}, TraceId = {Trace.TraceId}"; + return $@"SpanId = {SpanId}, StartTime = {StartTime.ToLocalTime():h:mm:ss.fff tt}, ParentSpanId = {ParentSpanId}, Application = {Source.ApplicationKey}, UninstrumentedPeerApplication = {UninstrumentedPeer?.ApplicationKey}, TraceId = {Trace.TraceId}"; } public string GetDisplaySummary() @@ -180,11 +183,13 @@ static string BuildDisplaySummary(OtlpSpan span) } } - public static string? GetFieldValue(OtlpSpan span, string field) + public static FieldValues GetFieldValue(OtlpSpan span, string field) { + // FieldValues is a hack to support two values in a single field. + // Find a better way to do this if more than two values are needed. return field switch { - KnownResourceFields.ServiceNameField => span.Source.Application.ApplicationName, + KnownResourceFields.ServiceNameField => new FieldValues(span.Source.Application.ApplicationName, span.UninstrumentedPeer?.ApplicationName), KnownTraceFields.TraceIdField => span.TraceId, KnownTraceFields.SpanIdField => span.SpanId, KnownTraceFields.KindField => span.Kind.ToString(), @@ -194,4 +199,9 @@ static string BuildDisplaySummary(OtlpSpan span) _ => span.Attributes.GetValue(field) }; } + + public record struct FieldValues(string? Value1, string? Value2 = null) + { + public static implicit operator FieldValues(string? value) => new FieldValues(value); + } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs b/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs index 26b58b81677..08fa5fe0c76 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs @@ -5,6 +5,17 @@ namespace Aspire.Dashboard.Otlp.Storage; public readonly record struct ApplicationKey(string Name, string? InstanceId) : IComparable { + public static ApplicationKey Create(string name) + { + var separator = name.LastIndexOf('-'); + if (separator == -1) + { + return new ApplicationKey(Name: name, InstanceId: null); + } + + return new ApplicationKey(Name: name.Substring(0, separator), InstanceId: name.Substring(separator + 1)); + } + public int CompareTo(ApplicationKey other) { var c = string.Compare(Name, other.Name, StringComparisons.ResourceName); diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index c194026fb04..a869ab52ebe 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -24,9 +24,10 @@ namespace Aspire.Dashboard.Otlp.Storage; -public sealed class TelemetryRepository +public sealed class TelemetryRepository : IDisposable { private readonly PauseManager _pauseManager; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; private readonly ILogger _logger; private readonly object _lock = new(); @@ -50,6 +51,7 @@ public sealed class TelemetryRepository private readonly Dictionary _traceScopes = new(); private readonly CircularBuffer _traces; private readonly List _spanLinks = new(); + private readonly List _peerResolverSubscriptions = new(); internal readonly OtlpContext _otlpContext; public bool HasDisplayedMaxLogLimitMessage { get; set; } @@ -62,7 +64,7 @@ public sealed class TelemetryRepository internal List SpanLinks => _spanLinks; internal List TracesSubscriptions => _tracesSubscriptions; - public TelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions, PauseManager pauseManager) + public TelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions, PauseManager pauseManager, IEnumerable outgoingPeerResolvers) { _logger = loggerFactory.CreateLogger(typeof(TelemetryRepository)); _otlpContext = new OtlpContext @@ -71,10 +73,15 @@ public TelemetryRepository(ILoggerFactory loggerFactory, IOptions GetApplications() + public List GetApplications(bool includeUninstrumentedPeers = false) { - return GetApplicationsCore(name: null); + return GetApplicationsCore(includeUninstrumentedPeers, name: null); } - public List GetApplicationsByName(string name) + public List GetApplicationsByName(string name, bool includeUninstrumentedPeers = false) { - return GetApplicationsCore(name); + return GetApplicationsCore(includeUninstrumentedPeers, name); } - private List GetApplicationsCore(string? name) + private List GetApplicationsCore(bool includeUninstrumentedPeers, string? name) { IEnumerable results = _applications.Values; + if (!includeUninstrumentedPeers) + { + results = results.Where(a => !a.UninstrumentedPeer); + } if (name != null) { results = results.Where(a => string.Equals(a.ApplicationKey.Name, name, StringComparisons.ResourceName)); @@ -135,14 +146,20 @@ private List GetApplicationsCore(string? name) return application; } - public List GetApplications(ApplicationKey key) + public List GetApplications(ApplicationKey key, bool includeUninstrumentedPeers = false) { if (key.InstanceId == null) { - return GetApplicationsByName(key.Name); + return GetApplicationsByName(key.Name, includeUninstrumentedPeers: includeUninstrumentedPeers); } - return [GetApplication(key)]; + var app = GetApplication(key); + if (app == null || (app.UninstrumentedPeer && !includeUninstrumentedPeers)) + { + return []; + } + + return [app]; } public Dictionary GetApplicationUnviewedErrorLogsCount() @@ -197,32 +214,37 @@ private OtlpApplicationView GetOrAddApplicationView(Resource resource) var key = resource.GetApplicationKey(); - // Fast path. - if (_applications.TryGetValue(key, out var application)) - { - return application.GetView(resource.Attributes); - } - - // Slower get or add path. - (application, var isNew) = GetOrAddApplication(key, resource); + var (application, isNew) = GetOrAddApplication(key, uninstrumentedPeer: false); if (isNew) { RaiseSubscriptionChanged(_applicationSubscriptions); } return application.GetView(resource.Attributes); + } - (OtlpApplication, bool) GetOrAddApplication(ApplicationKey key, Resource resource) + private (OtlpApplication Application, bool IsNew) GetOrAddApplication(ApplicationKey key, bool uninstrumentedPeer) + { + // Fast path. + if (_applications.TryGetValue(key, out var application)) { - // This GetOrAdd allocates a closure, so we avoid it if possible. - var newApplication = false; - var application = _applications.GetOrAdd(key, _ => - { - newApplication = true; - return new OtlpApplication(key.Name, key.InstanceId!, _otlpContext); - }); - return (application, newApplication); + application.SetUninstrumentedPeer(uninstrumentedPeer); + return (Application: application, IsNew: false); } + + // Slower get or add path. + // This GetOrAdd allocates a closure, so we avoid it if possible. + var newApplication = false; + application = _applications.GetOrAdd(key, _ => + { + newApplication = true; + return new OtlpApplication(key.Name, key.InstanceId!, uninstrumentedPeer, _otlpContext); + }); + if (!newApplication) + { + application.SetUninstrumentedPeer(uninstrumentedPeer); + } + return (Application: application, IsNew: newApplication); } public Subscription OnNewApplications(Func callback) @@ -462,7 +484,7 @@ public List GetTracePropertyKeys(ApplicationKey? applicationKey) List? applications = null; if (applicationKey != null) { - applications = GetApplications(applicationKey.Value); + applications = GetApplications(applicationKey.Value, includeUninstrumentedPeers: true); } _tracesLock.EnterReadLock(); @@ -489,7 +511,7 @@ public GetTracesResponse GetTraces(GetTracesRequest context) List? applications = null; if (context.ApplicationKey is { } key) { - applications = GetApplications(key); + applications = GetApplications(key, includeUninstrumentedPeers: true); if (applications.Count == 0) { @@ -582,10 +604,12 @@ private static bool MatchApplications(OtlpTrace t, List applica { for (var i = 0; i < applications.Count; i++) { + var applicationKey = applications[i].ApplicationKey; + // Spans collection type returns a struct enumerator so it's ok to foreach inside another loop. foreach (var span in t.Spans) { - if (span.Source.ApplicationKey == applications[i].ApplicationKey) + if (span.Source.ApplicationKey == applicationKey || span.UninstrumentedPeer?.ApplicationKey == applicationKey) { return true; } @@ -607,7 +631,7 @@ public void ClearTraces(ApplicationKey? applicationKey = null) List? applications = null; if (applicationKey.HasValue) { - applications = GetApplications(applicationKey.Value); + applications = GetApplications(applicationKey.Value, includeUninstrumentedPeers: true); } _tracesLock.EnterWriteLock(); @@ -708,10 +732,16 @@ public Dictionary GetTraceFieldValues(string attributeName) { foreach (var span in trace.Spans) { - var value = OtlpSpan.GetFieldValue(span, attributeName); - if (value != null) + var values = OtlpSpan.GetFieldValue(span, attributeName); + if (values.Value1 != null) { - ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(attributesValues, value, out _); + ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(attributesValues, values.Value1, out _); + // Adds to dictionary if not present. + count++; + } + if (values.Value2 != null) + { + ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(attributesValues, values.Value2, out _); // Adds to dictionary if not present. count++; } @@ -913,24 +943,23 @@ internal void AddTracesCore(AddContext context, OtlpApplicationView applicationV continue; } - OtlpTrace? lastTrace = null; + var updatedTraces = new Dictionary, OtlpTrace>(); foreach (var span in scopeSpan.Spans) { try { OtlpTrace? trace; - bool newTrace = false; + var newTrace = false; - // Fast path to check if the span is in the same trace as the last span. - if (lastTrace != null && span.TraceId.Span.SequenceEqual(lastTrace.Key.Span)) - { - trace = lastTrace; - } - else if (!TryGetTraceById(_traces, span.TraceId.Memory, out trace)) + // Fast path to check if the span is in a trace that's been updated this add call. + if (!updatedTraces.TryGetValue(span.TraceId.Memory, out trace)) { - trace = new OtlpTrace(span.TraceId.Memory); - newTrace = true; + if (!TryGetTraceById(_traces, span.TraceId.Memory, out trace)) + { + trace = new OtlpTrace(span.TraceId.Memory); + newTrace = true; + } } var newSpan = CreateSpan(applicationView, span, trace, scope, _otlpContext); @@ -1018,7 +1047,7 @@ internal void AddTracesCore(AddContext context, OtlpApplicationView applicationV // Newly added or updated trace should always been in the collection. Debug.Assert(_traces.Contains(trace), "Trace not found in traces collection."); - lastTrace = trace; + updatedTraces[trace.Key] = trace; } catch (Exception ex) { @@ -1030,6 +1059,12 @@ internal void AddTracesCore(AddContext context, OtlpApplicationView applicationV AssertSpanLinks(); } + // After spans are updated, loop through traces and their spans and update uninstrumented peer values. + // These can change + foreach (var (_, updatedTrace) in updatedTraces) + { + CalculateTraceUninstrumentedPeers(updatedTrace); + } } } finally @@ -1054,6 +1089,48 @@ static bool TryGetTraceById(CircularBuffer traces, ReadOnlyMemory outgoingPeerResolvers) + { + // Attempt to resolve uninstrumented peer to a friendly name from the span. + foreach (var resolver in outgoingPeerResolvers) + { + if (resolver.TryResolvePeer(span.Attributes, out _, out var matchedResourced)) + { + return matchedResourced; + } + } + + return null; + } + [Conditional("DEBUG")] private void AssertTraceOrder() { @@ -1247,4 +1324,32 @@ public List GetInstrumentsSummaries(ApplicationKey key) }; } } + + private Task OnPeerChanged() + { + _tracesLock.EnterWriteLock(); + + try + { + // When peers change then we need to recalculate the uninstrumented peers of spans. + foreach (var trace in _traces) + { + CalculateTraceUninstrumentedPeers(trace); + } + } + finally + { + _tracesLock.ExitWriteLock(); + } + + return Task.CompletedTask; + } + + public void Dispose() + { + foreach (var subscription in _peerResolverSubscriptions) + { + subscription.Dispose(); + } + } } diff --git a/src/Aspire.Dashboard/ResourceService/DashboardClient.cs b/src/Aspire.Dashboard/ResourceService/DashboardClient.cs index 87877eeeec9..07aded2955c 100644 --- a/src/Aspire.Dashboard/ResourceService/DashboardClient.cs +++ b/src/Aspire.Dashboard/ResourceService/DashboardClient.cs @@ -48,7 +48,6 @@ internal sealed class DashboardClient : IDashboardClient private readonly ILoggerFactory _loggerFactory; private readonly IDashboardClientStatus _dashboardClientStatus; - private readonly BrowserTimeProvider _timeProvider; private readonly IKnownPropertyLookup _knownPropertyLookup; private readonly DashboardOptions _dashboardOptions; private readonly ILogger _logger; @@ -73,13 +72,11 @@ public DashboardClient( IConfiguration configuration, IOptions dashboardOptions, IDashboardClientStatus dashboardClientStatus, - BrowserTimeProvider timeProvider, IKnownPropertyLookup knownPropertyLookup, Action? configureHttpHandler = null) { _loggerFactory = loggerFactory; _dashboardClientStatus = dashboardClientStatus; - _timeProvider = timeProvider; _knownPropertyLookup = knownPropertyLookup; _dashboardOptions = dashboardOptions.Value; @@ -341,7 +338,7 @@ async Task WatchResourcesAsync() foreach (var resource in response.InitialData.Resources) { // Add to map. - var viewModel = resource.ToViewModel(_timeProvider, _knownPropertyLookup, _logger); + var viewModel = resource.ToViewModel(_knownPropertyLookup, _logger); _resourceByName[resource.Name] = viewModel; // Send this update to any subscribers too. @@ -361,7 +358,7 @@ async Task WatchResourcesAsync() if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert) { // Upsert (i.e. add or replace) - var viewModel = change.Upsert.ToViewModel(_timeProvider, _knownPropertyLookup, _logger); + var viewModel = change.Upsert.ToViewModel(_knownPropertyLookup, _logger); _resourceByName[change.Upsert.Name] = viewModel; changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); } @@ -606,7 +603,7 @@ internal void SetInitialDataReceived(IList? initialData = null) { foreach (var data in initialData) { - _resourceByName[data.Name] = data.ToViewModel(_timeProvider, _knownPropertyLookup, _logger); + _resourceByName[data.Name] = data.ToViewModel(_knownPropertyLookup, _logger); } } } diff --git a/src/Aspire.Dashboard/ResourceService/Partials.cs b/src/Aspire.Dashboard/ResourceService/Partials.cs index d922da5f162..82e46260352 100644 --- a/src/Aspire.Dashboard/ResourceService/Partials.cs +++ b/src/Aspire.Dashboard/ResourceService/Partials.cs @@ -18,7 +18,7 @@ partial class Resource /// /// Converts this gRPC message object to a view model for use in the dashboard UI. /// - public ResourceViewModel ToViewModel(BrowserTimeProvider timeProvider, IKnownPropertyLookup knownPropertyLookup, ILogger logger) + public ResourceViewModel ToViewModel(IKnownPropertyLookup knownPropertyLookup, ILogger logger) { try { @@ -31,7 +31,7 @@ public ResourceViewModel ToViewModel(BrowserTimeProvider timeProvider, IKnownPro CreationTimeStamp = ValidateNotNull(CreatedAt).ToDateTime(), StartTimeStamp = StartedAt?.ToDateTime(), StopTimeStamp = StoppedAt?.ToDateTime(), - Properties = CreatePropertyViewModels(Properties, timeProvider, knownPropertyLookup, logger), + Properties = CreatePropertyViewModels(Properties, knownPropertyLookup, logger), Environment = GetEnvironment(), Urls = GetUrls(), Volumes = GetVolumes(), @@ -149,7 +149,7 @@ static FluentUIIconVariant MapIconVariant(IconVariant iconVariant) } } - private ImmutableDictionary CreatePropertyViewModels(RepeatedField properties, BrowserTimeProvider timeProvider, IKnownPropertyLookup knownPropertyLookup, ILogger logger) + private ImmutableDictionary CreatePropertyViewModels(RepeatedField properties, IKnownPropertyLookup knownPropertyLookup, ILogger logger) { var builder = ImmutableDictionary.CreateBuilder(StringComparers.ResourcePropertyName); @@ -161,8 +161,7 @@ private ImmutableDictionary CreatePropertyVie value: ValidateNotNull(property.Value), isValueSensitive: property.IsSensitive, knownProperty: knownProperty, - priority: priority, - timeProvider: timeProvider); + priority: priority); if (builder.ContainsKey(propertyViewModel.Name)) { diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs index edb2540367c..6861f2400c1 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs @@ -23,7 +23,7 @@ public void Render_ManyDuplicateAttributes_NoDuplicateKeys() StructuredLogsSetupHelpers.SetupStructuredLogsDetails(this); var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; - var app = new OtlpApplication("app1", "instance1", context); + var app = new OtlpApplication("app1", "instance1", uninstrumentedPeer: false, context); var view = new OtlpApplicationView(app, new RepeatedField { new KeyValue { Key = "Message", Value = new AnyValue { StringValue = "value1" } }, diff --git a/tests/Aspire.Dashboard.Tests/BrowserLinkOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/BrowserLinkOutgoingPeerResolverTests.cs index 9f2e94afb6e..4e5e5bd118f 100644 --- a/tests/Aspire.Dashboard.Tests/BrowserLinkOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/BrowserLinkOutgoingPeerResolverTests.cs @@ -113,6 +113,6 @@ public void LocalHostAndPathUrlAttribute_Match() private static bool TryResolvePeerName(IOutgoingPeerResolver resolver, KeyValuePair[] attributes, out string? peerName) { - return resolver.TryResolvePeerName(attributes, out peerName); + return resolver.TryResolvePeer(attributes, out peerName, out _); } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs index a1b0d6fc819..42a2b0cbd22 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs @@ -16,7 +16,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; using DashboardServiceBase = Aspire.ResourceService.Proto.V1.DashboardService.DashboardServiceBase; @@ -131,7 +130,6 @@ private static async Task CreateDashboardClientAsync( configuration: new ConfigurationManager(), dashboardOptions: Options.Create(options), dashboardClientStatus: new TestDashboardClientStatus(), - timeProvider: new BrowserTimeProvider(NullLoggerFactory.Instance), knownPropertyLookup: new MockKnownPropertyLookup(), configureHttpHandler: handler => handler.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index db006e581ff..3271d63ff4c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -58,7 +58,7 @@ public async ValueTask InitializeAsync() { builder.Configuration.AddConfiguration(config); builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddSingleton(); }); await DashboardApp.StartAsync(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index 7eadd9fc3ab..bcf6aef30ae 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -4,7 +4,6 @@ using Aspire.Dashboard.Model; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; @@ -15,8 +14,6 @@ public sealed class MockDashboardClientStatus : IDashboardClientStatus public sealed class MockDashboardClient : IDashboardClient { - private static readonly BrowserTimeProvider s_timeProvider = new(NullLoggerFactory.Instance); - public MockDashboardClient(IDashboardClientStatus dashboardClientStatus) { _dashboardClientStatus = dashboardClientStatus; @@ -36,9 +33,8 @@ public MockDashboardClient(IDashboardClientStatus dashboardClientStatus) StringValue = "C:/MyProjectPath/Project.csproj" }, isValueSensitive: false, - knownProperty: new(KnownProperties.Project.Path, "Path"), - priority: 0, - timeProvider: s_timeProvider)) + knownProperty: new(KnownProperties.Project.Path, loc => "Path"), + priority: 0)) }.ToDictionary(), state: KnownResourceState.Running); diff --git a/tests/Aspire.Dashboard.Tests/Model/ApplicationsSelectHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ApplicationsSelectHelpersTests.cs index 7041230eb39..695206b917d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ApplicationsSelectHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ApplicationsSelectHelpersTests.cs @@ -214,6 +214,6 @@ private static OtlpApplication CreateOtlpApplication(string name, string instanc }; var applicationKey = OtlpHelpers.GetApplicationKey(resource); - return new OtlpApplication(applicationKey.Name, applicationKey.InstanceId!, TelemetryTestHelpers.CreateContext()); + return new OtlpApplication(applicationKey.Name, applicationKey.InstanceId!, uninstrumentedPeer: false, TelemetryTestHelpers.CreateContext()); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index a9797e78b2e..51411702b15 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -16,8 +16,6 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class DashboardClientTests { - private static readonly BrowserTimeProvider s_timeProvider = new(NullLoggerFactory.Instance); - private readonly IConfiguration _configuration; private readonly IOptions _dashboardOptions; @@ -152,7 +150,7 @@ public async Task SubscribeResources_HasInitialData_InitialDataReturned() private DashboardClient CreateResourceServiceClient() { - return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new TestDashboardClientStatus(), s_timeProvider, new MockKnownPropertyLookup()); + return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new TestDashboardClientStatus(), new MockKnownPropertyLookup()); } private sealed class TestDashboardClientStatus : IDashboardClientStatus diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs index a3ecec34f29..93d5ee40955 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs @@ -5,7 +5,6 @@ using Aspire.Dashboard.Utils; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Aspire.Dashboard.Tests.Model; @@ -24,17 +23,17 @@ public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, Resou if (testData.ExecutableArguments is not null) { - properties.TryAdd(KnownProperties.Executable.Args, new ResourcePropertyViewModel(KnownProperties.Executable.Args, Value.ForList(testData.ExecutableArguments.Select(Value.ForString).ToArray()), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory()))); + properties.TryAdd(KnownProperties.Executable.Args, new ResourcePropertyViewModel(KnownProperties.Executable.Args, Value.ForList(testData.ExecutableArguments.Select(Value.ForString).ToArray()), false, null, 0)); } if (testData.AppArgs is not null) { - properties.TryAdd(KnownProperties.Resource.AppArgs, new ResourcePropertyViewModel(KnownProperties.Resource.AppArgs, Value.ForList(testData.AppArgs.Select(Value.ForString).ToArray()), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory()))); + properties.TryAdd(KnownProperties.Resource.AppArgs, new ResourcePropertyViewModel(KnownProperties.Resource.AppArgs, Value.ForList(testData.AppArgs.Select(Value.ForString).ToArray()), false, null, 0)); } if (testData.AppArgsSensitivity is not null) { - properties.TryAdd(KnownProperties.Resource.AppArgsSensitivity, new ResourcePropertyViewModel(KnownProperties.Resource.AppArgsSensitivity, Value.ForList(testData.AppArgsSensitivity.Select(b => Value.ForNumber(Convert.ToInt32(b))).ToArray()), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory()))); + properties.TryAdd(KnownProperties.Resource.AppArgsSensitivity, new ResourcePropertyViewModel(KnownProperties.Resource.AppArgsSensitivity, Value.ForList(testData.AppArgsSensitivity.Select(b => Value.ForNumber(Convert.ToInt32(b))).ToArray()), false, null, 0)); } var resource = ModelTestHelpers.CreateResource( @@ -57,7 +56,7 @@ public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, Resou void AddStringProperty(string propertyName, string? propertyValue) { - properties.TryAdd(propertyName, new ResourcePropertyViewModel(propertyName, propertyValue is null ? Value.ForNull() : Value.ForString(propertyValue), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory()))); + properties.TryAdd(propertyName, new ResourcePropertyViewModel(propertyName, propertyValue is null ? Value.ForNull() : Value.ForString(propertyValue), false, null, 0)); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs index 2aba11ce4dc..ce6ed7bbeae 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs @@ -6,7 +6,6 @@ using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.FluentUI.AspNetCore.Components; using Xunit; using Enum = System.Enum; @@ -73,7 +72,7 @@ public void ResourceViewModel_ReturnsCorrectIconAndTooltip( var propertiesDictionary = new Dictionary(); if (exitCode is not null) { - propertiesDictionary.TryAdd(KnownProperties.Resource.ExitCode, new ResourcePropertyViewModel(KnownProperties.Resource.ExitCode, Value.ForNumber((double)exitCode), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory()))); + propertiesDictionary.TryAdd(KnownProperties.Resource.ExitCode, new ResourcePropertyViewModel(KnownProperties.Resource.ExitCode, Value.ForNumber((double)exitCode), false, null, 0)); } var resource = ModelTestHelpers.CreateResource( @@ -86,7 +85,7 @@ public void ResourceViewModel_ReturnsCorrectIconAndTooltip( if (exitCode is not null) { - resource.Properties.TryAdd(KnownProperties.Resource.ExitCode, new ResourcePropertyViewModel(KnownProperties.Resource.ExitCode, Value.ForNumber((double)exitCode), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory()))); + resource.Properties.TryAdd(KnownProperties.Resource.ExitCode, new ResourcePropertyViewModel(KnownProperties.Resource.ExitCode, Value.ForNumber((double)exitCode), false, null, 0)); } var localizer = new TestStringLocalizer(); diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs index 9eefdb84743..ab1926c08c4 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs @@ -14,7 +14,6 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class ResourceViewModelTests { private static readonly DateTime s_dateTime = new(2000, 12, 30, 23, 59, 59, DateTimeKind.Utc); - private static readonly BrowserTimeProvider s_timeProvider = new(NullLoggerFactory.Instance); [Theory] [InlineData(KnownResourceState.Starting, null, null)] @@ -48,7 +47,7 @@ public void ToViewModel_EmptyEnvVarName_Success() }; // Act - var vm = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(), NullLogger.Instance); + var vm = resource.ToViewModel(new MockKnownPropertyLookup(), NullLogger.Instance); // Assert Assert.Collection(vm.Environment, @@ -76,7 +75,7 @@ public void ToViewModel_DuplicatePropertyNames_Success() }; // Act - var vm = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(), NullLogger.Instance); + var vm = resource.ToViewModel(new MockKnownPropertyLookup(), NullLogger.Instance); // Assert Assert.Collection(vm.Properties, @@ -100,7 +99,7 @@ public void ToViewModel_MissingRequiredData_FailWithFriendlyError() }; // Act - var ex = Assert.Throws(() => resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(), NullLogger.Instance)); + var ex = Assert.Throws(() => resource.ToViewModel(new MockKnownPropertyLookup(), NullLogger.Instance)); // Assert Assert.Equal(@"Error converting resource ""TestName-abc"" to ResourceViewModel.", ex.Message); @@ -123,10 +122,10 @@ public void ToViewModel_CopiesProperties() } }; - var kp = new KnownProperty("foo", "bar"); + var kp = new KnownProperty("foo", loc => "bar"); // Act - var viewModel = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(123, kp), NullLogger.Instance); + var viewModel = resource.ToViewModel(new MockKnownPropertyLookup(123, kp), NullLogger.Instance); // Assert Assert.Collection( diff --git a/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs index 385adc46b7a..0c0fd3514b2 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs @@ -1,7 +1,6 @@ // 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; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Tests.Shared.Telemetry; @@ -17,8 +16,8 @@ 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 app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); + var app2 = new OtlpApplication("app2", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); @@ -26,7 +25,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, @@ -52,7 +51,7 @@ public void MatchesFilter_VariousCases_ReturnsExpected(string filter, bool expec { // Arrange var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; - var app = new OtlpApplication("app1", "instance", context); + var app = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); @@ -78,10 +77,7 @@ public void MatchesFilter_VariousCases_ReturnsExpected(string filter, bool expec var vm = SpanWaterfallViewModel.Create( trace, - new SpanWaterfallViewModel.TraceDetailState( - [new TestPeerResolver()], - [] - )).First(); + new SpanWaterfallViewModel.TraceDetailState([])).First(); // Act var result = vm.MatchesFilter(filter, a => a.Application.ApplicationName, out _); @@ -95,7 +91,7 @@ public void MatchesFilter_ParentSpanIncludedWhenChildMatched() { // Arrange var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; - var app1 = new OtlpApplication("app1", "instance", context); + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); var parentSpan = TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "parent", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)); @@ -103,7 +99,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]; @@ -117,7 +113,7 @@ public void MatchesFilter_ChildSpanIncludedWhenParentMatched() { // Arrange var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; - var app1 = new OtlpApplication("app1", "instance", context); + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); var parentSpan = TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "parent", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc)); @@ -125,7 +121,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]; @@ -135,27 +131,6 @@ public void MatchesFilter_ChildSpanIncludedWhenParentMatched() Assert.False(child.MatchesFilter("parent", a => a.Application.ApplicationName, out _)); } - private sealed class TestPeerResolver : IOutgoingPeerResolver - { - public bool TryResolvePeerName(KeyValuePair[] attributes, out string? name) - { - var peerService = attributes.FirstOrDefault(a => a.Key == "peer.service"); - if (!string.IsNullOrEmpty(peerService.Value)) - { - name = peerService.Value; - return true; - } - - name = null; - return false; - } - - public IDisposable OnPeerChanges(Func callback) - { - return EmptyDisposable.Instance; - } - } - private sealed class EmptyDisposable : IDisposable { public static EmptyDisposable Instance { get; } = new(); diff --git a/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs index 32a32281bfd..433e54e670f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs @@ -16,7 +16,7 @@ public void GetOrderedApplications_SingleSpan_GroupedResult() { // Arrange var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; - var app1 = new OtlpApplication("app1", "instance", context); + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc))); @@ -37,8 +37,8 @@ public void GetOrderedApplications_MultipleUnparentedSpans_GroupedResult() { // 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 app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); + var app2 = new OtlpApplication("app2", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "1-2", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc))); @@ -64,8 +64,8 @@ public void GetOrderedApplications_ChildSpanAfterParentSpan_GroupedResult() { // 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 app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); + var app2 = new OtlpApplication("app2", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc))); @@ -91,9 +91,9 @@ public void GetOrderedApplications_ChildSpanDifferentStartTime_GroupedResult() { // 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 app3 = new OtlpApplication("app3", "instance", context); + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); + var app2 = new OtlpApplication("app2", "instance", uninstrumentedPeer: false, context); + var app3 = new OtlpApplication("app3", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc))); @@ -119,4 +119,36 @@ public void GetOrderedApplications_ChildSpanDifferentStartTime_GroupedResult() Assert.Equal(app2, g.Application); }); } + + [Fact] + public void GetOrderedApplications_HasUninstrumentedPeer_AddedToResults() + { + // Arrange + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); + var app2 = new OtlpApplication("app2", "instance", uninstrumentedPeer: false, context); + var app3 = new OtlpApplication("app3", "instance", uninstrumentedPeer: true, context); + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); + var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc))); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 3, DateTimeKind.Utc), uninstrumentedPeer: app3)); + + // Act + var results = TraceHelpers.GetOrderedApplications(trace); + + // Assert + Assert.Collection(results, + g => + { + Assert.Equal(app1, g.Application); + }, + g => + { + Assert.Equal(app2, g.Application); + }, + g => + { + Assert.Equal(app3, g.Application); + }); + } } diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 6ac04d21489..e5264dd8ba9 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -12,11 +12,12 @@ namespace Aspire.Dashboard.Tests; public class ResourceOutgoingPeerResolverTests { - private static ResourceViewModel CreateResource(string name, string? serviceAddress = null, int? servicePort = null, string? displayName = null) + private static ResourceViewModel CreateResource(string name, string? serviceAddress = null, int? servicePort = null, string? displayName = null, KnownResourceState? state = null) { return ModelTestHelpers.CreateResource( appName: name, displayName: displayName, + state: state, urls: serviceAddress is null || servicePort is null ? [] : [new UrlViewModel(name, new($"http://{serviceAddress}:{servicePort}"), isInternal: false, isInactive: false, displayProperties: UrlDisplayPropertiesViewModel.Empty)]); } @@ -124,18 +125,19 @@ public async Task OnPeerChanges_DataUpdates_EventRaised() var resultChannel = Channel.CreateUnbounded(); var dashboardClient = new MockDashboardClient(tcs.Task); var resolver = new ResourceOutgoingPeerResolver(dashboardClient); - var changeCount = 1; + var changeCount = 0; resolver.OnPeerChanges(async () => { - await resultChannel.Writer.WriteAsync(changeCount++); + await resultChannel.Writer.WriteAsync(++changeCount); }); var readValue = 0; Assert.False(resultChannel.Reader.TryRead(out readValue)); // Act 1 + // Initial resource causes change. tcs.SetResult(new ResourceViewModelSubscription( - [CreateResource("test")], + [CreateResource("test", serviceAddress: "localhost", servicePort: 8080)], GetChanges())); // Assert 1 @@ -143,13 +145,32 @@ public async Task OnPeerChanges_DataUpdates_EventRaised() Assert.Equal(1, readValue); // Act 2 - await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2"))); + // New resource causes change. + await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2", serviceAddress: "localhost", servicePort: 8080, state: KnownResourceState.Starting))); // Assert 2 readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout(); Assert.Equal(2, readValue); + // Act 3 + // URL change causes change. + await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2", serviceAddress: "localhost", servicePort: 8081, state: KnownResourceState.Starting))); + + // Assert 3 + readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout(); + Assert.Equal(3, readValue); + + // Act 4 + // Resource update doesn't cause change. + await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2", serviceAddress: "localhost", servicePort: 8081, state: KnownResourceState.Running))); + + // Dispose so that we know that all changes are processed. await resolver.DisposeAsync().DefaultTimeout(); + resultChannel.Writer.Complete(); + + // Assert 4 + Assert.False(await resultChannel.Reader.WaitToReadAsync().DefaultTimeout()); + Assert.Equal(3, changeCount); async IAsyncEnumerable> GetChanges([EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -194,7 +215,7 @@ public void NameAndDisplayNameDifferent_MultipleInstances_ReturnName() private static bool TryResolvePeerName(IDictionary resources, KeyValuePair[] attributes, out string? peerName) { - return ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, attributes, out peerName); + return ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, attributes, out peerName, out _); } private sealed class MockDashboardClient(Task subscribeResult) : IDashboardClient diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/OtlpSpanTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/OtlpSpanTests.cs index bf713f6f675..d5014ffa567 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/OtlpSpanTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/OtlpSpanTests.cs @@ -18,7 +18,7 @@ public void AllProperties() { // Arrange var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; - var app1 = new OtlpApplication("app1", "instance", context); + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); var scope = new OtlpScope(TelemetryTestHelpers.CreateScope(), context); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index d301a672f7d..a89ee8c76fe 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -3,9 +3,11 @@ using System.Globalization; using System.Text; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf; using Google.Protobuf.Collections; using Microsoft.Extensions.Logging; @@ -72,6 +74,7 @@ public void AddTraces() { Assert.Equal("TestService", app.ApplicationName); Assert.Equal("TestId", app.InstanceId); + Assert.False(app.UninstrumentedPeer); }); var traces = repository.GetTraces(new GetTracesRequest @@ -1231,13 +1234,15 @@ public void GetTraces_AttributeFilters() [InlineData(KnownTraceFields.TraceIdField, "31")] [InlineData(KnownTraceFields.SpanIdField, "312d31")] [InlineData(KnownTraceFields.StatusField, "Unset")] - [InlineData(KnownTraceFields.KindField, "Internal")] + [InlineData(KnownTraceFields.KindField, "Client")] [InlineData(KnownResourceFields.ServiceNameField, "app1")] + [InlineData(KnownResourceFields.ServiceNameField, "TestPeer")] [InlineData(KnownSourceFields.NameField, "TestScope")] public void GetTraces_KnownFilters(string name, string value) { // Arrange - var repository = CreateRepository(); + var outgoingPeerResolver = new TestOutgoingPeerResolver(); + var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); repository.AddTraces(addContext, new RepeatedField() @@ -1250,7 +1255,7 @@ public void GetTraces_KnownFilters(string name, string value) new ScopeSpans { Scope = CreateScope(), - Spans = { CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), attributes: [KeyValuePair.Create("key1", "value1")]) } + Spans = { CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), attributes: [KeyValuePair.Create("key1", "value1"), KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-1")], kind: Span.Types.SpanKind.Client) } } } } @@ -1923,4 +1928,241 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() AssertId("3-2", s.SpanId); }); } + + private sealed class TestOutgoingPeerResolver : IOutgoingPeerResolver, IDisposable + { + private readonly Func[], (string? Name, ResourceViewModel? Resource)>? _onResolve; + private readonly List> _callbacks; + + public TestOutgoingPeerResolver(Func[], (string? Name, ResourceViewModel? Resource)>? onResolve = null) + { + _onResolve = onResolve; + _callbacks = new(); + } + + public void Dispose() + { + } + + public IDisposable OnPeerChanges(Func callback) + { + _callbacks.Add(callback); + return this; + } + + public async Task InvokePeerChanges() + { + foreach (var callback in _callbacks) + { + await callback(); + } + } + + public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResourced) + { + if (_onResolve != null) + { + (name, matchedResourced) = _onResolve(attributes); + return (name != null); + } + + name = "TestPeer"; + matchedResourced = ModelTestHelpers.CreateResource(appName: "TestPeer"); + return true; + } + } + + [Fact] + public void AddTraces_HaveUninstrumentedPeers() + { + // Arrange + var outgoingPeerResolver = new TestOutgoingPeerResolver(); + var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); + + // Act + var addContext = new AddContext(); + repository.AddTraces(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), attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-1")], kind: Span.Types.SpanKind.Client), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-2")], kind: Span.Types.SpanKind.Client) + } + } + } + } + }); + + // Assert + Assert.Equal(0, addContext.FailureCount); + + var applications = repository.GetApplications(includeUninstrumentedPeers: true); + Assert.Collection(applications, + app => + { + Assert.Equal("TestPeer", app.ApplicationName); + Assert.Null(app.InstanceId); + Assert.True(app.UninstrumentedPeer); + }, + app => + { + Assert.Equal("TestService", app.ApplicationName); + Assert.Equal("TestId", app.InstanceId); + Assert.False(app.UninstrumentedPeer); + }); + + var uninstrumentedPeerApp = applications.Single(a => a.UninstrumentedPeer); + + var traces = repository.GetTraces(new GetTracesRequest + { + ApplicationKey = uninstrumentedPeerApp.ApplicationKey, + FilterText = string.Empty, + StartIndex = 0, + Count = 10, + Filters = [] + }); + + var trace = Assert.Single(traces.PagedResult.Items); + Assert.Collection(trace.Spans, + s => + { + AssertId("1-1", s.SpanId); + Assert.Null(s.UninstrumentedPeer); + }, + s => + { + AssertId("1-2", s.SpanId); + Assert.NotNull(s.UninstrumentedPeer); + Assert.Equal("TestPeer", s.UninstrumentedPeer.ApplicationName); + }); + } + + [Fact] + public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() + { + // Arrange + var matchPeer = false; + var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => + { + if (matchPeer) + { + var name = "TestPeer"; + var matchedResourced = ModelTestHelpers.CreateResource(appName: "TestPeer"); + + return (name, matchedResourced); + } + else + { + return (null, null); + } + }); + var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); + + // Act + var addContext = new AddContext(); + repository.AddTraces(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), attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-1")], kind: Span.Types.SpanKind.Client), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "value-2")], kind: Span.Types.SpanKind.Client) + } + } + } + } + }); + + // Assert + Assert.Equal(0, addContext.FailureCount); + + var applications = repository.GetApplications(includeUninstrumentedPeers: true); + Assert.Collection(applications, + app => + { + Assert.Equal("TestService", app.ApplicationName); + Assert.Equal("TestId", app.InstanceId); + Assert.False(app.UninstrumentedPeer); + }); + + var traces = repository.GetTraces(new GetTracesRequest + { + ApplicationKey = applications[0].ApplicationKey, + FilterText = string.Empty, + StartIndex = 0, + Count = 10, + Filters = [] + }); + + var trace = Assert.Single(traces.PagedResult.Items); + Assert.Collection(trace.Spans, + s => + { + AssertId("1-1", s.SpanId); + Assert.Null(s.UninstrumentedPeer); + }, + s => + { + AssertId("1-2", s.SpanId); + Assert.Null(s.UninstrumentedPeer); + }); + + matchPeer = true; + await outgoingPeerResolver.InvokePeerChanges(); + + applications = repository.GetApplications(includeUninstrumentedPeers: true); + Assert.Collection(applications, + app => + { + Assert.Equal("TestPeer", app.ApplicationName); + Assert.Null(app.InstanceId); + Assert.True(app.UninstrumentedPeer); + }, + app => + { + Assert.Equal("TestService", app.ApplicationName); + Assert.Equal("TestId", app.InstanceId); + Assert.False(app.UninstrumentedPeer); + }); + + var uninstrumentedPeerApp = applications.Single(a => a.UninstrumentedPeer); + + traces = repository.GetTraces(new GetTracesRequest + { + ApplicationKey = uninstrumentedPeerApp.ApplicationKey, + FilterText = string.Empty, + StartIndex = 0, + Count = 10, + Filters = [] + }); + + trace = Assert.Single(traces.PagedResult.Items); + Assert.Collection(trace.Spans, + s => + { + AssertId("1-1", s.SpanId); + Assert.Null(s.UninstrumentedPeer); + }, + s => + { + AssertId("1-2", s.SpanId); + Assert.NotNull(s.UninstrumentedPeer); + Assert.Equal("TestPeer", s.UninstrumentedPeer.ApplicationName); + }); + } } diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs index c40a2ef7e9a..5d60933183b 100644 --- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs +++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs @@ -145,7 +145,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, List? links = 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, Span.Types.SpanKind? kind = null) { var span = new Span { @@ -154,7 +154,8 @@ public static Span CreateSpan(string traceId, string spanId, DateTime startTime, ParentSpanId = parentSpanId is null ? ByteString.Empty : ByteString.CopyFrom(Encoding.UTF8.GetBytes(parentSpanId)), StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), EndTimeUnixNano = DateTimeToUnixNanoseconds(endTime), - Name = $"Test span. Id: {spanId}" + Name = $"Test span. Id: {spanId}", + Kind = kind ?? Span.Types.SpanKind.Internal }; if (events != null) { @@ -227,7 +228,8 @@ public static TelemetryRepository CreateRepository( int? maxTraceCount = null, TimeSpan? subscriptionMinExecuteInterval = null, ILoggerFactory? loggerFactory = null, - PauseManager? pauseManager = null) + PauseManager? pauseManager = null, + IOutgoingPeerResolver[]? outgoingPeerResolvers = null) { var options = new TelemetryLimitOptions(); if (maxMetricsCount != null) @@ -254,7 +256,8 @@ public static TelemetryRepository CreateRepository( var repository = new TelemetryRepository( loggerFactory ?? NullLoggerFactory.Instance, Options.Create(new DashboardOptions { TelemetryLimits = options }), - pauseManager ?? new PauseManager()); + pauseManager ?? new PauseManager(), + outgoingPeerResolvers ?? []); if (subscriptionMinExecuteInterval != null) { repository._subscriptionMinExecuteInterval = subscriptionMinExecuteInterval.Value; @@ -291,7 +294,8 @@ public static OtlpContext CreateContext(TelemetryLimitOptions? options = null, I } public static OtlpSpan CreateOtlpSpan(OtlpApplication app, OtlpTrace trace, OtlpScope scope, string spanId, string? parentSpanId, DateTime startDate, - KeyValuePair[]? attributes = null, OtlpSpanStatusCode? statusCode = null, string? statusMessage = null, OtlpSpanKind kind = OtlpSpanKind.Unspecified) + KeyValuePair[]? attributes = null, OtlpSpanStatusCode? statusCode = null, string? statusMessage = null, OtlpSpanKind kind = OtlpSpanKind.Unspecified, + OtlpApplication? uninstrumentedPeer = null) { return new OtlpSpan(app.GetView([]), trace, scope) { @@ -307,7 +311,8 @@ public static OtlpSpan CreateOtlpSpan(OtlpApplication app, OtlpTrace trace, Otlp StartTime = startDate, State = null, Status = statusCode ?? OtlpSpanStatusCode.Unset, - StatusMessage = statusMessage + StatusMessage = statusMessage, + UninstrumentedPeer = uninstrumentedPeer }; } }