From 5c5f36d124c8ee393aac26d5363da64e0473112e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 2 Jun 2026 12:00:33 +0800 Subject: [PATCH 1/4] Fix telemetry streaming to wait for resource to appear When streaming spans/logs with a resource filter via follow=true, if the resource hasn't sent any telemetry yet, the stream would immediately return empty. This fixes the issue by waiting for the resource to appear via the OnNewResources subscription before starting the watcher. Uses a bounded channel (capacity 1, DropOldest) as the signaling mechanism to avoid race conditions between notifications and state checks. Fixes #17802 --- .../Api/TelemetryApiService.cs | 60 ++++++++++------- .../TelemetryApiServiceTests.cs | 66 +++++++++++++++++++ 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index ffb32a8cb5e..c3b70a23d98 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Assistant; using Aspire.Dashboard.Model.Otlp; @@ -266,18 +267,9 @@ public async IAsyncEnumerable FollowSpansAsync( string? search, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Resolve resource keys - var resources = telemetryRepository.GetResources(); - var resourceKeys = ResolveResourceKeys(resources, resourceNames); - - // For streaming, if resources were specified but can't be resolved, filter everything out - var hasResourceFilter = resourceNames is { Length: > 0 }; - var invalidResourceFilter = hasResourceFilter && resourceKeys is null; - - if (invalidResourceFilter) - { - yield break; - } + // Resolve resource keys, waiting for the resource to appear if it doesn't exist yet. + // Throws OperationCanceledException if the client disconnects before the resource appears. + var resourceKeys = await WaitForResourceKeysAsync(resourceNames, cancellationToken).ConfigureAwait(false); // Convert structured search qualifiers into TelemetryFilter objects for per-span filtering List spanFilters = []; @@ -320,18 +312,9 @@ public async IAsyncEnumerable FollowLogsAsync( string? search, [EnumeratorCancellation] CancellationToken cancellationToken) { - // Resolve resource keys - var resources = telemetryRepository.GetResources(); - var resourceKeys = ResolveResourceKeys(resources, resourceNames); - - // For streaming, if resources were specified but can't be resolved, filter everything out - var hasResourceFilter = resourceNames is { Length: > 0 }; - var invalidResourceFilter = hasResourceFilter && resourceKeys is null; - - if (invalidResourceFilter) - { - yield break; - } + // Resolve resource keys, waiting for the resource to appear if it doesn't exist yet. + // Throws OperationCanceledException if the client disconnects before the resource appears. + var resourceKeys = await WaitForResourceKeysAsync(resourceNames, cancellationToken).ConfigureAwait(false); // Build filters var filters = new List(); @@ -562,6 +545,35 @@ private static void AddSpanFiltersFromQualifiers(SearchFilter parsedSearch, List _ => FilterCondition.Contains }; + /// + /// Resolves resource names to ResourceKeys, waiting for the resources to appear if they + /// don't exist yet. This enables streaming subscriptions started before telemetry arrives + /// to pick up data once the resource is first seen. + /// Throws OperationCanceledException if cancellation is triggered before the resources appear. + /// + private async Task> WaitForResourceKeysAsync(string[]? resourceNames, CancellationToken cancellationToken) + { + // Subscribe before the first check so no notification can be missed between + // GetResources() and the subscription registration. + var signal = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }); + using var subscription = telemetryRepository.OnNewResources(() => + { + signal.Writer.TryWrite(true); + return Task.CompletedTask; + }); + + while (true) + { + var resources = telemetryRepository.GetResources(); + if (ResolveResourceKeys(resources, resourceNames) is { } result) + { + return result; + } + + await signal.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + } + /// /// Resolves resource names to ResourceKeys. /// Returns null if any specified resource is not found. diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 37d4cf903fa..6dbcc0c1558 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -645,6 +645,72 @@ private static List GetAllSpans(TelemetryApiResponse result) .ToList() ?? []; } + [Fact] + public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() + { + var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); + var service = CreateService(repository); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var receivedItems = new List(); + + // Start streaming for a resource that doesn't exist yet. + var streamTask = Task.Run(async () => + { + await foreach (var item in service.FollowSpansAsync(["service1"], null, null, null, cancellationToken: cts.Token)) + { + receivedItems.Add(item); + if (receivedItems.Count >= 1) + { + break; + } + } + }, cts.Token); + + // Give the streaming task time to start waiting for the resource. + await Task.Delay(100, cts.Token); + + // Now add spans for the resource - this should unblock the stream. + AddSpans(repository, count: 1); + + await streamTask; + + Assert.Single(receivedItems); + } + + [Fact] + public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() + { + var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); + var service = CreateService(repository); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var receivedItems = new List(); + + // Start streaming for a resource that doesn't exist yet. + var streamTask = Task.Run(async () => + { + await foreach (var item in service.FollowLogsAsync(["service1"], null, null, null, cts.Token)) + { + receivedItems.Add(item); + if (receivedItems.Count >= 1) + { + break; + } + } + }, cts.Token); + + // Give the streaming task time to start waiting for the resource. + await Task.Delay(100, cts.Token); + + // Now add logs for the resource - this should unblock the stream. + AddLogs(repository, ["hello"]); + + await streamTask; + + Assert.Single(receivedItems); + } + // SpanId is serialized as lowercase hex per the OTLP/JSON spec // (see https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding), and our // CreateSpan test helper stores the friendly identifier as the raw UTF-8 bytes of From 7e40583509ff04ffb479f4751531385cbd0f2c94 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 2 Jun 2026 14:16:12 +0800 Subject: [PATCH 2/4] Address review feedback: skip channel allocation when no filter, remove Task.Delay from tests --- .../Api/TelemetryApiService.cs | 61 ++++++++++--------- .../Assistant/AssistantChatDataContext.cs | 4 +- .../GenAI/GenAIVisualizerDialogViewModel.cs | 2 +- .../Model/StructuredLogsViewModel.cs | 4 +- .../Otlp/Storage/GetLogsContext.cs | 4 +- .../Storage/TelemetryRepository.Watchers.cs | 2 +- .../Otlp/Storage/TelemetryRepository.cs | 12 ++-- .../Integration/OtlpHttpJsonTests.cs | 4 +- .../Model/TelemetryExportServiceTests.cs | 2 +- .../TelemetryApiServiceTests.cs | 52 +++++----------- .../TelemetryRepositoryTests/LogTests.cs | 44 ++++++------- .../TelemetryRepositoryTests.cs | 14 ++--- 12 files changed, 96 insertions(+), 109 deletions(-) diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index c3b70a23d98..32b1841687d 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -46,9 +46,9 @@ internal sealed class TelemetryApiService( var spanFilters = new List(); var searchTextFragments = ParseAndApplySearchFilters(search, spanFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); - // Get spans for all resource keys + // Get spans for all resource keys (empty list means no filter / all resources) var allSpans = new List(); - foreach (var resourceKey in resourceKeys) + foreach (var resourceKey in resourceKeys.Count > 0 ? resourceKeys.Select(k => (ResourceKey?)k) : [null]) { var result = telemetryRepository.GetSpans(new GetSpansRequest { @@ -103,9 +103,9 @@ internal sealed class TelemetryApiService( var traceFilters = new List(); var searchTextFragments = ParseAndApplySearchFilters(search, traceFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); - // Get traces for all resource keys + // Get traces for all resource keys (empty list means no filter / all resources) var allTraces = new List(); - foreach (var resourceKey in resourceKeys) + foreach (var resourceKey in resourceKeys.Count > 0 ? resourceKeys.Select(k => (ResourceKey?)k) : [null]) { var result = telemetryRepository.GetTraces(new GetTracesRequest { @@ -221,22 +221,17 @@ internal sealed class TelemetryApiService( var searchTextFragments = ParseAndApplySearchFilters(search, filters, AddLogFiltersFromQualifiers, key => ResolveLogFieldKey(key) is not null); - // Get logs for all resource keys - var allLogs = new List(); - foreach (var resourceKey in resourceKeys) + // Get logs for all resource keys (empty list means no filter / all resources) + var result = telemetryRepository.GetLogs(new GetLogsContext { - var result = telemetryRepository.GetLogs(new GetLogsContext - { - ResourceKey = resourceKey, - StartIndex = 0, - Count = int.MaxValue, - Filters = filters, - TextFragments = searchTextFragments - }); - allLogs.AddRange(result.Items); - } + ResourceKeys = resourceKeys, + StartIndex = 0, + Count = int.MaxValue, + Filters = filters, + TextFragments = searchTextFragments + }); - var logs = allLogs; + var logs = result.Items; var totalCount = logs.Count; @@ -278,7 +273,7 @@ public async IAsyncEnumerable FollowSpansAsync( // Build the watch request with all filters pushed into the repository var watchRequest = new WatchSpansRequest { - ResourceKey = resourceKeys is { Count: 1 } ? resourceKeys[0] : null, + ResourceKey = resourceKeys is [var singleKey] ? singleKey : null, Filters = spanFilters, TraceId = traceId, HasError = hasError, @@ -291,7 +286,7 @@ public async IAsyncEnumerable FollowSpansAsync( // Multi-resource filtering: repository only supports single ResourceKey, // so for multi-resource queries we filter here. if (resourceKeys is { Count: > 1 } && - !resourceKeys.Any(k => k?.EqualsCompositeName(span.Source.ResourceKey.GetCompositeName()) == true)) + !resourceKeys.Any(k => k.EqualsCompositeName(span.Source.ResourceKey.GetCompositeName()))) { continue; } @@ -348,7 +343,7 @@ public async IAsyncEnumerable FollowLogsAsync( // Build the watch request with all filters pushed into the repository var watchRequest = new WatchLogsRequest { - ResourceKey = resourceKeys is { Count: 1 } ? resourceKeys[0] : null, + ResourceKey = resourceKeys is [var singleKey] ? singleKey : null, Filters = filters, TextFragments = searchTextFragments }; @@ -359,7 +354,7 @@ public async IAsyncEnumerable FollowLogsAsync( // Multi-resource filtering: repository only supports single ResourceKey, // so for multi-resource queries we filter here. if (resourceKeys is { Count: > 1 } && - !resourceKeys.Any(k => k?.EqualsCompositeName(log.ResourceView.ResourceKey.GetCompositeName()) == true)) + !resourceKeys.Any(k => k.EqualsCompositeName(log.ResourceView.ResourceKey.GetCompositeName()))) { continue; } @@ -551,8 +546,14 @@ private static void AddSpanFiltersFromQualifiers(SearchFilter parsedSearch, List /// to pick up data once the resource is first seen. /// Throws OperationCanceledException if cancellation is triggered before the resources appear. /// - private async Task> WaitForResourceKeysAsync(string[]? resourceNames, CancellationToken cancellationToken) + private async Task> WaitForResourceKeysAsync(string[]? resourceNames, CancellationToken cancellationToken) { + if (resourceNames is null || resourceNames.Length == 0) + { + // No filter - return immediately without allocating a channel or subscription. + return []; + } + // Subscribe before the first check so no notification can be missed between // GetResources() and the subscription registration. var signal = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }); @@ -577,24 +578,26 @@ private static void AddSpanFiltersFromQualifiers(SearchFilter parsedSearch, List /// /// Resolves resource names to ResourceKeys. /// Returns null if any specified resource is not found. - /// If no resources are specified, returns a list with a single null key (no filter). + /// Returns an empty list when no resource filter is specified (meaning all resources). /// - private static List? ResolveResourceKeys(IReadOnlyList resources, string[]? resourceNames) + private static List? ResolveResourceKeys(IReadOnlyList resources, string[]? resourceNames) { if (resourceNames is null || resourceNames.Length == 0) { - // No filter - return a list with null to indicate "all resources" - return [null]; + return []; } - var keys = new List(); + var keys = new List(); foreach (var resourceName in resourceNames) { if (!AIHelpers.TryResolveResourceForTelemetry(resources, resourceName, out _, out var resourceKey)) { return null; } - keys.Add(resourceKey); + if (resourceKey is { } key) + { + keys.Add(key); + } } return keys; } diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 1b91704eb76..5164ae816d4 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -126,7 +126,7 @@ public async Task GetStructuredLogsAsync( // If support is added for ordering logs by timestamp then improve this. var logs = TelemetryRepository.GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKeys = resourceKey is { } rk ? [rk] : [], StartIndex = 0, Count = int.MaxValue, Filters = [] @@ -207,7 +207,7 @@ public async Task GetTraceStructuredLogsAsync( var logs = TelemetryRepository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], Count = int.MaxValue, StartIndex = 0, Filters = [traceIdFilter] diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs index 1d4bcf676ce..256878c5a7b 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs @@ -596,7 +596,7 @@ private static List GetSpanLogEntries(TelemetryRepository telemetr { var logsContext = new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], Count = int.MaxValue, StartIndex = 0, Filters = [ diff --git a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs index dd90c2fa697..5d2653c7fd0 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs @@ -115,7 +115,7 @@ public PagedResult GetLogs() logs = _telemetryRepository.GetLogs(new GetLogsContext { - ResourceKey = ResourceKey, + ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = StartIndex, Count = Count, Filters = filters @@ -154,7 +154,7 @@ public PagedResult GetErrorLogs(int count) var errorLogs = _telemetryRepository.GetLogs(new GetLogsContext { - ResourceKey = ResourceKey, + ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = 0, Count = count, Filters = filters diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs b/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs index 52e83519a50..6bd2768977b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class GetLogsContext { - public required ResourceKey? ResourceKey { get; init; } + public required IReadOnlyList ResourceKeys { get; init; } public required int StartIndex { get; init; } public required int Count { get; init; } public required List Filters { get; init; } @@ -15,7 +15,7 @@ public sealed class GetLogsContext public static GetLogsContext ForResourceKey(ResourceKey resourceKey) => new() { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = int.MaxValue, Filters = [] diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs index 2dadbbd6a4d..ee66ae263e7 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs @@ -148,7 +148,7 @@ public async IAsyncEnumerable WatchLogsAsync( // Get existing logs snapshot (capped to prevent OOM) var existingLogs = GetLogs(new GetLogsContext { - ResourceKey = request.ResourceKey, + ResourceKeys = request.ResourceKey is { } key ? [key] : [], StartIndex = 0, Count = MaxWatcherSnapshotCount, Filters = request.Filters, diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 4cb03a3de0c..7fcdfc57238 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -442,9 +442,13 @@ public void AddLogsCore(AddContext context, OtlpResourceView resourceView, Repea public PagedResult GetLogs(GetLogsContext context) { List? resources = null; - if (context.ResourceKey is { } key) + if (context.ResourceKeys is { Count: > 0 } keys) { - resources = GetResources(key); + resources = []; + foreach (var key in keys) + { + resources.AddRange(GetResources(key)); + } if (resources.Count == 0) { @@ -512,7 +516,7 @@ public List GetLogsForSpan(string traceId, string spanId) { var logsContext = new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], Count = int.MaxValue, StartIndex = 0, Filters = @@ -543,7 +547,7 @@ public List GetLogsForTrace(string traceId) { var logsContext = new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], Count = int.MaxValue, StartIndex = 0, Filters = diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs index 80ce1381a46..c3b7dc1553f 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs @@ -586,7 +586,7 @@ public async Task CallService_Logs_JsonContentType_Success() var logs = telemetryRepository.GetLogs(new GetLogsContext { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -663,7 +663,7 @@ public async Task CallService_Events_JsonContentType_Success() var logs = telemetryRepository.GetLogs(new GetLogsContext { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 50acdaa3941..86eeea03512 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -53,7 +53,7 @@ public void ConvertLogsToOtlpJson_SingleLog_ReturnsCorrectStructure() var resource = resources[0]; var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = int.MaxValue, Filters = [] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 6dbcc0c1558..64303258c85 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -652,30 +652,20 @@ public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var receivedItems = new List(); - - // Start streaming for a resource that doesn't exist yet. - var streamTask = Task.Run(async () => - { - await foreach (var item in service.FollowSpansAsync(["service1"], null, null, null, cancellationToken: cts.Token)) - { - receivedItems.Add(item); - if (receivedItems.Count >= 1) - { - break; - } - } - }, cts.Token); + // Start enumerating - MoveNextAsync will block until data arrives. + var enumerator = service.FollowSpansAsync(["service1"], null, null, null, cancellationToken: cts.Token).GetAsyncEnumerator(cts.Token); + var moveNextTask = enumerator.MoveNextAsync(); - // Give the streaming task time to start waiting for the resource. - await Task.Delay(100, cts.Token); + // The task should not complete yet because the resource doesn't exist. + Assert.False(moveNextTask.IsCompleted); // Now add spans for the resource - this should unblock the stream. AddSpans(repository, count: 1); - await streamTask; + Assert.True(await moveNextTask); + Assert.NotNull(enumerator.Current); - Assert.Single(receivedItems); + await enumerator.DisposeAsync(); } [Fact] @@ -685,30 +675,20 @@ public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var receivedItems = new List(); - - // Start streaming for a resource that doesn't exist yet. - var streamTask = Task.Run(async () => - { - await foreach (var item in service.FollowLogsAsync(["service1"], null, null, null, cts.Token)) - { - receivedItems.Add(item); - if (receivedItems.Count >= 1) - { - break; - } - } - }, cts.Token); + // Start enumerating - MoveNextAsync will block until data arrives. + var enumerator = service.FollowLogsAsync(["service1"], null, null, null, cts.Token).GetAsyncEnumerator(cts.Token); + var moveNextTask = enumerator.MoveNextAsync(); - // Give the streaming task time to start waiting for the resource. - await Task.Delay(100, cts.Token); + // The task should not complete yet because the resource doesn't exist. + Assert.False(moveNextTask.IsCompleted); // Now add logs for the resource - this should unblock the stream. AddLogs(repository, ["hello"]); - await streamTask; + Assert.True(await moveNextTask); + Assert.NotNull(enumerator.Current); - Assert.Single(receivedItems); + await enumerator.DisposeAsync(); } // SpanId is serialized as lowercase hex per the OTLP/JSON spec diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index e5125b711c7..389d3609bb2 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -64,7 +64,7 @@ public void AddLogs() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -119,7 +119,7 @@ public void AddLogs_NoBody_EmptyMessage() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -171,7 +171,7 @@ public void AddLogs_MultipleOutOfOrder() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -419,7 +419,7 @@ public void GetLogs_UnknownResource() // Act var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = new ResourceKey("TestService", "UnknownResource"), + ResourceKeys = [new ResourceKey("TestService", "UnknownResource")], StartIndex = 0, Count = 10, Filters = [] @@ -517,7 +517,7 @@ public async Task Subscriptions_AddLog() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 1, Filters = [] @@ -659,7 +659,7 @@ public void AddLogs_AttributeLimits_LimitsApplied() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -807,7 +807,7 @@ public void FilterLogs_With_Message_Returns_CorrectLog() // Assert Assert.Empty(repository.GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 1, Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = nameof(OtlpLogEntry.Message), Value = "does_not_contain" }] @@ -815,7 +815,7 @@ public void FilterLogs_With_Message_Returns_CorrectLog() Assert.Single(repository.GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 1, Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = nameof(OtlpLogEntry.Message), Value = "message" }] @@ -854,7 +854,7 @@ public void FilterLogs_With_EventName_Returns_CorrectLog() // Assert Assert.Empty(repository.GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 1, Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = KnownStructuredLogFields.EventNameField, Value = "does_not_contain" }] @@ -862,7 +862,7 @@ public void FilterLogs_With_EventName_Returns_CorrectLog() Assert.Single(repository.GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 1, Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = KnownStructuredLogFields.EventNameField, Value = "MyEvent" }] @@ -923,7 +923,7 @@ public void AddLogs_MultipleResources_SameInstanceId_CreateMultipleResources() var logs1 = repository.GetLogs(new GetLogsContext { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -946,7 +946,7 @@ public void AddLogs_MultipleResources_SameInstanceId_CreateMultipleResources() var logs2 = repository.GetLogs(new GetLogsContext { - ResourceKey = resources[1].ResourceKey, + ResourceKeys = [resources[1].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -1022,7 +1022,7 @@ public void GetLogs_MultipleInstances() var resourceKey = new ResourceKey("resource1", InstanceId: null); var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -1112,7 +1112,7 @@ public void RemoveLogs_All() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1177,7 +1177,7 @@ public void RemoveLogs_SelectedResource() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1251,7 +1251,7 @@ public void RemoveLogs_MultipleSelectedResources() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1291,7 +1291,7 @@ public void AddLogs_ObservedUnixTimeNanos() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1332,7 +1332,7 @@ public void AddLogs_EventName_FromLogRecordField() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1373,7 +1373,7 @@ public void AddLogs_EventName_FromLegacyAttribute() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1416,7 +1416,7 @@ public void AddLogs_EventName_FieldTakesPrecedenceOverAttribute() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1458,7 +1458,7 @@ public void AddLogs_EventName_NullWhenNotSet() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1516,7 +1516,7 @@ public void GetLogs_DisabledFiltersAreIgnored() var logs = repository.GetLogs(new GetLogsContext { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = filters diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index 8b15ef02969..dd35cc85bf5 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -38,7 +38,7 @@ public void AddData_WhilePaused_IsDiscarded() AddTrace(); var resourceKey = new ResourceKey("resource", "resource"); - Assert.Empty(repository.GetLogs(new GetLogsContext { ResourceKey = resourceKey, Count = 100, Filters = [], StartIndex = 0 }).Items); + Assert.Empty(repository.GetLogs(new GetLogsContext { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).Items); Assert.Null(repository.GetResource(resourceKey)); Assert.Empty(repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); @@ -49,7 +49,7 @@ public void AddData_WhilePaused_IsDiscarded() AddLog(); AddMetric(); AddTrace(); - Assert.Single(repository.GetLogs(new GetLogsContext { ResourceKey = resourceKey, Count = 100, Filters = [], StartIndex = 0 }).Items); + Assert.Single(repository.GetLogs(new GetLogsContext { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).Items); var resource = repository.GetResource(resourceKey); Assert.NotNull(resource); Assert.NotEmpty(resource.GetInstrumentsSummary()); @@ -230,7 +230,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() Assert.Equal(1, errorCount2); // Assert - resource1 logs cleared, but traces and metrics remain - var logs = repository.GetLogs(new GetLogsContext { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Single(logs.Items); Assert.Equal("log-resource2-456", logs.Items[0].Message); @@ -242,7 +242,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() // Assert - resource2 data is unaffected var resource2Key = new ResourceKey("resource2", "456"); - var resource2Logs = repository.GetLogs(new GetLogsContext { ResourceKey = resource2Key, StartIndex = 0, Count = 10, Filters = [] }); + var resource2Logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [resource2Key], StartIndex = 0, Count = 10, Filters = [] }); Assert.Single(resource2Logs.Items); Assert.Equal("log-resource2-456", resource2Logs.Items[0].Message); @@ -271,7 +271,7 @@ public void ClearSelectedSignals_OtherResourcesRemainUnaffected() repository.ClearSelectedSignals(selectedResources); // Assert - resource1 and resource3 data is unaffected - var logs = repository.GetLogs(new GetLogsContext { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Equal(2, logs.TotalItemCount); Assert.Contains(logs.Items, l => l.Message == "log-resource1-111"); Assert.Contains(logs.Items, l => l.Message == "log-resource3-333"); @@ -315,7 +315,7 @@ public void ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() Assert.Null(resourceAfter); // Assert - All telemetry data is cleared - var logs = repository.GetLogs(new GetLogsContext { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(logs.Items); var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); @@ -346,7 +346,7 @@ public void ClearSelectedSignals_PartialClear_ResourceNotRemoved() Assert.NotNull(resourceAfter); // Assert - Logs and traces are cleared, but metrics remain - var logs = repository.GetLogs(new GetLogsContext { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(logs.Items); var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); From 1ff423f5fe2cafe721db364b0f4aa7a8470bb30e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 2 Jun 2026 15:21:43 +0800 Subject: [PATCH 3/4] Refactor GetTracesRequest, GetSpansRequest, WatchSpansRequest, WatchLogsRequest to use ResourceKeys Change ResourceKey? ResourceKey property to IReadOnlyList ResourceKeys on all telemetry request types for consistency with GetLogsContext. This pushes multi-resource filtering into the repository layer, eliminating per-resource loops and client-side filtering in TelemetryApiService. --- .../Api/TelemetryApiService.cs | 64 ++++--------- .../Assistant/AssistantChatDataContext.cs | 2 +- src/Aspire.Dashboard/Model/TracesViewModel.cs | 4 +- .../Otlp/Storage/GetSpansRequest.cs | 2 +- .../Otlp/Storage/GetTracesRequest.cs | 4 +- .../Storage/TelemetryRepository.Watchers.cs | 8 +- .../Otlp/Storage/TelemetryRepository.cs | 16 +++- .../Otlp/Storage/WatchLogsRequest.cs | 2 +- .../Otlp/Storage/WatchSpansRequest.cs | 2 +- .../Controls/GenAIVisualizerDialogTests.cs | 6 +- .../Integration/OtlpHttpJsonTests.cs | 2 +- .../Model/TelemetryExportServiceTests.cs | 2 +- .../TelemetryRepositoryTests.cs | 38 ++++---- .../TelemetryRepositoryTests/TraceTests.cs | 96 +++++++++---------- 14 files changed, 116 insertions(+), 132 deletions(-) diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index 32b1841687d..0dc8d3450b5 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -47,21 +47,17 @@ internal sealed class TelemetryApiService( var searchTextFragments = ParseAndApplySearchFilters(search, spanFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); // Get spans for all resource keys (empty list means no filter / all resources) - var allSpans = new List(); - foreach (var resourceKey in resourceKeys.Count > 0 ? resourceKeys.Select(k => (ResourceKey?)k) : [null]) + var result = telemetryRepository.GetSpans(new GetSpansRequest { - var result = telemetryRepository.GetSpans(new GetSpansRequest - { - ResourceKey = resourceKey, - StartIndex = 0, - Count = int.MaxValue, - Filters = spanFilters, - TraceId = traceId, - HasError = hasError, - TextFragments = searchTextFragments - }); - allSpans.AddRange(result.PagedResult.Items); - } + ResourceKeys = resourceKeys, + StartIndex = 0, + Count = int.MaxValue, + Filters = spanFilters, + TraceId = traceId, + HasError = hasError, + TextFragments = searchTextFragments + }); + var allSpans = result.PagedResult.Items; var totalCount = allSpans.Count; @@ -104,19 +100,15 @@ internal sealed class TelemetryApiService( var searchTextFragments = ParseAndApplySearchFilters(search, traceFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); // Get traces for all resource keys (empty list means no filter / all resources) - var allTraces = new List(); - foreach (var resourceKey in resourceKeys.Count > 0 ? resourceKeys.Select(k => (ResourceKey?)k) : [null]) + var result = telemetryRepository.GetTraces(new GetTracesRequest { - var result = telemetryRepository.GetTraces(new GetTracesRequest - { - ResourceKey = resourceKey, - StartIndex = 0, - Count = int.MaxValue, - Filters = traceFilters, - TextFragments = searchTextFragments - }); - allTraces.AddRange(result.PagedResult.Items); - } + ResourceKeys = resourceKeys, + StartIndex = 0, + Count = int.MaxValue, + Filters = traceFilters, + TextFragments = searchTextFragments + }); + var allTraces = result.PagedResult.Items; var traces = allTraces; @@ -273,7 +265,7 @@ public async IAsyncEnumerable FollowSpansAsync( // Build the watch request with all filters pushed into the repository var watchRequest = new WatchSpansRequest { - ResourceKey = resourceKeys is [var singleKey] ? singleKey : null, + ResourceKeys = resourceKeys, Filters = spanFilters, TraceId = traceId, HasError = hasError, @@ -283,14 +275,6 @@ public async IAsyncEnumerable FollowSpansAsync( // Watch spans with filtering done inside the repository await foreach (var span in telemetryRepository.WatchSpansAsync(watchRequest, cancellationToken).ConfigureAwait(false)) { - // Multi-resource filtering: repository only supports single ResourceKey, - // so for multi-resource queries we filter here. - if (resourceKeys is { Count: > 1 } && - !resourceKeys.Any(k => k.EqualsCompositeName(span.Source.ResourceKey.GetCompositeName()))) - { - continue; - } - // Use compact JSON for NDJSON streaming (no indentation) yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false); } @@ -343,7 +327,7 @@ public async IAsyncEnumerable FollowLogsAsync( // Build the watch request with all filters pushed into the repository var watchRequest = new WatchLogsRequest { - ResourceKey = resourceKeys is [var singleKey] ? singleKey : null, + ResourceKeys = resourceKeys, Filters = filters, TextFragments = searchTextFragments }; @@ -351,14 +335,6 @@ public async IAsyncEnumerable FollowLogsAsync( // Watch logs with filtering done inside the repository await foreach (var log in telemetryRepository.WatchLogsAsync(watchRequest, cancellationToken).ConfigureAwait(false)) { - // Multi-resource filtering: repository only supports single ResourceKey, - // so for multi-resource queries we filter here. - if (resourceKeys is { Count: > 1 } && - !resourceKeys.Any(k => k.EqualsCompositeName(log.ResourceView.ResourceKey.GetCompositeName()))) - { - continue; - } - var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([log]); yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.DefaultOptions); } diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 5164ae816d4..23cf0a4245c 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -170,7 +170,7 @@ public async Task GetTracesAsync( var traces = TelemetryRepository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = resourceKey is { } rk ? [rk] : [], StartIndex = 0, Count = int.MaxValue, Filters = [] diff --git a/src/Aspire.Dashboard/Model/TracesViewModel.cs b/src/Aspire.Dashboard/Model/TracesViewModel.cs index 1055e12ac23..53ef52fd1c1 100644 --- a/src/Aspire.Dashboard/Model/TracesViewModel.cs +++ b/src/Aspire.Dashboard/Model/TracesViewModel.cs @@ -84,7 +84,7 @@ public PagedResult GetTraces() var result = _telemetryRepository.GetTraces(new GetTracesRequest { - ResourceKey = ResourceKey, + ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = StartIndex, Count = Count, Filters = filters, @@ -116,7 +116,7 @@ public PagedResult GetErrorTraces(int count) var errorTraces = _telemetryRepository.GetTraces(new GetTracesRequest { - ResourceKey = ResourceKey, + ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = 0, Count = count, Filters = filters, diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs index 2d17eb7ec63..8c68b2d5d44 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class GetSpansRequest { - public required ResourceKey? ResourceKey { get; init; } + public required IReadOnlyList ResourceKeys { get; init; } public required int StartIndex { get; init; } public required int Count { get; init; } public required List Filters { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs index 3e52797a74e..32b02340e5a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class GetTracesRequest { - public required ResourceKey? ResourceKey { get; init; } + public required IReadOnlyList ResourceKeys { get; init; } public required int StartIndex { get; init; } public required int Count { get; init; } public required List Filters { get; init; } @@ -16,7 +16,7 @@ public sealed class GetTracesRequest public static GetTracesRequest ForResourceKey(ResourceKey resourceKey) => new() { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = int.MaxValue, Filters = [] diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs index ee66ae263e7..77c41216dde 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs @@ -57,7 +57,7 @@ public async IAsyncEnumerable WatchSpansAsync( // (resource, traceId, hasError, telemetry filters, text fragments) at the query level. var existingSpans = GetSpans(new GetSpansRequest { - ResourceKey = request.ResourceKey, + ResourceKeys = request.ResourceKeys, StartIndex = 0, Count = MaxWatcherSnapshotCount, Filters = request.Filters, @@ -148,7 +148,7 @@ public async IAsyncEnumerable WatchLogsAsync( // Get existing logs snapshot (capped to prevent OOM) var existingLogs = GetLogs(new GetLogsContext { - ResourceKeys = request.ResourceKey is { } key ? [key] : [], + ResourceKeys = request.ResourceKeys, StartIndex = 0, Count = MaxWatcherSnapshotCount, Filters = request.Filters, @@ -224,7 +224,7 @@ private void PushSpansToWatchers(List spans, ResourceKey resourceKey) var request = watcher.Request; // Check if watcher is filtering by resource - if (request.ResourceKey is { } key && !key.Equals(resourceKey)) + if (request.ResourceKeys is { Count: > 0 } keys && !keys.Contains(resourceKey)) { continue; } @@ -269,7 +269,7 @@ private void PushLogsToWatchers(List logs, ResourceKey resourceKey var request = watcher.Request; // Check if watcher is filtering by resource - if (request.ResourceKey is { } key && !key.Equals(resourceKey)) + if (request.ResourceKeys is { Count: > 0 } keys && !keys.Contains(resourceKey)) { continue; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 7fcdfc57238..47d6819c498 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -620,9 +620,13 @@ public List GetTracePropertyKeys(ResourceKey? resourceKey) public GetTracesResponse GetTraces(GetTracesRequest context) { List? resources = null; - if (context.ResourceKey is { } key) + if (context.ResourceKeys is { Count: > 0 } keys) { - resources = GetResources(key, includeUninstrumentedPeers: true); + resources = []; + foreach (var key in keys) + { + resources.AddRange(GetResources(key, includeUninstrumentedPeers: true)); + } if (resources.Count == 0) { @@ -712,9 +716,13 @@ public GetTracesResponse GetTraces(GetTracesRequest context) public GetSpansResponse GetSpans(GetSpansRequest context) { List? resources = null; - if (context.ResourceKey is { } key) + if (context.ResourceKeys is { Count: > 0 } keys) { - resources = GetResources(key, includeUninstrumentedPeers: true); + resources = []; + foreach (var key in keys) + { + resources.AddRange(GetResources(key, includeUninstrumentedPeers: true)); + } if (resources.Count == 0) { diff --git a/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs index 458263bcf98..6fb82607709 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class WatchLogsRequest { - public required ResourceKey? ResourceKey { get; init; } + public required IReadOnlyList ResourceKeys { get; init; } public required List Filters { get; init; } public string[]? TextFragments { get; init; } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/WatchSpansRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/WatchSpansRequest.cs index 6c5385f7172..22d81081835 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/WatchSpansRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/WatchSpansRequest.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class WatchSpansRequest { - public required ResourceKey? ResourceKey { get; init; } + public required IReadOnlyList ResourceKeys { get; init; } public required List Filters { get; init; } public string? TraceId { get; init; } public bool? HasError { get; init; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs index b97fb82aa94..2bb32328e51 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -148,7 +148,7 @@ public async Task UpdateTelemetry_DifferentTrace_ContentInstanceUnchanged() var resource = resources[0]; var tracesResult = repository.GetTraces(new GetTracesRequest { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -231,7 +231,7 @@ public async Task UpdateTelemetry_SameTrace_ContentInstanceChanged() var resource = resources[0]; var tracesResult = repository.GetTraces(new GetTracesRequest { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -244,7 +244,7 @@ List GetContextGenAISpans() { var currentTrace = repository.GetTraces(new GetTracesRequest { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs index c3b7dc1553f..582a575bb68 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs @@ -526,7 +526,7 @@ public async Task CallService_Traces_JsonContentType_Success() var traces = telemetryRepository.GetTraces(new GetTracesRequest { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 86eeea03512..73c39f14a44 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -286,7 +286,7 @@ public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() var resource = resources[0]; var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = int.MaxValue, Filters = [] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index dd35cc85bf5..fd5db9fcf6e 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -40,7 +40,7 @@ public void AddData_WhilePaused_IsDiscarded() var resourceKey = new ResourceKey("resource", "resource"); Assert.Empty(repository.GetLogs(new GetLogsContext { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).Items); Assert.Null(repository.GetResource(resourceKey)); - Assert.Empty(repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); + Assert.Empty(repository.GetTraces(new GetTracesRequest { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); pauseManager.SetStructuredLogsPaused(false); pauseManager.SetMetricsPaused(false); @@ -53,7 +53,7 @@ public void AddData_WhilePaused_IsDiscarded() var resource = repository.GetResource(resourceKey); Assert.NotNull(resource); Assert.NotEmpty(resource.GetInstrumentsSummary()); - Assert.Single(repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); + Assert.Single(repository.GetTraces(new GetTracesRequest { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); void AddLog() { @@ -234,7 +234,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() Assert.Single(logs.Items); Assert.Equal("log-resource2-456", logs.Items[0].Message); - var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Equal(2, traces.PagedResult.TotalItemCount); var resource1Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource1", "123")); @@ -246,7 +246,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() Assert.Single(resource2Logs.Items); Assert.Equal("log-resource2-456", resource2Logs.Items[0].Message); - var resource2Traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resource2Key, StartIndex = 0, Count = 10, Filters = [] }); + var resource2Traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [resource2Key], StartIndex = 0, Count = 10, Filters = [] }); Assert.Single(resource2Traces.PagedResult.Items); var resource2Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource2", "456")); @@ -277,7 +277,7 @@ public void ClearSelectedSignals_OtherResourcesRemainUnaffected() Assert.Contains(logs.Items, l => l.Message == "log-resource3-333"); Assert.DoesNotContain(logs.Items, l => l.Message == "log-resource2-222"); - var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Equal(2, traces.PagedResult.TotalItemCount); var resource1Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource1", "111")); @@ -318,7 +318,7 @@ public void ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(logs.Items); - var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(traces.PagedResult.Items); // Assert - Resources list is empty @@ -349,7 +349,7 @@ public void ClearSelectedSignals_PartialClear_ResourceNotRemoved() var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(logs.Items); - var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(traces.PagedResult.Items); var metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource1", "123")); @@ -391,7 +391,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_ThenNewSpans() // Act var watchTask = Task.Run(async () => { - await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = [] }, cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [], Filters = [] }, cts.Token)) { receivedSpans.Add(span); if (receivedSpans.Count == 1) @@ -453,7 +453,7 @@ public async Task WatchSpansAsync_CanBeCancelled() watchStarted.TrySetResult(); try { - await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = [] }, cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [], Filters = [] }, cts.Token)) { count++; } @@ -509,7 +509,7 @@ public async Task WatchLogsAsync_ReturnsExistingLogs_ThenNewLogs() // Act var watchTask = Task.Run(async () => { - await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = [] }, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKeys = [], Filters = [] }, cts.Token)) { receivedLogs.Add(log); if (receivedLogs.Count == 1) @@ -570,7 +570,7 @@ public async Task WatchLogsAsync_CanBeCancelled() watchStarted.TrySetResult(); try { - await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = [] }, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKeys = [], Filters = [] }, cts.Token)) { count++; } @@ -662,7 +662,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() // Act try { - await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = [] }, linkedCts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [], Filters = [] }, linkedCts.Token)) { receivedSpans.Add(span); if (receivedSpans.Count == expectedSpans) @@ -740,7 +740,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime_Across // Act try { - await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = [] }, linkedCts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [], Filters = [] }, linkedCts.Token)) { receivedSpans.Add(span); if (receivedSpans.Count == expectedSpans) @@ -809,7 +809,7 @@ public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() // Act - Watch only service1 try { - await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = new ResourceKey("service1", "inst1"), Filters = [] }, cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [new ResourceKey("service1", "inst1")], Filters = [] }, cts.Token)) { receivedSpans.Add(span); } @@ -868,7 +868,7 @@ public async Task WatchLogsAsync_FiltersAppliedWhenPushing() // Start watching with filter var watchTask = Task.Run(async () => { - await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = filters }, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKeys = [], Filters = filters }, cts.Token)) { receivedLogs.Add(log); if (receivedLogs.Count == 1) @@ -959,7 +959,7 @@ public async Task WatchLogsAsync_SeverityFilterApplied() // Start watching with severity filter var watchTask = Task.Run(async () => { - await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = filters }, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKeys = [], Filters = filters }, cts.Token)) { receivedLogs.Add(log); if (receivedLogs.Count == 1) @@ -1044,7 +1044,7 @@ public async Task WatchLogsAsync_TextFragmentsFilterApplied() { await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { - ResourceKey = null, + ResourceKeys = [], Filters = [], TextFragments = ["timeout", "error"] }, cts.Token)) @@ -1144,7 +1144,7 @@ public async Task WatchLogsAsync_DisabledFiltersAreIgnored() var watchTask = Task.Run(async () => { - await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = filters }, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKeys = [], Filters = filters }, cts.Token)) { receivedLogs.Add(log); if (receivedLogs.Count == 1) @@ -1241,7 +1241,7 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() var watchTask = Task.Run(async () => { - await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = filters }, cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [], Filters = filters }, cts.Token)) { receivedSpans.Add(span); if (receivedSpans.Count == 1) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 415e4b51728..354d946f105 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -78,7 +78,7 @@ public void AddTraces() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -136,7 +136,7 @@ public void AddTraces_SelfParent_Reject() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -190,7 +190,7 @@ public void AddTraces_MultipleSpansLoop_Reject() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -244,7 +244,7 @@ public void AddTraces_DuplicateTraceIds_Reject() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -314,7 +314,7 @@ public void AddTraces_Scope_Multiple() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -390,7 +390,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() var traces1 = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -431,7 +431,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() var traces2 = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -485,7 +485,7 @@ public void AddTraces_Spans_MultipleOutOrOrder() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -553,7 +553,7 @@ public void AddTraces_SpanEvents_ReturnData() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -629,7 +629,7 @@ public void AddTraces_SpanLinks_ReturnData() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -718,7 +718,7 @@ public void GetTraces_ReturnCopies() var traces1 = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -733,7 +733,7 @@ public void GetTraces_ReturnCopies() var traces2 = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -800,7 +800,7 @@ public void AddTraces_AttributeAndEventLimits_LimitsApplied() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -851,7 +851,7 @@ public void AddTraces_Links_BacklinksPopulated() AddTrace(repository, "1", s_testTime); var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -922,7 +922,7 @@ public void AddTraces_ExceedLimit_FirstInFirstOut() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -1028,7 +1028,7 @@ public void AddTraces_MultipleRootSpans_RootSpanIsEarliestWithoutParent() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1097,7 +1097,7 @@ public void GetTraces_MultipleInstances() var resourceKey = new ResourceKey("resource1", InstanceId: null); var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -1160,7 +1160,7 @@ public void GetTraces_AttributeFilters() // Act 1 var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [ @@ -1178,7 +1178,7 @@ public void GetTraces_AttributeFilters() // Act 2 traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [ @@ -1196,7 +1196,7 @@ public void GetTraces_AttributeFilters() // Act 3 traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [ @@ -1248,7 +1248,7 @@ public void GetTraces_KnownFilters(string name, string value) // Act 1 var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [ @@ -1263,7 +1263,7 @@ public void GetTraces_KnownFilters(string name, string value) // Act 2 traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [ @@ -1317,7 +1317,7 @@ public void GetTraces_FiltersPagingAndMaxDuration_ComputedFromAllMatchingTraces( // intentionally comes from all matching traces, not just the returned page. var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = new ResourceKey("resource1", InstanceId: null), + ResourceKeys = [new ResourceKey("resource1", InstanceId: null)], StartIndex = 1, Count = 1, Filters = @@ -1376,7 +1376,7 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() // Duration filter "> 50ms" should match because trace duration is 100ms. var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [new FieldTelemetryFilter { Field = KnownTraceFields.DurationField, Condition = FilterCondition.GreaterThan, Value = "50" }] @@ -1388,7 +1388,7 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() // even though the child span is only 5ms. traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resourceKey, + ResourceKeys = [resourceKey], StartIndex = 0, Count = 10, Filters = [new FieldTelemetryFilter { Field = KnownTraceFields.DurationField, Condition = FilterCondition.LessThan, Value = "10" }] @@ -1425,7 +1425,7 @@ public void GetTraces_NotEqualFilter_NonMatchingValue_ReturnsTrace() // Act - filter for key1 != "other_value" should return the trace since key1 is "value1" var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = new ResourceKey("resource1", InstanceId: null), + ResourceKeys = [new ResourceKey("resource1", InstanceId: null)], StartIndex = 0, Count = 10, Filters = [ @@ -1448,7 +1448,7 @@ public void AddTraces_OutOfOrder_FullName() var repository = CreateRepository(); var request = new GetTracesRequest { - ResourceKey = new ResourceKey("TestService", "TestId"), + ResourceKeys = [new ResourceKey("TestService", "TestId")], StartIndex = 0, Count = 10, Filters = [] @@ -1650,7 +1650,7 @@ public void AddTraces_SameResourceDifferentProperties_MultipleResourceViews() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resource.ResourceKey, + ResourceKeys = [resource.ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -1767,7 +1767,7 @@ public void RemoveTraces_All() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1845,7 +1845,7 @@ public void RemoveTraces_SelectedResource() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -1950,7 +1950,7 @@ public void RemoveTraces_MultipleSelectedResources() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -2041,7 +2041,7 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] @@ -2113,7 +2113,7 @@ public void AddTraces_HaveUninstrumentedPeers() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = uninstrumentedPeerApp.ResourceKey, + ResourceKeys = [uninstrumentedPeerApp.ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -2191,7 +2191,7 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() var traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = resources[0].ResourceKey, + ResourceKeys = [resources[0].ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -2232,7 +2232,7 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() traces = repository.GetTraces(new GetTracesRequest { - ResourceKey = uninstrumentedPeerApp.ResourceKey, + ResourceKeys = [uninstrumentedPeerApp.ResourceKey], StartIndex = 0, Count = 10, Filters = [] @@ -2327,7 +2327,7 @@ public void GetSpans_ReturnsAllSpans() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [] @@ -2368,7 +2368,7 @@ public void GetSpans_FilterByTraceId_ReturnsMatchingSpans() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [], @@ -2409,7 +2409,7 @@ public void GetSpans_FilterByHasError_ReturnsErrorSpansOnly() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [], @@ -2450,7 +2450,7 @@ public void GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [], @@ -2508,7 +2508,7 @@ public void GetSpans_FilterByResource_ReturnsMatchingSpans() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = serviceA.ResourceKey, + ResourceKeys = [serviceA.ResourceKey], StartIndex = 0, Count = int.MaxValue, Filters = [] @@ -2550,7 +2550,7 @@ public void GetSpans_FilterByDuration_ReturnsMatchingSpans() // Act - filter for spans with duration >= 100000ms (100s) var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = @@ -2598,7 +2598,7 @@ public void GetSpans_FilterByTextFragments_ReturnsMatchingSpans() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [], @@ -2640,7 +2640,7 @@ public void GetSpans_Pagination_ReturnsCorrectPage() // Act - get second page (skip 1, take 1) var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 1, Count = 1, Filters = [] @@ -2682,7 +2682,7 @@ public void GetSpans_CombinedFilters_ReturnsMatchingSpans() // Act - filter for error spans with "example.com" text var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [], @@ -2704,7 +2704,7 @@ public void GetSpans_EmptyRepository_ReturnsEmpty() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [] @@ -2743,7 +2743,7 @@ public void GetSpans_UnknownResource_ReturnsEmpty() // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = ResourceKey.Create("nonexistent", "unknown"), + ResourceKeys = [ResourceKey.Create("nonexistent", "unknown")], StartIndex = 0, Count = int.MaxValue, Filters = [] @@ -2789,7 +2789,7 @@ public void GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilte // Act var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = [], @@ -2828,7 +2828,7 @@ public void GetSpans_DisabledFiltersAreIgnored() // Enabled filter matches span name containing "span1", disabled filter would exclude everything var result = repository.GetSpans(new GetSpansRequest { - ResourceKey = null, + ResourceKeys = [], StartIndex = 0, Count = int.MaxValue, Filters = From 70d1d5e1e106f34829186f7f17a75d2b0fa69a88 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 2 Jun 2026 15:26:18 +0800 Subject: [PATCH 4/4] Add tests for multi-resource filtering in GetTraces, GetSpans, WatchSpans, WatchLogs --- .../TelemetryApiServiceTests.cs | 30 ++--- .../TelemetryRepositoryTests.cs | 107 ++++++++++++++++++ 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 64303258c85..3631c592220 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.Otlp.Storage; using Aspire.Otlp.Serialization; using Google.Protobuf.Collections; +using Microsoft.AspNetCore.InternalTesting; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; using Xunit; @@ -26,10 +27,9 @@ public async Task FollowSpansAsync_StreamsAllSpans() AddSpans(repository, count: 5); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var receivedItems = new List(); - await foreach (var item in service.FollowSpansAsync(null, null, null, null, cancellationToken: cts.Token)) + await foreach (var item in service.FollowSpansAsync(null, null, null, null).DefaultTimeout()) { receivedItems.Add(item); if (receivedItems.Count >= 5) @@ -48,10 +48,9 @@ public async Task FollowLogsAsync_StreamsAllLogs() AddLogs(repository, ["log1", "log2", "log3", "log4", "log5"]); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var receivedItems = new List(); - await foreach (var item in service.FollowLogsAsync(null, null, null, null, cts.Token)) + await foreach (var item in service.FollowLogsAsync(null, null, null, null, default).DefaultTimeout()) { receivedItems.Add(item); if (receivedItems.Count >= 5) @@ -87,17 +86,16 @@ public async Task FollowSpansAsync_WithInvalidResourceName_ReturnsNoSpans() AddSpans(repository, count: 1); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var receivedItems = new List(); try { - await foreach (var item in service.FollowSpansAsync(["nonexistent-service"], null, null, null, cancellationToken: cts.Token)) + await foreach (var item in service.FollowSpansAsync(["nonexistent-service"], null, null, null).DefaultTimeout()) { receivedItems.Add(item); } } - catch (OperationCanceledException) + catch (TimeoutException) { } @@ -111,17 +109,16 @@ public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() AddLogs(repository, ["log1"]); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var receivedItems = new List(); try { - await foreach (var item in service.FollowLogsAsync(["nonexistent-service"], null, null, null, cts.Token)) + await foreach (var item in service.FollowLogsAsync(["nonexistent-service"], null, null, null, default).DefaultTimeout()) { receivedItems.Add(item); } } - catch (OperationCanceledException) + catch (TimeoutException) { } @@ -169,10 +166,9 @@ public async Task FollowSpansAsync_WithTraceIdFilter_MatchesShortenedIds() ]); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var receivedItems = new List(); - await foreach (var streamedItem in service.FollowSpansAsync(null, "7472616", null, null, cancellationToken: cts.Token)) + await foreach (var streamedItem in service.FollowSpansAsync(null, "7472616", null, null).DefaultTimeout()) { receivedItems.Add(streamedItem); break; @@ -650,10 +646,9 @@ public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() { var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // Start enumerating - MoveNextAsync will block until data arrives. - var enumerator = service.FollowSpansAsync(["service1"], null, null, null, cancellationToken: cts.Token).GetAsyncEnumerator(cts.Token); + var enumerator = service.FollowSpansAsync(["service1"], null, null, null).GetAsyncEnumerator(); var moveNextTask = enumerator.MoveNextAsync(); // The task should not complete yet because the resource doesn't exist. @@ -662,7 +657,7 @@ public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() // Now add spans for the resource - this should unblock the stream. AddSpans(repository, count: 1); - Assert.True(await moveNextTask); + Assert.True(await moveNextTask.DefaultTimeout()); Assert.NotNull(enumerator.Current); await enumerator.DisposeAsync(); @@ -673,10 +668,9 @@ public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() { var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); var service = CreateService(repository); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // Start enumerating - MoveNextAsync will block until data arrives. - var enumerator = service.FollowLogsAsync(["service1"], null, null, null, cts.Token).GetAsyncEnumerator(cts.Token); + var enumerator = service.FollowLogsAsync(["service1"], null, null, null, default).GetAsyncEnumerator(); var moveNextTask = enumerator.MoveNextAsync(); // The task should not complete yet because the resource doesn't exist. @@ -685,7 +679,7 @@ public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() // Now add logs for the resource - this should unblock the stream. AddLogs(repository, ["hello"]); - Assert.True(await moveNextTask); + Assert.True(await moveNextTask.DefaultTimeout()); Assert.NotNull(enumerator.Current); await enumerator.DisposeAsync(); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index fd5db9fcf6e..fc0be1546c6 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -824,6 +824,113 @@ public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() Assert.Contains("span1", receivedSpans[0].Name); } + [Fact] + public void GetTraces_MultipleResourceKeys_ReturnsMatchingTracesOnly() + { + var repository = CreateRepository(); + + AddTestData(repository, "resource1", "inst1"); + AddTestData(repository, "resource2", "inst2"); + AddTestData(repository, "resource3", "inst3"); + + var key1 = new ResourceKey("resource1", "inst1"); + var key2 = new ResourceKey("resource2", "inst2"); + + // Act - query with two resource keys + var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [key1, key2], StartIndex = 0, Count = 10, Filters = [] }); + + // Assert - should return traces from both resource1 and resource2, but not resource3 + Assert.Collection(traces.PagedResult.Items, + t => AssertId("resource2-inst2", t.TraceId), + t => AssertId("resource1-inst1", t.TraceId)); + } + + [Fact] + public void GetSpans_MultipleResourceKeys_ReturnsMatchingSpansOnly() + { + var repository = CreateRepository(); + + AddTestData(repository, "service1", "inst1"); + AddTestData(repository, "service2", "inst2"); + AddTestData(repository, "service3", "inst3"); + + // Act - query spans for service1 and service2 only + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKeys = [new ResourceKey("service1", "inst1"), new ResourceKey("service2", "inst2")], + StartIndex = 0, + Count = 10, + Filters = [] + }); + + // Assert - should return spans from service1 and service2, not service3 + Assert.Collection(result.PagedResult.Items, + s => Assert.Equal("Test span. Id: service2-inst2-1", s.Name), + s => Assert.Equal("Test span. Id: service1-inst1-1", s.Name)); + } + + [Fact] + public async Task WatchSpansAsync_MultipleResourceKeys_FiltersCorrectly() + { + var repository = CreateRepository(); + + AddTestData(repository, "service1", "inst1"); + AddTestData(repository, "service2", "inst2"); + AddTestData(repository, "service3", "inst3"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var receivedSpans = new List(); + + // Act - Watch service1 and service2 (not service3) + try + { + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKeys = [new ResourceKey("service1", "inst1"), new ResourceKey("service2", "inst2")], Filters = [] }, cts.Token)) + { + receivedSpans.Add(span); + } + } + catch (OperationCanceledException) + { + // Expected + } + + // Assert - should receive spans from service1 and service2, not service3 + Assert.Collection(receivedSpans, + s => Assert.Equal("Test span. Id: service2-inst2-1", s.Name), + s => Assert.Equal("Test span. Id: service1-inst1-1", s.Name)); + } + + [Fact] + public async Task WatchLogsAsync_MultipleResourceKeys_FiltersCorrectly() + { + var repository = CreateRepository(); + + AddTestData(repository, "service1", "inst1"); + AddTestData(repository, "service2", "inst2"); + AddTestData(repository, "service3", "inst3"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var receivedLogs = new List(); + + // Act - Watch service1 and service2 (not service3) + try + { + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKeys = [new ResourceKey("service1", "inst1"), new ResourceKey("service2", "inst2")], Filters = [] }, cts.Token)) + { + receivedLogs.Add(log); + } + } + catch (OperationCanceledException) + { + // Expected + } + + // Assert - should receive logs from service1 and service2, not service3 + Assert.Collection(receivedLogs, + l => Assert.Equal("log-service2-inst2", l.Message), + l => Assert.Equal("log-service1-inst1", l.Message)); + } + [Fact] public async Task WatchLogsAsync_FiltersAppliedWhenPushing() {