diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index ffb32a8cb5e..0dc8d3450b5 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; @@ -45,22 +46,18 @@ 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 - var allSpans = new List(); - foreach (var resourceKey in resourceKeys) + // Get spans for all resource keys (empty list means no filter / all resources) + 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; @@ -102,20 +99,16 @@ 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 - var allTraces = new List(); - foreach (var resourceKey in resourceKeys) + // Get traces for all resource keys (empty list means no filter / all resources) + 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; @@ -220,22 +213,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; @@ -266,18 +254,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 = []; @@ -286,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 { Count: 1 } ? resourceKeys[0] : null, + ResourceKeys = resourceKeys, Filters = spanFilters, TraceId = traceId, HasError = hasError, @@ -296,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()) == true)) - { - continue; - } - // Use compact JSON for NDJSON streaming (no indentation) yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false); } @@ -320,18 +291,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(); @@ -365,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 { Count: 1 } ? resourceKeys[0] : null, + ResourceKeys = resourceKeys, Filters = filters, TextFragments = searchTextFragments }; @@ -373,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()) == true)) - { - continue; - } - var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([log]); yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.DefaultOptions); } @@ -562,27 +516,64 @@ 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) + { + 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 }); + 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. - /// 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..23cf0a4245c 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 = [] @@ -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 = [] @@ -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/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/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/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 2dadbbd6a4d..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 { - ResourceKey = request.ResourceKey, + 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 4cb03a3de0c..47d6819c498 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 = @@ -616,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) { @@ -708,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 80ce1381a46..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 = [] @@ -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..73c39f14a44 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 = [] @@ -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/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 37d4cf903fa..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; @@ -645,6 +641,50 @@ private static List GetAllSpans(TelemetryApiResponse result) .ToList() ?? []; } + [Fact] + public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() + { + var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); + var service = CreateService(repository); + + // Start enumerating - MoveNextAsync will block until data arrives. + 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. + Assert.False(moveNextTask.IsCompleted); + + // Now add spans for the resource - this should unblock the stream. + AddSpans(repository, count: 1); + + Assert.True(await moveNextTask.DefaultTimeout()); + Assert.NotNull(enumerator.Current); + + await enumerator.DisposeAsync(); + } + + [Fact] + public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() + { + var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); + var service = CreateService(repository); + + // Start enumerating - MoveNextAsync will block until data arrives. + 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. + Assert.False(moveNextTask.IsCompleted); + + // Now add logs for the resource - this should unblock the stream. + AddLogs(repository, ["hello"]); + + Assert.True(await moveNextTask.DefaultTimeout()); + Assert.NotNull(enumerator.Current); + + await enumerator.DisposeAsync(); + } + // 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 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..fc0be1546c6 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -38,9 +38,9 @@ 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); + Assert.Empty(repository.GetTraces(new GetTracesRequest { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); pauseManager.SetStructuredLogsPaused(false); pauseManager.SetMetricsPaused(false); @@ -49,11 +49,11 @@ 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()); - 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() { @@ -230,11 +230,11 @@ 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); - 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")); @@ -242,11 +242,11 @@ 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); - 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")); @@ -271,13 +271,13 @@ 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"); 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")); @@ -315,10 +315,10 @@ 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 = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(traces.PagedResult.Items); // Assert - Resources list is empty @@ -346,10 +346,10 @@ 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 = [] }); + 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); } @@ -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() { @@ -868,7 +975,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 +1066,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 +1151,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 +1251,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 +1348,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 =