diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor.cs index 95a1fdfa2a..98c67f0aed 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor.cs @@ -26,7 +26,12 @@ public interface IPropertyGridItem /// /// Gets the display name of the item. /// - string? Name { get; } + string Name { get; } + + /// + /// Gets the key of the item. Must be unique. + /// + public object Key => Name; /// /// Gets the display value of the item. @@ -91,7 +96,7 @@ public partial class PropertyGrid where TItem : IPropertyGridItem public IQueryable? Items { get; set; } [Parameter] - public Func ItemKey { get; init; } = static item => item.Name; + public Func ItemKey { get; init; } = static item => item.Key; [Parameter] public string GridTemplateColumns { get; set; } = "1fr 1fr"; diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor index 355de9b42e..cd2e73b439 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor @@ -36,7 +36,7 @@ @FilteredItems.Count() - @@ -47,7 +47,7 @@ @FilteredContextItems.Count() - @@ -58,7 +58,7 @@ @FilteredResourceItems.Count() - @@ -71,7 +71,6 @@ FilteredItems => + private IQueryable FilteredItems => ViewModel.Properties.Where(ApplyFilter).AsQueryable(); - private IQueryable FilteredContextItems => - _contextAttributes.Select(p => new SpanPropertyViewModel { Name = p.Key, Value = p.Value }) - .Where(ApplyFilter).AsQueryable(); + private IQueryable FilteredContextItems => + _contextAttributes.Where(ApplyFilter).AsQueryable(); - private IQueryable FilteredResourceItems => - ViewModel.Span.Source.AllProperties().Select(p => new SpanPropertyViewModel { Name = p.Key, Value = p.Value }) + private IQueryable FilteredResourceItems => + ViewModel.Span.Source.AllProperties().Select(p => new TelemetryPropertyViewModel { Name = p.DisplayName, Key = p.Key, Value = p.Value }) .Where(ApplyFilter).AsQueryable(); private IQueryable FilteredSpanEvents => @@ -49,11 +49,11 @@ public partial class SpanDetails : IDisposable private bool _isSpanBacklinksExpanded; private string _filter = ""; - private List> _contextAttributes = null!; + private List _contextAttributes = null!; private readonly CancellationTokenSource _cts = new(); - private bool ApplyFilter(SpanPropertyViewModel vm) + private bool ApplyFilter(TelemetryPropertyViewModel vm) { return vm.Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) || vm.Value?.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) == true; @@ -63,19 +63,19 @@ protected override void OnParametersSet() { _contextAttributes = [ - new KeyValuePair("Source", ViewModel.Span.Scope.ScopeName) + new TelemetryPropertyViewModel { Name = "Source", Key = KnownSourceFields.NameField, Value = ViewModel.Span.Scope.ScopeName } ]; if (!string.IsNullOrEmpty(ViewModel.Span.Scope.Version)) { - _contextAttributes.Add(new KeyValuePair("Version", ViewModel.Span.Scope.Version)); + _contextAttributes.Add(new TelemetryPropertyViewModel { Name = "Version", Key = KnownSourceFields.VersionField, Value = ViewModel.Span.Scope.ScopeName }); } if (!string.IsNullOrEmpty(ViewModel.Span.ParentSpanId)) { - _contextAttributes.Add(new KeyValuePair("ParentId", ViewModel.Span.ParentSpanId)); + _contextAttributes.Add(new TelemetryPropertyViewModel { Name = "ParentId", Key = KnownTraceFields.ParentIdField, Value = ViewModel.Span.ParentSpanId }); } if (!string.IsNullOrEmpty(ViewModel.Span.TraceId)) { - _contextAttributes.Add(new KeyValuePair("TraceId", ViewModel.Span.TraceId)); + _contextAttributes.Add(new TelemetryPropertyViewModel { Name = "TraceId", Key = KnownTraceFields.TraceIdField, Value = ViewModel.Span.TraceId }); } // Collapse details sections when they have no data. diff --git a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor index b0ebf36f3c..3b298265a7 100644 --- a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor @@ -27,7 +27,7 @@ @FilteredItems.Count() - @@ -38,7 +38,7 @@ @FilteredContextItems.Count() - @@ -51,7 +51,7 @@ @FilteredExceptionItems.Count() - @@ -63,7 +63,7 @@ @FilteredResourceItems.Count() - diff --git a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs index 227fa4dab5..0b5b85a3d9 100644 --- a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Otlp; using Microsoft.AspNetCore.Components; namespace Aspire.Dashboard.Components.Controls; @@ -14,64 +15,63 @@ public partial class StructuredLogDetails [Inject] public required BrowserTimeProvider TimeProvider { get; init; } - private IQueryable FilteredItems => - _logEntryAttributes.Select(p => new LogEntryPropertyViewModel { Name = p.Key, Value = p.Value }) - .Where(ApplyFilter).AsQueryable(); + internal IQueryable FilteredItems => + _logEntryAttributes.Where(ApplyFilter).AsQueryable(); - private IQueryable FilteredExceptionItems => - _exceptionAttributes.Select(p => new LogEntryPropertyViewModel { Name = p.Key, Value = p.Value }) - .Where(ApplyFilter).AsQueryable(); + internal IQueryable FilteredExceptionItems => + _exceptionAttributes.Where(ApplyFilter).AsQueryable(); - private IQueryable FilteredContextItems => - _contextAttributes.Select(p => new LogEntryPropertyViewModel { Name = p.Key, Value = p.Value }) - .Where(ApplyFilter).AsQueryable(); + internal IQueryable FilteredContextItems => + _contextAttributes.Where(ApplyFilter).AsQueryable(); - private IQueryable FilteredResourceItems => - ViewModel.LogEntry.ApplicationView.AllProperties().Select(p => new LogEntryPropertyViewModel { Name = p.Key, Value = p.Value }) + internal IQueryable FilteredResourceItems => + ViewModel.LogEntry.ApplicationView.AllProperties().Select(p => new TelemetryPropertyViewModel { Name = p.DisplayName, Key = p.Key, Value = p.Value }) .Where(ApplyFilter).AsQueryable(); private string _filter = ""; - private List> _logEntryAttributes = null!; - private List> _contextAttributes = null!; - private List> _exceptionAttributes = null!; + private List _logEntryAttributes = null!; + private List _contextAttributes = null!; + private List _exceptionAttributes = null!; protected override void OnParametersSet() { // Move some attributes to separate lists, e.g. exception attributes to their own list. // Remaining attributes are displayed along side the message. - var attributes = ViewModel.LogEntry.Attributes.ToList(); + var attributes = ViewModel.LogEntry.Attributes + .Select(a => new TelemetryPropertyViewModel { Name = a.Key, Key = $"unknown-{a.Key}", Value = a.Value }) + .ToList(); _contextAttributes = [ - new KeyValuePair("Category", ViewModel.LogEntry.Scope.ScopeName) + new TelemetryPropertyViewModel { Name ="Category", Key = KnownStructuredLogFields.CategoryField, Value = ViewModel.LogEntry.Scope.ScopeName } ]; - MoveAttributes(attributes, _contextAttributes, a => a.Key is "event.name" or "logrecord.event.id" or "logrecord.event.name"); + MoveAttributes(attributes, _contextAttributes, a => a.Name is "event.name" or "logrecord.event.id" or "logrecord.event.name"); if (HasTelemetryBaggage(ViewModel.LogEntry.TraceId)) { - _contextAttributes.Add(new KeyValuePair("TraceId", ViewModel.LogEntry.TraceId)); + _contextAttributes.Add(new TelemetryPropertyViewModel { Name = "TraceId", Key = KnownStructuredLogFields.TraceIdField, Value = ViewModel.LogEntry.TraceId }); } if (HasTelemetryBaggage(ViewModel.LogEntry.SpanId)) { - _contextAttributes.Add(new KeyValuePair("SpanId", ViewModel.LogEntry.SpanId)); + _contextAttributes.Add(new TelemetryPropertyViewModel { Name = "SpanId", Key = KnownStructuredLogFields.SpanIdField, Value = ViewModel.LogEntry.SpanId }); } if (HasTelemetryBaggage(ViewModel.LogEntry.ParentId)) { - _contextAttributes.Add(new KeyValuePair("ParentId", ViewModel.LogEntry.ParentId)); + _contextAttributes.Add(new TelemetryPropertyViewModel { Name = "ParentId", Key = KnownStructuredLogFields.ParentIdField, Value = ViewModel.LogEntry.ParentId }); } _exceptionAttributes = []; - MoveAttributes(attributes, _exceptionAttributes, a => a.Key.StartsWith("exception.", StringComparison.OrdinalIgnoreCase)); + MoveAttributes(attributes, _exceptionAttributes, a => a.Name.StartsWith("exception.", StringComparison.OrdinalIgnoreCase)); _logEntryAttributes = [ - new KeyValuePair("Level", ViewModel.LogEntry.Severity.ToString()), - new KeyValuePair("Message", ViewModel.LogEntry.Message), + new TelemetryPropertyViewModel { Name = "Level", Key = KnownStructuredLogFields.LevelField, Value = ViewModel.LogEntry.Severity.ToString() }, + new TelemetryPropertyViewModel { Name = "Message", Key = KnownStructuredLogFields.MessageField, Value = ViewModel.LogEntry.Message }, .. attributes, ]; } - private static void MoveAttributes(List> source, List> desintation, Func, bool> predicate) + private static void MoveAttributes(List source, List desintation, Func predicate) { var insertStart = desintation.Count; for (var i = source.Count - 1; i >= 0; i--) @@ -84,7 +84,7 @@ private static void MoveAttributes(List> source, Li } } - private bool ApplyFilter(LogEntryPropertyViewModel vm) + private bool ApplyFilter(TelemetryPropertyViewModel vm) { return vm.Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) || vm.Value?.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) == true; diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index d00408dce8..03580d5540 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -288,7 +288,7 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin else { var entryProperties = viewModel.Span.AllProperties() - .Select(kvp => new SpanPropertyViewModel { Name = kvp.Key, Value = kvp.Value }) + .Select(f => new TelemetryPropertyViewModel { Name = f.DisplayName, Key = f.Key, Value = f.Value }) .ToList(); var traceCache = new Dictionary(StringComparer.Ordinal); diff --git a/src/Aspire.Dashboard/Model/Otlp/KnownResourceFields.cs b/src/Aspire.Dashboard/Model/Otlp/KnownResourceFields.cs new file mode 100644 index 0000000000..b9eefc472e --- /dev/null +++ b/src/Aspire.Dashboard/Model/Otlp/KnownResourceFields.cs @@ -0,0 +1,10 @@ +// 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.Model.Otlp; + +public static class KnownResourceFields +{ + public const string ServiceNameField = "resource.servicename"; + public const string ServiceInstanceIdField = "resource.serviceinstanceid"; +} diff --git a/src/Aspire.Dashboard/Model/Otlp/KnownSourceFields.cs b/src/Aspire.Dashboard/Model/Otlp/KnownSourceFields.cs new file mode 100644 index 0000000000..05a85c692d --- /dev/null +++ b/src/Aspire.Dashboard/Model/Otlp/KnownSourceFields.cs @@ -0,0 +1,10 @@ +// 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.Model.Otlp; + +public static class KnownSourceFields +{ + public const string NameField = "source.name"; + public const string VersionField = "source.version"; +} diff --git a/src/Aspire.Dashboard/Model/Otlp/KnownStructuredLogFields.cs b/src/Aspire.Dashboard/Model/Otlp/KnownStructuredLogFields.cs index 2c82d70413..cc85ac856c 100644 --- a/src/Aspire.Dashboard/Model/Otlp/KnownStructuredLogFields.cs +++ b/src/Aspire.Dashboard/Model/Otlp/KnownStructuredLogFields.cs @@ -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.Model.Otlp; @@ -7,15 +7,16 @@ public static class KnownStructuredLogFields { public const string MessageField = "log.message"; public const string CategoryField = "log.category"; - public const string ApplicationField = "log.application"; public const string TraceIdField = "log.traceid"; public const string SpanIdField = "log.spanid"; + public const string ParentIdField = "log.parentid"; + public const string LevelField = "log.level"; public const string OriginalFormatField = "log.originalformat"; public static readonly List AllFields = [ MessageField, CategoryField, - ApplicationField, + KnownResourceFields.ServiceNameField, TraceIdField, SpanIdField, OriginalFormatField diff --git a/src/Aspire.Dashboard/Model/Otlp/KnownTraceFields.cs b/src/Aspire.Dashboard/Model/Otlp/KnownTraceFields.cs index e92ed9b139..fca6bb1946 100644 --- a/src/Aspire.Dashboard/Model/Otlp/KnownTraceFields.cs +++ b/src/Aspire.Dashboard/Model/Otlp/KnownTraceFields.cs @@ -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.Model.Otlp; @@ -8,18 +8,20 @@ public static class KnownTraceFields public const string NameField = "trace.name"; public const string KindField = "trace.kind"; public const string StatusField = "trace.status"; - public const string ApplicationField = "trace.application"; public const string TraceIdField = "trace.traceid"; public const string SpanIdField = "trace.spanid"; - public const string SourceField = "trace.source"; + + // Not used in search. + public const string StatusMessageField = "trace.statusmessage"; + public const string ParentIdField = "trace.parentid"; public static readonly List AllFields = [ NameField, KindField, StatusField, - ApplicationField, + KnownResourceFields.ServiceNameField, TraceIdField, SpanIdField, - SourceField + KnownSourceFields.NameField ]; } diff --git a/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs b/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs index f26cf54b49..326f033aa1 100644 --- a/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs +++ b/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs @@ -25,7 +25,6 @@ public static string ResolveFieldName(string name) return name switch { KnownStructuredLogFields.MessageField => "Message", - KnownStructuredLogFields.ApplicationField => "Application", KnownStructuredLogFields.TraceIdField => "TraceId", KnownStructuredLogFields.SpanIdField => "SpanId", KnownStructuredLogFields.OriginalFormatField => "OriginalFormat", @@ -34,9 +33,9 @@ public static string ResolveFieldName(string name) KnownTraceFields.SpanIdField => "SpanId", KnownTraceFields.TraceIdField => "TraceId", KnownTraceFields.KindField => "Kind", - KnownTraceFields.ApplicationField => "Application", KnownTraceFields.StatusField => "Status", - KnownTraceFields.SourceField => "Source", + KnownSourceFields.NameField => "Source", + KnownResourceFields.ServiceNameField => "Application", _ => name }; } diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index a9ecdb5581..13ee4c6f07 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -209,11 +209,13 @@ public sealed class ResourcePropertyViewModel : IPropertyGridItem public bool IsValueSensitive { get; } public bool IsValueMasked { get; set; } internal int Priority { get; } + private readonly string _key; - string? IPropertyGridItem.Name => KnownProperty?.DisplayName ?? Name; - + string IPropertyGridItem.Name => KnownProperty?.DisplayName ?? Name; string? IPropertyGridItem.Value => _displayValue.Value; + object IPropertyGridItem.Key => _key; + public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive, KnownProperty? knownProperty, int priority, BrowserTimeProvider timeProvider) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -225,6 +227,9 @@ public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive Priority = priority; IsValueMasked = isValueSensitive; + // Known and unknown properties are displayed together. Avoid any duplicate keys. + _key = KnownProperty != null ? KnownProperty.Key : $"unknown-{Name}"; + _tooltip = new(() => value.HasStringValue ? value.StringValue : value.ToString()); _displayValue = new(() => @@ -281,12 +286,15 @@ public UrlViewModel(string name, Uri url, bool isInternal) } } -public sealed record class VolumeViewModel(string? Source, string Target, string MountType, bool IsReadOnly) : IPropertyGridItem +public sealed record class VolumeViewModel(int index, string Source, string Target, string MountType, bool IsReadOnly) : IPropertyGridItem { - string? IPropertyGridItem.Name => Source; - + string IPropertyGridItem.Name => Source; string? IPropertyGridItem.Value => Target; + // Source could be empty for an anomymous volume so it can't be used as a key. + // Because there is no good key in data, use index of the volume in results. + object IPropertyGridItem.Key => index; + public bool MatchesFilter(string filter) => Source?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true || Target?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true; diff --git a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs index 706eee05a4..54108e839f 100644 --- a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs +++ b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs @@ -8,7 +8,7 @@ namespace Aspire.Dashboard.Model; public sealed class SpanDetailsViewModel { public required OtlpSpan Span { get; init; } - public required List Properties { get; init; } + public required List Properties { get; init; } public required List Links { get; init; } public required List Backlinks { get; init; } public required string Title { get; init; } diff --git a/src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs b/src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs deleted file mode 100644 index 4fba869eb6..0000000000 --- a/src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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.Components.Controls; - -namespace Aspire.Dashboard.Model; - -public sealed class SpanPropertyViewModel : IPropertyGridItem -{ - public required string Name { get; init; } - public required string Value { get; init; } -} diff --git a/src/Aspire.Dashboard/Model/LogEntryPropertyViewModel.cs b/src/Aspire.Dashboard/Model/TelemetryPropertyViewModel.cs similarity index 73% rename from src/Aspire.Dashboard/Model/LogEntryPropertyViewModel.cs rename to src/Aspire.Dashboard/Model/TelemetryPropertyViewModel.cs index 5b675b9d09..480a5a7104 100644 --- a/src/Aspire.Dashboard/Model/LogEntryPropertyViewModel.cs +++ b/src/Aspire.Dashboard/Model/TelemetryPropertyViewModel.cs @@ -5,8 +5,9 @@ namespace Aspire.Dashboard.Model; -public sealed class LogEntryPropertyViewModel : IPropertyGridItem +public sealed class TelemetryPropertyViewModel : IPropertyGridItem { public required string Name { get; init; } public required string Value { get; init; } + public required object Key { get; init; } } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs index 7e2d36579f..835bca6431 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs @@ -20,6 +20,7 @@ public class OtlpApplication public string ApplicationName { get; } public string InstanceId { get; } + public OtlpContext Context { get; } public ApplicationKey ApplicationKey => new ApplicationKey(ApplicationName, InstanceId); @@ -28,14 +29,11 @@ public class OtlpApplication private readonly Dictionary _instruments = new(); private readonly ConcurrentDictionary[], OtlpApplicationView> _applicationViews = new(ApplicationViewKeyComparer.Instance); - private readonly OtlpContext _context; - public OtlpApplication(string name, string instanceId, OtlpContext context) { ApplicationName = name; InstanceId = instanceId; - - _context = context; + Context = context; } public void AddMetrics(AddContext context, RepeatedField scopeMetrics) @@ -66,7 +64,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr Type = MapMetricType(metric.DataCase), Parent = GetMeter(sm.Scope) }, - Context = _context + Context = Context }); } @@ -75,7 +73,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr catch (Exception ex) { context.FailureCount++; - _context.Logger.LogInformation(ex, "Error adding metric."); + Context.Logger.LogInformation(ex, "Error adding metric."); } } } @@ -101,7 +99,7 @@ private OtlpMeter GetMeter(InstrumentationScope scope) { if (!_meters.TryGetValue(scope.Name, out var meter)) { - _meters.Add(scope.Name, meter = new OtlpMeter(scope, _context)); + _meters.Add(scope.Name, meter = new OtlpMeter(scope, Context)); } return meter; } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs index f520af8d54..9bd19465ce 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; @@ -19,46 +20,36 @@ public OtlpApplicationView(OtlpApplication application, RepeatedField { Application = application; - List>? properties = null; - foreach (var attribute in attributes) + var properties = attributes.ToKeyValuePairs(application.Context, filter: attribute => { switch (attribute.Key) { case OtlpApplication.SERVICE_NAME: case OtlpApplication.SERVICE_INSTANCE_ID: - // Values passed in via ctor and set to members. Don't add to properties collection. - break; + // Explicitly ignore these + return false; default: - properties ??= []; - properties.Add(new KeyValuePair(attribute.Key, attribute.Value.GetString())); - break; - + return true; } - } + }); - if (properties != null) - { - // Sort so keys are in a consistent order for equality check. - properties.Sort((p1, p2) => string.Compare(p1.Key, p2.Key, StringComparisons.OtlpAttribute)); - Properties = properties.ToArray(); - } - else - { - Properties = []; - } + // Sort so keys are in a consistent order for equality check. + Array.Sort(properties, (p1, p2) => string.Compare(p1.Key, p2.Key, StringComparisons.OtlpAttribute)); + + Properties = properties; } - public Dictionary AllProperties() + public List AllProperties() { - var props = new Dictionary(StringComparers.OtlpAttribute) + var props = new List { - { OtlpApplication.SERVICE_NAME, Application.ApplicationName }, - { OtlpApplication.SERVICE_INSTANCE_ID, Application.InstanceId } + new OtlpDisplayField { DisplayName = "service.name", Key = KnownResourceFields.ServiceNameField, Value = Application.ApplicationName }, + new OtlpDisplayField { DisplayName = "service.instance.id", Key = KnownResourceFields.ServiceInstanceIdField, Value = Application.InstanceId } }; foreach (var kv in Properties) { - props.TryAdd(kv.Key, kv.Value); + props.Add(new OtlpDisplayField { DisplayName = kv.Key, Key = $"unknown-{kv.Key}", Value = kv.Value }); } return props; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpDisplayField.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpDisplayField.cs new file mode 100644 index 0000000000..3c41f5bceb --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpDisplayField.cs @@ -0,0 +1,11 @@ +// 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.Otlp.Model; + +public class OtlpDisplayField +{ + public required string DisplayName { get; init; } + public required object Key { get; init; } + public required string Value{ get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 6f620e184b..4d5c6cdb6b 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -196,7 +196,7 @@ public static KeyValuePair[] ToKeyValuePairs(this RepeatedField< } var readLimit = Math.Min(attributes.Count, context.Options.MaxAttributeCount); - var values = new List>(readLimit); + List>? values = null; for (var i = 0; i < attributes.Count; i++) { var attribute = attributes[i]; @@ -206,6 +206,8 @@ public static KeyValuePair[] ToKeyValuePairs(this RepeatedField< continue; } + values ??= new List>(readLimit); + var value = TruncateString(attribute.Value.GetString(), context.Options.MaxAttributeLength); // If there are duplicates then last value wins. @@ -228,7 +230,7 @@ public static KeyValuePair[] ToKeyValuePairs(this RepeatedField< } } - return values.ToArray(); + return values?.ToArray() ?? []; static int GetIndex(List> values, string name) { diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs index ff914ef1f1..d725cc02d7 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs @@ -95,11 +95,11 @@ public OtlpLogEntry(LogRecord record, OtlpApplicationView logApp, OtlpScope scop return field switch { KnownStructuredLogFields.MessageField => log.Message, - KnownStructuredLogFields.ApplicationField => log.ApplicationView.Application.ApplicationName, KnownStructuredLogFields.TraceIdField => log.TraceId, KnownStructuredLogFields.SpanIdField => log.SpanId, KnownStructuredLogFields.OriginalFormatField => log.OriginalFormat, KnownStructuredLogFields.CategoryField => log.Scope.ScopeName, + KnownResourceFields.ServiceNameField => log.ApplicationView.Application.ApplicationName, _ => log.Attributes.GetValue(field) }; } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs index d85615f17c..4caedd73a2 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs @@ -71,28 +71,28 @@ public static OtlpSpan Clone(OtlpSpan item, OtlpTrace trace) }; } - public Dictionary AllProperties() + public List AllProperties() { - var props = new Dictionary + var props = new List { - { "SpanId", SpanId }, - { "Name", Name }, - { "Kind", Kind.ToString() }, + new OtlpDisplayField { DisplayName = "SpanId", Key = KnownTraceFields.SpanIdField, Value = SpanId }, + new OtlpDisplayField { DisplayName = "Name", Key = KnownTraceFields.NameField, Value = Name }, + new OtlpDisplayField { DisplayName = "Kind", Key = KnownTraceFields.KindField, Value = Kind.ToString() }, }; if (Status != OtlpSpanStatusCode.Unset) { - props.Add("Status", Status.ToString()); + props.Add(new OtlpDisplayField { DisplayName = "Status", Key = KnownTraceFields.StatusField, Value = Status.ToString() }); } if (!string.IsNullOrEmpty(StatusMessage)) { - props.Add("StatusMessage", StatusMessage); + props.Add(new OtlpDisplayField { DisplayName = "StatusMessage", Key = KnownTraceFields.StatusField, Value = Status.ToString() }); } foreach (var kv in Attributes.OrderBy(a => a.Key)) { - props.TryAdd(kv.Key, kv.Value); + props.Add(new OtlpDisplayField { DisplayName = kv.Key, Key = $"unknown-{kv.Key}", Value = kv.Value }); } return props; @@ -107,12 +107,12 @@ private string DebuggerToString() { return field switch { - KnownTraceFields.ApplicationField => span.Source.Application.ApplicationName, + KnownResourceFields.ServiceNameField => span.Source.Application.ApplicationName, KnownTraceFields.TraceIdField => span.TraceId, KnownTraceFields.SpanIdField => span.SpanId, KnownTraceFields.KindField => span.Kind.ToString(), KnownTraceFields.StatusField => span.Status.ToString(), - KnownTraceFields.SourceField => span.Scope.ScopeName, + KnownSourceFields.NameField => span.Scope.ScopeName, KnownTraceFields.NameField => span.Name, _ => span.Attributes.GetValue(field) }; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs index 16e246cac7..f920d065b4 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs @@ -17,6 +17,7 @@ public class OtlpSpanEvent(OtlpSpan span) : IPropertyGridItem public required string Name { get; init; } public required DateTime Time { get; init; } public required KeyValuePair[] Attributes { get; init; } - string? IPropertyGridItem.Name => DurationFormatter.FormatDuration(Time - span.StartTime); + string IPropertyGridItem.Name => DurationFormatter.FormatDuration(Time - span.StartTime); + object IPropertyGridItem.Key => InternalId; string? IPropertyGridItem.Value => Name; } diff --git a/src/Aspire.Dashboard/ResourceService/Partials.cs b/src/Aspire.Dashboard/ResourceService/Partials.cs index 395fd16f21..c9245b2f70 100644 --- a/src/Aspire.Dashboard/ResourceService/Partials.cs +++ b/src/Aspire.Dashboard/ResourceService/Partials.cs @@ -94,7 +94,7 @@ ImmutableArray GetUrls() ImmutableArray GetVolumes() { return Volumes - .Select(v => new VolumeViewModel(v.Source, v.Target, v.MountType, v.IsReadOnly)) + .Select((v, i) => new VolumeViewModel(i, v.Source, v.Target, v.MountType, v.IsReadOnly)) .ToImmutableArray(); } diff --git a/src/Aspire.Hosting/Dashboard/proto/Partials.cs b/src/Aspire.Hosting/Dashboard/proto/Partials.cs index 9d03604616..80e5fc4b98 100644 --- a/src/Aspire.Hosting/Dashboard/proto/Partials.cs +++ b/src/Aspire.Hosting/Dashboard/proto/Partials.cs @@ -54,7 +54,7 @@ public static Resource FromSnapshot(ResourceSnapshot snapshot) { resource.Volumes.Add(new Volume { - Source = volume.Source, + Source = volume.Source ?? string.Empty, Target = volume.Target, MountType = volume.MountType, IsReadOnly = volume.IsReadOnly diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs new file mode 100644 index 0000000000..ceb9ebd36a --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs @@ -0,0 +1,76 @@ +// 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.Components.Controls; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Tests.Shared.Telemetry; +using Bunit; +using Google.Protobuf.Collections; +using Microsoft.Extensions.Logging.Abstractions; +using OpenTelemetry.Proto.Common.V1; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +[UseCulture("en-US")] +public class StructuredLogDetailsTests : TestContext +{ + [Fact] + public void Render_ManyDuplicateAttributes_NoDuplicateKeys() + { + // Arrange + StructuredLogsSetupHelpers.SetupStructuredLogsDetails(this); + + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var app = new OtlpApplication("app1", "instance1", context); + var view = new OtlpApplicationView(app, new RepeatedField + { + new KeyValue { Key = "Message", Value = new AnyValue { StringValue = "value1" } }, + new KeyValue { Key = "Message", Value = new AnyValue { StringValue = "value2" } }, + new KeyValue { Key = OtlpApplication.SERVICE_NAME, Value = new AnyValue { StringValue = "value1" } } + }); + var model = new StructureLogsDetailsViewModel + { + LogEntry = new OtlpLogEntry( + record: TelemetryTestHelpers.CreateLogRecord(attributes: + [ + KeyValuePair.Create("Message", "value1"), + KeyValuePair.Create("Message", "value2"), + KeyValuePair.Create("event.name", "value1"), + KeyValuePair.Create("event.name", "value2") + ]), + logApp: view, + scope: new OtlpScope(TelemetryTestHelpers.CreateScope( + attributes: + [ + KeyValuePair.Create("Message", "value1"), + KeyValuePair.Create("Message", "value2") + ]), + context: context), + context: context) + }; + + // Act + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewModel, model); + }); + + // Assert + AssertUniqueKeys(cut.Instance.FilteredContextItems); + AssertUniqueKeys(cut.Instance.FilteredExceptionItems); + AssertUniqueKeys(cut.Instance.FilteredResourceItems); + AssertUniqueKeys(cut.Instance.FilteredItems); + + static void AssertUniqueKeys(IEnumerable properties) + { + var duplicate = properties.GroupBy(p => p.Key).Where(g => g.Count() >= 2).FirstOrDefault(); + if (duplicate != null) + { + Assert.Fail($"Duplicate properties with key '{duplicate.Key}'."); + } + } + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs new file mode 100644 index 0000000000..48936cbe7e --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs @@ -0,0 +1,44 @@ +// 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.Otlp.Storage; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal static class StructuredLogsSetupHelpers +{ + public static void SetupStructuredLogsDetails(TestContext context) + { + context.Services.AddLocalization(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + + var version = typeof(FluentMain).Assembly.GetName().Version!; + + var dividerModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Divider/FluentDivider.razor.js", version)); + dividerModule.SetupVoid("setDividerAriaOrientation"); + + var searchModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Search/FluentSearch.razor.js", version)); + searchModule.SetupVoid("addAriaHidden", _ => true); + + var dataGridModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DataGrid/FluentDataGrid.razor.js", version)); + var dataGridRef = dataGridModule.SetupModule("init", _ => true); + dataGridRef.SetupVoid("stop"); + + var keycodeModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/KeyCode/FluentKeyCode.razor.js", version)); + keycodeModule.Setup("RegisterKeyCode", _ => true); + } + + private static string GetFluentFile(string filePath, Version version) + { + return $"{filePath}?v={version}"; + } +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 1fedca85a2..87beb26abb 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -1064,8 +1064,8 @@ public void GetTraces_AttributeFilters() [InlineData(KnownTraceFields.SpanIdField, "312d31")] [InlineData(KnownTraceFields.StatusField, "Unset")] [InlineData(KnownTraceFields.KindField, "Internal")] - [InlineData(KnownTraceFields.ApplicationField, "app1")] - [InlineData(KnownTraceFields.SourceField, "TestScope")] + [InlineData(KnownResourceFields.ServiceNameField, "app1")] + [InlineData(KnownSourceFields.NameField, "TestScope")] public void GetTraces_KnownFilters(string name, string value) { // Arrange