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