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(