diff --git a/.agents/skills/aspire/references/monitoring.md b/.agents/skills/aspire/references/monitoring.md index 773f13495dc..b68fb799a87 100644 --- a/.agents/skills/aspire/references/monitoring.md +++ b/.agents/skills/aspire/references/monitoring.md @@ -29,7 +29,9 @@ aspire otel traces [resource] --format Json aspire otel spans [resource] --format Json aspire otel logs --trace-id --format Json aspire otel logs [resource] --search "connection timeout" -aspire otel spans [resource] --search "/api/products" +aspire otel traces [resource] --search "/api/products" +aspire otel logs [resource] --search "severity:error" +aspire otel spans [resource] --search "@http.method:GET duration:>100" aspire logs [resource] aspire logs [resource] --search "error" ``` @@ -44,6 +46,43 @@ Keep these points in mind: - `[resource]` is optional. Include it to filter results to a single resource; omit it to see all resources. - `--search` can be combined with other options like `--format Json`, `--trace-id`, `--limit`, and resource filtering. +## Filtering + +The `--search` option filters output by matching text against log/span content. + +- Multiple words are AND'd — all fragments must match. +- Use `"quoted phrases"` for multi-word fragments: `--search "\"connection timeout\""`. +- Qualifiers support quoted values: `--search "message:\"connection failed\""`. + +### Console Logs (`aspire logs --search`) + +Matches against log line text content and resource name. Only free-text is supported (no structured qualifiers). + +```bash +aspire logs redis --search "timeout" +aspire logs --follow --search "\"connection error\"" +``` + +### Structured Telemetry (`aspire otel logs/traces/spans --search`) + +Supports free-text and structured qualifiers in a single query. + +**Free-text** matches against message, resource name, scope, trace/span IDs, severity/status, and all attribute keys/values. + +**Structured qualifiers** filter on specific fields with `key:value` syntax: + +- Log keys: `severity`, `resource`, `scope`, `message`, `trace-id`, `span-id`, `event` +- Span/Trace keys: `name`, `resource`, `scope`, `status`, `kind`, `trace-id`, `span-id`, `duration` +- Custom attributes: `@http.method:GET`, `@db.system:redis` +- Negation: `-severity:debug`, `-@db.system:redis` +- Comparison: `duration:>100`, `duration:>=50` + +```bash +aspire otel logs --search "severity:error \"connection failed\"" +aspire otel spans --search "@http.method:GET duration:>100 status:error" +aspire otel logs --search "resource:api -severity:debug" +``` + ## Scenario: I Need A Sharable Diagnostics Bundle Use this command when you need a portable handoff artifact for deeper analysis or for another person to inspect offline. diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index c1a9b15f687..eb900a8ab87 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -146,6 +146,7 @@ + diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 71949e7490a..cdbbadef85e 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -485,11 +485,22 @@ private static string FormatTimestamp(DateTime timestamp) private static bool MatchesSearch(LogEntry entry, string search) { + var fragments = SearchTextParser.ParseFragments(search); + if (fragments.Length == 0) + { + return true; + } + var content = entry.RawContent ?? entry.Content ?? string.Empty; var prefix = entry.ResourcePrefix ?? string.Empty; - return content.Contains(search, StringComparisons.FullTextSearch) || - prefix.Contains(search, StringComparisons.FullTextSearch) || - AnsiParser.StripControlSequences(content).Contains(search, StringComparisons.FullTextSearch); + var stripped = AnsiParser.StripControlSequences(content); + + // Console logs have no structured attributes, so all search text is treated as + // free-text fragments matched against the log content and resource name. + return SearchTextParser.MatchesAllFragments(fragments, (content, prefix, stripped), static (state, fragment) => + state.content.Contains(fragment, StringComparisons.FullTextSearch) || + state.prefix.Contains(fragment, StringComparisons.FullTextSearch) || + state.stripped.Contains(fragment, StringComparisons.FullTextSearch)); } private static string ResolveResourceName(string resourceName, IEnumerable snapshots) diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 97c595d430d..0623c8dc698 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -102,14 +102,6 @@ internal static class TelemetryCommandHelpers Description = TelemetryCommandStrings.SearchOptionDescription }; - /// - /// Minimum span duration option for spans and traces commands. - /// - internal static Option CreateMinimumDurationOption() => new("--min-duration", "--min-duration-ms") - { - Description = TelemetryCommandStrings.MinimumDurationOptionDescription - }; - /// /// Dashboard URL option for connecting directly to a standalone dashboard. /// diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 73d22425938..957e96e363f 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -42,7 +42,6 @@ internal sealed class TelemetrySpansCommand : BaseCommand private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); private static readonly Option s_searchOption = TelemetryCommandHelpers.CreateSearchOption(); - private static readonly Option s_minimumDurationOption = TelemetryCommandHelpers.CreateMinimumDurationOption(); public TelemetrySpansCommand( IInteractionService interactionService, @@ -75,7 +74,6 @@ public TelemetrySpansCommand( Options.Add(s_dashboardUrlOption); Options.Add(s_apiKeyOption); Options.Add(s_searchOption); - Options.Add(s_minimumDurationOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -92,7 +90,6 @@ protected override async Task ExecuteAsync(ParseResult parseResul var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); var apiKey = parseResult.GetValue(s_apiKeyOption); var search = parseResult.GetValue(s_searchOption); - var minimumDuration = parseResult.GetValue(s_minimumDurationOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -108,7 +105,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.FromExitCode(dashboardApi.ExitCode); } - return CommandResult.FromExitCode(await FetchSpansAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, traceId, hasError, limit, follow, format, dashboardOnly: dashboardUrl is not null, dashboardApi.DashboardUrl!, search, minimumDuration, cancellationToken)); + return CommandResult.FromExitCode(await FetchSpansAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, traceId, hasError, limit, follow, format, dashboardOnly: dashboardUrl is not null, dashboardApi.DashboardUrl!, search, cancellationToken)); } private async Task FetchSpansAsync( @@ -123,7 +120,6 @@ private async Task FetchSpansAsync( bool dashboardOnly, string dashboardUrl, string? search, - double? minimumDuration, CancellationToken cancellationToken) { try @@ -147,7 +143,7 @@ private async Task FetchSpansAsync( // Build URL with query parameters int? effectiveLimit = (limit.HasValue && !follow) ? limit.Value : null; - var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, traceId: traceId, hasError: hasError, limit: effectiveLimit, follow: follow ? true : null, search: search, minDurationMs: minimumDuration); + var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, traceId: traceId, hasError: hasError, limit: effectiveLimit, follow: follow ? true : null, search: search); _logger.LogDebug("Fetching spans from {Url}", url); diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index 5dff04eb633..a898f3f104a 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -42,7 +42,6 @@ internal sealed class TelemetryTracesCommand : BaseCommand private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); private static readonly Option s_searchOption = TelemetryCommandHelpers.CreateSearchOption(); - private static readonly Option s_minimumDurationOption = TelemetryCommandHelpers.CreateMinimumDurationOption(); public TelemetryTracesCommand( IInteractionService interactionService, @@ -74,7 +73,6 @@ public TelemetryTracesCommand( Options.Add(s_dashboardUrlOption); Options.Add(s_apiKeyOption); Options.Add(s_searchOption); - Options.Add(s_minimumDurationOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -90,7 +88,6 @@ protected override async Task ExecuteAsync(ParseResult parseResul var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); var apiKey = parseResult.GetValue(s_apiKeyOption); var search = parseResult.GetValue(s_searchOption); - var minimumDuration = parseResult.GetValue(s_minimumDurationOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -110,11 +107,11 @@ protected override async Task ExecuteAsync(ParseResult parseResul { if (!string.IsNullOrEmpty(traceId)) { - return CommandResult.FromExitCode(await FetchSingleTraceAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, traceId, format, dashboardApi.DashboardUrl!, minimumDuration, cancellationToken)); + return CommandResult.FromExitCode(await FetchSingleTraceAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, traceId, format, dashboardApi.DashboardUrl!, cancellationToken)); } else { - return CommandResult.FromExitCode(await FetchTracesAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, hasError, limit, format, dashboardApi.DashboardUrl!, search, minimumDuration, cancellationToken)); + return CommandResult.FromExitCode(await FetchTracesAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, hasError, limit, format, dashboardApi.DashboardUrl!, search, cancellationToken)); } } catch (HttpRequestException ex) @@ -132,7 +129,6 @@ private async Task FetchSingleTraceAsync( string traceId, OutputFormat format, string dashboardUrl, - double? minimumDuration, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -144,7 +140,7 @@ private async Task FetchSingleTraceAsync( // Pre-resolve colors so assignment is deterministic regardless of data order TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); - var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId, minDurationMs: minimumDuration); + var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId); _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url); @@ -194,7 +190,6 @@ private async Task FetchTracesAsync( OutputFormat format, string dashboardUrl, string? search, - double? minimumDuration, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -214,7 +209,7 @@ private async Task FetchTracesAsync( // Pre-resolve colors so assignment is deterministic regardless of data order TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); - var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, hasError: hasError, limit: limit, search: search, minDurationMs: minimumDuration); + var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, hasError: hasError, limit: limit, search: search); _logger.LogDebug("Fetching traces from {Url}", url); diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index 581878a18d0..9c9f08a965f 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -84,13 +84,23 @@ public override async ValueTask CallToolAsync(CallToolContext co var entries = logEntries.GetEntries().ToList(); - // Apply full-text search filter on log content + // Console logs have no structured attributes, so all search text is treated as + // free-text fragments matched against the log content and resource name. if (!string.IsNullOrEmpty(search)) { - entries = entries.Where(e => - (e.Content is not null && e.Content.Contains(search, StringComparisons.FullTextSearch)) || - (e.RawContent is not null && e.RawContent.Contains(search, StringComparisons.FullTextSearch))) - .ToList(); + var fragments = SearchTextParser.ParseFragments(search); + if (fragments.Length > 0) + { + entries = entries.Where(e => + SearchTextParser.MatchesAllFragments( + fragments, + (e.Content ?? string.Empty, e.RawContent ?? string.Empty, e.ResourcePrefix ?? string.Empty), + static (state, fragment) => + state.Item1.Contains(fragment, StringComparisons.FullTextSearch) || + state.Item2.Contains(fragment, StringComparisons.FullTextSearch) || + state.Item3.Contains(fragment, StringComparisons.FullTextSearch))) + .ToList(); + } } // When search is applied, total reflects matching entries. Otherwise, use the diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs index 6c23abe4b2e..7c9e79a199d 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -176,15 +176,6 @@ internal static string SearchOptionDescription { return ResourceManager.GetString("SearchOptionDescription", resourceCulture); } } - - /// - /// Looks up a localized string similar to Filter by minimum span duration in milliseconds. - /// - internal static string MinimumDurationOptionDescription { - get { - return ResourceManager.GetString("MinimumDurationOptionDescription", resourceCulture); - } - } /// /// Looks up a localized string similar to The --limit value must be a positive number.. diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index 6045b7e002e..769ad3d5811 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -156,9 +156,6 @@ Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - - Filter by minimum span duration in milliseconds - The --limit value must be a positive number. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index 461e7f0f28c..45cc712dcd8 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -162,11 +162,6 @@ Umožňuje zobrazit strukturované protokoly z rozhraní API telemetrie řídicího panelu. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtrovat podle názvu prostředku. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index a1d5fa054bd..9acf8e681c5 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -162,11 +162,6 @@ Zeigen Sie strukturierte Protokolle aus der Dashboard-Telemetrie-API an. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtern Sie nach Ressourcennamen. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index 3ff25ec63cf..462e69d593d 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -162,11 +162,6 @@ Ver los registros estructurados desde la API de telemetría del panel. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtrar por nombre de recurso. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index e792591df9e..13a0312da4b 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -162,11 +162,6 @@ Afficher les journaux structurés via l’API de télémétrie du tableau de bord. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtrer par nom de ressource. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 73a9b4fc68a..87223e646f4 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -162,11 +162,6 @@ Visualizza i log strutturati dall'API di telemetria del dashboard. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtrare per nome della risorsa. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index f4898a7863b..a0e72c5788b 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -162,11 +162,6 @@ ダッシュボード テレメトリ API から構造化ログを表示します。 - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name リソース名でフィルター処理します。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 4a9f91df09e..c16e5ceb3dc 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -162,11 +162,6 @@ 대시보드 원격 분석 API에서 구조화된 로그를 확인합니다. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name 리소스 이름으로 필터링 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 8692d87702f..37c7c0792dd 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -162,11 +162,6 @@ Wyświetl strukturalne logi z interfejsu API telemetrii pulpitu nawigacyjnego. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtruj według nazwy zasobu. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index bed3f92054b..a892ac03ba3 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -162,11 +162,6 @@ Veja os logs estruturados da API de telemetria do Painel. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Filtre por nome de recurso. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 22532376645..c52093d147c 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -162,11 +162,6 @@ Просмотр структурированных журналов через API телеметрии панели мониторинга. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Фильтровать по имени ресурса. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index ae951926aad..36c535956ce 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -162,11 +162,6 @@ Pano telemetri API'sinden yapılandırılmış günlükleri görüntüleyin. - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name Kaynak adına göre filtreleyin. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index e903bac4030..1a13eb9bdbf 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -162,11 +162,6 @@ 查看仪表板遥测 API 中的结构化日志。 - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name 按资源名称筛选。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 91c10dbe78a..9efc6c0d3f6 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -162,11 +162,6 @@ 從儀表板遙測 API 檢視結構化記錄。 - - Filter by minimum span duration in milliseconds - Filter by minimum span duration in milliseconds - - Filter by resource name 依資源名稱篩選。 diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index f6642666f44..ffb32a8cb5e 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -29,7 +29,7 @@ internal sealed class TelemetryApiService( /// Returns null if resource filter is specified but not found. /// Supports multiple resource names. /// - public TelemetryApiResponse? GetSpans(string[]? resourceNames, string? traceId, bool? hasError, int? limit, string? search = null, double? minDurationMs = null) + public TelemetryApiResponse? GetSpans(string[]? resourceNames, string? traceId, bool? hasError, int? limit, string? search = null) { // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); @@ -41,56 +41,31 @@ internal sealed class TelemetryApiService( var effectiveLimit = limit ?? DefaultLimit; + // Convert structured search qualifiers into TelemetryFilter objects for repository-level filtering + 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) { - var result = telemetryRepository.GetTraces(new GetTracesRequest + var result = telemetryRepository.GetSpans(new GetSpansRequest { ResourceKey = resourceKey, StartIndex = 0, Count = int.MaxValue, - Filters = [], - FilterText = string.Empty + Filters = spanFilters, + TraceId = traceId, + HasError = hasError, + TextFragments = searchTextFragments }); - allSpans.AddRange(result.PagedResult.Items.SelectMany(t => t.Spans)); - } - - var spans = allSpans; - - // TODO: Consider adding an ExcludeFromApi property on resources in the future. - // Currently the API returns all telemetry data for all resources. - - // Filter by traceId - if (!string.IsNullOrEmpty(traceId)) - { - spans = spans.Where(s => OtlpHelpers.MatchTelemetryId(traceId, s.TraceId)).ToList(); - } - - // Filter by hasError - if (hasError == true) - { - spans = spans.Where(s => s.Status == OtlpSpanStatusCode.Error).ToList(); - } - else if (hasError == false) - { - spans = spans.Where(s => s.Status != OtlpSpanStatusCode.Error).ToList(); + allSpans.AddRange(result.PagedResult.Items); } - // Apply full-text search across all span fields - if (!string.IsNullOrEmpty(search)) - { - spans = spans.Where(s => MatchesSearch(s, search)).ToList(); - } - - if (GetMinimumDuration(minDurationMs) is { } minimumDuration) - { - spans = spans.Where(s => s.Duration >= minimumDuration).ToList(); - } - - var totalCount = spans.Count; + var totalCount = allSpans.Count; // Apply limit (take from end for most recent) + var spans = allSpans; if (spans.Count > effectiveLimit) { spans = spans.Skip(spans.Count - effectiveLimit).ToList(); @@ -111,7 +86,7 @@ internal sealed class TelemetryApiService( /// Returns null if resource filter is specified but not found. /// Supports multiple resource names. /// - public TelemetryApiResponse? GetTraces(string[]? resourceNames, bool? hasError, int? limit, string? search = null, double? minDurationMs = null) + public TelemetryApiResponse? GetTraces(string[]? resourceNames, bool? hasError, int? limit, string? search = null) { // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); @@ -123,6 +98,10 @@ internal sealed class TelemetryApiService( var effectiveLimit = limit ?? DefaultTraceLimit; + // Convert structured search qualifiers into TelemetryFilter objects for repository-level filtering + 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) @@ -132,8 +111,8 @@ internal sealed class TelemetryApiService( ResourceKey = resourceKey, StartIndex = 0, Count = int.MaxValue, - Filters = [], - FilterText = string.Empty + Filters = traceFilters, + TextFragments = searchTextFragments }); allTraces.AddRange(result.PagedResult.Items); } @@ -150,59 +129,16 @@ internal sealed class TelemetryApiService( traces = traces.Where(t => !t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList(); } - // Apply full-text search: a trace matches if its name matches or any span within it matches - if (!string.IsNullOrEmpty(search)) - { - traces = traces.Where(t => - t.FullName.Contains(search, StringComparisons.FullTextSearch) || - t.Spans.Any(s => MatchesSearch(s, search))).ToList(); - } + var totalCount = traces.Count; - List spans; - int totalCount; - int returnedCount; - - if (GetMinimumDuration(minDurationMs) is { } minimumDuration) + // Apply limit (take from end for most recent) + if (traces.Count > effectiveLimit) { - var returnedTraceSpans = new Queue>(); - totalCount = 0; - - foreach (var trace in traces) - { - var matchingSpans = GetSpansMatchingMinimumDuration(trace.Spans, minimumDuration).ToList(); - if (matchingSpans.Count == 0) - { - continue; - } - - totalCount++; - - if (effectiveLimit > 0) - { - returnedTraceSpans.Enqueue(matchingSpans); - if (returnedTraceSpans.Count > effectiveLimit) - { - returnedTraceSpans.Dequeue(); - } - } - } - - spans = returnedTraceSpans.SelectMany(s => s).ToList(); - returnedCount = returnedTraceSpans.Count; + traces = traces.Skip(traces.Count - effectiveLimit).ToList(); } - else - { - totalCount = traces.Count; - // Apply limit (take from end for most recent) - if (traces.Count > effectiveLimit) - { - traces = traces.Skip(traces.Count - effectiveLimit).ToList(); - } - - spans = traces.SelectMany(t => t.Spans).ToList(); - returnedCount = traces.Count; - } + var spans = traces.SelectMany(t => t.Spans).ToList(); + var returnedCount = traces.Count; var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); @@ -218,7 +154,7 @@ internal sealed class TelemetryApiService( /// Gets a specific trace by ID with all spans in OTLP format. /// Returns null if trace not found. /// - public TelemetryApiResponse? GetTrace(string traceId, double? minDurationMs = null) + public TelemetryApiResponse? GetTrace(string traceId) { var trace = telemetryRepository.GetTrace(traceId); if (trace is null) @@ -226,7 +162,7 @@ internal sealed class TelemetryApiService( return null; } - var spans = GetSpansMatchingMinimumDuration(trace.Spans, GetMinimumDuration(minDurationMs)).ToList(); + var spans = trace.Spans.ToList(); var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); @@ -282,6 +218,8 @@ 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) @@ -291,19 +229,14 @@ internal sealed class TelemetryApiService( ResourceKey = resourceKey, StartIndex = 0, Count = int.MaxValue, - Filters = filters + Filters = filters, + TextFragments = searchTextFragments }); allLogs.AddRange(result.Items); } var logs = allLogs; - // Apply full-text search across all log fields - if (!string.IsNullOrEmpty(search)) - { - logs = logs.Where(l => MatchesSearch(l, search)).ToList(); - } - var totalCount = logs.Count; // Apply limit (take from end for most recent) @@ -331,7 +264,6 @@ public async IAsyncEnumerable FollowSpansAsync( string? traceId, bool? hasError, string? search, - double? minDurationMs = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Resolve resource keys @@ -342,43 +274,32 @@ public async IAsyncEnumerable FollowSpansAsync( var hasResourceFilter = resourceNames is { Length: > 0 }; var invalidResourceFilter = hasResourceFilter && resourceKeys is null; - var minimumDuration = GetMinimumDuration(minDurationMs); - - // Watch all spans and filter - await foreach (var span in telemetryRepository.WatchSpansAsync(null, cancellationToken).ConfigureAwait(false)) + if (invalidResourceFilter) { - // If resource filter is invalid (resources specified but not found), skip all - if (invalidResourceFilter) - { - continue; - } - - // Filter by resource if specified - if (resourceKeys is { Count: > 0 } && !resourceKeys.Any(k => k is null) && - !resourceKeys.Any(k => k?.EqualsCompositeName(span.Source.ResourceKey.GetCompositeName()) == true)) - { - continue; - } - - // Apply traceId filter - if (!string.IsNullOrEmpty(traceId) && !OtlpHelpers.MatchTelemetryId(traceId, span.TraceId)) - { - continue; - } + yield break; + } - // Apply hasError filter - if (hasError.HasValue && (span.Status == OtlpSpanStatusCode.Error) != hasError.Value) - { - continue; - } + // Convert structured search qualifiers into TelemetryFilter objects for per-span filtering + List spanFilters = []; + var searchTextFragments = ParseAndApplySearchFilters(search, spanFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); - // Apply full-text search filter - if (!string.IsNullOrEmpty(search) && !MatchesSearch(span, search)) - { - continue; - } + // Build the watch request with all filters pushed into the repository + var watchRequest = new WatchSpansRequest + { + ResourceKey = resourceKeys is { Count: 1 } ? resourceKeys[0] : null, + Filters = spanFilters, + TraceId = traceId, + HasError = hasError, + TextFragments = searchTextFragments + }; - if (minimumDuration is { } duration && span.Duration < duration) + // 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; } @@ -407,6 +328,11 @@ public async IAsyncEnumerable FollowLogsAsync( var hasResourceFilter = resourceNames is { Length: > 0 }; var invalidResourceFilter = hasResourceFilter && resourceKeys is null; + if (invalidResourceFilter) + { + yield break; + } + // Build filters var filters = new List(); @@ -434,28 +360,27 @@ public async IAsyncEnumerable FollowLogsAsync( } } - // Watch all logs and filter by resource - await foreach (var log in telemetryRepository.WatchLogsAsync(null, filters, cancellationToken).ConfigureAwait(false)) + var searchTextFragments = ParseAndApplySearchFilters(search, filters, AddLogFiltersFromQualifiers, key => ResolveLogFieldKey(key) is not null); + + // Build the watch request with all filters pushed into the repository + var watchRequest = new WatchLogsRequest { - // If resource filter is invalid (resources specified but not found), skip all - if (invalidResourceFilter) - { - continue; - } + ResourceKey = resourceKeys is { Count: 1 } ? resourceKeys[0] : null, + Filters = filters, + TextFragments = searchTextFragments + }; - // Filter by resource if specified - if (resourceKeys is { Count: > 0 } && !resourceKeys.Any(k => k is null) && + // 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; } - // Apply full-text search filter - if (!string.IsNullOrEmpty(search) && !MatchesSearch(log, search)) - { - continue; - } - var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([log]); yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.DefaultOptions); } @@ -482,156 +407,160 @@ public ResourceInfoJson[] GetResources() } /// - /// Checks whether a log entry matches a full-text search string. - /// Searches across message, attribute values, scope name, event name, trace ID, span ID, - /// severity, and resource name using case-insensitive contains matching. + /// Parses the search string and appends the resulting qualifier-based filters to . + /// Returns the extracted free-text fragments, or null if no search text was provided. /// - private static bool MatchesSearch(OtlpLogEntry log, string search) + private static string[]? ParseAndApplySearchFilters( + string? search, + List filters, + Action> addFilters, + Func isKnownKey) { - if (log.Message.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (log.Scope.Name.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (log.EventName is not null && log.EventName.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (log.TraceId.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (log.SpanId.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (log.Severity.ToString().Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (log.ResourceView.Resource.ResourceName.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - foreach (var attribute in log.Attributes) + if (!string.IsNullOrEmpty(search)) { - if (attribute.Key.Contains(search, StringComparisons.FullTextSearch) || - attribute.Value.Contains(search, StringComparisons.FullTextSearch)) + var parsedSearch = SearchTextParser.ParseSearch(search, isKnownKey); + if (!parsedSearch.IsEmpty) { - return true; + addFilters(parsedSearch, filters); + return parsedSearch.TextFragments; } } - return false; + return null; } /// - /// Checks whether a span matches a full-text search string. - /// Searches across name, attribute values, span ID, trace ID, status message, - /// scope name, event names, and resource name using case-insensitive contains matching. + /// Converts search qualifiers into objects for log filtering. + /// Maps user-facing qualifier keys (e.g., "severity", "message") to internal field constants. + /// Attribute qualifiers (@-prefixed) pass the key directly for attribute fallback lookup. /// - private static bool MatchesSearch(OtlpSpan span, string search) + private static void AddLogFiltersFromQualifiers(SearchFilter parsedSearch, List filters) { - if (span.Name.Contains(search, StringComparisons.FullTextSearch)) + foreach (var qualifier in parsedSearch.Qualifiers) { - return true; - } - - if (span.SpanId.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } - - if (span.TraceId.Contains(search, StringComparisons.FullTextSearch)) - { - return true; - } + var field = qualifier.IsAttribute ? qualifier.Key : ResolveLogFieldKey(qualifier.Key); + if (field is null) + { + // Unknown bare qualifier key — skip (treated as text at a higher level if needed) + continue; + } - if (span.StatusMessage is not null && span.StatusMessage.Contains(search, StringComparisons.FullTextSearch)) - { - return true; + filters.Add(new FieldTelemetryFilter + { + Field = field, + Value = qualifier.Value, + Condition = ToFilterCondition(qualifier.Operator, negated: false) + }); } - if (span.Scope.Name.Contains(search, StringComparisons.FullTextSearch)) + foreach (var qualifier in parsedSearch.NegatedQualifiers) { - return true; - } + var field = qualifier.IsAttribute ? qualifier.Key : ResolveLogFieldKey(qualifier.Key); + if (field is null) + { + continue; + } - if (span.Source.Resource.ResourceName.Contains(search, StringComparisons.FullTextSearch)) - { - return true; + filters.Add(new FieldTelemetryFilter + { + Field = field, + Value = qualifier.Value, + Condition = ToFilterCondition(qualifier.Operator, negated: true) + }); } + } - if (span.Status.ToString().Contains(search, StringComparisons.FullTextSearch)) + /// + /// Converts search qualifiers into objects for span/trace filtering. + /// Maps user-facing qualifier keys (e.g., "status", "duration") to internal field constants. + /// Attribute qualifiers (@-prefixed) pass the key directly for attribute fallback lookup. + /// + private static void AddSpanFiltersFromQualifiers(SearchFilter parsedSearch, List filters) + { + foreach (var qualifier in parsedSearch.Qualifiers) { - return true; - } + var field = qualifier.IsAttribute ? qualifier.Key : ResolveSpanFieldKey(qualifier.Key); + if (field is null) + { + continue; + } - if (span.Kind.ToString().Contains(search, StringComparisons.FullTextSearch)) - { - return true; + filters.Add(new FieldTelemetryFilter + { + Field = field, + Value = qualifier.Value, + Condition = ToFilterCondition(qualifier.Operator, negated: false) + }); } - foreach (var attribute in span.Attributes) + foreach (var qualifier in parsedSearch.NegatedQualifiers) { - if (attribute.Key.Contains(search, StringComparisons.FullTextSearch) || - attribute.Value.Contains(search, StringComparisons.FullTextSearch)) + var field = qualifier.IsAttribute ? qualifier.Key : ResolveSpanFieldKey(qualifier.Key); + if (field is null) { - return true; + continue; } - } - foreach (var evt in span.Events) - { - if (evt.Name.Contains(search, StringComparisons.FullTextSearch)) + filters.Add(new FieldTelemetryFilter { - return true; - } + Field = field, + Value = qualifier.Value, + Condition = ToFilterCondition(qualifier.Operator, negated: true) + }); } - - return false; } - private static TimeSpan? GetMinimumDuration(double? minimumDurationMilliseconds) + /// + /// Maps user-facing log qualifier key names to internal field constants used by + /// . Returns null for unrecognized keys. + /// + private static string? ResolveLogFieldKey(string key) => key switch { - if (minimumDurationMilliseconds is not > 0) - { - return null; - } - - var value = minimumDurationMilliseconds.GetValueOrDefault(); - if (!double.IsFinite(value)) - { - return null; - } - - if (value >= TimeSpan.MaxValue.TotalMilliseconds) - { - return TimeSpan.MaxValue; - } + "severity" or "level" => KnownStructuredLogFields.LevelField, + "resource" => KnownResourceFields.ServiceNameField, + "scope" or "category" => KnownStructuredLogFields.CategoryField, + "message" or "msg" => KnownStructuredLogFields.MessageField, + "trace-id" or "traceid" => KnownStructuredLogFields.TraceIdField, + "span-id" or "spanid" => KnownStructuredLogFields.SpanIdField, + "event" => KnownStructuredLogFields.EventNameField, + _ => null + }; - return TimeSpan.FromMilliseconds(value); - } - - private static IEnumerable GetSpansMatchingMinimumDuration(IEnumerable spans, TimeSpan? minimumDuration) + /// + /// Maps user-facing span qualifier key names to internal field constants used by + /// . Returns null for unrecognized keys. + /// + private static string? ResolveSpanFieldKey(string key) => key switch { - if (minimumDuration is not { } duration) - { - return spans; - } + "name" => KnownTraceFields.NameField, + "resource" => KnownResourceFields.ServiceNameField, + "scope" or "source" => KnownSourceFields.NameField, + "status" => KnownTraceFields.StatusField, + "kind" => KnownTraceFields.KindField, + "trace-id" or "traceid" => KnownTraceFields.TraceIdField, + "span-id" or "spanid" => KnownTraceFields.SpanIdField, + "duration" => KnownTraceFields.DurationField, + _ => null + }; - return spans.Where(s => s.Duration >= duration); - } + /// + /// Maps a to the corresponding , + /// inverting the logic when the qualifier is negated. + /// + private static FilterCondition ToFilterCondition(ComparisonOperator op, bool negated) => (op, negated) switch + { + (ComparisonOperator.Contains, false) => FilterCondition.Contains, + (ComparisonOperator.Contains, true) => FilterCondition.NotContains, + (ComparisonOperator.GreaterThan, false) => FilterCondition.GreaterThan, + (ComparisonOperator.GreaterThan, true) => FilterCondition.LessThanOrEqual, + (ComparisonOperator.GreaterThanOrEqual, false) => FilterCondition.GreaterThanOrEqual, + (ComparisonOperator.GreaterThanOrEqual, true) => FilterCondition.LessThan, + (ComparisonOperator.LessThan, false) => FilterCondition.LessThan, + (ComparisonOperator.LessThan, true) => FilterCondition.GreaterThanOrEqual, + (ComparisonOperator.LessThanOrEqual, false) => FilterCondition.LessThanOrEqual, + (ComparisonOperator.LessThanOrEqual, true) => FilterCondition.GreaterThan, + _ => FilterCondition.Contains + }; /// /// Resolves resource names to ResourceKeys. diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 4da518e1af3..8ab6a3f7c3c 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -293,6 +293,7 @@ + diff --git a/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs b/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs index 0702d1d34f7..adcf493fc78 100644 --- a/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs +++ b/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs @@ -151,16 +151,15 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa [FromQuery] int? limit, [FromQuery] bool? follow, [FromQuery] string? search, - [FromQuery] double? minDurationMs, CancellationToken cancellationToken) => { if (follow == true) { - await StreamNdjsonAsync(httpContext, service.FollowSpansAsync(resource, traceId, hasError, search, minDurationMs, cancellationToken), cancellationToken).ConfigureAwait(false); + await StreamNdjsonAsync(httpContext, service.FollowSpansAsync(resource, traceId, hasError, search, cancellationToken), cancellationToken).ConfigureAwait(false); return Results.Empty; } - var response = service.GetSpans(resource, traceId, hasError, limit, search, minDurationMs); + var response = service.GetSpans(resource, traceId, hasError, limit, search); if (response is null) { return Results.NotFound(new ProblemDetails @@ -212,10 +211,9 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa [FromQuery] string[]? resource, [FromQuery] bool? hasError, [FromQuery] int? limit, - [FromQuery] string? search, - [FromQuery] double? minDurationMs) => + [FromQuery] string? search) => { - var response = service.GetTraces(resource, hasError, limit, search, minDurationMs); + var response = service.GetTraces(resource, hasError, limit, search); if (response is null) { return Results.NotFound(new ProblemDetails @@ -231,10 +229,9 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa // GET /api/telemetry/traces/{traceId} - Get a specific trace with all spans in OTLP format group.MapGet("/traces/{traceId}", ( TelemetryApiService service, - string traceId, - [FromQuery] double? minDurationMs) => + string traceId) => { - var response = service.GetTrace(traceId, minDurationMs); + var response = service.GetTrace(traceId); if (response is null) { return Results.NotFound(new ProblemDetails diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 1437a6d202a..1b91704eb76 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -173,8 +173,7 @@ public async Task GetTracesAsync( ResourceKey = resourceKey, StartIndex = 0, Count = int.MaxValue, - Filters = [], - FilterText = string.Empty + Filters = [] }); var spans = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, _outgoingPeerResolvers.ToArray()).ResourceSpans; diff --git a/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs b/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs index e533336a448..3e3165e9358 100644 --- a/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs +++ b/src/Aspire.Dashboard/Model/Otlp/TelemetryFilter.cs @@ -75,10 +75,13 @@ public static string ConditionToString(FilterCondition c, IStringLocalizer (a, b) => string.Equals(a, b, StringComparisons.OtlpFieldValue), FilterCondition.Contains => (a, b) => a != null && a.Contains(b, StringComparisons.OtlpFieldValue), - // Condition.GreaterThan => (a, b) => a > b, - // Condition.LessThan => (a, b) => a < b, - // Condition.GreaterThanOrEqual => (a, b) => a >= b, - // Condition.LessThanOrEqual => (a, b) => a <= b, + // Comparison operators are only meaningful for numeric fields. For string fields, + // never match — following the same approach as GitHub search, which only allows + // comparison operators on known numeric qualifiers. + FilterCondition.GreaterThan => static (a, b) => false, + FilterCondition.LessThan => static (a, b) => false, + FilterCondition.GreaterThanOrEqual => static (a, b) => false, + FilterCondition.LessThanOrEqual => static (a, b) => false, FilterCondition.NotEqual => (a, b) => !string.Equals(a, b, StringComparisons.OtlpFieldValue), FilterCondition.NotContains => (a, b) => a != null && !a.Contains(b, StringComparisons.OtlpFieldValue), _ => throw new ArgumentOutOfRangeException(nameof(c), c, null) diff --git a/src/Aspire.Dashboard/Model/TracesViewModel.cs b/src/Aspire.Dashboard/Model/TracesViewModel.cs index 219080684d3..1055e12ac23 100644 --- a/src/Aspire.Dashboard/Model/TracesViewModel.cs +++ b/src/Aspire.Dashboard/Model/TracesViewModel.cs @@ -85,10 +85,10 @@ public PagedResult GetTraces() var result = _telemetryRepository.GetTraces(new GetTracesRequest { ResourceKey = ResourceKey, - FilterText = FilterText, StartIndex = StartIndex, Count = Count, - Filters = filters + Filters = filters, + TraceNameFilterText = FilterText }); traces = result.PagedResult; @@ -117,10 +117,10 @@ public PagedResult GetErrorTraces(int count) var errorTraces = _telemetryRepository.GetTraces(new GetTracesRequest { ResourceKey = ResourceKey, - FilterText = FilterText, StartIndex = 0, Count = count, - Filters = filters + Filters = filters, + TraceNameFilterText = FilterText }); return errorTraces.PagedResult; diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs b/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs index 3863d8ae26a..52e83519a50 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs @@ -11,6 +11,7 @@ public sealed class GetLogsContext public required int StartIndex { get; init; } public required int Count { get; init; } public required List Filters { get; init; } + public string[]? TextFragments { get; init; } public static GetLogsContext ForResourceKey(ResourceKey resourceKey) => new() { diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs new file mode 100644 index 00000000000..2d17eb7ec63 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/GetSpansRequest.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model.Otlp; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed class GetSpansRequest +{ + public required ResourceKey? ResourceKey { get; init; } + public required int StartIndex { get; init; } + public required int Count { get; init; } + public required List Filters { get; init; } + public string? TraceId { get; init; } + public bool? HasError { get; init; } + public string[]? TextFragments { get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetSpansResponse.cs b/src/Aspire.Dashboard/Otlp/Storage/GetSpansResponse.cs new file mode 100644 index 00000000000..d3d4c05142b --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/GetSpansResponse.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Otlp.Model; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed class GetSpansResponse +{ + public required PagedResult PagedResult { get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs index 566e4f50371..3e52797a74e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetTracesRequest.cs @@ -10,15 +10,15 @@ public sealed class GetTracesRequest public required ResourceKey? ResourceKey { get; init; } public required int StartIndex { get; init; } public required int Count { get; init; } - public required string FilterText { get; init; } public required List Filters { get; init; } + public string? TraceNameFilterText { get; init; } + public string[]? TextFragments { get; init; } public static GetTracesRequest ForResourceKey(ResourceKey resourceKey) => new() { ResourceKey = resourceKey, StartIndex = 0, Count = int.MaxValue, - FilterText = string.Empty, Filters = [] }; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs index a5a99249a80..2dadbbd6a4d 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs @@ -27,13 +27,12 @@ public sealed partial class TelemetryRepository /// /// Streams spans as they arrive using push-based delivery. /// Yields existing spans first, then new ones as they're added. + /// Filtering (resource, traceId, hasError, telemetry filters, text fragments) is applied + /// inside the repository before yielding. /// O(1) per new span instead of O(n) re-query. /// - /// Optional filter by resource. - /// Cancellation token. - /// An async enumerable of spans. public async IAsyncEnumerable WatchSpansAsync( - ResourceKey? resourceKey, + WatchSpansRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { // Create a bounded channel to receive pushed spans @@ -42,7 +41,7 @@ public async IAsyncEnumerable WatchSpansAsync( FullMode = BoundedChannelFullMode.DropOldest }); - var watcher = new SpanWatcher(resourceKey, channel); + var watcher = new SpanWatcher(request, channel); // Register watcher FIRST to avoid race condition where spans could be // added between getting the snapshot and registering. @@ -54,32 +53,34 @@ public async IAsyncEnumerable WatchSpansAsync( try { - // Get existing spans from traces (capped to prevent OOM) - var existingTraces = GetTraces(new GetTracesRequest + // Get existing spans directly using GetSpans, which applies all filters + // (resource, traceId, hasError, telemetry filters, text fragments) at the query level. + var existingSpans = GetSpans(new GetSpansRequest { - ResourceKey = resourceKey, + ResourceKey = request.ResourceKey, StartIndex = 0, Count = MaxWatcherSnapshotCount, - Filters = [], - FilterText = string.Empty + Filters = request.Filters, + TraceId = request.TraceId, + HasError = request.HasError, + TextFragments = request.TextFragments }); // Track seen span IDs to deduplicate spans that arrive during the snapshot read. - // Race condition: watcher is registered BEFORE GetTraces, so spans arriving during + // Race condition: watcher is registered BEFORE GetSpans, so spans arriving during // the snapshot read are pushed to the channel AND included in the snapshot. // Unlike logs (which have monotonically increasing InternalId), span IDs are random // hex strings, so we need a HashSet rather than a simple counter. // The HashSet is cleared after draining to prevent unbounded memory growth. var seenSpanIds = new HashSet(); - // Yield existing spans ordered by start time so streaming clients - // receive the initial snapshot in chronological order. - var existingSpans = existingTraces.PagedResult.Items - .SelectMany(trace => trace.Spans) - .Where(span => resourceKey is null || span.Source.ResourceKey.Equals(resourceKey)) - .OrderBy(span => span.StartTime); + // Sort spans by start time so streaming clients receive the initial snapshot in + // chronological order. GetSpans returns spans grouped by trace (trace-order within + // each trace), but spans from different traces can overlap in time. + var orderedSpans = existingSpans.PagedResult.Items.OrderBy(s => s.StartTime); - foreach (var span in existingSpans) + // Yield existing spans ordered by start time + foreach (var span in orderedSpans) { seenSpanIds.Add(span.SpanId); yield return span; @@ -98,7 +99,7 @@ public async IAsyncEnumerable WatchSpansAsync( // (they weren't in the snapshot taken earlier). This prevents unbounded memory growth. seenSpanIds.Clear(); - // Stream new spans as they're pushed + // Stream new spans as they're pushed (already filtered in PushSpansToWatchers) await foreach (var span in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { yield return span; @@ -118,15 +119,12 @@ public async IAsyncEnumerable WatchSpansAsync( /// /// Streams logs as they arrive using push-based delivery. /// Yields existing logs first, then new ones as they're added. + /// Filtering (resource, telemetry filters, text fragments) is applied + /// inside the repository before yielding. /// O(1) per new log instead of O(n) re-query. /// - /// Optional filter by resource. - /// Optional filters for logs. - /// Cancellation token. - /// An async enumerable of log entries. public async IAsyncEnumerable WatchLogsAsync( - ResourceKey? resourceKey, - IEnumerable? filters, + WatchLogsRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { // Create a bounded channel to receive pushed logs @@ -135,8 +133,7 @@ public async IAsyncEnumerable WatchLogsAsync( FullMode = BoundedChannelFullMode.DropOldest }); - var filterList = filters?.ToList() ?? []; - var watcher = new LogWatcher(resourceKey, filterList, channel); + var watcher = new LogWatcher(request, channel); // Register watcher FIRST to avoid race condition where logs could be // added between getting the snapshot and registering. @@ -151,10 +148,11 @@ public async IAsyncEnumerable WatchLogsAsync( // Get existing logs snapshot (capped to prevent OOM) var existingLogs = GetLogs(new GetLogsContext { - ResourceKey = resourceKey, + ResourceKey = request.ResourceKey, StartIndex = 0, Count = MaxWatcherSnapshotCount, - Filters = filterList + Filters = request.Filters, + TextFragments = request.TextFragments }); // Track the highest log ID we've yielded to deduplicate @@ -223,8 +221,16 @@ private void PushSpansToWatchers(List spans, ResourceKey resourceKey) { foreach (var watcher in watchers) { + var request = watcher.Request; + // Check if watcher is filtering by resource - if (watcher.ResourceKey is { } key && !key.Equals(resourceKey)) + if (request.ResourceKey is { } key && !key.Equals(resourceKey)) + { + continue; + } + + // Apply all watcher filters before pushing to channel + if (!MatchesSpanCriteria(span, request.TraceId, request.HasError, request.Filters, request.TextFragments)) { continue; } @@ -260,14 +266,22 @@ private void PushLogsToWatchers(List logs, ResourceKey resourceKey { foreach (var watcher in watchers) { + var request = watcher.Request; + // Check if watcher is filtering by resource - if (watcher.ResourceKey is { } key && !key.Equals(resourceKey)) + if (request.ResourceKey is { } key && !key.Equals(resourceKey)) + { + continue; + } + + // Apply watcher telemetry filters before pushing to channel + if (request.Filters.Count > 0 && !MatchesFilters(log, request.Filters)) { continue; } - // Apply watcher filters before pushing to channel - if (watcher.Filters.Count > 0 && !MatchesFilters(log, watcher.Filters)) + // Apply text fragment matching + if (request.TextFragments is { Length: > 0 } fragments && !MatchesLogTextFragments(log, fragments)) { continue; } @@ -325,9 +339,9 @@ private void DisposeWatchers() /// /// Represents a span watcher for push-based streaming. /// - private sealed class SpanWatcher(ResourceKey? resourceKey, Channel channel) + private sealed class SpanWatcher(WatchSpansRequest request, Channel channel) { - public ResourceKey? ResourceKey => resourceKey; + public WatchSpansRequest Request => request; public Channel Channel => channel; } @@ -335,10 +349,9 @@ private sealed class SpanWatcher(ResourceKey? resourceKey, Channel cha /// Represents a log watcher for push-based streaming. /// Includes filters to apply when pushing logs to the channel. /// - private sealed class LogWatcher(ResourceKey? resourceKey, List filters, Channel channel) + private sealed class LogWatcher(WatchLogsRequest request, Channel channel) { - public ResourceKey? ResourceKey => resourceKey; - public List Filters => filters; + public WatchLogsRequest Request => request; public Channel Channel => channel; } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 2e77aa60052..4cb03a3de0c 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -467,6 +467,11 @@ public PagedResult GetLogs(GetLogsContext context) results = filter.Apply(results); } + if (context.TextFragments is { Length: > 0 } textFragments) + { + results = results.Where(l => MatchesLogTextFragments(l, textFragments)); + } + return OtlpHelpers.GetItems(results, context.StartIndex, context.Count, _logs.IsFull); } finally @@ -633,7 +638,8 @@ public GetTracesResponse GetTraces(GetTracesRequest context) var optimizedFilters = CreateOptimizedTraceFilters(filters); var resourceFilter = resources is { Count: > 0 } ? resources : null; var hasTelemetryFilters = filters.Count > 0; - var hasFilterText = !string.IsNullOrWhiteSpace(context.FilterText); + var hasFilterText = !string.IsNullOrWhiteSpace(context.TraceNameFilterText); + var hasTextFragments = context.TextFragments is { Length: > 0 }; var startIndex = Math.Max(context.StartIndex, 0); var count = Math.Max(context.Count, 0); List? items = null; @@ -647,7 +653,7 @@ public GetTracesResponse GetTraces(GetTracesRequest context) continue; } - if (hasFilterText && !trace.FullName.Contains(context.FilterText, StringComparison.OrdinalIgnoreCase)) + if (hasFilterText && !trace.FullName.Contains(context.TraceNameFilterText!, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -657,6 +663,11 @@ public GetTracesResponse GetTraces(GetTracesRequest context) continue; } + if (hasTextFragments && !MatchesTraceTextFragments(trace, context.TextFragments!)) + { + continue; + } + totalItemCount++; var duration = trace.Duration; @@ -694,6 +705,232 @@ public GetTracesResponse GetTraces(GetTracesRequest context) } } + public GetSpansResponse GetSpans(GetSpansRequest context) + { + List? resources = null; + if (context.ResourceKey is { } key) + { + resources = GetResources(key, includeUninstrumentedPeers: true); + + if (resources.Count == 0) + { + return new GetSpansResponse + { + PagedResult = PagedResult.Empty + }; + } + } + + _tracesLock.EnterReadLock(); + + try + { + var filters = context.Filters.GetEnabledFilters().ToList(); + var resourceFilter = resources is { Count: > 0 } ? resources : null; + var hasTraceIdFilter = !string.IsNullOrEmpty(context.TraceId); + var startIndex = Math.Max(context.StartIndex, 0); + var count = Math.Max(context.Count, 0); + List? items = null; + var totalItemCount = 0; + + foreach (var trace in _traces) + { + if (resourceFilter is not null && !MatchResources(trace, resourceFilter)) + { + continue; + } + + if (hasTraceIdFilter && !OtlpHelpers.MatchTelemetryId(context.TraceId!, trace.TraceId)) + { + continue; + } + + foreach (var span in trace.Spans) + { + if (!MatchesSpanCriteria(span, context.TraceId, context.HasError, filters, context.TextFragments)) + { + continue; + } + + totalItemCount++; + + if (totalItemCount > startIndex && (items?.Count ?? 0) < count) + { + items ??= new List(Math.Min(count, 64)); + items.Add(span); + } + } + } + + var pagedResults = new PagedResult + { + Items = items ?? new List(), + TotalItemCount = totalItemCount, + IsFull = _traces.IsFull + }; + + return new GetSpansResponse + { + PagedResult = pagedResults + }; + } + finally + { + _tracesLock.ExitReadLock(); + } + } + + /// + /// Applies traceId, hasError, telemetry filters, and text fragment matching to a span. + /// Shared between GetSpans (initial query) and PushSpansToWatchers (push path). + /// + private static bool MatchesSpanCriteria(OtlpSpan span, string? traceId, bool? hasError, List filters, string[]? textFragments) + { + if (!string.IsNullOrEmpty(traceId) && !OtlpHelpers.MatchTelemetryId(traceId, span.TraceId)) + { + return false; + } + + if (hasError.HasValue && (span.Status == OtlpSpanStatusCode.Error) != hasError.Value) + { + return false; + } + + if (filters.Count > 0 && !MatchesSpanFilters(span, filters)) + { + return false; + } + + if (textFragments is { Length: > 0 } fragments && !MatchesSpanTextFragments(span, fragments)) + { + return false; + } + + return true; + } + + /// + /// Returns true when the span matches all enabled filters applied directly to the span. + /// + private static bool MatchesSpanFilters(OtlpSpan span, List filters) + { + foreach (var filter in filters) + { + if (!filter.Enabled) + { + continue; + } + if (!filter.Apply(span)) + { + return false; + } + } + + return true; + } + + /// + /// Returns true when the span's searchable fields match all text fragments. + /// + private static bool MatchesSpanTextFragments(OtlpSpan span, string[] fragments) + { + return SearchTextParser.MatchesAllFragments(fragments, span, static (span, fragment) => + { + if (span.Name.Contains(fragment, StringComparisons.FullTextSearch) || + span.SpanId.Contains(fragment, StringComparisons.FullTextSearch) || + span.TraceId.Contains(fragment, StringComparisons.FullTextSearch) || + span.Scope.Name.Contains(fragment, StringComparisons.FullTextSearch) || + span.Source.Resource.ResourceName.Contains(fragment, StringComparisons.FullTextSearch) || + span.Status.ToString().Contains(fragment, StringComparisons.FullTextSearch) || + span.Kind.ToString().Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + + if (span.StatusMessage is not null && span.StatusMessage.Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + + foreach (var attribute in span.Attributes) + { + if (attribute.Key.Contains(fragment, StringComparisons.FullTextSearch) || + attribute.Value.Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + } + + foreach (var evt in span.Events) + { + if (evt.Name.Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + } + + return false; + }); + } + + /// + /// Returns true when the trace matches all text fragments. A trace matches if its full name + /// matches all fragments or any of its spans matches all fragments. + /// + private static bool MatchesTraceTextFragments(OtlpTrace trace, string[] fragments) + { + if (SearchTextParser.MatchesAllFragments(fragments, trace.FullName, static (fullName, fragment) => + fullName.Contains(fragment, StringComparisons.FullTextSearch))) + { + return true; + } + + foreach (var span in trace.Spans) + { + if (MatchesSpanTextFragments(span, fragments)) + { + return true; + } + } + + return false; + } + + /// + /// Returns true when the log entry's searchable fields match all text fragments. + /// + private static bool MatchesLogTextFragments(OtlpLogEntry log, string[] fragments) + { + return SearchTextParser.MatchesAllFragments(fragments, log, static (log, fragment) => + { + if (log.Message.Contains(fragment, StringComparisons.FullTextSearch) || + log.Scope.Name.Contains(fragment, StringComparisons.FullTextSearch) || + log.TraceId.Contains(fragment, StringComparisons.FullTextSearch) || + log.SpanId.Contains(fragment, StringComparisons.FullTextSearch) || + log.Severity.ToString().Contains(fragment, StringComparisons.FullTextSearch) || + log.ResourceView.Resource.ResourceName.Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + + if (log.EventName is not null && log.EventName.Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + + foreach (var attribute in log.Attributes) + { + if (attribute.Key.Contains(fragment, StringComparisons.FullTextSearch) || + attribute.Value.Contains(fragment, StringComparisons.FullTextSearch)) + { + return true; + } + } + + return false; + }); + } + private static List? CreateOptimizedTraceFilters(List filters) { List? result = null; diff --git a/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs new file mode 100644 index 00000000000..458263bcf98 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/WatchLogsRequest.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model.Otlp; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed class WatchLogsRequest +{ + public required ResourceKey? ResourceKey { 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 new file mode 100644 index 00000000000..6c5385f7172 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/WatchSpansRequest.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model.Otlp; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed class WatchSpansRequest +{ + public required ResourceKey? ResourceKey { get; init; } + public required List Filters { get; init; } + public string? TraceId { get; init; } + public bool? HasError { get; init; } + public string[]? TextFragments { get; init; } +} diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs index f8ff4e348ab..e5d66d67480 100644 --- a/src/Shared/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -240,9 +240,8 @@ public static string TelemetryLogsApiUrl(string baseUrl, List? resources /// Optional maximum number of results to return. /// Optional flag to enable streaming mode. /// Optional full-text search string to filter results. - /// Optional minimum span duration in milliseconds. /// The full API URL. - public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, string? traceId = null, bool? hasError = null, int? limit = null, bool? follow = null, string? search = null, double? minDurationMs = null) + public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, string? traceId = null, bool? hasError = null, int? limit = null, bool? follow = null, string? search = null) { var url = $"/{TelemetryApiBasePath}/spans"; url = AddResourceParams(url, resources); @@ -266,10 +265,6 @@ public static string TelemetrySpansApiUrl(string baseUrl, List? resource { url = AddQueryString(url, "search", search); } - if (minDurationMs is not null) - { - url = AddQueryString(url, "minDurationMs", minDurationMs.Value.ToString(CultureInfo.InvariantCulture)); - } return CombineUrl(baseUrl, url); } @@ -281,9 +276,8 @@ public static string TelemetrySpansApiUrl(string baseUrl, List? resource /// Optional filter for error status. /// Optional maximum number of results to return. /// Optional full-text search string to filter results. - /// Optional minimum span duration in milliseconds. /// The full API URL. - public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, bool? hasError = null, int? limit = null, string? search = null, double? minDurationMs = null) + public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, bool? hasError = null, int? limit = null, string? search = null) { var url = $"/{TelemetryApiBasePath}/traces"; url = AddResourceParams(url, resources); @@ -299,10 +293,6 @@ public static string TelemetryTracesApiUrl(string baseUrl, List? resourc { url = AddQueryString(url, "search", search); } - if (minDurationMs is not null) - { - url = AddQueryString(url, "minDurationMs", minDurationMs.Value.ToString(CultureInfo.InvariantCulture)); - } return CombineUrl(baseUrl, url); } @@ -311,15 +301,10 @@ public static string TelemetryTracesApiUrl(string baseUrl, List? resourc /// /// The dashboard base URL. /// The trace ID. - /// Optional minimum span duration in milliseconds. /// The full API URL. - public static string TelemetryTraceDetailApiUrl(string baseUrl, string traceId, double? minDurationMs = null) + public static string TelemetryTraceDetailApiUrl(string baseUrl, string traceId) { var path = $"/{TelemetryApiBasePath}/traces/{Uri.EscapeDataString(traceId)}"; - if (minDurationMs is not null) - { - path = AddQueryString(path, "minDurationMs", minDurationMs.Value.ToString(CultureInfo.InvariantCulture)); - } return CombineUrl(baseUrl, path); } diff --git a/src/Shared/SearchTextParser.cs b/src/Shared/SearchTextParser.cs new file mode 100644 index 00000000000..cb27a5591bd --- /dev/null +++ b/src/Shared/SearchTextParser.cs @@ -0,0 +1,399 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire; + +/// +/// The comparison operation for a qualifier value. +/// +internal enum ComparisonOperator +{ + /// Field value must contain the qualifier value (default string matching). + Contains, + /// Numeric: field value must be greater than qualifier value. + GreaterThan, + /// Numeric: field value must be greater than or equal to qualifier value. + GreaterThanOrEqual, + /// Numeric: field value must be less than qualifier value. + LessThan, + /// Numeric: field value must be less than or equal to qualifier value. + LessThanOrEqual +} + +/// +/// Represents a parsed search query containing free-text fragments and structured key:value qualifiers. +/// All terms are AND'd: every text fragment and qualifier must match for an item to pass. +/// +internal sealed class SearchFilter +{ + public static readonly SearchFilter Empty = new([], [], []); + + public SearchFilter(string[] textFragments, SearchQualifier[] qualifiers, SearchQualifier[] negatedQualifiers) + { + TextFragments = textFragments; + Qualifiers = qualifiers; + NegatedQualifiers = negatedQualifiers; + } + + /// + /// Free-text terms that must each match at least one searchable field. + /// + public string[] TextFragments { get; } + + /// + /// Positive key:value qualifiers. Each must match its targeted field. + /// + public SearchQualifier[] Qualifiers { get; } + + /// + /// Negated -key:value qualifiers. Each must NOT match its targeted field. + /// + public SearchQualifier[] NegatedQualifiers { get; } + + public bool IsEmpty => TextFragments.Length == 0 && Qualifiers.Length == 0 && NegatedQualifiers.Length == 0; +} + +/// +/// A single key:value qualifier from a search query, optionally with a comparison operator. +/// +internal sealed class SearchQualifier +{ + public SearchQualifier(string key, string value, ComparisonOperator op = ComparisonOperator.Contains, bool isAttribute = false) + { + Key = key; + Value = value; + Operator = op; + IsAttribute = isAttribute; + } + + /// + /// The qualifier key (e.g., "severity", "resource", "http.method", "duration"). + /// Stored in lowercase for case-insensitive key matching. + /// + public string Key { get; } + + /// + /// The qualifier value to match against the field. + /// For comparison operators, this is the numeric string (e.g., "100"). + /// + public string Value { get; } + + /// + /// The comparison operator. Defaults to for string matching. + /// + public ComparisonOperator Operator { get; } + + /// + /// Whether this qualifier targets a custom attribute (prefixed with @ in the search text). + /// When true, matching skips the field resolver and only checks attributes. + /// When false, matching uses the field resolver for known fields; unknown keys are treated as literal text. + /// + public bool IsAttribute { get; } +} + +/// +/// Parses search text into fragments and structured qualifiers, splitting on whitespace while treating +/// quoted text as a single token. Supports key:value qualifiers, -key:value negation, and comparison +/// operators (e.g., duration:>100, duration:>=500). +/// +/// +/// Behavior mirrors gh CLI search with Datadog-style attribute prefixing: +/// - Unquoted words are split on whitespace into individual fragments. +/// - Text enclosed in double quotes is treated as a single fragment (quotes are stripped). +/// - key:value tokens are parsed as structured qualifiers targeting a named/known field. +/// - @key:value tokens target custom attributes (the @ prefix is required for attributes). +/// - -key:value or -@key:value tokens are parsed as negated qualifiers (exclude matches). +/// - key:"value with spaces" allows quoted values in qualifiers. +/// - Comparison operators in the value: key:>N, key:>=N, key:<N, key:<=N. +/// - All terms are AND'd: every fragment and qualifier must independently match. +/// - Unknown bare qualifiers (no @ prefix, not a known field) are treated as literal text. +/// +/// Examples: +/// "hello world" → text: ["hello", "world"] +/// "severity:error \"connection failed\"" → qualifiers: [severity=error], text: ["connection failed"] +/// "-severity:debug resource:api" → negated: [severity=debug], qualifiers: [resource=api] +/// "@http.method:GET" → attribute qualifier: [http.method=GET] +/// "-@db.system:redis" → negated attribute qualifier: [db.system=redis] +/// "duration:>100" → qualifiers: [duration > 100] +/// "duration:>=500 status:error" → qualifiers: [duration >= 500, status=error] +/// +internal static class SearchTextParser +{ + /// + /// Parses the search text into a containing text fragments, + /// positive qualifiers, and negated qualifiers. + /// + /// The raw search text to parse. + /// + /// Optional predicate that returns true when a qualifier key (lowercase) is recognized. + /// When provided, a key:value token whose key is not recognized (and has no @ + /// attribute prefix) is treated as a literal text fragment rather than a structured qualifier. + /// Pass null to accept any key. + /// + public static SearchFilter ParseSearch([NotNullWhen(true)] string? search, Func? isKnownKey = null) + { + if (string.IsNullOrWhiteSpace(search)) + { + return SearchFilter.Empty; + } + + var textFragments = new List(); + var qualifiers = new List(); + var negatedQualifiers = new List(); + + var span = search.AsSpan().Trim(); + var current = 0; + + while (current < span.Length) + { + // Skip whitespace between tokens + while (current < span.Length && char.IsWhiteSpace(span[current])) + { + current++; + } + + if (current >= span.Length) + { + break; + } + + if (span[current] == '"') + { + // Quoted free-text fragment + var value = ReadQuotedValue(span, ref current); + if (value.Length > 0) + { + textFragments.Add(value); + } + } + else + { + // Read unquoted token (could be qualifier or free-text) + var tokenStart = current; + var isNegated = span[current] == '-' && current + 1 < span.Length && !char.IsWhiteSpace(span[current + 1]); + + if (isNegated) + { + current++; // skip the '-' prefix for now + } + + // Detect @ prefix for attribute qualifiers (e.g., @http.method:GET or -@status:error). + var isAttribute = current < span.Length && span[current] == '@'; + if (isAttribute) + { + current++; // skip the '@' prefix + } + + // Find the colon that separates key from value in a qualifier. + // A qualifier requires: non-empty key, colon not at start/end of the logical token. + var keyStart = current; + var colonIndex = -1; + + while (current < span.Length && !char.IsWhiteSpace(span[current])) + { + if (span[current] == ':' && colonIndex == -1 && current > keyStart) + { + colonIndex = current; + break; + } + + // If we hit a quote mid-token without finding a colon yet, it's not a qualifier pattern + if (span[current] == '"') + { + break; + } + + current++; + } + + if (colonIndex > keyStart) + { + // We found a qualifier pattern: key:value or key:"quoted value" + var key = span[keyStart..colonIndex].ToString().ToLowerInvariant(); + + // If a known-keys set is provided and this is not an @-attribute qualifier, + // verify the key is recognized. Unrecognized keys (e.g., "http" from a URL + // like "http://example.com") are treated as literal text fragments. + if (isKnownKey is not null && !isAttribute && !isKnownKey(key)) + { + // Rewind to token start and consume the whole token as free text + current = tokenStart; + while (current < span.Length && !char.IsWhiteSpace(span[current])) + { + current++; + } + + var fragment = span[tokenStart..current].ToString(); + if (fragment.Length > 0) + { + textFragments.Add(fragment); + } + } + else + { + current = colonIndex + 1; // move past the colon + + string value; + var op = ComparisonOperator.Contains; + + if (current < span.Length && span[current] == '"') + { + // Quoted value: key:"value with spaces" + value = ReadQuotedValue(span, ref current); + } + else + { + // Check for comparison operator prefix: >, >=, <, <= + if (current < span.Length && (span[current] == '>' || span[current] == '<')) + { + var opChar = span[current]; + current++; + + if (current < span.Length && span[current] == '=') + { + op = opChar == '>' ? ComparisonOperator.GreaterThanOrEqual : ComparisonOperator.LessThanOrEqual; + current++; + } + else + { + op = opChar == '>' ? ComparisonOperator.GreaterThan : ComparisonOperator.LessThan; + } + } + + // Unquoted value: read until whitespace + var valueStart = current; + while (current < span.Length && !char.IsWhiteSpace(span[current])) + { + current++; + } + value = span[valueStart..current].ToString(); + } + + if (value.Length > 0) + { + var qualifier = new SearchQualifier(key, value, op, isAttribute); + if (isNegated) + { + negatedQualifiers.Add(qualifier); + } + else + { + qualifiers.Add(qualifier); + } + } + else + { + // Empty value after colon (e.g., "key:" or "key:\"\"") → treat whole thing as text + var fragment = span[tokenStart..current].ToString(); + if (fragment.Length > 0) + { + textFragments.Add(fragment); + } + } + } + } + else + { + // Not a qualifier — read as free-text fragment (reset to token start if negation prefix was consumed) + current = tokenStart; + var start = current; + while (current < span.Length && !char.IsWhiteSpace(span[current]) && span[current] != '"') + { + current++; + } + + var fragment = span[start..current].ToString(); + if (fragment.Length > 0) + { + textFragments.Add(fragment); + } + } + } + } + + if (textFragments.Count == 0 && qualifiers.Count == 0 && negatedQualifiers.Count == 0) + { + return SearchFilter.Empty; + } + + return new SearchFilter( + textFragments.ToArray(), + qualifiers.ToArray(), + negatedQualifiers.ToArray()); + } + + /// + /// Parses the search text into individual text fragments (legacy API). + /// Qualifier values are included as text fragments for backward-compatible full-text matching. + /// Returns an empty array if the search text is null or empty. + /// + public static string[] ParseFragments([NotNullWhen(true)] string? search) + { + if (string.IsNullOrWhiteSpace(search)) + { + return []; + } + + var filter = ParseSearch(search); + if (filter.IsEmpty) + { + return []; + } + + // Include qualifier values as text fragments for backward-compatible full-text matching + var allFragments = new List(filter.TextFragments); + foreach (var q in filter.Qualifiers) + { + allFragments.Add(q.Value); + } + foreach (var q in filter.NegatedQualifiers) + { + allFragments.Add(q.Value); + } + + return allFragments.ToArray(); + } + + /// + /// Returns true if all fragments match against the given state, using a delegate to test each fragment. + /// The delegate should return true if the fragment is found in any searchable field of . + /// + public static bool MatchesAllFragments(string[] fragments, TState state, Func matchesFragment) + { + foreach (var fragment in fragments) + { + if (!matchesFragment(state, fragment)) + { + return false; + } + } + + return true; + } + + /// + /// Reads a quoted string starting at the current position (which should be the opening quote). + /// Advances past the closing quote. + /// + private static string ReadQuotedValue(ReadOnlySpan span, ref int current) + { + current++; // skip opening quote + var start = current; + while (current < span.Length && span[current] != '"') + { + current++; + } + + var value = span[start..current].ToString(); + + // Skip closing quote if present + if (current < span.Length) + { + current++; + } + + return value; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs index 2048500ed3f..92230296c52 100644 --- a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using System.Threading.Channels; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Cli.Resources; @@ -1120,6 +1121,72 @@ public async Task LogsCommand_WithSearchOption_NoMatch_ReturnsEmptyLogs() Assert.Empty(logsOutput.Logs); } + [Fact] + public async Task LogsCommand_WithSearchOption_MultipleWords_MatchesEachFragmentSeparately() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + using var provider = CreateLogsTestServices(workspace, outputWriter, disableAnsi: true, + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Connection timeout error on port 6379", IsError = true }, + new ResourceLogLine { ResourceName = "redis", LineNumber = 2, Content = "Connection established successfully", IsError = false }, + new ResourceLogLine { ResourceName = "redis", LineNumber = 3, Content = "Timeout waiting for response", IsError = true }, + new ResourceLogLine { ResourceName = "redis", LineNumber = 4, Content = "Ready to accept connections", IsError = false } + ]); + + var command = provider.GetRequiredService(); + // Two words: both "Connection" AND "timeout" must appear in the same log line + var result = command.Parse("logs redis --search \"Connection timeout\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + + // Only the line containing BOTH fragments should match + Assert.Single(logsOutput.Logs); + Assert.Contains("Connection timeout error", logsOutput.Logs[0].Content); + } + + [Fact] + public async Task LogsCommand_WithSearchOption_QualifierSyntaxTreatedAsFreeText() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + using var provider = CreateLogsTestServices(workspace, outputWriter, disableAnsi: true, + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "level:error something failed", IsError = true }, + new ResourceLogLine { ResourceName = "redis", LineNumber = 2, Content = "Normal operation", IsError = false } + ]); + + var command = provider.GetRequiredService(); + // Qualifier-like syntax "level:error" should be treated as free text for logs + var result = command.Parse("logs redis --search level:error --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + + // The qualifier value "error" is treated as a text fragment and matches + Assert.Single(logsOutput.Logs); + Assert.Contains("level:error", logsOutput.Logs[0].Content); + } + [Fact] public async Task LogsCommand_PassesSnapshotFiltersToConsoleLogsRequest() { @@ -1357,6 +1424,93 @@ public async Task LogsCommand_FollowWithTailAndSearch_FiltersTailOutput() Assert.DoesNotContain(outputWriter.Logs, l => l.Contains("Client connected", StringComparison.Ordinal)); } + [Fact] + public async Task LogsCommand_FollowWithSearch_FiltersExistingAndStreamedLogs() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var allowFollowLogsTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowExitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var logLines = Channel.CreateUnbounded(); + var outputWriter = new TestOutputTextWriter(outputHelper, line => + { + if (line.StartsWith("[", StringComparison.Ordinal)) + { + logLines.Writer.TryWrite(line); + } + }); + + using var provider = CreateLogsTestServices(workspace, outputWriter, disableAnsi: true, + configureConnection: connection => + { + connection.AppHostInfo = CreateAppHostInfo(workspace, Environment.ProcessId); + connection.GetResourceLogsHandler = (resourceName, follow, cancellationToken) => + { + if (!follow) + { + return InitialLogsForFollowSearchAsync(cancellationToken); + } + + return StreamedLogsForFollowSearchAsync(allowFollowLogsTcs.Task, allowExitTcs.Task, cancellationToken); + }; + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs redis --follow --tail 10 --search timeout"); + + var commandTask = result.InvokeAsync(); + + // Read initial tail log from channel and assert + var initialLog = await logLines.Reader.ReadAsync().AsTask().DefaultTimeout(); + Assert.Equal("[redis] Connection timeout error", initialLog); + + // Allow follow logs to flow + allowFollowLogsTcs.SetResult(); + + // Read streamed follow log from channel and assert + var followLog = await logLines.Reader.ReadAsync().AsTask().DefaultTimeout(); + Assert.Equal("[redis] Another timeout occurred", followLog); + + // Allow the stream to exit + allowExitTcs.SetResult(); + + var exitCode = await commandTask.DefaultTimeout(); + Assert.Equal(CliExitCodes.Success, exitCode); + + logLines.Writer.Complete(); + + // Verify no additional log lines were written + await logLines.Reader.Completion.DefaultTimeout(); + Assert.False(logLines.Reader.TryRead(out _)); + } + + private static async IAsyncEnumerable InitialLogsForFollowSearchAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Ready to accept connections", IsError = false }; + yield return new ResourceLogLine { ResourceName = "redis", LineNumber = 2, Content = "Connection timeout error", IsError = true }; + yield return new ResourceLogLine { ResourceName = "redis", LineNumber = 3, Content = "Client connected from 127.0.0.1", IsError = false }; + await Task.CompletedTask; + cancellationToken.ThrowIfCancellationRequested(); + } + + private static async IAsyncEnumerable StreamedLogsForFollowSearchAsync( + Task allowLogs, + Task allowExit, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Wait until the test allows follow logs to flow + await allowLogs.WaitAsync(cancellationToken); + + yield return new ResourceLogLine { ResourceName = "redis", LineNumber = 4, Content = "Processing request", IsError = false }; + yield return new ResourceLogLine { ResourceName = "redis", LineNumber = 5, Content = "Another timeout occurred", IsError = true }; + + // Wait until the test allows the stream to terminate + await allowExit.WaitAsync(cancellationToken); + throw new ObjectDisposedException("StreamJsonRpc.JsonRpc"); + } + private static async IAsyncEnumerable FollowTailSearchLogsAsync( bool follow, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs index 0ad2cb496be..dc36c3637e0 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs @@ -160,7 +160,7 @@ public async Task TelemetrySpansCommand_WithDashboardUrl_FetchesSpansDirectly() var handler = new MockHttpMessageHandler(request => { - var url = request.RequestUri!.ToString(); + var url = request.RequestUri!.AbsoluteUri; if (url.Contains("/api/telemetry/resources")) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -231,7 +231,7 @@ public async Task TelemetrySpansCommand_WithDashboardUrl_401_DisplaysAuthFailedM var handler = new MockHttpMessageHandler(request => { - var url = request.RequestUri!.ToString(); + var url = request.RequestUri!.AbsoluteUri; if (url.Contains("/api/telemetry/resources")) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -269,7 +269,7 @@ public async Task TelemetrySpansCommand_WithDashboardUrl_ResourcesEndpoint404_Di var handler = new MockHttpMessageHandler(request => { - var url = request.RequestUri!.ToString(); + var url = request.RequestUri!.AbsoluteUri; if (url.Contains("/api/telemetry/")) { // All telemetry API endpoints return 404 when API is not enabled @@ -347,7 +347,7 @@ public async Task TelemetrySpansCommand_JsonOutput_ProducesExpectedJson() var handler = new MockHttpMessageHandler(request => { - var url = request.RequestUri!.ToString(); + var url = request.RequestUri!.AbsoluteUri; if (url.Contains("/api/telemetry/resources")) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -416,7 +416,7 @@ public async Task TelemetrySpansCommand_WithSearchOption_PassesSearchToUrl() var handler = new MockHttpMessageHandler(request => { - var url = request.RequestUri!.ToString(); + var url = request.RequestUri!.AbsoluteUri; if (url.Contains("/api/telemetry/resources")) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -451,12 +451,11 @@ public async Task TelemetrySpansCommand_WithSearchOption_PassesSearchToUrl() var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.NotNull(capturedUrl); - Assert.Contains("search=", capturedUrl); + Assert.Equal("http://localhost:18888/api/telemetry/spans?search=GET%20%2Findex", capturedUrl); } [Fact] - public async Task TelemetrySpansCommand_WithMinimumDurationOption_PassesMinimumDurationToUrl() + public async Task TelemetrySpansCommand_WithDurationSearchFilter_PassesDurationFilterInSearchUrl() { using var workspace = TemporaryWorkspace.Create(outputHelper); var outputWriter = new TestOutputTextWriter(outputHelper); @@ -464,7 +463,7 @@ public async Task TelemetrySpansCommand_WithMinimumDurationOption_PassesMinimumD var handler = new MockHttpMessageHandler(request => { - var url = request.RequestUri!.ToString(); + var url = request.RequestUri!.AbsoluteUri; if (url.Contains("/api/telemetry/resources")) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -494,12 +493,11 @@ public async Task TelemetrySpansCommand_WithMinimumDurationOption_PassesMinimumD using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("otel spans --dashboard-url http://localhost:18888 --min-duration 50.5"); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888 --search \"duration:>=50.5\""); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.NotNull(capturedUrl); - Assert.Contains("minDurationMs=50.5", capturedUrl); + Assert.Equal("http://localhost:18888/api/telemetry/spans?search=duration%3A%3E%3D50.5", capturedUrl); } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs index 56939885e6c..7aba6e86c0a 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs @@ -717,12 +717,11 @@ public async Task TelemetryTracesCommand_WithSearchOption_PassesSearchToUrl() var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.NotNull(capturedUrl); - Assert.Contains("search=frontend", capturedUrl); + Assert.Equal("http://localhost:18888/api/telemetry/traces?search=frontend", capturedUrl); } [Fact] - public async Task TelemetryTracesCommand_WithMinimumDurationOption_PassesMinimumDurationToUrl() + public async Task TelemetryTracesCommand_WithDurationSearchFilter_PassesDurationFilterInSearchUrl() { using var workspace = TemporaryWorkspace.Create(outputHelper); var outputWriter = new TestOutputTextWriter(outputHelper); @@ -760,67 +759,11 @@ public async Task TelemetryTracesCommand_WithMinimumDurationOption_PassesMinimum using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --min-duration 50.5"); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --search \"duration:>=50.5\""); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.NotNull(capturedUrl); - Assert.Contains("minDurationMs=50.5", capturedUrl); - } - - [Fact] - public async Task TelemetryTracesCommand_WithTraceIdAndMinimumDurationOption_PassesMinimumDurationToUrl() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); - string? capturedUrl = null; - - var apiResponse = new TelemetryApiResponse - { - Data = new OtlpTelemetryDataJson { ResourceSpans = [] }, - TotalCount = 0, - ReturnedCount = 0 - }; - var responseJson = JsonSerializer.Serialize(apiResponse, OtlpJsonSerializerContext.Default.TelemetryApiResponse); - - var handler = new MockHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.Contains("/api/telemetry/resources")) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") - }; - } - if (url.Contains("/api/telemetry/traces/abc1234567890def")) - { - capturedUrl = url; - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json") - }; - } - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; - }); - services.AddSingleton(handler); - services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); - - using var provider = services.BuildServiceProvider(); - var command = provider.GetRequiredService(); - var result = command.Parse("otel traces --trace-id abc1234567890def --format json --dashboard-url http://localhost:18888 --min-duration 50.5"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.NotNull(capturedUrl); - Assert.Contains("minDurationMs=50.5", capturedUrl); + Assert.Equal("http://localhost:18888/api/telemetry/traces?search=duration%3A>%3D50.5", capturedUrl); } } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs index 864aa130d3a..85d18f71454 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -371,6 +371,80 @@ public async Task ListConsoleLogsTool_WithNonStringSearchValue_IgnoresSearch() Assert.Contains("Goodbye world", codeBlockContent); } + [Fact] + public async Task ListConsoleLogsTool_WithSearch_MultipleWords_MatchesEachFragmentSeparately() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = + [ + new ResourceLogLine { ResourceName = "api-service", LineNumber = 1, Content = "Connection timeout error on port 5000", IsError = true }, + new ResourceLogLine { ResourceName = "api-service", LineNumber = 2, Content = "Connection established successfully", IsError = false }, + new ResourceLogLine { ResourceName = "api-service", LineNumber = 3, Content = "Timeout waiting for response", IsError = true }, + new ResourceLogLine { ResourceName = "api-service", LineNumber = 4, Content = "Ready to accept connections", IsError = false } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement, + // Two words: both "Connection" AND "timeout" must appear in the same log line + ["search"] = JsonDocument.Parse("\"Connection timeout\"").RootElement + }; + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + // Only the line containing BOTH fragments should match + Assert.Contains("Connection timeout error", codeBlockContent); + Assert.DoesNotContain("established", codeBlockContent); + Assert.DoesNotContain("Timeout waiting", codeBlockContent); + Assert.DoesNotContain("Ready to accept", codeBlockContent); + } + + [Fact] + public async Task ListConsoleLogsTool_WithSearch_QualifierSyntaxTreatedAsFreeText() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = + [ + new ResourceLogLine { ResourceName = "api-service", LineNumber = 1, Content = "level:error something failed", IsError = true }, + new ResourceLogLine { ResourceName = "api-service", LineNumber = 2, Content = "Normal operation", IsError = false } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement, + // Qualifier-like syntax should be treated as free text for logs + ["search"] = JsonDocument.Parse("\"level:error\"").RootElement + }; + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + // The qualifier value "error" is treated as a text fragment and matches + Assert.Contains("level:error something failed", codeBlockContent); + Assert.DoesNotContain("Normal operation", codeBlockContent); + } + private static string ExtractCodeBlockContent(string text) { var match = Regex.Match(text, @"```plaintext\s*(.*?)\s*```", RegexOptions.Singleline); diff --git a/tests/Aspire.Cli.Tests/Utils/SearchTextParserTests.cs b/tests/Aspire.Cli.Tests/Utils/SearchTextParserTests.cs new file mode 100644 index 00000000000..279e56d6a30 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/SearchTextParserTests.cs @@ -0,0 +1,364 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.Utils; + +public class SearchTextParserTests +{ + #region ParseSearch - Basic + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseSearch_EmptyOrNull_ReturnsEmpty(string? search) + { + var filter = SearchTextParser.ParseSearch(search); + Assert.True(filter.IsEmpty); + } + + [Fact] + public void ParseSearch_SingleWord_ReturnsTextFragment() + { + var filter = SearchTextParser.ParseSearch("hello"); + Assert.Single(filter.TextFragments, "hello"); + Assert.Empty(filter.Qualifiers); + Assert.Empty(filter.NegatedQualifiers); + } + + [Fact] + public void ParseSearch_MultipleWords_SplitsIntoFragments() + { + var filter = SearchTextParser.ParseSearch("hello world foo"); + Assert.Equal(["hello", "world", "foo"], filter.TextFragments); + } + + [Fact] + public void ParseSearch_QuotedText_TreatedAsSingleFragment() + { + var filter = SearchTextParser.ParseSearch("\"hello world\""); + Assert.Single(filter.TextFragments, "hello world"); + } + + [Fact] + public void ParseSearch_MixedQuotedAndUnquoted() + { + var filter = SearchTextParser.ParseSearch("error \"connection failed\" timeout"); + Assert.Equal(["error", "connection failed", "timeout"], filter.TextFragments); + } + + #endregion + + #region ParseSearch - Qualifiers + + [Fact] + public void ParseSearch_SimpleQualifier() + { + var filter = SearchTextParser.ParseSearch("severity:error"); + Assert.Empty(filter.TextFragments); + Assert.Single(filter.Qualifiers); + Assert.Equal("severity", filter.Qualifiers[0].Key); + Assert.Equal("error", filter.Qualifiers[0].Value); + Assert.Equal(ComparisonOperator.Contains, filter.Qualifiers[0].Operator); + } + + [Fact] + public void ParseSearch_QualifierKeyIsLowercased() + { + var filter = SearchTextParser.ParseSearch("Severity:Error"); + Assert.Equal("severity", filter.Qualifiers[0].Key); + Assert.Equal("Error", filter.Qualifiers[0].Value); + } + + [Fact] + public void ParseSearch_QualifierWithQuotedValue() + { + var filter = SearchTextParser.ParseSearch("message:\"connection timed out\""); + Assert.Single(filter.Qualifiers); + Assert.Equal("message", filter.Qualifiers[0].Key); + Assert.Equal("connection timed out", filter.Qualifiers[0].Value); + } + + [Fact] + public void ParseSearch_DottedKey() + { + var filter = SearchTextParser.ParseSearch("http.method:GET"); + Assert.Single(filter.Qualifiers); + Assert.Equal("http.method", filter.Qualifiers[0].Key); + Assert.Equal("GET", filter.Qualifiers[0].Value); + } + + [Fact] + public void ParseSearch_QuotedQualifierSyntax_TreatedAsTextFragment() + { + var filter = SearchTextParser.ParseSearch("\"http.method:GET\""); + Assert.Single(filter.TextFragments, "http.method:GET"); + Assert.Empty(filter.Qualifiers); + } + + [Fact] + public void ParseSearch_MultipleQualifiers() + { + var filter = SearchTextParser.ParseSearch("severity:error resource:api"); + Assert.Equal(2, filter.Qualifiers.Length); + Assert.Equal("severity", filter.Qualifiers[0].Key); + Assert.Equal("error", filter.Qualifiers[0].Value); + Assert.Equal("resource", filter.Qualifiers[1].Key); + Assert.Equal("api", filter.Qualifiers[1].Value); + } + + [Fact] + public void ParseSearch_QualifierWithEmptyValue_TreatedAsText() + { + var filter = SearchTextParser.ParseSearch("key:"); + Assert.Single(filter.TextFragments, "key:"); + Assert.Empty(filter.Qualifiers); + } + + [Fact] + public void ParseSearch_QualifierWithEmptyQuotedValue_TreatedAsText() + { + var filter = SearchTextParser.ParseSearch("key:\"\""); + Assert.Single(filter.TextFragments, "key:\"\""); + Assert.Empty(filter.Qualifiers); + } + + #endregion + + #region ParseSearch - Negation + + [Fact] + public void ParseSearch_NegatedQualifier() + { + var filter = SearchTextParser.ParseSearch("-severity:debug"); + Assert.Empty(filter.TextFragments); + Assert.Empty(filter.Qualifiers); + Assert.Single(filter.NegatedQualifiers); + Assert.Equal("severity", filter.NegatedQualifiers[0].Key); + Assert.Equal("debug", filter.NegatedQualifiers[0].Value); + } + + [Fact] + public void ParseSearch_NegatedQualifierWithQuotedValue() + { + var filter = SearchTextParser.ParseSearch("-message:\"not important\""); + Assert.Single(filter.NegatedQualifiers); + Assert.Equal("message", filter.NegatedQualifiers[0].Key); + Assert.Equal("not important", filter.NegatedQualifiers[0].Value); + } + + [Fact] + public void ParseSearch_MixedPositiveAndNegated() + { + var filter = SearchTextParser.ParseSearch("severity:error -resource:debug hello"); + Assert.Single(filter.TextFragments, "hello"); + Assert.Single(filter.Qualifiers); + Assert.Equal("severity", filter.Qualifiers[0].Key); + Assert.Single(filter.NegatedQualifiers); + Assert.Equal("resource", filter.NegatedQualifiers[0].Key); + } + + #endregion + + #region ParseSearch - Attribute Prefix (@) + + [Fact] + public void ParseSearch_AttributeQualifier() + { + var filter = SearchTextParser.ParseSearch("@http.method:GET"); + Assert.Single(filter.Qualifiers); + Assert.Equal("http.method", filter.Qualifiers[0].Key); + Assert.Equal("GET", filter.Qualifiers[0].Value); + Assert.True(filter.Qualifiers[0].IsAttribute); + } + + [Fact] + public void ParseSearch_NegatedAttributeQualifier() + { + var filter = SearchTextParser.ParseSearch("-@db.system:redis"); + Assert.Single(filter.NegatedQualifiers); + Assert.Equal("db.system", filter.NegatedQualifiers[0].Key); + Assert.Equal("redis", filter.NegatedQualifiers[0].Value); + Assert.True(filter.NegatedQualifiers[0].IsAttribute); + } + + [Fact] + public void ParseSearch_BareQualifierIsNotAttribute() + { + var filter = SearchTextParser.ParseSearch("status:error"); + Assert.Single(filter.Qualifiers); + Assert.False(filter.Qualifiers[0].IsAttribute); + } + + [Fact] + public void ParseSearch_AttributeWithQuotedValue() + { + var filter = SearchTextParser.ParseSearch("@user.name:\"John Doe\""); + Assert.Single(filter.Qualifiers); + Assert.Equal("user.name", filter.Qualifiers[0].Key); + Assert.Equal("John Doe", filter.Qualifiers[0].Value); + Assert.True(filter.Qualifiers[0].IsAttribute); + } + + [Fact] + public void ParseSearch_AttributeWithComparisonOperator() + { + var filter = SearchTextParser.ParseSearch("@response.time:>500"); + Assert.Single(filter.Qualifiers); + Assert.Equal("response.time", filter.Qualifiers[0].Key); + Assert.Equal("500", filter.Qualifiers[0].Value); + Assert.Equal(ComparisonOperator.GreaterThan, filter.Qualifiers[0].Operator); + Assert.True(filter.Qualifiers[0].IsAttribute); + } + + #endregion + + #region ParseSearch - Comparison Operators + + [Fact] + public void ParseSearch_GreaterThan() + { + var filter = SearchTextParser.ParseSearch("duration:>100"); + Assert.Single(filter.Qualifiers); + Assert.Equal("duration", filter.Qualifiers[0].Key); + Assert.Equal("100", filter.Qualifiers[0].Value); + Assert.Equal(ComparisonOperator.GreaterThan, filter.Qualifiers[0].Operator); + } + + [Fact] + public void ParseSearch_GreaterThanOrEqual() + { + var filter = SearchTextParser.ParseSearch("duration:>=500"); + Assert.Single(filter.Qualifiers); + Assert.Equal("duration", filter.Qualifiers[0].Key); + Assert.Equal("500", filter.Qualifiers[0].Value); + Assert.Equal(ComparisonOperator.GreaterThanOrEqual, filter.Qualifiers[0].Operator); + } + + [Fact] + public void ParseSearch_LessThan() + { + var filter = SearchTextParser.ParseSearch("duration:<50"); + Assert.Single(filter.Qualifiers); + Assert.Equal("duration", filter.Qualifiers[0].Key); + Assert.Equal("50", filter.Qualifiers[0].Value); + Assert.Equal(ComparisonOperator.LessThan, filter.Qualifiers[0].Operator); + } + + [Fact] + public void ParseSearch_LessThanOrEqual() + { + var filter = SearchTextParser.ParseSearch("duration:<=200"); + Assert.Single(filter.Qualifiers); + Assert.Equal("duration", filter.Qualifiers[0].Key); + Assert.Equal("200", filter.Qualifiers[0].Value); + Assert.Equal(ComparisonOperator.LessThanOrEqual, filter.Qualifiers[0].Operator); + } + + [Fact] + public void ParseSearch_NegatedComparisonOperator() + { + var filter = SearchTextParser.ParseSearch("-duration:>1000"); + Assert.Single(filter.NegatedQualifiers); + Assert.Equal("duration", filter.NegatedQualifiers[0].Key); + Assert.Equal("1000", filter.NegatedQualifiers[0].Value); + Assert.Equal(ComparisonOperator.GreaterThan, filter.NegatedQualifiers[0].Operator); + } + + #endregion + + #region ParseSearch - Complex / Mixed + + [Fact] + public void ParseSearch_ComplexMixed() + { + var filter = SearchTextParser.ParseSearch("error severity:warning -resource:test duration:>100 \"connection reset\""); + Assert.Equal(["error", "connection reset"], filter.TextFragments); + Assert.Equal(2, filter.Qualifiers.Length); + Assert.Equal("severity", filter.Qualifiers[0].Key); + Assert.Equal("warning", filter.Qualifiers[0].Value); + Assert.Equal("duration", filter.Qualifiers[1].Key); + Assert.Equal("100", filter.Qualifiers[1].Value); + Assert.Equal(ComparisonOperator.GreaterThan, filter.Qualifiers[1].Operator); + Assert.Single(filter.NegatedQualifiers); + Assert.Equal("resource", filter.NegatedQualifiers[0].Key); + } + + #endregion + + #region ParseFragments - Backward Compatibility + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseFragments_EmptyOrNull_ReturnsEmpty(string? search) + { + var result = SearchTextParser.ParseFragments(search); + Assert.Empty(result); + } + + [Fact] + public void ParseFragments_TextOnly() + { + var result = SearchTextParser.ParseFragments("hello world"); + Assert.Equal(["hello", "world"], result); + } + + [Fact] + public void ParseFragments_IncludesQualifierValues() + { + var result = SearchTextParser.ParseFragments("severity:error hello"); + Assert.Contains("hello", result); + Assert.Contains("error", result); + } + + [Fact] + public void ParseFragments_IncludesNegatedQualifierValues() + { + var result = SearchTextParser.ParseFragments("-severity:debug"); + Assert.Contains("debug", result); + } + + #endregion + + #region MatchesAllFragments + + [Fact] + public void MatchesAllFragments_EmptyFragments_ReturnsTrue() + { + Assert.True(SearchTextParser.MatchesAllFragments([], "anything", static (state, fragment) => + state.Contains(fragment, StringComparisons.FullTextSearch))); + } + + [Fact] + public void MatchesAllFragments_SingleMatch() + { + Assert.True(SearchTextParser.MatchesAllFragments(["hello"], "hello world", static (state, fragment) => + state.Contains(fragment, StringComparisons.FullTextSearch))); + } + + [Fact] + public void MatchesAllFragments_CaseInsensitive() + { + Assert.True(SearchTextParser.MatchesAllFragments(["HELLO"], "hello world", static (state, fragment) => + state.Contains(fragment, StringComparisons.FullTextSearch))); + } + + [Fact] + public void MatchesAllFragments_AllMustMatch() + { + Assert.False(SearchTextParser.MatchesAllFragments(["hello", "missing"], "hello world", static (state, fragment) => + state.Contains(fragment, StringComparisons.FullTextSearch))); + } + + [Fact] + public void MatchesAllFragments_DifferentCandidates() + { + Assert.True(SearchTextParser.MatchesAllFragments(["hello", "world"], ("hello", "world"), static (state, fragment) => + state.Item1.Contains(fragment, StringComparisons.FullTextSearch) || + state.Item2.Contains(fragment, StringComparisons.FullTextSearch))); + } + + #endregion +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs index bbf3996edfb..b97fb82aa94 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -149,7 +149,6 @@ public async Task UpdateTelemetry_DifferentTrace_ContentInstanceUnchanged() var tracesResult = repository.GetTraces(new GetTracesRequest { ResourceKey = resource.ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -233,7 +232,6 @@ public async Task UpdateTelemetry_SameTrace_ContentInstanceChanged() var tracesResult = repository.GetTraces(new GetTracesRequest { ResourceKey = resource.ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -247,7 +245,6 @@ List GetContextGenAISpans() var currentTrace = repository.GetTraces(new GetTracesRequest { ResourceKey = resource.ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] diff --git a/tests/Aspire.Dashboard.Tests/DashboardUrlsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardUrlsTests.cs index 18ccf9c0cce..d73d339da75 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardUrlsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardUrlsTests.cs @@ -107,14 +107,6 @@ public void TelemetrySpansApiUrl_WithSearch_AppendsSearchQueryParameter() Assert.Contains("search=GET%20%2Fapi", url); } - [Fact] - public void TelemetrySpansApiUrl_WithMinimumDuration_AppendsMinDurationQueryParameter() - { - var url = DashboardUrls.TelemetrySpansApiUrl("http://localhost:18888", minDurationMs: 50.5); - - Assert.Contains("minDurationMs=50.5", url); - } - [Fact] public void TelemetryTracesApiUrl_WithSearch_AppendsSearchQueryParameter() { @@ -123,22 +115,6 @@ public void TelemetryTracesApiUrl_WithSearch_AppendsSearchQueryParameter() Assert.Contains("search=timeout", url); } - [Fact] - public void TelemetryTracesApiUrl_WithMinimumDuration_AppendsMinDurationQueryParameter() - { - var url = DashboardUrls.TelemetryTracesApiUrl("http://localhost:18888", minDurationMs: 50.5); - - Assert.Contains("minDurationMs=50.5", url); - } - - [Fact] - public void TelemetryTraceDetailApiUrl_WithMinimumDuration_AppendsMinDurationQueryParameter() - { - var url = DashboardUrls.TelemetryTraceDetailApiUrl("http://localhost:18888", "trace1", minDurationMs: 50.5); - - Assert.Contains("minDurationMs=50.5", url); - } - [Fact] public void TelemetryLogsApiUrl_WithSearchAndOtherParams_AppendsAllParameters() { diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs index 375add4115d..80ce1381a46 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs @@ -529,7 +529,6 @@ public async Task CallService_Traces_JsonContentType_Success() ResourceKey = resource.ResourceKey, StartIndex = 0, Count = 10, - FilterText = string.Empty, Filters = [] }); Assert.NotEmpty(traces.PagedResult.Items); diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 0cc1eff64f8..50acdaa3941 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -289,7 +289,6 @@ public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() ResourceKey = resource.ResourceKey, StartIndex = 0, Count = int.MaxValue, - FilterText = string.Empty, Filters = [] }); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 83c0cbc2fa1..37d4cf903fa 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -63,26 +63,6 @@ public async Task FollowLogsAsync_StreamsAllLogs() Assert.Equal(5, receivedItems.Count); } - [Theory] - [InlineData(false, "ok-span", "error-span")] - [InlineData(true, "error-span", "ok-span")] - public void GetSpans_HasErrorFilter_ReturnsExpectedSpans(bool hasError, string expectedSpan, string excludedSpan) - { - var repository = CreateRepository(); - AddSpansWithStatus(repository); - - var service = CreateService(repository); - - var result = service.GetSpans(resourceNames: null, traceId: null, hasError: hasError, limit: null); - - Assert.NotNull(result); - Assert.Equal(1, result.ReturnedCount); - - var json = System.Text.Json.JsonSerializer.Serialize(result.Data); - Assert.Contains(expectedSpan, json); - Assert.DoesNotContain(excludedSpan, json); - } - [Theory] [InlineData(false, 1)] [InlineData(true, 1)] @@ -177,39 +157,6 @@ public void GetTrace_VariousTraceIds_ReturnsExpectedResult(string lookupId, bool } } - [Theory] - [InlineData("747261636531", 1)] - [InlineData("7472616", 1)] - [InlineData("747261", 0)] - public void GetSpans_WithTraceIdFilter_MatchesShortenedIds(string traceIdFilter, int expectedCount) - { - var repository = CreateRepository(); - var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); - - AddSpansToRepository(repository, [ - CreateSpan(traceId: traceId, spanId: "matching-span", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)), - CreateSpan(traceId: "other-trace", spanId: "other-span", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)) - ]); - - var service = CreateService(repository); - - var result = service.GetSpans(resourceNames: null, traceId: traceIdFilter, hasError: null, limit: null); - - Assert.NotNull(result); - Assert.Equal(expectedCount, result.ReturnedCount); - - var json = System.Text.Json.JsonSerializer.Serialize(result.Data); - if (expectedCount > 0) - { - Assert.Contains("matching-span", json); - } - else - { - Assert.DoesNotContain("matching-span", json); - } - Assert.DoesNotContain("other-span", json); - } - [Fact] public async Task FollowSpansAsync_WithTraceIdFilter_MatchesShortenedIds() { @@ -237,7 +184,7 @@ public async Task FollowSpansAsync_WithTraceIdFilter_MatchesShortenedIds() } [Fact] - public void GetTrace_WithMinimumDurationMs_FiltersShortSpans() + public void GetTrace_ReturnsAllSpansForTrace() { var repository = CreateRepository(); var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); @@ -249,63 +196,11 @@ public void GetTrace_WithMinimumDurationMs_FiltersShortSpans() var service = CreateService(repository); - var result = service.GetTrace("747261636531", minDurationMs: 50); - - Assert.NotNull(result); - Assert.Equal(1, result.TotalCount); - Assert.Equal(1, result.ReturnedCount); - - var span = Assert.Single(GetAllSpans(result)); - Assert.Equal("long-span", DecodeSpanId(span.SpanId)); - } - - [Fact] - public void GetSpans_WithLimit_ReturnsMostRecentSpans() - { - var repository = CreateRepository(); - AddSpansToRepository(repository, [ - CreateSpan(traceId: "trace1", spanId: "old-span", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)), - CreateSpan(traceId: "trace2", spanId: "mid-span", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)), - CreateSpan(traceId: "trace3", spanId: "new-span", startTime: s_testTime.AddMinutes(4), endTime: s_testTime.AddMinutes(5)) - ]); - - var service = CreateService(repository); - - var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: 2); - - Assert.NotNull(result); - Assert.Equal(3, result.TotalCount); - Assert.Equal(2, result.ReturnedCount); - - var json = System.Text.Json.JsonSerializer.Serialize(result.Data); - Assert.DoesNotContain("old-span", json); - Assert.Contains("mid-span", json); - Assert.Contains("new-span", json); - } - - [Fact] - public void GetSpans_WithMinimumDurationMs_FiltersShortSpans() - { - var repository = CreateRepository(); - AddSpansToRepository(repository, [ - CreateSpan(traceId: "trace1", spanId: "short-span", startTime: s_testTime, endTime: s_testTime.AddMilliseconds(49)), - CreateSpan(traceId: "trace2", spanId: "threshold-span", startTime: s_testTime.AddSeconds(1), endTime: s_testTime.AddSeconds(1).AddMilliseconds(50)), - CreateSpan(traceId: "trace3", spanId: "long-span", startTime: s_testTime.AddSeconds(2), endTime: s_testTime.AddSeconds(2).AddMilliseconds(75)) - ]); - - var service = CreateService(repository); - - var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: null, minDurationMs: 50); + var result = service.GetTrace("747261636531"); Assert.NotNull(result); Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.ReturnedCount); - - var spanIds = GetAllSpans(result).Select(s => DecodeSpanId(s.SpanId)).ToList(); - Assert.Equal(2, spanIds.Count); - Assert.Contains("threshold-span", spanIds); - Assert.Contains("long-span", spanIds); - Assert.DoesNotContain("short-span", spanIds); } [Fact] @@ -329,14 +224,14 @@ public void GetTraces_WithLimit_ReturnsMostRecentTraces() } [Fact] - public void GetTraces_WithLimitAndMinimumDurationMs_ReturnsMostRecentMatchingTraces() + public void GetTraces_WithLimitAndDurationSearchFilter_ReturnsMostRecentMatchingTraces() { var repository = CreateRepository(); AddSpans(repository, count: 3, startMinuteSpacing: 10); var service = CreateService(repository); - var result = service.GetTraces(resourceNames: null, hasError: null, limit: 2, minDurationMs: 50); + var result = service.GetTraces(resourceNames: null, hasError: null, limit: 2, search: "duration:>=50"); Assert.NotNull(result); Assert.Equal(3, result.TotalCount); @@ -350,7 +245,7 @@ public void GetTraces_WithLimitAndMinimumDurationMs_ReturnsMostRecentMatchingTra } [Fact] - public void GetTraces_WithMinimumDurationMs_FiltersShortSpans() + public void GetTraces_WithDurationSearchFilter_FiltersShortSpans() { var repository = CreateRepository(); AddSpansToRepository(repository, [ @@ -363,18 +258,22 @@ public void GetTraces_WithMinimumDurationMs_FiltersShortSpans() var service = CreateService(repository); - var result = service.GetTraces(resourceNames: null, hasError: null, limit: null, minDurationMs: 50); + var result = service.GetTraces(resourceNames: null, hasError: null, limit: null, search: "duration:>=50"); Assert.NotNull(result); + // The trace with short-trace-span (49ms) is excluded because no span matches the filter. + // The mixed-trace is included because mixed-long-span (50ms) matches, and all its spans are returned. Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.ReturnedCount); - var span = Assert.Single(GetAllSpans(result)); - Assert.Equal("mixed-long-span", DecodeSpanId(span.SpanId)); + var spans = GetAllSpans(result); + Assert.Equal(2, spans.Count); + Assert.Contains(spans, s => DecodeSpanId(s.SpanId) == "mixed-short-span"); + Assert.Contains(spans, s => DecodeSpanId(s.SpanId) == "mixed-long-span"); } [Fact] - public void GetTraces_WithHasErrorAndMinimumDurationMs_FiltersDisplayedSpansAfterTraceSelection() + public void GetTraces_WithHasErrorAndDurationSearchFilter_ReturnsAllSpansFromMatchingTraces() { var repository = CreateRepository(); AddSpansToRepository(repository, [ @@ -394,14 +293,19 @@ public void GetTraces_WithHasErrorAndMinimumDurationMs_FiltersDisplayedSpansAfte var service = CreateService(repository); - var result = service.GetTraces(resourceNames: null, hasError: true, limit: null, minDurationMs: 50); + var result = service.GetTraces(resourceNames: null, hasError: true, limit: null, search: "duration:>=50"); Assert.NotNull(result); + // The trace matches hasError because it has an error span. + // The duration filter selects the trace because long-ok-span (50ms) matches. + // All spans from the matching trace are returned. Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.ReturnedCount); - var span = Assert.Single(GetAllSpans(result)); - Assert.Equal("long-ok-span", DecodeSpanId(span.SpanId)); + var spans = GetAllSpans(result); + Assert.Equal(2, spans.Count); + Assert.Contains(spans, s => DecodeSpanId(s.SpanId) == "short-error-span"); + Assert.Contains(spans, s => DecodeSpanId(s.SpanId) == "long-ok-span"); } [Fact] @@ -497,27 +401,6 @@ public void GetLogs_WithSearch_MatchesAttributes() Assert.Equal(1, result.ReturnedCount); } - [Theory] - [InlineData("span1", 1)] - [InlineData("products", 1)] - public void GetSpans_WithSearch_FiltersSpans(string search, int expectedCount) - { - var repository = CreateRepository(); - AddSpansToRepository(repository, [ - CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), - attributes: [new KeyValuePair("http.url", "/api/products")]), - CreateSpan(traceId: "trace2", spanId: "span2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), - attributes: [new KeyValuePair("http.url", "/api/orders")]) - ]); - - var service = CreateService(repository); - - var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: null, search: search); - - Assert.NotNull(result); - Assert.Equal(expectedCount, result.ReturnedCount); - } - [Theory] [InlineData("span1", 1)] [InlineData("nonexistent-xyz", 0)] @@ -548,6 +431,115 @@ public void GetTraces_WithSearch_FiltersTraces(string search, int expectedCount) } } + [Fact] + public void GetSpans_WithAttributeFilter_FiltersSpans() + { + var repository = CreateRepository(); + AddSpansToRepository(repository, [ + CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), + attributes: [new KeyValuePair("http.method", "GET")]), + CreateSpan(traceId: "trace1", spanId: "span2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), + attributes: [new KeyValuePair("http.method", "POST")]) + ]); + + var service = CreateService(repository); + + var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: null, search: "@http.method:GET"); + + Assert.NotNull(result); + Assert.Equal(1, result.ReturnedCount); + + var spans = GetAllSpans(result); + Assert.Single(spans); + Assert.Equal("span1", DecodeSpanId(spans[0].SpanId)); + } + + [Fact] + public void GetTraces_WithAttributeFilter_FiltersTraces() + { + var repository = CreateRepository(); + AddSpansToRepository(repository, [ + CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), + attributes: [new KeyValuePair("http.method", "GET")]) + ]); + AddSpansToRepository(repository, [ + CreateSpan(traceId: "trace2", spanId: "span2", startTime: s_testTime.AddMinutes(10), endTime: s_testTime.AddMinutes(11), + attributes: [new KeyValuePair("http.method", "POST")]) + ]); + + var service = CreateService(repository); + + var result = service.GetTraces(resourceNames: null, hasError: null, limit: null, search: "@http.method:POST"); + + Assert.NotNull(result); + Assert.Equal(1, result.ReturnedCount); + + var spanIds = GetAllSpans(result).Select(s => DecodeSpanId(s.SpanId)).ToList(); + Assert.Contains("span2", spanIds); + Assert.DoesNotContain("span1", spanIds); + } + + [Fact] + public void GetLogs_WithAttributeFilter_FiltersLogs() + { + var repository = CreateRepository(); + AddLogsToRepository(repository, [ + CreateLogRecord(time: s_testTime, message: "log1", severity: SeverityNumber.Info, + attributes: [new KeyValuePair("http.method", "GET")]), + CreateLogRecord(time: s_testTime.AddMinutes(1), message: "log2", severity: SeverityNumber.Info, + attributes: [new KeyValuePair("http.method", "POST")]) + ]); + + var service = CreateService(repository); + + var result = service.GetLogs(resourceNames: null, traceId: null, severity: null, limit: null, search: "@http.method:GET"); + + Assert.NotNull(result); + Assert.Equal(1, result.ReturnedCount); + } + + [Fact] + public void GetSpans_WithDurationRangeFilter_ReturnsSpansInRange() + { + var repository = CreateRepository(); + AddSpansToRepository(repository, [ + CreateSpan(traceId: "trace1", spanId: "short-span", startTime: s_testTime, endTime: s_testTime.AddMilliseconds(30)), + CreateSpan(traceId: "trace1", spanId: "mid-span", startTime: s_testTime.AddSeconds(1), endTime: s_testTime.AddSeconds(1).AddMilliseconds(75)), + CreateSpan(traceId: "trace1", spanId: "long-span", startTime: s_testTime.AddSeconds(2), endTime: s_testTime.AddSeconds(2).AddMilliseconds(200)) + ]); + + var service = CreateService(repository); + + // Filter for spans with duration > 50ms AND < 100ms (only mid-span at 75ms matches) + var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: null, search: "duration:>50 duration:<100"); + + Assert.NotNull(result); + Assert.Equal(1, result.ReturnedCount); + + var spans = GetAllSpans(result); + Assert.Single(spans); + Assert.Equal("mid-span", DecodeSpanId(spans[0].SpanId)); + } + + [Fact] + public void GetLogs_WithUrlSearch_MatchesExactScheme() + { + var repository = CreateRepository(); + AddLogs(repository, [ + "Request to http://www.contoso.com/api completed", + "Request to https://www.contoso.com/api completed", + "No URL in this message" + ]); + + var service = CreateService(repository); + + // The entire URL should be treated as a text fragment, not parsed as a qualifier + var result = service.GetLogs(resourceNames: null, traceId: null, severity: null, limit: null, search: "http://www.contoso.com"); + + Assert.NotNull(result); + Assert.Equal(1, result.ReturnedCount); + } + /// /// Adds spans with sequential trace/span IDs to the repository. Each span is added in a separate /// AddTraces call so that it gets its own trace entry. @@ -584,17 +576,6 @@ private static void AddSpansToRepository(TelemetryRepository repository, IEnumer }); } - /// - /// Adds one OK span and one Error span to the repository for hasError filter tests. - /// - private static void AddSpansWithStatus(TelemetryRepository repository) - { - AddSpansToRepository(repository, [ - CreateSpan(traceId: "trace1", spanId: "ok-span", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), status: new Status { Code = Status.Types.StatusCode.Ok }), - CreateSpan(traceId: "trace2", spanId: "error-span", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), status: new Status { Code = Status.Types.StatusCode.Error }) - ]); - } - /// /// Adds two traces (separate trace IDs) with OK and Error status for hasError filter tests. /// diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 218f797b85f..e5125b711c7 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -1469,4 +1469,61 @@ public void AddLogs_EventName_NullWhenNotSet() Assert.Null(resource.EventName); }); } + + [Fact] + public void GetLogs_DisabledFiltersAreIgnored() + { + var repository = CreateRepository(); + + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "matching log", severity: SeverityNumber.Info), + CreateLogRecord(time: s_testTime.AddSeconds(1), message: "other log", severity: SeverityNumber.Info) + } + } + } + } + }); + + // Enabled filter matches "matching", disabled filter would exclude everything + var filters = new List + { + new FieldTelemetryFilter + { + Field = nameof(OtlpLogEntry.Message), + Value = "matching", + Condition = FilterCondition.Contains, + Enabled = true + }, + new FieldTelemetryFilter + { + Field = nameof(OtlpLogEntry.Message), + Value = "IMPOSSIBLE", + Condition = FilterCondition.Contains, + Enabled = false + } + }; + + var logs = repository.GetLogs(new GetLogsContext + { + ResourceKey = null, + StartIndex = 0, + Count = 10, + Filters = filters + }); + + // The disabled filter should be ignored — only the enabled "matching" filter applies + Assert.Single(logs.Items); + Assert.Equal("matching log", logs.Items[0].Message); + } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index cac52331be3..8b15ef02969 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 { ResourceKey = 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, FilterText = string.Empty }).PagedResult.Items); + Assert.Empty(repository.GetTraces(new GetTracesRequest { ResourceKey = 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, FilterText = string.Empty }).PagedResult.Items); + Assert.Single(repository.GetTraces(new GetTracesRequest { ResourceKey = 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, FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, 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, FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] }); + var resource2Traces = repository.GetTraces(new GetTracesRequest { ResourceKey = 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, FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, 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 { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(logs.Items); - var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, 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 { ResourceKey = null, StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(logs.Items); - var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] }); + var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, 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(resourceKey: null, cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, 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(resourceKey: null, cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, 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(resourceKey: null, filters: null, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, 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(resourceKey: null, filters: null, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = [] }, cts.Token)) { count++; } @@ -662,7 +662,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() // Act try { - await foreach (var span in repository.WatchSpansAsync(resourceKey: null, linkedCts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = [] }, linkedCts.Token)) { receivedSpans.Add(span); if (receivedSpans.Count == expectedSpans) @@ -683,6 +683,85 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() Assert.Equal("Test span. Id: span-late", receivedSpans[2].Name); } + [Fact] + public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime_AcrossTracesWithOverlappingTimes() + { + // Arrange + var repository = CreateRepository(); + + // Add two traces with multiple spans that overlap in time. + // Trace1 starts earlier but has a span that is later than Trace2's spans. + // Without explicit sorting, iterating trace-by-trace would yield: + // T=1 (trace1), T=8 (trace1), then T=3 (trace2), T=5 (trace2) + // Correct chronological order is: T=1, T=3, T=5, T=8 + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace1", spanId: "span-t1-early", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(2)), + CreateSpan(traceId: "trace1", spanId: "span-t1-late", startTime: s_testTime.AddMinutes(8), endTime: s_testTime.AddMinutes(9), parentSpanId: "span-t1-early") + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace2", spanId: "span-t2-mid1", startTime: s_testTime.AddMinutes(3), endTime: s_testTime.AddMinutes(4)), + CreateSpan(traceId: "trace2", spanId: "span-t2-mid2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(6), parentSpanId: "span-t2-mid1") + } + } + } + } + }); + + const int expectedSpans = 4; + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + using var doneCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, doneCts.Token); + var receivedSpans = new List(); + + // Act + try + { + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = [] }, linkedCts.Token)) + { + receivedSpans.Add(span); + if (receivedSpans.Count == expectedSpans) + { + doneCts.Cancel(); + } + } + } + catch (OperationCanceledException) + { + // Expected when all spans received + } + + // Assert - spans should be globally ordered by start time, not grouped by trace + Assert.Collection(receivedSpans, + span => Assert.Equal("Test span. Id: span-t1-early", span.Name), + span => Assert.Equal("Test span. Id: span-t2-mid1", span.Name), + span => Assert.Equal("Test span. Id: span-t2-mid2", span.Name), + span => Assert.Equal("Test span. Id: span-t1-late", span.Name)); + } + [Fact] public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() { @@ -730,7 +809,7 @@ public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() // Act - Watch only service1 try { - await foreach (var span in repository.WatchSpansAsync(new ResourceKey("service1", "inst1"), cts.Token)) + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = new ResourceKey("service1", "inst1"), Filters = [] }, cts.Token)) { receivedSpans.Add(span); } @@ -789,7 +868,7 @@ public async Task WatchLogsAsync_FiltersAppliedWhenPushing() // Start watching with filter var watchTask = Task.Run(async () => { - await foreach (var log in repository.WatchLogsAsync(resourceKey: null, filters: filters, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = filters }, cts.Token)) { receivedLogs.Add(log); if (receivedLogs.Count == 1) @@ -880,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(resourceKey: null, filters: filters, cts.Token)) + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = filters }, cts.Token)) { receivedLogs.Add(log); if (receivedLogs.Count == 1) @@ -930,6 +1009,283 @@ public async Task WatchLogsAsync_SeverityFilterApplied() Assert.Contains(receivedLogs, l => l.Message == "critical log"); } + [Fact] + public async Task WatchLogsAsync_TextFragmentsFilterApplied() + { + var repository = CreateRepository(); + + // Add initial logs — one matches text fragments, one doesn't + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "connection timeout error", severity: SeverityNumber.Error), + CreateLogRecord(time: s_testTime.AddSeconds(1), message: "request completed successfully", severity: SeverityNumber.Info) + } + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var receivedLogs = new List(); + var firstLogReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Watch with text fragments that should match "timeout" AND "error" + var watchTask = Task.Run(async () => + { + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest + { + ResourceKey = null, + Filters = [], + TextFragments = ["timeout", "error"] + }, cts.Token)) + { + receivedLogs.Add(log); + if (receivedLogs.Count == 1) + { + firstLogReceived.TrySetResult(); + } + if (receivedLogs.Count >= 2) + { + break; + } + } + }); + + // Wait for initial matching log to be received + await firstLogReceived.Task; + + // Add more logs — one matches both fragments, one matches only one + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime.AddSeconds(2), message: "timeout waiting for response", severity: SeverityNumber.Info), + CreateLogRecord(time: s_testTime.AddSeconds(3), message: "database timeout error occurred", severity: SeverityNumber.Error) + } + } + } + } + }); + + await watchTask; + + // Assert — only logs containing BOTH "timeout" AND "error" should be received + Assert.Equal(2, receivedLogs.Count); + Assert.Equal("connection timeout error", receivedLogs[0].Message); + Assert.Equal("database timeout error occurred", receivedLogs[1].Message); + } + + [Fact] + public async Task WatchLogsAsync_DisabledFiltersAreIgnored() + { + var repository = CreateRepository(); + + // Create two filters: one enabled (matches "match"), one disabled (excludes everything) + var filters = new List + { + new FieldTelemetryFilter + { + Field = nameof(OtlpLogEntry.Message), + Value = "match", + Condition = FilterCondition.Contains, + Enabled = true + }, + new FieldTelemetryFilter + { + Field = nameof(OtlpLogEntry.Message), + Value = "ZZZZZ_IMPOSSIBLE", + Condition = FilterCondition.Contains, + Enabled = false + } + }; + + // Add a matching log + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "this should match", severity: SeverityNumber.Info), + CreateLogRecord(time: s_testTime.AddSeconds(1), message: "no keyword here", severity: SeverityNumber.Info) + } + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var receivedLogs = new List(); + var firstLogReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var watchTask = Task.Run(async () => + { + await foreach (var log in repository.WatchLogsAsync(new WatchLogsRequest { ResourceKey = null, Filters = filters }, cts.Token)) + { + receivedLogs.Add(log); + if (receivedLogs.Count == 1) + { + firstLogReceived.TrySetResult(); + } + if (receivedLogs.Count >= 2) + { + break; + } + } + }); + + await firstLogReceived.Task; + + // Push a new matching log + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime.AddSeconds(2), message: "another match here", severity: SeverityNumber.Info), + CreateLogRecord(time: s_testTime.AddSeconds(3), message: "does not match enabled filter", severity: SeverityNumber.Info) + } + } + } + } + }); + + await watchTask; + + // The disabled filter ("ZZZZZ_IMPOSSIBLE") should be ignored. + // Only the enabled "match" filter applies. + Assert.Equal(2, receivedLogs.Count); + Assert.Equal("this should match", receivedLogs[0].Message); + Assert.Equal("another match here", receivedLogs[1].Message); + } + + [Fact] + public async Task WatchSpansAsync_DisabledFiltersAreIgnored() + { + var repository = CreateRepository(); + + // Create two filters: one enabled (matches span name containing "span1"), one disabled + var filters = new List + { + new FieldTelemetryFilter + { + Field = KnownTraceFields.NameField, + Value = "span1", + Condition = FilterCondition.Contains, + Enabled = true + }, + new FieldTelemetryFilter + { + Field = KnownTraceFields.NameField, + Value = "ZZZZZ_IMPOSSIBLE", + Condition = FilterCondition.Contains, + Enabled = false + } + }; + + // Add spans — one whose name contains "span1", one that doesn't + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)), + CreateSpan(traceId: "trace1", spanId: "span2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)) + } + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var receivedSpans = new List(); + var firstSpanReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var watchTask = Task.Run(async () => + { + await foreach (var span in repository.WatchSpansAsync(new WatchSpansRequest { ResourceKey = null, Filters = filters }, cts.Token)) + { + receivedSpans.Add(span); + if (receivedSpans.Count == 1) + { + firstSpanReceived.TrySetResult(); + } + if (receivedSpans.Count >= 2) + { + break; + } + } + }); + + await firstSpanReceived.Task; + + // Push a new span that matches the enabled filter + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace2", spanId: "span1b", startTime: s_testTime.AddMinutes(4), endTime: s_testTime.AddMinutes(5)), + CreateSpan(traceId: "trace2", spanId: "span3", startTime: s_testTime.AddMinutes(6), endTime: s_testTime.AddMinutes(7)) + } + } + } + } + }); + + await watchTask; + + // The disabled filter should be ignored — only the enabled "span1" name filter applies + Assert.Equal(2, receivedSpans.Count); + Assert.Contains("span1", receivedSpans[0].Name); + Assert.Contains("span1b", receivedSpans[1].Name); + } + #endregion private static void AddTestData(TelemetryRepository repository, string resourceName, string instanceId) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 3b4f35dff40..415e4b51728 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -79,7 +79,6 @@ public void AddTraces() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -138,7 +137,6 @@ public void AddTraces_SelfParent_Reject() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -193,7 +191,6 @@ public void AddTraces_MultipleSpansLoop_Reject() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -248,7 +245,6 @@ public void AddTraces_DuplicateTraceIds_Reject() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -319,7 +315,6 @@ public void AddTraces_Scope_Multiple() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -396,7 +391,6 @@ public void AddTraces_Traces_MultipleOutOrOrder() var traces1 = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -438,7 +432,6 @@ public void AddTraces_Traces_MultipleOutOrOrder() var traces2 = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -493,7 +486,6 @@ public void AddTraces_Spans_MultipleOutOrOrder() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -562,7 +554,6 @@ public void AddTraces_SpanEvents_ReturnData() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -639,7 +630,6 @@ public void AddTraces_SpanLinks_ReturnData() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -729,7 +719,6 @@ public void GetTraces_ReturnCopies() var traces1 = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -745,7 +734,6 @@ public void GetTraces_ReturnCopies() var traces2 = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -813,7 +801,6 @@ public void AddTraces_AttributeAndEventLimits_LimitsApplied() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -865,7 +852,6 @@ public void AddTraces_Links_BacklinksPopulated() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -937,7 +923,6 @@ public void AddTraces_ExceedLimit_FirstInFirstOut() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1044,7 +1029,6 @@ public void AddTraces_MultipleRootSpans_RootSpanIsEarliestWithoutParent() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1114,7 +1098,6 @@ public void GetTraces_MultipleInstances() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1178,7 +1161,6 @@ public void GetTraces_AttributeFilters() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [ @@ -1197,7 +1179,6 @@ public void GetTraces_AttributeFilters() traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [ @@ -1216,7 +1197,6 @@ public void GetTraces_AttributeFilters() traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [ @@ -1269,7 +1249,6 @@ public void GetTraces_KnownFilters(string name, string value) var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [ @@ -1285,7 +1264,6 @@ public void GetTraces_KnownFilters(string name, string value) traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [ @@ -1340,7 +1318,6 @@ public void GetTraces_FiltersPagingAndMaxDuration_ComputedFromAllMatchingTraces( var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = new ResourceKey("resource1", InstanceId: null), - FilterText = string.Empty, StartIndex = 1, Count = 1, Filters = @@ -1400,7 +1377,6 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [new FieldTelemetryFilter { Field = KnownTraceFields.DurationField, Condition = FilterCondition.GreaterThan, Value = "50" }] @@ -1413,7 +1389,6 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [new FieldTelemetryFilter { Field = KnownTraceFields.DurationField, Condition = FilterCondition.LessThan, Value = "10" }] @@ -1451,7 +1426,6 @@ public void GetTraces_NotEqualFilter_NonMatchingValue_ReturnsTrace() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = new ResourceKey("resource1", InstanceId: null), - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [ @@ -1475,7 +1449,6 @@ public void AddTraces_OutOfOrder_FullName() var request = new GetTracesRequest { ResourceKey = new ResourceKey("TestService", "TestId"), - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1678,7 +1651,6 @@ public void AddTraces_SameResourceDifferentProperties_MultipleResourceViews() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resource.ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1796,7 +1768,6 @@ public void RemoveTraces_All() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1875,7 +1846,6 @@ public void RemoveTraces_SelectedResource() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -1981,7 +1951,6 @@ public void RemoveTraces_MultipleSelectedResources() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -2073,7 +2042,6 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = null, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -2146,7 +2114,6 @@ public void AddTraces_HaveUninstrumentedPeers() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = uninstrumentedPeerApp.ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -2225,7 +2192,6 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() var traces = repository.GetTraces(new GetTracesRequest { ResourceKey = resources[0].ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -2267,7 +2233,6 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() traces = repository.GetTraces(new GetTracesRequest { ResourceKey = uninstrumentedPeerApp.ResourceKey, - FilterText = string.Empty, StartIndex = 0, Count = 10, Filters = [] @@ -2331,4 +2296,562 @@ public void AddTraces_UninstrumentedPeer_InstanceIdDashes_AppKeyResolvedCorrectl Assert.True(resource.UninstrumentedPeer); }); } + + [Fact] + public void GetSpans_ReturnsAllSpans() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"), + CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(8)) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [] + }); + + // Assert + Assert.Equal(3, result.PagedResult.TotalItemCount); + Assert.Equal(3, result.PagedResult.Items.Count); + } + + [Fact] + public void GetSpans_FilterByTraceId_ReturnsMatchingSpans() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"), + CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(8)) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + TraceId = "31" // hex prefix of "1" + }); + + // Assert + Assert.Equal(2, result.PagedResult.TotalItemCount); + Assert.All(result.PagedResult.Items, s => AssertId("1", s.TraceId)); + } + + [Fact] + public void GetSpans_FilterByHasError_ReturnsErrorSpansOnly() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), status: new Status { Code = Status.Types.StatusCode.Error }), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", status: new Status { Code = Status.Types.StatusCode.Ok }) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + HasError = true + }); + + // Assert + Assert.Equal(1, result.PagedResult.TotalItemCount); + AssertId("1-1", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), status: new Status { Code = Status.Types.StatusCode.Error }), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", status: new Status { Code = Status.Types.StatusCode.Ok }) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + HasError = false + }); + + // Assert + Assert.Equal(1, result.PagedResult.TotalItemCount); + AssertId("1-2", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_FilterByResource_ReturnsMatchingSpans() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(name: "service-a", instanceId: "a1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "service-b", instanceId: "b1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(8)) + } + } + } + } + }); + + var resources = repository.GetResources(); + var serviceA = resources.Single(r => r.ResourceName == "service-a"); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = serviceA.ResourceKey, + StartIndex = 0, + Count = int.MaxValue, + Filters = [] + }); + + // Assert + Assert.Equal(1, result.PagedResult.TotalItemCount); + AssertId("1-1", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_FilterByDuration_ReturnsMatchingSpans() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // 9 minutes = 540000ms + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + // 1 minute = 60000ms + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(6), parentSpanId: "1-1") + } + } + } + } + }); + + // Act - filter for spans with duration >= 100000ms (100s) + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = + [ + new FieldTelemetryFilter + { + Field = KnownTraceFields.DurationField, + Condition = FilterCondition.GreaterThanOrEqual, + Value = "100000" + } + ] + }); + + // Assert - only the long span matches + Assert.Equal(1, result.PagedResult.TotalItemCount); + AssertId("1-1", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_FilterByTextFragments_ReturnsMatchingSpans() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), attributes: [KeyValuePair.Create("http.url", "https://example.com/api")]), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", attributes: [KeyValuePair.Create("db.system", "postgresql")]) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + TextFragments = ["example.com"] + }); + + // Assert + Assert.Equal(1, result.PagedResult.TotalItemCount); + AssertId("1-1", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_Pagination_ReturnsCorrectPage() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1"), + CreateSpan(traceId: "1", spanId: "1-3", startTime: s_testTime.AddMinutes(3), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1") + } + } + } + } + }); + + // Act - get second page (skip 1, take 1) + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 1, + Count = 1, + Filters = [] + }); + + // Assert + Assert.Equal(3, result.PagedResult.TotalItemCount); + Assert.Single(result.PagedResult.Items); + AssertId("1-2", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_CombinedFilters_ReturnsMatchingSpans() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10), status: new Status { Code = Status.Types.StatusCode.Error }, attributes: [KeyValuePair.Create("http.url", "https://example.com")]), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1", status: new Status { Code = Status.Types.StatusCode.Ok }, attributes: [KeyValuePair.Create("http.url", "https://example.com")]), + CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(8), status: new Status { Code = Status.Types.StatusCode.Error }, attributes: [KeyValuePair.Create("db.system", "redis")]) + } + } + } + } + }); + + // Act - filter for error spans with "example.com" text + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + HasError = true, + TextFragments = ["example.com"] + }); + + // Assert + Assert.Equal(1, result.PagedResult.TotalItemCount); + AssertId("1-1", result.PagedResult.Items[0].SpanId); + } + + [Fact] + public void GetSpans_EmptyRepository_ReturnsEmpty() + { + // Arrange + var repository = CreateRepository(); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [] + }); + + // Assert + Assert.Equal(0, result.PagedResult.TotalItemCount); + Assert.Empty(result.PagedResult.Items); + } + + [Fact] + public void GetSpans_UnknownResource_ReturnsEmpty() + { + // Arrange + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = ResourceKey.Create("nonexistent", "unknown"), + StartIndex = 0, + Count = int.MaxValue, + Filters = [] + }); + + // Assert + Assert.Equal(0, result.PagedResult.TotalItemCount); + Assert.Empty(result.PagedResult.Items); + } + + [Theory] + [InlineData("747261636531", 1)] // full hex trace ID — prefix match + [InlineData("7472616", 1)] // 7 chars — meets ShortenedIdLength, prefix match + [InlineData("747261", 0)] // 6 chars — below ShortenedIdLength, requires exact match + public void GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilter, int expectedCount) + { + // Arrange + var repository = CreateRepository(); + + // Use a trace ID whose hex representation is "747261636531" (UTF-8 bytes of "trace1") + var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: traceId, spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "other", spanId: "2-1", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(8)) + } + } + } + } + }); + + // Act + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + TraceId = traceIdFilter + }); + + // Assert + Assert.Equal(expectedCount, result.PagedResult.TotalItemCount); + } + + [Fact] + public void GetSpans_DisabledFiltersAreIgnored() + { + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)), + CreateSpan(traceId: "trace1", spanId: "span2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)) + } + } + } + } + }); + + // Enabled filter matches span name containing "span1", disabled filter would exclude everything + var result = repository.GetSpans(new GetSpansRequest + { + ResourceKey = null, + StartIndex = 0, + Count = int.MaxValue, + Filters = + [ + new FieldTelemetryFilter + { + Field = KnownTraceFields.NameField, + Value = "span1", + Condition = FilterCondition.Contains, + Enabled = true + }, + new FieldTelemetryFilter + { + Field = KnownTraceFields.NameField, + Value = "IMPOSSIBLE", + Condition = FilterCondition.Contains, + Enabled = false + } + ] + }); + + // The disabled filter should be ignored — only the enabled "span1" filter applies + Assert.Equal(1, result.PagedResult.TotalItemCount); + Assert.Contains("span1", result.PagedResult.Items[0].Name); + } }