diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor
index e1dff11479..07434dba1b 100644
--- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor
+++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor
@@ -10,7 +10,7 @@
- @((MarkupString)string.Format(ControlsStrings.SpanDetailsResource, ViewModel.Span.Source.ApplicationName))
+ @((MarkupString)string.Format(ControlsStrings.SpanDetailsResource, ViewModel.Span.Source.Application.ApplicationName))
diff --git a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor
index d6169bc5e7..e1205cb665 100644
--- a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor
+++ b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor
@@ -7,7 +7,7 @@
- @((MarkupString)string.Format(ControlsStrings.StructuredLogsDetailsResource, ViewModel.LogEntry.Application.ApplicationName))
+ @((MarkupString)string.Format(ControlsStrings.StructuredLogsDetailsResource, ViewModel.LogEntry.ApplicationView.Application.ApplicationName))
diff --git a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs
index e8eae38539..116fcbc008 100644
--- a/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs
+++ b/src/Aspire.Dashboard/Components/Controls/StructuredLogDetails.razor.cs
@@ -28,7 +28,7 @@ public partial class StructuredLogDetails
.Where(ApplyFilter).AsQueryable();
private IQueryable
FilteredResourceItems =>
- ViewModel.LogEntry.Application.AllProperties().Select(p => new LogEntryPropertyViewModel { Name = p.Key, Value = p.Value })
+ ViewModel.LogEntry.ApplicationView.AllProperties().Select(p => new LogEntryPropertyViewModel { Name = p.Key, Value = p.Value })
.Where(ApplyFilter).AsQueryable();
private string _filter = "";
diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
index 528c213833..d5fb9db3ab 100644
--- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
@@ -8,7 +8,6 @@
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
-using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
@@ -31,7 +30,7 @@ public partial class Resources : ComponentBase, IAsyncDisposable
private Subscription? _logsSubscription;
private IList? _gridColumns;
- private Dictionary? _applicationUnviewedErrorCounts;
+ private Dictionary? _applicationUnviewedErrorCounts;
[Inject]
public required IDashboardClient DashboardClient { get; init; }
@@ -224,7 +223,7 @@ async Task SubscribeResourcesAsync()
}
}
- private bool ApplicationErrorCountsChanged(Dictionary newApplicationUnviewedErrorCounts)
+ private bool ApplicationErrorCountsChanged(Dictionary newApplicationUnviewedErrorCounts)
{
if (_applicationUnviewedErrorCounts == null || _applicationUnviewedErrorCounts.Count != newApplicationUnviewedErrorCounts.Count)
{
diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor
index 8ebf3cf8e2..6b4d1cd590 100644
--- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor
+++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor
@@ -119,9 +119,9 @@
OnRowClick="@(r => r.ExecuteOnDefault(d => OnShowPropertiesAsync(d, buttonId: null)))"
Class="enable-row-click">
-
-
- @GetResourceName(context.Application)
+
+
+ @GetResourceName(context.ApplicationView)
diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
index 5bc8e9133f..c3bf7d59c3 100644
--- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
@@ -344,7 +344,7 @@ private async Task HandleAfterFilterBindAsync()
await this.AfterViewModelChangedAsync(_contentLayout, true);
}
- private string GetResourceName(OtlpApplication app) => OtlpApplication.GetResourceName(app, _applications);
+ private string GetResourceName(OtlpApplicationView app) => OtlpApplication.GetResourceName(app.Application, _applications);
private string GetRowClass(OtlpLogEntry entry)
{
diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
index 2a21f5eabd..ffeabdb040 100644
--- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
@@ -349,7 +349,7 @@ private async Task ClearSelectedSpanAsync(bool causedByUserAction = false)
_elementIdBeforeDetailsViewOpened = null;
}
- private string GetResourceName(OtlpApplication app) => OtlpApplication.GetResourceName(app, _applications);
+ private string GetResourceName(OtlpApplicationView app) => OtlpApplication.GetResourceName(app, _applications);
public void Dispose()
{
diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor b/src/Aspire.Dashboard/Components/Pages/Traces.razor
index 87db99d779..3e96c52cf4 100644
--- a/src/Aspire.Dashboard/Components/Pages/Traces.razor
+++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor
@@ -59,7 +59,7 @@
- @foreach (var item in context.Spans.GroupBy(s => s.Source).OrderBy(g => g.Min(s => s.StartTime)))
+ @foreach (var item in context.Spans.GroupBy(s => s.Source.Application).OrderBy(g => g.Min(s => s.StartTime)))
{
diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
index 88441428e9..d2e01dee90 100644
--- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
@@ -228,6 +228,7 @@ private async Task HandleAfterFilterBindAsync()
}
private string GetResourceName(OtlpApplication app) => OtlpApplication.GetResourceName(app, _applications);
+ private string GetResourceName(OtlpApplicationView app) => OtlpApplication.GetResourceName(app, _applications);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor
index 14b7672f88..acccf9e208 100644
--- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor
+++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor
@@ -1,6 +1,7 @@
@using Aspire.Dashboard.Extensions
@using Aspire.Dashboard.Model
@using Aspire.Dashboard.Otlp.Model
+@using Aspire.Dashboard.Otlp.Storage
@using Aspire.Dashboard.Resources
@using Humanizer
@@ -99,5 +100,5 @@ else
[Parameter, EditorRequired]
- public required Dictionary? UnviewedErrorCounts { get; set; }
+ public required Dictionary? UnviewedErrorCounts { get; set; }
}
diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs
index b18e91eda7..89a23cb098 100644
--- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs
+++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs
@@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Dashboard.Model;
-using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
@@ -17,7 +16,7 @@ public partial class UnreadLogErrorsBadge
[Parameter, EditorRequired]
public required ResourceViewModel Resource { get; set; }
[Parameter, EditorRequired]
- public required Dictionary? UnviewedErrorCounts { get; set; }
+ public required Dictionary? UnviewedErrorCounts { get; set; }
[Inject]
public required TelemetryRepository TelemetryRepository { get; init; }
@@ -42,7 +41,7 @@ protected override void OnParametersSet()
return (null, 0);
}
- if (!UnviewedErrorCounts.TryGetValue(application, out var count) || count == 0)
+ if (!UnviewedErrorCounts.TryGetValue(application.ApplicationKey, out var count) || count == 0)
{
return (null, 0);
}
diff --git a/src/Aspire.Dashboard/Extensions/FluentUIExtensions.cs b/src/Aspire.Dashboard/Extensions/FluentUIExtensions.cs
index ef1e89720b..b47ab69fa0 100644
--- a/src/Aspire.Dashboard/Extensions/FluentUIExtensions.cs
+++ b/src/Aspire.Dashboard/Extensions/FluentUIExtensions.cs
@@ -10,7 +10,7 @@ public static Dictionary GetClipboardCopyAdditionalAttributes(st
// No onclick attribute is added here. The CSP restricts inline scripts, including onclick.
// Instead, a click event listener is added to the document and clicking the button is bubbled up to the event.
// The document click listener looks for a button element and these attributes.
- var attributes = new Dictionary(StringComparers.Attribute)
+ var attributes = new Dictionary(StringComparers.HtmlAttribute)
{
{ "data-text", text ?? string.Empty },
{ "data-precopy", precopy ?? string.Empty },
@@ -28,7 +28,7 @@ public static Dictionary GetClipboardCopyAdditionalAttributes(st
public static Dictionary GetOpenTextVisualizerAdditionalAttributes(string textValue, string textValueDescription, params (string Attribute, object Value)[] additionalAttributes)
{
- var attributes = new Dictionary(StringComparers.Attribute)
+ var attributes = new Dictionary(StringComparers.HtmlAttribute)
{
{ "data-text", textValue },
{ "data-textvisualizer-description", textValueDescription }
diff --git a/src/Aspire.Dashboard/Model/Otlp/LogFilter.cs b/src/Aspire.Dashboard/Model/Otlp/LogFilter.cs
index f7a028f443..36fad60325 100644
--- a/src/Aspire.Dashboard/Model/Otlp/LogFilter.cs
+++ b/src/Aspire.Dashboard/Model/Otlp/LogFilter.cs
@@ -102,7 +102,7 @@ private static Func ConditionToFuncNumber(FilterCondition
return Field switch
{
KnownMessageField => x.Message,
- KnownApplicationField => x.Application.ApplicationName,
+ KnownApplicationField => x.ApplicationView.Application.ApplicationName,
KnownTraceIdField => x.TraceId,
KnownSpanIdField => x.SpanId,
KnownOriginalFormatField => x.OriginalFormat,
diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs
index 44b936e58f..910bb45cdb 100644
--- a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs
+++ b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs
@@ -1,13 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Collections.Concurrent;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Otlp.Storage;
using Google.Protobuf.Collections;
using OpenTelemetry.Proto.Common.V1;
using OpenTelemetry.Proto.Metrics.V1;
-using OpenTelemetry.Proto.Resource.V1;
namespace Aspire.Dashboard.Otlp.Model;
@@ -26,31 +27,13 @@ public class OtlpApplication
private readonly ReaderWriterLockSlim _metricsLock = new();
private readonly Dictionary _meters = new();
private readonly Dictionary _instruments = new();
+ private readonly ConcurrentDictionary[], OtlpApplicationView> _applicationViews = new(ApplicationViewKeyComparer.Instance);
private readonly ILogger _logger;
private readonly TelemetryLimitOptions _options;
- public KeyValuePair[] Properties { get; }
-
- public OtlpApplication(string name, string instanceId, Resource resource, ILogger logger, TelemetryLimitOptions options)
+ public OtlpApplication(string name, string instanceId, ILogger logger, TelemetryLimitOptions options)
{
- var properties = new List>();
- foreach (var attribute in resource.Attributes)
- {
- switch (attribute.Key)
- {
- case SERVICE_NAME:
- case SERVICE_INSTANCE_ID:
- // Values passed in via ctor and set to members. Don't add to properties collection.
- break;
- default:
- properties.Add(new KeyValuePair(attribute.Key, attribute.Value.GetString()));
- break;
-
- }
- }
- Properties = properties.ToArray();
-
ApplicationName = name;
InstanceId = instanceId;
@@ -58,20 +41,6 @@ public OtlpApplication(string name, string instanceId, Resource resource, ILogge
_options = options;
}
- public Dictionary AllProperties()
- {
- var props = new Dictionary();
- props.Add(SERVICE_NAME, ApplicationName);
- props.Add(SERVICE_INSTANCE_ID, InstanceId);
-
- foreach (var kv in Properties)
- {
- props.TryAdd(kv.Key, kv.Value);
- }
-
- return props;
- }
-
public void AddMetrics(AddContext context, RepeatedField scopeMetrics)
{
_metricsLock.EnterWriteLock();
@@ -185,6 +154,9 @@ public static Dictionary> GetReplicasByApplication
.ToDictionary(grouping => grouping.Key, grouping => grouping.ToList());
}
+ public static string GetResourceName(OtlpApplicationView app, List allApplications) =>
+ GetResourceName(app.Application, allApplications);
+
public static string GetResourceName(OtlpApplication app, List allApplications)
{
var count = 0;
@@ -216,4 +188,69 @@ public static string GetResourceName(OtlpApplication app, List
return app.ApplicationName;
}
+
+ internal List GetViews() => _applicationViews.Values.ToList();
+
+ internal OtlpApplicationView GetView(RepeatedField attributes)
+ {
+ // Inefficient to create this to possibly throw it away.
+ var view = new OtlpApplicationView(this, attributes);
+
+ if (_applicationViews.TryGetValue(view.Properties, out var applicationView))
+ {
+ return applicationView;
+ }
+
+ return _applicationViews.GetOrAdd(view.Properties, view);
+ }
+
+ ///
+ /// Application views are equal when all properties are equal.
+ ///
+ private sealed class ApplicationViewKeyComparer : IEqualityComparer[]>
+ {
+ public static readonly ApplicationViewKeyComparer Instance = new();
+
+ public bool Equals(KeyValuePair[]? x, KeyValuePair[]? y)
+ {
+ if (x == y)
+ {
+ return true;
+ }
+ if (x == null || y == null)
+ {
+ return false;
+ }
+ if (x.Length != y.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < x.Length; i++)
+ {
+ if (!string.Equals(x[i].Key, y[i].Key, StringComparisons.OtlpAttribute))
+ {
+ return false;
+ }
+ if (!string.Equals(x[i].Value, y[i].Value, StringComparisons.OtlpAttribute))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public int GetHashCode([DisallowNull] KeyValuePair[] obj)
+ {
+ var hashCode = new HashCode();
+ for (var i = 0; i < obj.Length; i++)
+ {
+ hashCode.Add(StringComparers.OtlpAttribute.GetHashCode(obj[i].Key));
+ hashCode.Add(StringComparers.OtlpAttribute.GetHashCode(obj[i].Value));
+ }
+
+ return hashCode.ToHashCode();
+ }
+ }
}
diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs
new file mode 100644
index 0000000000..f520af8d54
--- /dev/null
+++ b/src/Aspire.Dashboard/Otlp/Model/OtlpApplicationView.cs
@@ -0,0 +1,66 @@
+// 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;
+using Aspire.Dashboard.Otlp.Storage;
+using Google.Protobuf.Collections;
+using OpenTelemetry.Proto.Common.V1;
+
+namespace Aspire.Dashboard.Otlp.Model;
+
+[DebuggerDisplay("Application = {Application}, Properties = {Properties.Count}")]
+public class OtlpApplicationView
+{
+ public ApplicationKey ApplicationKey => Application.ApplicationKey;
+ public OtlpApplication Application { get; }
+ public KeyValuePair[] Properties { get; }
+
+ public OtlpApplicationView(OtlpApplication application, RepeatedField attributes)
+ {
+ Application = application;
+
+ List>? properties = null;
+ foreach (var attribute in attributes)
+ {
+ 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;
+ default:
+ properties ??= [];
+ properties.Add(new KeyValuePair(attribute.Key, attribute.Value.GetString()));
+ break;
+
+ }
+ }
+
+ 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 = [];
+ }
+ }
+
+ public Dictionary AllProperties()
+ {
+ var props = new Dictionary(StringComparers.OtlpAttribute)
+ {
+ { OtlpApplication.SERVICE_NAME, Application.ApplicationName },
+ { OtlpApplication.SERVICE_INSTANCE_ID, Application.InstanceId }
+ };
+
+ foreach (var kv in Properties)
+ {
+ props.TryAdd(kv.Key, kv.Value);
+ }
+
+ return props;
+ }
+}
diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs
index 1ad14ed4c5..6c6c933734 100644
--- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs
+++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs
@@ -19,11 +19,11 @@ public class OtlpLogEntry
public string TraceId { get; }
public string ParentId { get; }
public string? OriginalFormat { get; }
- public OtlpApplication Application { get; }
+ public OtlpApplicationView ApplicationView { get; }
public OtlpScope Scope { get; }
public Guid InternalId { get; }
- public OtlpLogEntry(LogRecord record, OtlpApplication logApp, OtlpScope scope, TelemetryLimitOptions options)
+ public OtlpLogEntry(LogRecord record, OtlpApplicationView logApp, OtlpScope scope, TelemetryLimitOptions options)
{
string? originalFormat = null;
string? parentId = null;
@@ -55,7 +55,7 @@ public OtlpLogEntry(LogRecord record, OtlpApplication logApp, OtlpScope scope, T
SpanId = record.SpanId.ToHexString();
TraceId = record.TraceId.ToHexString();
ParentId = parentId ?? string.Empty;
- Application = logApp;
+ ApplicationView = logApp;
Scope = scope;
InternalId = Guid.NewGuid();
}
diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs
index c3486040ce..50a99a994c 100644
--- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs
+++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs
@@ -21,7 +21,7 @@ public class OtlpSpan
public string TraceId => Trace.TraceId;
public OtlpTrace Trace { get; }
- public OtlpApplication Source { get; }
+ public OtlpApplicationView Source { get; }
public required string SpanId { get; init; }
public required string? ParentSpanId { get; init; }
@@ -43,9 +43,9 @@ public class OtlpSpan
public IEnumerable GetChildSpans() => Trace.Spans.Where(s => s.ParentSpanId == SpanId);
public OtlpSpan? GetParentSpan() => string.IsNullOrEmpty(ParentSpanId) ? null : Trace.Spans.Where(s => s.SpanId == ParentSpanId).FirstOrDefault();
- public OtlpSpan(OtlpApplication application, OtlpTrace trace, OtlpScope scope)
+ public OtlpSpan(OtlpApplicationView applicationView, OtlpTrace trace, OtlpScope scope)
{
- Source = application;
+ Source = applicationView;
Trace = trace;
Scope = scope;
}
diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs
index bf268ae399..225a12575c 100644
--- a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs
+++ b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs
@@ -91,7 +91,7 @@ public void AddSpan(OtlpSpan span)
static string BuildFullName(OtlpSpan existingSpan)
{
- return $"{existingSpan.Source.ApplicationName}: {existingSpan.Name}";
+ return $"{existingSpan.Source.Application.ApplicationName}: {existingSpan.Name}";
}
}
diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs
index 005485735b..414f7606ee 100644
--- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs
+++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs
@@ -36,7 +36,7 @@ public sealed class TelemetryRepository
private readonly Dictionary _logScopes = new();
private readonly CircularBuffer _logs;
private readonly HashSet<(OtlpApplication Application, string PropertyKey)> _logPropertyKeys = new();
- private readonly Dictionary _applicationUnviewedErrorLogs = new();
+ private readonly Dictionary _applicationUnviewedErrorLogs = new();
private readonly ReaderWriterLockSlim _tracesLock = new();
private readonly Dictionary _traceScopes = new();
@@ -128,7 +128,7 @@ public List GetApplications(ApplicationKey key)
return [GetApplication(key)];
}
- public Dictionary GetApplicationUnviewedErrorLogsCount()
+ public Dictionary GetApplicationUnviewedErrorLogsCount()
{
_logsLock.EnterReadLock();
@@ -162,7 +162,7 @@ internal void MarkViewedErrorLogs(ApplicationKey? key)
foreach (var application in applications)
{
// Mark one application logs as viewed.
- if (_applicationUnviewedErrorLogs.Remove(application))
+ if (_applicationUnviewedErrorLogs.Remove(application.ApplicationKey))
{
RaiseSubscriptionChanged(_logSubscriptions);
}
@@ -174,7 +174,7 @@ internal void MarkViewedErrorLogs(ApplicationKey? key)
}
}
- public OtlpApplication GetOrAddApplication(Resource resource)
+ private OtlpApplicationView GetOrAddApplicationView(Resource resource)
{
ArgumentNullException.ThrowIfNull(resource);
@@ -183,7 +183,7 @@ public OtlpApplication GetOrAddApplication(Resource resource)
// Fast path.
if (_applications.TryGetValue(key, out var application))
{
- return application;
+ return application.GetView(resource.Attributes);
}
// Slower get or add path.
@@ -193,7 +193,7 @@ public OtlpApplication GetOrAddApplication(Resource resource)
RaiseSubscriptionChanged(_applicationSubscriptions);
}
- return application;
+ return application.GetView(resource.Attributes);
(OtlpApplication, bool) GetOrAddApplication(ApplicationKey key, Resource resource)
{
@@ -202,7 +202,7 @@ public OtlpApplication GetOrAddApplication(Resource resource)
var application = _applications.GetOrAdd(key, _ =>
{
newApplication = true;
- return new OtlpApplication(key.Name, key.InstanceId!, resource, _logger, _dashboardOptions.TelemetryLimits);
+ return new OtlpApplication(key.Name, key.InstanceId!, _logger, _dashboardOptions.TelemetryLimits);
});
return (application, newApplication);
}
@@ -262,10 +262,10 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs
{
foreach (var rl in resourceLogs)
{
- OtlpApplication application;
+ OtlpApplicationView applicationView;
try
{
- application = GetOrAddApplication(rl.Resource);
+ applicationView = GetOrAddApplicationView(rl.Resource);
}
catch (Exception ex)
{
@@ -274,13 +274,13 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs
continue;
}
- AddLogsCore(context, application, rl.ScopeLogs);
+ AddLogsCore(context, applicationView, rl.ScopeLogs);
}
RaiseSubscriptionChanged(_logSubscriptions);
}
- public void AddLogsCore(AddContext context, OtlpApplication application, RepeatedField scopeLogs)
+ public void AddLogsCore(AddContext context, OtlpApplicationView applicationView, RepeatedField scopeLogs)
{
_logsLock.EnterWriteLock();
@@ -312,7 +312,7 @@ public void AddLogsCore(AddContext context, OtlpApplication application, Repeate
{
try
{
- var logEntry = new OtlpLogEntry(record, application, scope, _dashboardOptions.TelemetryLimits);
+ var logEntry = new OtlpLogEntry(record, applicationView, scope, _dashboardOptions.TelemetryLimits);
// Insert log entry in the correct position based on timestamp.
// Logs can be added out of order by different services.
@@ -336,22 +336,22 @@ public void AddLogsCore(AddContext context, OtlpApplication application, Repeate
// Notifying the user there are errors and then immediately clearing the notification is confusing.
if (logEntry.Severity >= LogLevel.Error)
{
- if (!_logSubscriptions.Any(s => s.SubscriptionType == SubscriptionType.Read && (s.ApplicationKey == application.ApplicationKey || s.ApplicationKey == null)))
+ if (!_logSubscriptions.Any(s => s.SubscriptionType == SubscriptionType.Read && (s.ApplicationKey == applicationView.ApplicationKey || s.ApplicationKey == null)))
{
- if (_applicationUnviewedErrorLogs.TryGetValue(application, out var count))
+ if (_applicationUnviewedErrorLogs.TryGetValue(applicationView.ApplicationKey, out var count))
{
- _applicationUnviewedErrorLogs[application] = ++count;
+ _applicationUnviewedErrorLogs[applicationView.ApplicationKey] = ++count;
}
else
{
- _applicationUnviewedErrorLogs.Add(application, 1);
+ _applicationUnviewedErrorLogs.Add(applicationView.ApplicationKey, 1);
}
}
}
foreach (var kvp in logEntry.Attributes)
{
- _logPropertyKeys.Add((application, kvp.Key));
+ _logPropertyKeys.Add((applicationView.Application, kvp.Key));
}
}
catch (Exception ex)
@@ -388,7 +388,7 @@ public PagedResult GetLogs(GetLogsContext context)
var results = _logs.AsEnumerable();
if (applications?.Count > 0)
{
- results = results.Where(l => MatchApplications(l.Application, applications));
+ results = results.Where(l => MatchApplications(l.ApplicationView.Application, applications));
}
foreach (var filter in context.Filters)
@@ -581,10 +581,10 @@ public void AddMetrics(AddContext context, RepeatedField resour
{
foreach (var rm in resourceMetrics)
{
- OtlpApplication application;
+ OtlpApplicationView applicationView;
try
{
- application = GetOrAddApplication(rm.Resource);
+ applicationView = GetOrAddApplicationView(rm.Resource);
}
catch (Exception ex)
{
@@ -593,7 +593,7 @@ public void AddMetrics(AddContext context, RepeatedField resour
continue;
}
- application.AddMetrics(context, rm.ScopeMetrics);
+ applicationView.Application.AddMetrics(context, rm.ScopeMetrics);
}
RaiseSubscriptionChanged(_metricsSubscriptions);
@@ -603,10 +603,10 @@ public void AddTraces(AddContext context, RepeatedField resourceS
{
foreach (var rs in resourceSpans)
{
- OtlpApplication application;
+ OtlpApplicationView applicationView;
try
{
- application = GetOrAddApplication(rs.Resource);
+ applicationView = GetOrAddApplicationView(rs.Resource);
}
catch (Exception ex)
{
@@ -615,7 +615,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS
continue;
}
- AddTracesCore(context, application, rs.ScopeSpans);
+ AddTracesCore(context, applicationView, rs.ScopeSpans);
}
RaiseSubscriptionChanged(_tracesSubscriptions);
@@ -648,7 +648,7 @@ internal static OtlpSpanKind ConvertSpanKind(SpanKind? kind)
};
}
- internal void AddTracesCore(AddContext context, OtlpApplication application, RepeatedField scopeSpans)
+ internal void AddTracesCore(AddContext context, OtlpApplicationView applicationView, RepeatedField scopeSpans)
{
_tracesLock.EnterWriteLock();
@@ -696,7 +696,7 @@ internal void AddTracesCore(AddContext context, OtlpApplication application, Rep
newTrace = true;
}
- var newSpan = CreateSpan(application, span, trace, scope, _dashboardOptions.TelemetryLimits);
+ var newSpan = CreateSpan(applicationView, span, trace, scope, _dashboardOptions.TelemetryLimits);
trace.AddSpan(newSpan);
// The new span might be linked to by an existing span.
@@ -861,7 +861,7 @@ private void AssertSpanLinks()
}
}
- private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpTrace trace, OtlpScope scope, TelemetryLimitOptions options)
+ private static OtlpSpan CreateSpan(OtlpApplicationView applicationView, Span span, OtlpTrace trace, OtlpScope scope, TelemetryLimitOptions options)
{
var id = span.SpanId?.ToHexString();
if (id is null)
@@ -899,7 +899,7 @@ private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpT
});
}
- var newSpan = new OtlpSpan(application, trace, scope)
+ var newSpan = new OtlpSpan(applicationView, trace, scope)
{
SpanId = id,
ParentSpanId = span.ParentSpanId?.ToHexString(),
diff --git a/src/Shared/StringComparers.cs b/src/Shared/StringComparers.cs
index be2d961d88..a3e1fc4bbf 100644
--- a/src/Shared/StringComparers.cs
+++ b/src/Shared/StringComparers.cs
@@ -19,8 +19,9 @@ internal static class StringComparers
public static StringComparer EnvironmentVariableName => StringComparer.InvariantCultureIgnoreCase;
public static StringComparer UrlPath => StringComparer.OrdinalIgnoreCase;
public static StringComparer UrlHost => StringComparer.OrdinalIgnoreCase;
- public static StringComparer Attribute => StringComparer.Ordinal;
+ public static StringComparer HtmlAttribute => StringComparer.Ordinal;
public static StringComparer GridColumn => StringComparer.Ordinal;
+ public static StringComparer OtlpAttribute => StringComparer.Ordinal;
}
internal static class StringComparisons
@@ -37,6 +38,7 @@ internal static class StringComparisons
public static StringComparison EnvironmentVariableName => StringComparison.InvariantCultureIgnoreCase;
public static StringComparison UrlPath => StringComparison.OrdinalIgnoreCase;
public static StringComparison UrlHost => StringComparison.OrdinalIgnoreCase;
- public static StringComparison Attribute => StringComparison.Ordinal;
+ public static StringComparison HtmlAttribute => StringComparison.Ordinal;
public static StringComparison GridColumn => StringComparison.Ordinal;
+ public static StringComparison OtlpAttribute => StringComparison.Ordinal;
}
diff --git a/tests/Aspire.Dashboard.Tests/Model/ApplicationsSelectHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ApplicationsSelectHelpersTests.cs
index fef49d9ed7..2721f2887d 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!, resource, NullLogger.Instance, new TelemetryLimitOptions());
+ return new OtlpApplication(applicationKey.Name, applicationKey.InstanceId!, NullLogger.Instance, new TelemetryLimitOptions());
}
}
diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs
index f7ab1124ac..4412081581 100644
--- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs
+++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs
@@ -194,27 +194,27 @@ public void AddLogs_Error_UnviewedCount()
var unviewedCounts1 = repository.GetApplicationUnviewedErrorLogsCount();
- Assert.True(unviewedCounts1.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "1"))!, out var unviewedCount1));
+ Assert.True(unviewedCounts1.TryGetValue(new ApplicationKey("TestService", "1"), out var unviewedCount1));
Assert.Equal(2, unviewedCount1);
- Assert.True(unviewedCounts1.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "2"))!, out var unviewedCount2));
+ Assert.True(unviewedCounts1.TryGetValue(new ApplicationKey("TestService", "2"), out var unviewedCount2));
Assert.Equal(1, unviewedCount2);
repository.MarkViewedErrorLogs(new ApplicationKey("TestService", "1"));
var unviewedCounts2 = repository.GetApplicationUnviewedErrorLogsCount();
- Assert.False(unviewedCounts2.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "1"))!, out _));
+ Assert.False(unviewedCounts2.TryGetValue(new ApplicationKey("TestService", "1"), out _));
- Assert.True(unviewedCounts2.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "2"))!, out unviewedCount2));
+ Assert.True(unviewedCounts2.TryGetValue(new ApplicationKey("TestService", "2"), out unviewedCount2));
Assert.Equal(1, unviewedCount2);
repository.MarkViewedErrorLogs(null);
var unviewedCounts3 = repository.GetApplicationUnviewedErrorLogsCount();
- Assert.False(unviewedCounts3.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "1"))!, out _));
- Assert.False(unviewedCounts3.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "2"))!, out _));
+ Assert.False(unviewedCounts3.TryGetValue(new ApplicationKey("TestService", "1"), out _));
+ Assert.False(unviewedCounts3.TryGetValue(new ApplicationKey("TestService", "2"), out _));
}
[Fact]
@@ -265,8 +265,8 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionAll()
var unviewedCounts = repository.GetApplicationUnviewedErrorLogsCount();
- Assert.False(unviewedCounts.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "1"))!, out _));
- Assert.False(unviewedCounts.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "2"))!, out _));
+ Assert.False(unviewedCounts.TryGetValue(new ApplicationKey("TestService", "1"), out _));
+ Assert.False(unviewedCounts.TryGetValue(new ApplicationKey("TestService", "2"), out _));
}
[Fact]
@@ -317,8 +317,8 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionOneApp()
var unviewedCounts = repository.GetApplicationUnviewedErrorLogsCount();
- Assert.False(unviewedCounts.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "1"))!, out _));
- Assert.True(unviewedCounts.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "2"))!, out var unviewedCount));
+ Assert.False(unviewedCounts.TryGetValue(new ApplicationKey("TestService", "1"), out _));
+ Assert.True(unviewedCounts.TryGetValue(new ApplicationKey("TestService", "2"), out var unviewedCount));
Assert.Equal(1, unviewedCount);
}
@@ -355,7 +355,7 @@ public void AddLogs_Error_UnviewedCount_WithNonReadSubscription()
var unviewedCounts = repository.GetApplicationUnviewedErrorLogsCount();
- Assert.True(unviewedCounts.TryGetValue(repository.GetApplication(new ApplicationKey("TestService", "1"))!, out var unviewedCount));
+ Assert.True(unviewedCounts.TryGetValue(new ApplicationKey("TestService", "1"), out var unviewedCount));
Assert.Equal(1, unviewedCount);
}
diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs
index 8706ff2ec4..3a8dabfaad 100644
--- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs
+++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs
@@ -1059,4 +1059,148 @@ public void AddTraces_OutOfOrder_FullName()
trace = Assert.Single(repository.GetTraces(request).PagedResult.Items);
Assert.Equal("TestService: Test span. Id: 1-1", trace.FullName);
}
+
+ [Fact]
+ public void AddTraces_SameResourceDifferentProperties_MultipleResourceViews()
+ {
+ // Arrange
+ var repository = CreateRepository();
+
+ // Act
+ var addContext = new AddContext();
+ repository.AddTraces(addContext, new RepeatedField()
+ {
+ new ResourceSpans
+ {
+ Resource = CreateResource(attributes: [KeyValuePair.Create("prop1", "value1")]),
+ ScopeSpans =
+ {
+ new ScopeSpans
+ {
+ Scope = CreateScope(),
+ Spans =
+ {
+ CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10))
+ }
+ }
+ }
+ },
+ new ResourceSpans
+ {
+ Resource = CreateResource(attributes: [KeyValuePair.Create("prop2", "value1"), KeyValuePair.Create("prop1", "value2")]),
+ ScopeSpans =
+ {
+ new ScopeSpans
+ {
+ Scope = CreateScope(),
+ Spans =
+ {
+ CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1")
+ }
+ }
+ }
+ },
+ new ResourceSpans
+ {
+ Resource = CreateResource(attributes: [KeyValuePair.Create("prop1", "value2"), KeyValuePair.Create("prop2", "value1")]),
+ ScopeSpans =
+ {
+ new ScopeSpans
+ {
+ Scope = CreateScope(),
+ Spans =
+ {
+ CreateSpan(traceId: "1", spanId: "1-3", startTime: s_testTime.AddMinutes(10), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1")
+ }
+ }
+ }
+ }
+ });
+
+ // Assert
+ Assert.Equal(0, addContext.FailureCount);
+
+ // Spans belong to the same application
+ var application = Assert.Single(repository.GetApplications());
+ Assert.Equal("TestService", application.ApplicationName);
+ Assert.Equal("TestId", application.InstanceId);
+
+ // Spans have different views
+ var views = application.GetViews().OrderBy(v => v.Properties.Length).ToList();
+ Assert.Collection(views,
+ v =>
+ {
+ Assert.Collection(v.Properties,
+ p =>
+ {
+ Assert.Equal("prop1", p.Key);
+ Assert.Equal("value1", p.Value);
+ });
+ },
+ v =>
+ {
+ Assert.Collection(v.Properties,
+ p =>
+ {
+ Assert.Equal("prop1", p.Key);
+ Assert.Equal("value2", p.Value);
+ },
+ p =>
+ {
+ Assert.Equal("prop2", p.Key);
+ Assert.Equal("value1", p.Value);
+ });
+ });
+
+ var traces = repository.GetTraces(new GetTracesRequest
+ {
+ ApplicationKey = application.ApplicationKey,
+ FilterText = string.Empty,
+ StartIndex = 0,
+ Count = 10
+ });
+ var trace = Assert.Single(traces.PagedResult.Items);
+
+ Assert.Collection(trace.Spans,
+ s =>
+ {
+ AssertId("1-1", s.SpanId);
+ Assert.Collection(s.Source.Properties,
+ p =>
+ {
+ Assert.Equal("prop1", p.Key);
+ Assert.Equal("value1", p.Value);
+ });
+ },
+ s =>
+ {
+ AssertId("1-2", s.SpanId);
+ Assert.Collection(s.Source.Properties,
+ p =>
+ {
+ Assert.Equal("prop1", p.Key);
+ Assert.Equal("value2", p.Value);
+ },
+ p =>
+ {
+ Assert.Equal("prop2", p.Key);
+ Assert.Equal("value1", p.Value);
+ });
+ },
+ s =>
+ {
+ AssertId("1-3", s.SpanId);
+ Assert.Collection(s.Source.Properties,
+ p =>
+ {
+ Assert.Equal("prop1", p.Key);
+ Assert.Equal("value2", p.Value);
+ },
+ p =>
+ {
+ Assert.Equal("prop2", p.Key);
+ Assert.Equal("value1", p.Value);
+ });
+ });
+ }
}
diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs
index 7497d29119..25f4f02015 100644
--- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs
+++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs
@@ -194,9 +194,9 @@ public static LogRecord CreateLogRecord(DateTime? time = null, string? message =
return logRecord;
}
- public static Resource CreateResource(string? name = null, string? instanceId = null)
+ public static Resource CreateResource(string? name = null, string? instanceId = null, IEnumerable>? attributes = null)
{
- return new Resource()
+ var resource = new Resource()
{
Attributes =
{
@@ -204,6 +204,16 @@ public static Resource CreateResource(string? name = null, string? instanceId =
new KeyValue { Key = "service.instance.id", Value = new AnyValue { StringValue = instanceId ?? "TestId" } }
}
};
+
+ if (attributes != null)
+ {
+ foreach (var attribute in attributes)
+ {
+ resource.Attributes.Add(new KeyValue { Key = attribute.Key, Value = new AnyValue { StringValue = attribute.Value } });
+ }
+ }
+
+ return resource;
}
public static TelemetryRepository CreateRepository(