diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 800eac92ec8..87bfe1cf112 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -63,8 +63,6 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable private Dictionary _currentCache = new Dictionary(); private Dictionary _newCache = new Dictionary(); - private readonly record struct SpanKey(string TraceId, string SpanId); - protected override void OnInitialized() { // Copy the token so there is no chance it is accessed on CTS after it is disposed. diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs index 040b4ce2596..6f72cf07c6d 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs @@ -116,7 +116,7 @@ private void UpdateSpanActionsMenu() } }); - if (GenAIHelpers.IsGenAISpan(ViewModel.Span.Attributes)) + if (GenAIHelpers.HasGenAIAttribute(ViewModel.Span.Attributes)) { _spanActionsMenuItems.Add(new MenuButtonItem { diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor index 7fc37191548..7001136a0da 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor @@ -148,7 +148,7 @@ @* Tooltip is displayed by the message GridValue instance *@ - + @if (!string.IsNullOrEmpty(context.TraceId)) diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 44ebca8ebbc..da0e8c31b71 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -541,6 +541,22 @@ public async Task UpdateViewModelFromQueryAsync(StructuredLogsPageViewModel view await InvokeAsync(_dataGrid.SafeRefreshDataAsync); } + private bool IsGenAILogEntry(OtlpLogEntry logEntry) + { + if (string.IsNullOrEmpty(logEntry.SpanId) || string.IsNullOrEmpty(logEntry.TraceId)) + { + return false; + } + + if (GenAIHelpers.HasGenAIAttribute(logEntry.Attributes)) + { + // GenAI telemetry is on the log entry. + return true; + } + + return ViewModel.HasGenAISpan(logEntry.TraceId, logEntry.SpanId); + } + private async Task LaunchGenAIVisualizerAsync(OtlpLogEntry logEntry) { var available = await TraceLinkHelpers.WaitForSpanToBeAvailableAsync( @@ -571,7 +587,13 @@ await GenAIVisualizerDialog.OpenDialogAsync( var filters = ViewModel.GetFilters(); filters.Add(new FieldTelemetryFilter { - Field = GenAIHelpers.GenAISystem, + Field = KnownStructuredLogFields.SpanIdField, + Condition = FilterCondition.NotEqual, + Value = string.Empty + }); + filters.Add(new FieldTelemetryFilter + { + Field = KnownStructuredLogFields.TraceIdField, Condition = FilterCondition.NotEqual, Value = string.Empty }); @@ -584,10 +606,22 @@ await GenAIVisualizerDialog.OpenDialogAsync( Filters = filters }); - return logs.Items - .DistinctBy(l => (l.SpanId, l.TraceId)) - .Select(l => TelemetryRepository.GetSpan(l.TraceId, l.SpanId)!) - .ToList(); + var genAISpans = new List(); + foreach (var l in logs.Items.DistinctBy(l => (l.SpanId, l.TraceId))) + { + var span = TelemetryRepository.GetSpan(l.TraceId, l.SpanId); + if (span == null) + { + continue; + } + + if (GenAIHelpers.HasGenAIAttribute(l.Attributes) || GenAIHelpers.HasGenAIAttribute(span.Attributes)) + { + genAISpans.Add(span); + } + } + + return genAISpans; }); } } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index ded184ac134..50e2b78c4dc 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -544,7 +544,7 @@ private async Task ToggleSpanLogsAsync(OtlpLogEntry logEntry) private static bool IsGenAISpan(SpanWaterfallViewModel spanViewModel) { - return GenAIHelpers.IsGenAISpan(spanViewModel.Span.Attributes); + return GenAIHelpers.HasGenAIAttribute(spanViewModel.Span.Attributes); } private async Task OnGenAIClickedAsync(OtlpSpan span) diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor index 8d809aa87fb..0c30b50b57c 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor @@ -17,9 +17,7 @@ HighlightText="@FilterText" StopClickPropagation="true"> - @if (!string.IsNullOrEmpty(LogEntry.TraceId) && - !string.IsNullOrEmpty(LogEntry.SpanId) && - GenAIHelpers.IsGenAISpan(LogEntry.Attributes)) + @if (IsGenAILogCallback(LogEntry)) { LaunchGenAIVisualizerCallback { get; set; } + [Parameter, EditorRequired] + public required Func IsGenAILogCallback { get; set; } + private string? _exceptionText; protected override void OnInitialized() diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIHelpers.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIHelpers.cs index 7cc39f69b59..3f04698b893 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIHelpers.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIHelpers.cs @@ -34,7 +34,7 @@ public static class GenAIHelpers public const string ErrorType = "error.type"; - public static bool IsGenAISpan(KeyValuePair[] attributes) + public static bool HasGenAIAttribute(KeyValuePair[] attributes) { return attributes.GetValueWithFallback(GenAISystem, GenAIProviderName) is { Length: > 0 }; } diff --git a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs index c20b1291843..6690931b4a3 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs @@ -1,6 +1,8 @@ // 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 Aspire.Dashboard.Model.GenAI; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; @@ -11,6 +13,8 @@ public class StructuredLogsViewModel { private readonly TelemetryRepository _telemetryRepository; private readonly List _filters = new(); + // Cache span lookups for GenAI attributes to avoid repeated lookups. + private readonly ConcurrentDictionary _spanGenAICache = new(); private PagedResult? _logs; private ResourceKey? _resourceKey; @@ -29,10 +33,37 @@ public StructuredLogsViewModel(TelemetryRepository telemetryRepository) public string FilterText { get => _filterText; set => SetValue(ref _filterText, value); } public IReadOnlyList Filters => _filters; + public bool HasGenAISpan(string traceId, string spanId) + { + // Get a flag indicating whether the span has GenAI telemetry on it. + // This is cached to avoid repeated lookups. The cache is cleared when logs change. + // It's ok that this isn't completely thread safe, i.e. get and a clear happen at the same time. + + var spanKey = new SpanKey(traceId, spanId); + + if (_spanGenAICache.TryGetValue(spanKey, out var value)) + { + return value; + } + + var span = _telemetryRepository.GetSpan(spanKey.TraceId, spanKey.SpanId); + var hasGenAISpan = false; + + if (span != null) + { + // Only cache a value if a span is present. + // We don't want to cache false if there is no span because the span may be added later. + hasGenAISpan = GenAIHelpers.HasGenAIAttribute(span.Attributes); + _spanGenAICache.TryAdd(spanKey, hasGenAISpan); + } + + return hasGenAISpan; + } + public void ClearFilters() { _filters.Clear(); - _logs = null; + ClearData(); } public void AddFilter(FieldTelemetryFilter filter) @@ -47,14 +78,14 @@ public void AddFilter(FieldTelemetryFilter filter) } _filters.Add(filter); - _logs = null; + ClearData(); } public bool RemoveFilter(FieldTelemetryFilter filter) { if (_filters.Remove(filter)) { - _logs = null; + ClearData(); return true; } return false; @@ -72,7 +103,7 @@ private void SetValue(ref T field, T value) } field = value; - _logs = null; + ClearData(); } public PagedResult GetLogs() @@ -135,5 +166,8 @@ public PagedResult GetErrorLogs(int count) public void ClearData() { _logs = null; + + // Clear cache whenever log data changes to prevent it growing forever. + _spanGenAICache.Clear(); } } diff --git a/src/Aspire.Dashboard/Otlp/Model/SpanKey.cs b/src/Aspire.Dashboard/Otlp/Model/SpanKey.cs new file mode 100644 index 00000000000..7fb3b08709c --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/SpanKey.cs @@ -0,0 +1,6 @@ +// 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 readonly record struct SpanKey(string TraceId, string SpanId);