From bea0a2d7e33233fe58069a08d6bfa30dd1fe48e4 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 11:27:45 +0800 Subject: [PATCH 1/9] Add --dashboard-url and --api-key options to aspire agent mcp and aspire otel commands Add support for connecting directly to a standalone Aspire Dashboard without an AppHost via --dashboard-url and optional --api-key options on the 'aspire agent mcp' and 'aspire otel logs/spans/traces' commands. - Introduce IDashboardInfoProvider abstraction (BackchannelDashboardInfoProvider and StaticDashboardInfoProvider) to decouple MCP tools from the backchannel - Add --dashboard-url and --api-key options to TelemetryLogs/Spans/TracesCommand - Add dashboard-only mode to AgentMcpCommand (exposes only telemetry tools) - Add smart error handling: 401 suggests --api-key, 404 checks if API is enabled, connection refused reports unreachable dashboard - Add tests for all new functionality --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 124 +++++--- .../Commands/TelemetryCommandHelpers.cs | 92 +++++- .../Commands/TelemetryLogsCommand.cs | 23 +- .../Commands/TelemetrySpansCommand.cs | 23 +- .../Commands/TelemetryTracesCommand.cs | 38 ++- .../Mcp/Tools/IDashboardInfoProvider.cs | 45 +++ .../Mcp/Tools/ListStructuredLogsTool.cs | 8 +- .../Mcp/Tools/ListTraceStructuredLogsTool.cs | 8 +- src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs | 8 +- .../TelemetryCommandStrings.Designer.cs | 42 +++ .../Resources/TelemetryCommandStrings.resx | 21 ++ .../xlf/TelemetryCommandStrings.cs.xlf | 35 +++ .../xlf/TelemetryCommandStrings.de.xlf | 35 +++ .../xlf/TelemetryCommandStrings.es.xlf | 35 +++ .../xlf/TelemetryCommandStrings.fr.xlf | 35 +++ .../xlf/TelemetryCommandStrings.it.xlf | 35 +++ .../xlf/TelemetryCommandStrings.ja.xlf | 35 +++ .../xlf/TelemetryCommandStrings.ko.xlf | 35 +++ .../xlf/TelemetryCommandStrings.pl.xlf | 35 +++ .../xlf/TelemetryCommandStrings.pt-BR.xlf | 35 +++ .../xlf/TelemetryCommandStrings.ru.xlf | 35 +++ .../xlf/TelemetryCommandStrings.tr.xlf | 35 +++ .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 35 +++ .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 35 +++ .../Commands/AgentMcpCommandTests.cs | 294 ++++++++++-------- .../Commands/TelemetryLogsCommandTests.cs | 239 ++++++++++++++ .../Commands/TelemetrySpansCommandTests.cs | 109 +++++++ .../Commands/TelemetryTracesCommandTests.cs | 109 +++++++ .../Mcp/ListStructuredLogsToolTests.cs | 4 +- .../Mcp/ListTracesToolTests.cs | 4 +- 30 files changed, 1453 insertions(+), 193 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index beafae19f11..f652a634830 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -28,13 +28,23 @@ namespace Aspire.Cli.Commands; /// internal sealed class AgentMcpCommand : BaseCommand { - private readonly Dictionary _knownTools; + private readonly Dictionary _knownTools = []; private readonly IMcpResourceToolRefreshService _resourceToolRefreshService; private McpServer? _server; private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor; private readonly IMcpTransportFactory _transportFactory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IPackagingService _packagingService; + private readonly IEnvironmentChecker _environmentChecker; + private readonly IDocsSearchService _docsSearchService; + private readonly IDocsIndexService _docsIndexService; + private readonly CliExecutionContext _executionContext; + private bool _dashboardOnlyMode; + + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); /// /// Gets the dictionary of known MCP tools. Exposed for testing purposes. @@ -62,24 +72,16 @@ public AgentMcpCommand( _transportFactory = transportFactory; _loggerFactory = loggerFactory; _logger = logger; + _httpClientFactory = httpClientFactory; + _packagingService = packagingService; + _environmentChecker = environmentChecker; + _docsSearchService = docsSearchService; + _docsIndexService = docsIndexService; + _executionContext = executionContext; _resourceToolRefreshService = new McpResourceToolRefreshService(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()); - _knownTools = new Dictionary - { - [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), - [KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), - [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), - [KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), - [KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), - [KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor), - [KnownMcpTools.Doctor] = new DoctorTool(environmentChecker), - [KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService), - [KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService), - [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService), - [KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService) - }; + + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override bool UpdateNotificationsEnabled => false; @@ -95,6 +97,40 @@ internal Task ExecuteCommandAsync(ParseResult parseResult, CancellationToke protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); + + if (dashboardUrl is not null) + { + _dashboardOnlyMode = true; + var uri = new Uri(dashboardUrl); + var baseUrl = $"{uri.Scheme}://{uri.Authority}"; + IDashboardInfoProvider staticProvider = new StaticDashboardInfoProvider(baseUrl, apiKey); + + _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + } + else + { + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(_auxiliaryBackchannelMonitor, _logger); + + _knownTools[KnownMcpTools.ListResources] = new ListResourcesTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(_auxiliaryBackchannelMonitor, _executionContext); + _knownTools[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(_auxiliaryBackchannelMonitor, _executionContext); + _knownTools[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(_packagingService, _executionContext, _auxiliaryBackchannelMonitor); + _knownTools[KnownMcpTools.Doctor] = new DoctorTool(_environmentChecker); + _knownTools[KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService); + _knownTools[KnownMcpTools.ListDocs] = new ListDocsTool(_docsIndexService); + _knownTools[KnownMcpTools.SearchDocs] = new SearchDocsTool(_docsSearchService, _docsIndexService); + _knownTools[KnownMcpTools.GetDoc] = new GetDocTool(_docsIndexService); + } + var icons = McpIconHelper.GetAspireIcons(typeof(AgentMcpCommand).Assembly, "Aspire.Cli.Mcp.Resources"); var options = new McpServerOptions @@ -135,32 +171,40 @@ private async ValueTask HandleListToolsAsync(RequestContext(); - tools.AddRange(KnownTools.Values.Select(tool => new Tool + tools.AddRange(KnownTools.Select(tool => new Tool { - Name = tool.Name, - Description = tool.Description, - InputSchema = tool.GetInputSchema() + Name = tool.Value.Name, + Description = tool.Value.Description, + InputSchema = tool.Value.GetInputSchema() })); try { - // Refresh resource tools if needed (e.g., AppHost selection changed or invalidated) - if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap)) + // In dashboard-only mode, skip resource tool discovery + if (_dashboardOnlyMode) { - // Don't send tools/list_changed here — the client already called tools/list - // and will receive the up-to-date result. Sending a notification during the - // list handler would cause the client to call tools/list again, creating an - // infinite loop when tool availability is unstable (e.g., container MCP tools - // oscillating between available/unavailable). - (resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken); + _logger.LogDebug("Dashboard-only mode: skipping resource tool discovery"); } - - tools.AddRange(resourceToolMap.Select(x => new Tool + else { - Name = x.Key, - Description = x.Value.Tool.Description, - InputSchema = x.Value.Tool.InputSchema - })); + // Refresh resource tools if needed (e.g., AppHost selection changed or invalidated) + if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap)) + { + // Don't send tools/list_changed here — the client already called tools/list + // and will receive the up-to-date result. Sending a notification during the + // list handler would cause the client to call tools/list again, creating an + // infinite loop when tool availability is unstable (e.g., container MCP tools + // oscillating between available/unavailable). + (resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken); + } + + tools.AddRange(resourceToolMap.Select(x => new Tool + { + Name = x.Key, + Description = x.Value.Tool.Description, + InputSchema = x.Value.Tool.InputSchema + })); + } } catch (Exception ex) { @@ -179,6 +223,14 @@ private async ValueTask HandleCallToolAsync(RequestContext + /// Dashboard URL option for connecting directly to a standalone dashboard. + /// + internal static Option CreateDashboardUrlOption() => new("--dashboard-url") + { + Description = TelemetryCommandStrings.DashboardUrlOptionDescription + }; + + /// + /// API key option for authenticating with a standalone dashboard. + /// + internal static Option CreateApiKeyOption() => new("--api-key") + { + Description = TelemetryCommandStrings.ApiKeyOptionDescription + }; + #endregion /// @@ -105,8 +123,26 @@ public static bool HasJsonContentType(HttpResponseMessage response) AppHostConnectionResolver connectionResolver, IInteractionService interactionService, FileInfo? projectFile, + string? dashboardUrl, + string? apiKey, CancellationToken cancellationToken) { + // Validate mutual exclusivity of --apphost and --dashboard-url + if (projectFile is not null && dashboardUrl is not null) + { + interactionService.DisplayError(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive); + return (false, null, null, null, ExitCodeConstants.InvalidCommand); + } + + // Direct dashboard URL mode — bypass AppHost discovery + if (dashboardUrl is not null) + { + var uri = new Uri(dashboardUrl); + var baseUrl = $"{uri.Scheme}://{uri.Authority}"; + var token = apiKey ?? string.Empty; + return (true, baseUrl, token, baseUrl, 0); + } + var result = await connectionResolver.ResolveConnectionAsync( projectFile, SharedCommandStrings.ScanningForRunningAppHosts, @@ -128,9 +164,9 @@ public static bool HasJsonContentType(HttpResponseMessage response) } // Extract dashboard base URL (without /login path) for hyperlinks - var dashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault()); + var extractedDashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault()); - return (true, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, dashboardUrl, 0); + return (true, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, extractedDashboardUrl, 0); } /// @@ -155,10 +191,60 @@ public static bool HasJsonContentType(HttpResponseMessage response) public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiToken) { var client = factory.CreateClient(); - client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken); + if (!string.IsNullOrEmpty(apiToken)) + { + client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken); + } return client; } + /// + /// Produces a user-friendly error message for dashboard API failures when using --dashboard-url. + /// + public static async Task GetDashboardApiErrorMessageAsync( + HttpRequestException ex, + string dashboardBaseUrl, + IHttpClientFactory httpClientFactory, + ILogger logger, + CancellationToken cancellationToken) + { + if (ex.StatusCode == HttpStatusCode.Unauthorized) + { + return TelemetryCommandStrings.DashboardAuthFailed; + } + + if (ex.StatusCode == HttpStatusCode.NotFound) + { + // Probe the dashboard base URL to distinguish "wrong URL" from "API not enabled" + try + { + using var probeClient = httpClientFactory.CreateClient(); + var probeResponse = await probeClient.GetAsync(dashboardBaseUrl, cancellationToken).ConfigureAwait(false); + + if (probeResponse.IsSuccessStatusCode) + { + // Dashboard is reachable but the API endpoint returned 404 — API not enabled + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardApiNotEnabled, dashboardBaseUrl); + } + } + catch (Exception probeEx) + { + logger.LogDebug(probeEx, "Dashboard probe failed for {Url}", dashboardBaseUrl); + } + + // Dashboard base URL is also not reachable — wrong URL + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlNotReachable, dashboardBaseUrl); + } + + if (ex.StatusCode is null) + { + // No HTTP status — connection refused or network error + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardConnectionFailed, dashboardBaseUrl); + } + + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message); + } + public static bool TryResolveResourceNames( string? resourceName, IList resources, diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index d07af446a8e..6b2b0dd8a65 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -37,6 +37,8 @@ internal sealed class TelemetryLogsCommand : BaseCommand private static readonly Option s_formatOption = TelemetryCommandHelpers.CreateFormatOption(); private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id"); + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); // Logs-specific option private static readonly Option s_severityOption = new("--severity") { @@ -69,6 +71,8 @@ public TelemetryLogsCommand( Options.Add(s_formatOption); Options.Add(s_limitOption); Options.Add(s_traceIdOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); Options.Add(s_severityOption); } @@ -83,6 +87,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var limit = parseResult.GetValue(s_limitOption); var traceId = parseResult.GetValue(s_traceIdOption); var severity = parseResult.GetValue(s_severityOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -92,14 +98,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, cancellationToken); + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, cancellationToken); if (!success) { return exitCode; } - return await FetchLogsAsync(baseUrl!, apiToken!, resourceName, traceId, severity, limit, follow, format, cancellationToken); + return await FetchLogsAsync(baseUrl!, apiToken!, resourceName, traceId, severity, limit, follow, format, dashboardUrl, cancellationToken); } private async Task FetchLogsAsync( @@ -111,6 +117,7 @@ private async Task FetchLogsAsync( int? limit, bool follow, OutputFormat format, + string? dashboardUrl, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -160,7 +167,17 @@ private async Task FetchLogsAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch logs from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + + if (dashboardUrl is not null) + { + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + } + else + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + } + return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 7c06a63ede3..90a9e0a501a 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -38,6 +38,8 @@ internal sealed class TelemetrySpansCommand : BaseCommand private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id"); private static readonly Option s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption(); + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); public TelemetrySpansCommand( IInteractionService interactionService, @@ -66,6 +68,8 @@ public TelemetrySpansCommand( Options.Add(s_limitOption); Options.Add(s_traceIdOption); Options.Add(s_hasErrorOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -79,6 +83,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var limit = parseResult.GetValue(s_limitOption); var traceId = parseResult.GetValue(s_traceIdOption); var hasError = parseResult.GetValue(s_hasErrorOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -88,14 +94,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, cancellationToken); + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, cancellationToken); if (!success) { return exitCode; } - return await FetchSpansAsync(baseUrl!, apiToken!, resourceName, traceId, hasError, limit, follow, format, cancellationToken); + return await FetchSpansAsync(baseUrl!, apiToken!, resourceName, traceId, hasError, limit, follow, format, dashboardUrl, cancellationToken); } private async Task FetchSpansAsync( @@ -107,6 +113,7 @@ private async Task FetchSpansAsync( int? limit, bool follow, OutputFormat format, + string? dashboardUrl, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -161,7 +168,17 @@ private async Task FetchSpansAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch spans from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + + if (dashboardUrl is not null) + { + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + } + else + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + } + return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index b97018fd7e5..dc31a589da6 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -37,6 +37,8 @@ internal sealed class TelemetryTracesCommand : BaseCommand private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id", "-t"); private static readonly Option s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption(); + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); public TelemetryTracesCommand( IInteractionService interactionService, @@ -64,6 +66,8 @@ public TelemetryTracesCommand( Options.Add(s_limitOption); Options.Add(s_traceIdOption); Options.Add(s_hasErrorOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -76,6 +80,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var limit = parseResult.GetValue(s_limitOption); var traceId = parseResult.GetValue(s_traceIdOption); var hasError = parseResult.GetValue(s_hasErrorOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -85,7 +91,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, cancellationToken); + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, cancellationToken); if (!success) { @@ -94,11 +100,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (!string.IsNullOrEmpty(traceId)) { - return await FetchSingleTraceAsync(baseUrl!, apiToken!, traceId, format, cancellationToken); + return await FetchSingleTraceAsync(baseUrl!, apiToken!, traceId, format, dashboardUrl, cancellationToken); } else { - return await FetchTracesAsync(baseUrl!, apiToken!, resourceName, hasError, limit, format, cancellationToken); + return await FetchTracesAsync(baseUrl!, apiToken!, resourceName, hasError, limit, format, dashboardUrl, cancellationToken); } } @@ -107,6 +113,7 @@ private async Task FetchSingleTraceAsync( string apiToken, string traceId, OutputFormat format, + string? dashboardUrl, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -157,7 +164,17 @@ private async Task FetchSingleTraceAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch trace from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + + if (dashboardUrl is not null) + { + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + } + else + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + } + return ExitCodeConstants.DashboardFailure; } } @@ -169,6 +186,7 @@ private async Task FetchTracesAsync( bool? hasError, int? limit, OutputFormat format, + string? dashboardUrl, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -231,7 +249,17 @@ private async Task FetchTracesAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + + if (dashboardUrl is not null) + { + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + } + else + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + } + return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs b/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs new file mode 100644 index 00000000000..9718fdedc31 --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Mcp.Tools; + +/// +/// Provides dashboard connection info (API token, base URL, dashboard UI URL) for telemetry access. +/// +internal interface IDashboardInfoProvider +{ + /// + /// Gets dashboard connection info for telemetry API access. + /// + /// A tuple of (apiToken, apiBaseUrl, dashboardBaseUrl). apiToken may be empty for unsecured dashboards. + Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken); +} + +/// +/// Gets dashboard info from the AppHost backchannel (default behavior). +/// +internal sealed class BackchannelDashboardInfoProvider( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + ILogger logger) : IDashboardInfoProvider +{ + public Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken) + { + return McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken); + } +} + +/// +/// Returns dashboard info from statically-provided URL and optional API key (for standalone dashboards). +/// +internal sealed class StaticDashboardInfoProvider(string dashboardUrl, string? apiKey) : IDashboardInfoProvider +{ + public Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken) + { + // For unsecured dashboards, apiToken is empty string (no X-API-Key header will be sent) + var apiToken = apiKey ?? string.Empty; + return Task.FromResult((apiToken, dashboardUrl, (string?)dashboardUrl)); + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index 4df3c286b4d..61420613d46 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing structured logs. /// Gets log data directly from the Dashboard telemetry API. /// -internal sealed class ListStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListStructuredLogs; @@ -43,7 +42,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await dashboardInfoProvider.GetDashboardInfoAsync(cancellationToken).ConfigureAwait(false); // Extract resourceName from arguments string? resourceName = null; @@ -101,7 +100,8 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch structured logs from Dashboard API"); - throw new McpProtocolException($"Failed to fetch structured logs: {ex.Message}", McpErrorCode.InternalError); + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken); + throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 87320996e7f..39f3eb1b496 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing structured logs for a specific distributed trace. /// Gets log data directly from the Dashboard telemetry API. /// -internal sealed class ListTraceStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListTraceStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraceStructuredLogs; @@ -44,7 +43,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await dashboardInfoProvider.GetDashboardInfoAsync(cancellationToken).ConfigureAwait(false); // Extract traceId from arguments (required) string? traceId = null; @@ -101,7 +100,8 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch structured logs for trace from Dashboard API"); - throw new McpProtocolException($"Failed to fetch structured logs for trace: {ex.Message}", McpErrorCode.InternalError); + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken); + throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 187b8479a7f..71ca34dbf57 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing distributed traces. /// Gets trace data directly from the Dashboard telemetry API. /// -internal sealed class ListTracesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListTracesTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraces; @@ -43,7 +42,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await dashboardInfoProvider.GetDashboardInfoAsync(cancellationToken).ConfigureAwait(false); // Extract resourceName from arguments string? resourceName = null; @@ -101,7 +100,8 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - throw new McpProtocolException($"Failed to fetch traces: {ex.Message}", McpErrorCode.InternalError); + var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken); + throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs index 0b3a98e079c..709f11abc74 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -248,5 +248,47 @@ internal static string HeaderStatus { return ResourceManager.GetString("HeaderStatus", resourceCulture); } } + + internal static string DashboardUrlOptionDescription { + get { + return ResourceManager.GetString("DashboardUrlOptionDescription", resourceCulture); + } + } + + internal static string ApiKeyOptionDescription { + get { + return ResourceManager.GetString("ApiKeyOptionDescription", resourceCulture); + } + } + + internal static string DashboardUrlAndAppHostExclusive { + get { + return ResourceManager.GetString("DashboardUrlAndAppHostExclusive", resourceCulture); + } + } + + internal static string DashboardAuthFailed { + get { + return ResourceManager.GetString("DashboardAuthFailed", resourceCulture); + } + } + + internal static string DashboardUrlNotReachable { + get { + return ResourceManager.GetString("DashboardUrlNotReachable", resourceCulture); + } + } + + internal static string DashboardApiNotEnabled { + get { + return ResourceManager.GetString("DashboardApiNotEnabled", resourceCulture); + } + } + + internal static string DashboardConnectionFailed { + get { + return ResourceManager.GetString("DashboardConnectionFailed", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index e280daac87c..01d1b8a2412 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -186,4 +186,25 @@ Status + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index f57dbf060b5..d12f7eb72ec 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Rozhraní API řídicího panelu není k dispozici. Ujistěte se, že hostitel aplikací běží s povoleným řídicím panelem. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Umožňuje zobrazit telemetrická data (protokoly, rozsahy, trasování) ze spuštěné aplikace Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index f206be4c57b..91fbc716896 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Die Dashboard-API ist nicht verfügbar. Stellen Sie sicher, dass der AppHost mit aktiviertem Dashboard ausgeführt wird. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Zeigen Sie Telemetriedaten (Protokolle, Spans, Traces) einer laufenden Aspire-Anwendung an. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index 137d856362e..3d5dbb28b2d 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. La API de panel no está disponible. Asegúrese de que AppHost se esté ejecutando con Panel habilitado. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Ver los datos de telemetría (registros, intervalos, seguimientos) de una aplicación de Aspire en ejecución. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 60461856b4d..f204cc07f1b 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Désolé, l’API du tableau de bord n’est pas disponible. Vérifiez que AppHost fonctionne avec le tableau de bord activé. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Affichez les données de télémétrie (journaux, spans, traces) d’une application Aspire en cours d’exécution. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 9b65868633c..1fd9e49e2df 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. L'API del dashboard non è disponibile. Verificare che AppHost sia in esecuzione con il dashboard abilitato. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Visualizza i dati di telemetria (log, span, tracce) da un'applicazione Aspire in esecuzione. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index 2cda145956f..baaec034002 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. ダッシュボード API は利用できません。ダッシュボードが有効になっている状態で AppHost が実行されていることを確認します。 + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 実行中の Aspire アプリケーションのテレメトリ データ (ログ、スパン、トレース) を表示します。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 2307913ec36..fdfe7ffa5a9 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. 대시보드 API를 사용할 수 없습니다. AppHost가 대시보드를 활성화한 상태로 실행 중인지 확인하세요. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 실행 중인 Aspire 애플리케이션의 원격 분석 데이터(로그, 범위, 추적)를 확인합니다. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 78ff6c1aac2..edda69d59d7 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Interfejs API pulpitu nawigacyjnego jest niedostępny. Upewnij się, że host aplikacji działa z włączonym pulpitem nawigacyjnym. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Wyświetl dane telemetryczne (logi, zakresy, śledzenia) z uruchomionej aplikacji Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 3ec8a739d07..68f06704e7f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. A API do painel não está disponível. Verifique se o AppHost está em execução com o Painel habilitado. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Veja os dados de telemetria (logs, spans, rastreamentos) de um aplicativo Aspire em execução. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 2c92b98b1a1..a6c6d25006c 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. API панели мониторинга недоступен. Убедитесь, что AppHost запущен с включенной панелью мониторинга. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Просмотр данных телеметрии (журналы, диапазоны, трассировки) из запущенного приложения Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 61d3d5c0536..8f1c01efe09 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Pano API'si kullanılamıyor. AppHost'un Pano özelliği etkin olarak çalıştığından emin olun. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Çalışan bir Aspire uygulamasından telemetri verilerini (günlükler, yayılmalar, izlemeler) görüntüleyin. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index 4e374c36db1..75091aae61f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. 仪表板 API 不可用。请确保 AppHost 在运行时启用了仪表板。 + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 查看正在运行的 Aspire 应用程序中的遥测数据(日志、跨度、跟踪)。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 52a4ff8a881..7efbb9118ae 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -2,11 +2,46 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. 儀表板 API 無法使用。確保 AppHost 執行中且儀表板功能已啟用。 + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 從執行中的 Aspire 應用程式檢視遙測資料 (記錄、範圍、追蹤)。 diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs index 3bf05fa8694..b635baf6002 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Cli.Mcp; @@ -9,6 +10,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Client; @@ -22,105 +24,81 @@ namespace Aspire.Cli.Tests.Commands; /// without starting a new CLI process. The IO communication between the MCP server /// and test client is abstracted using in-memory pipes via DI. /// -public class AgentMcpCommandTests(ITestOutputHelper outputHelper) : IAsyncLifetime +public class AgentMcpCommandTests(ITestOutputHelper outputHelper) { - private TemporaryWorkspace _workspace = null!; - private ServiceProvider _serviceProvider = null!; - private TestMcpServerTransport _testTransport = null!; - private McpClient _mcpClient = null!; - private AgentMcpCommand _agentMcpCommand = null!; - private Task _serverRunTask = null!; - private CancellationTokenSource _cts = null!; - private ILoggerFactory _loggerFactory = null!; - private TestAuxiliaryBackchannelMonitor _backchannelMonitor = null!; - - public async ValueTask InitializeAsync() + private async Task CreateMcpClientAsync(string? dashboardUrl = null) { - _cts = new CancellationTokenSource(); - _workspace = TemporaryWorkspace.Create(outputHelper); + var cts = new CancellationTokenSource(); + var workspace = TemporaryWorkspace.Create(outputHelper); + var loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); + var testTransport = new TestMcpServerTransport(loggerFactory); + var backchannelMonitor = new TestAuxiliaryBackchannelMonitor(); - // Create the test transport with in-memory pipes - _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); - _testTransport = new TestMcpServerTransport(_loggerFactory); - - // Create a backchannel monitor that we can configure for resource tool tests - _backchannelMonitor = new TestAuxiliaryBackchannelMonitor(); - - // Create services using CliTestHelper with custom MCP transport and test docs service - var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper, options => + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - // Override the MCP transport factory with our test transport (which implements IMcpTransportFactory) - options.McpServerTransportFactory = _ => _testTransport; - // Override the docs index service with a test implementation that doesn't make network calls + options.McpServerTransportFactory = _ => testTransport; options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); - // Override the backchannel monitor with our test implementation - options.AuxiliaryBackchannelMonitorFactory = _ => _backchannelMonitor; + options.AuxiliaryBackchannelMonitorFactory = _ => backchannelMonitor; }); - _serviceProvider = services.BuildServiceProvider(); + if (dashboardUrl is not null) + { + 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/")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"data\":{},\"totalCount\":0,\"returnedCount\":0}", System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + } - // Get the AgentMcpCommand from DI and start the server - _agentMcpCommand = _serviceProvider.GetRequiredService(); - var rootCommand = _serviceProvider.GetRequiredService(); - var parseResult = rootCommand.Parse("agent mcp"); + var serviceProvider = services.BuildServiceProvider(); + var agentMcpCommand = serviceProvider.GetRequiredService(); + var rootCommand = serviceProvider.GetRequiredService(); + var commandLine = dashboardUrl is not null + ? $"agent mcp --dashboard-url {dashboardUrl}" + : "agent mcp"; + var parseResult = rootCommand.Parse(commandLine); - // Start the MCP server in the background - _serverRunTask = Task.Run(async () => + var serverRunTask = Task.Run(async () => { try { - await _agentMcpCommand.ExecuteCommandAsync(parseResult, _cts.Token); + await agentMcpCommand.ExecuteCommandAsync(parseResult, cts.Token); } catch (OperationCanceledException) { - // Expected when cancellation is requested } - }, _cts.Token); - - // Create and connect the MCP client using the test transport's client side - _mcpClient = await _testTransport.CreateClientAsync(_loggerFactory, _cts.Token); - } - - public async ValueTask DisposeAsync() - { - if (_mcpClient is not null) - { - await _mcpClient.DisposeAsync(); - } + }, cts.Token); - await _cts.CancelAsync(); + var mcpClient = await testTransport.CreateClientAsync(loggerFactory, cts.Token); - try - { - if (_serverRunTask is not null) - { - await _serverRunTask.WaitAsync(TimeSpan.FromSeconds(2)); - } - } - catch (OperationCanceledException) + return new McpTestContext(mcpClient, cts, workspace, serverRunTask, testTransport, serviceProvider, loggerFactory) { - // Expected when cancellation is requested - } - catch (TimeoutException) - { - // Server didn't stop in time, but that's OK for tests - } - - _testTransport?.Dispose(); - if (_serviceProvider is not null) - { - await _serviceProvider.DisposeAsync(); - } - _workspace?.Dispose(); - _loggerFactory?.Dispose(); - _cts?.Dispose(); + BackchannelMonitor = backchannelMonitor + }; } [Fact] public async Task McpServer_ListTools_ReturnsExpectedTools() { - // Act - var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + await using var ctx = await CreateMcpClientAsync(); + + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(tools); @@ -151,14 +129,15 @@ static void AssertTool(string expectedName, McpClientTool tool) [Fact] public async Task McpServer_ListTools_IncludesResourceMcpTools() { - // Arrange - Create a mock backchannel with a resource that has MCP tools + await using var ctx = await CreateMcpClientAsync(); + var mockBackchannel = new TestAppHostAuxiliaryBackchannel { Hash = "test-apphost-hash", IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -190,14 +169,11 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools() ] }; - // Register the mock backchannel - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // First call refresh_tools to discover the resource tools - await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout(); - // Act - List all tools - var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - Verify resource tools are included Assert.NotNull(tools); @@ -217,7 +193,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools() [Fact] public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() { - // Arrange - Create a mock backchannel with a resource that has MCP tools + await using var ctx = await CreateMcpClientAsync(); + var expectedToolResult = "Tool executed successfully with custom data"; string? callResourceName = null; string? callToolName = null; @@ -228,7 +205,7 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -265,16 +242,13 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() } }; - // Register the mock backchannel - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // First call refresh_tools to discover the resource tools - await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout(); - // Act - Call the resource tool (name format: {resource_name}_{tool_name} with dashes replaced by underscores) - var result = await _mcpClient.CallToolAsync( + var result = await ctx.Client.CallToolAsync( "my_resource_do_something", - cancellationToken: _cts.Token).DefaultTimeout(); + cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(result); @@ -294,7 +268,8 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() [Fact] public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() { - // Arrange - Simulate resource snapshots that use a unique resource id and a logical display name. + await using var ctx = await CreateMcpClientAsync(); + var expectedToolResult = "List schemas completed"; string? callResourceName = null; string? callToolName = null; @@ -305,7 +280,7 @@ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -341,11 +316,10 @@ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() } }; - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout(); - // Act - var result = await _mcpClient.CallToolAsync("db1_mcp_list_schemas", cancellationToken: _cts.Token).DefaultTimeout(); + var result = await ctx.Client.CallToolAsync("db1_mcp_list_schemas", cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(result); @@ -357,10 +331,11 @@ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() [Fact] public async Task McpServer_CallTool_ListAppHosts_ReturnsResult() { - // Act - var result = await _mcpClient.CallToolAsync( + await using var ctx = await CreateMcpClientAsync(); + + var result = await ctx.Client.CallToolAsync( KnownMcpTools.ListAppHosts, - cancellationToken: _cts.Token).DefaultTimeout(); + cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(result); @@ -376,9 +351,10 @@ public async Task McpServer_CallTool_ListAppHosts_ReturnsResult() [Fact] public async Task McpServer_CallTool_RefreshTools_ReturnsResult() { - // Arrange - Set up a channel to receive the ToolListChanged notification + await using var ctx = await CreateMcpClientAsync(); + var notificationChannel = Channel.CreateUnbounded(); - await using var notificationHandler = _mcpClient.RegisterNotificationHandler( + await using var notificationHandler = ctx.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => { @@ -386,10 +362,9 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() return default; }); - // Act - var result = await _mcpClient.CallToolAsync( + var result = await ctx.Client.CallToolAsync( KnownMcpTools.RefreshTools, - cancellationToken: _cts.Token).DefaultTimeout(); + cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - Verify result Assert.NotNull(result); @@ -404,8 +379,7 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() var expectedToolCount = KnownMcpTools.All.Count; Assert.Equal($"Tools refreshed: {expectedToolCount} tools available", textContent.Text); - // Assert - Verify the ToolListChanged notification was received - var notification = await notificationChannel.Reader.ReadAsync(_cts.Token).AsTask().DefaultTimeout(); + var notification = await notificationChannel.Reader.ReadAsync(ctx.Cts.Token).AsTask().DefaultTimeout(); Assert.NotNull(notification); Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method); } @@ -413,15 +387,15 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() [Fact] public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() { - // Arrange - Create a mock backchannel with a resource that has MCP tools - // This simulates the db-mcp scenario where resource tools become available + await using var ctx = await CreateMcpClientAsync(); + var mockBackchannel = new TestAppHostAuxiliaryBackchannel { Hash = "test-apphost-hash", IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -448,12 +422,10 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() ] }; - // Register the mock backchannel so resource tools will be discovered - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // Set up a channel to detect any tools/list_changed notifications var notificationCount = 0; - await using var notificationHandler = _mcpClient.RegisterNotificationHandler( + await using var notificationHandler = ctx.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => { @@ -461,19 +433,16 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() return default; }); - // Act - Call ListTools which should discover the resource tools via refresh - // but should NOT send a tools/list_changed notification (that would cause an infinite loop) - var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - tools should include the resource tool Assert.NotNull(tools); var dbMcpTool = tools.FirstOrDefault(t => t.Name == "db_mcp_query_database"); Assert.NotNull(dbMcpTool); - // Assert - no tools/list_changed notification should have been sent. using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); var notificationChannel = Channel.CreateUnbounded(); - await using var channelHandler = _mcpClient.RegisterNotificationHandler( + await using var channelHandler = ctx.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, (notification, _) => { @@ -499,7 +468,8 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() [Fact] public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchanged() { - // Arrange - Create a mock backchannel and track how many times GetResourceSnapshotsAsync is called + await using var ctx = await CreateMcpClientAsync(); + var getResourceSnapshotsCallCount = 0; var mockBackchannel = new TestAppHostAuxiliaryBackchannel { @@ -507,7 +477,7 @@ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchan IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, GetResourceSnapshotsHandler = (ct) => @@ -538,11 +508,10 @@ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchan } }; - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // Act - Call ListTools twice - var tools1 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); - var tools2 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools1 = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); + var tools2 = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - Both calls return the resource tool Assert.Contains(tools1, t => t.Name == "db_mcp_query_db"); @@ -559,11 +528,40 @@ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchan [Fact] public async Task McpServer_CallTool_UnknownTool_ReturnsError() { - // Act & Assert - The MCP client throws McpProtocolException when the server returns an error + await using var ctx = await CreateMcpClientAsync(); + var exception = await Assert.ThrowsAsync(async () => - await _mcpClient.CallToolAsync( + await ctx.Client.CallToolAsync( "nonexistent_tool_that_does_not_exist", - cancellationToken: _cts.Token).DefaultTimeout()); + cancellationToken: ctx.Cts.Token).DefaultTimeout()); + + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + } + + [Fact] + public async Task McpServer_DashboardOnlyMode_ListTools_ReturnsOnlyTelemetryTools() + { + await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888"); + + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); + + Assert.NotNull(tools); + Assert.Equal(3, tools.Count); + Assert.Collection(tools.OrderBy(t => t.Name), + tool => Assert.Equal(KnownMcpTools.ListStructuredLogs, tool.Name), + tool => Assert.Equal(KnownMcpTools.ListTraceStructuredLogs, tool.Name), + tool => Assert.Equal(KnownMcpTools.ListTraces, tool.Name)); + } + + [Fact] + public async Task McpServer_DashboardOnlyMode_CallNonTelemetryTool_ReturnsError() + { + await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888"); + + var exception = await Assert.ThrowsAsync(async () => + await ctx.Client.CallToolAsync( + KnownMcpTools.ListResources, + cancellationToken: ctx.Cts.Token).DefaultTimeout()); Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); } @@ -578,3 +576,41 @@ private static string GetResultText(CallToolResult result) return string.Empty; } } + +internal sealed class McpTestContext( + McpClient client, + CancellationTokenSource cts, + TemporaryWorkspace workspace, + Task serverRunTask, + TestMcpServerTransport testTransport, + ServiceProvider serviceProvider, + ILoggerFactory loggerFactory) : IAsyncDisposable +{ + public McpClient Client => client; + public CancellationTokenSource Cts => cts; + public TemporaryWorkspace Workspace => workspace; + public TestAuxiliaryBackchannelMonitor? BackchannelMonitor { get; init; } + + public async ValueTask DisposeAsync() + { + await client.DisposeAsync(); + await cts.CancelAsync(); + + try + { + await serverRunTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (OperationCanceledException) + { + } + catch (TimeoutException) + { + } + + testTransport.Dispose(); + await serviceProvider.DisposeAsync(); + workspace.Dispose(); + loggerFactory.Dispose(); + cts.Dispose(); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs index a2fc429c6b5..fe3926f2fce 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs @@ -1,13 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -145,4 +148,240 @@ private static string BuildLogsJson(params (string serviceName, string? instance return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse); } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_FetchesLogsDirectly() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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/logs")) + { + var json = BuildLogsJson(("redis", null, 9, "Information", "Ready to accept connections", s_testTime)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, 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))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + var logLines = outputWriter.Logs.Where(l => l.Contains("redis")).ToList(); + Assert.Single(logLines); + Assert.Contains("Ready to accept connections", logLines[0]); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrlAndApiKey_SendsApiKeyHeader() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + string? capturedApiKey = null; + + var handler = new MockHttpMessageHandler(request => + { + if (request.Headers.TryGetValues("X-API-Key", out var values)) + { + capturedApiKey = values.FirstOrDefault(); + } + 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/logs")) + { + var json = BuildLogsJson(("redis", null, 9, "Information", "Connected", s_testTime)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, 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))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888 --api-key my-secret-key"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Equal("my-secret-key", capturedApiKey); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive)); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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") + }; + } + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains("--api-key")); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_404WithReachableBase_DisplaysApiNotEnabledMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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/")) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + // Base URL probe returns OK + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains("Dashboard:Api:Enabled")); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_ConnectionRefused_DisplaysConnectionFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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") + }; + } + // Simulate connection refused (HttpRequestException with no status code) + throw new HttpRequestException("Connection refused"); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains("Could not connect")); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs index 2dd7a09d70b..726ba985f1f 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs @@ -1,13 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -147,4 +150,110 @@ private static string BuildSpansJson(params (string serviceName, string? instanc return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse); } + + [Fact] + public async Task TelemetrySpansCommand_WithDashboardUrl_FetchesSpansDirectly() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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/spans")) + { + var json = BuildSpansJson(("frontend", null, "span001", "GET /index", s_testTime, s_testTime.AddMilliseconds(50), false)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, 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))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + var spanLines = outputWriter.Logs.Where(l => l.Contains("frontend")).ToList(); + Assert.Single(spanLines); + Assert.Contains("GET /index", spanLines[0]); + } + + [Fact] + public async Task TelemetrySpansCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive)); + } + + [Fact] + public async Task TelemetrySpansCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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") + }; + } + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains("--api-key")); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs index 5bdebbf32dd..1161a9bf5cb 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs @@ -1,13 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -195,4 +198,110 @@ private static string BuildTracesJson(params (string traceId, string serviceName return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse); } + + [Fact] + public async Task TelemetryTracesCommand_WithDashboardUrl_FetchesTracesDirectly() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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")) + { + var json = BuildTracesJson(("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, 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))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + var dataRows = ParseTableDataRows(outputWriter.Logs); + Assert.Single(dataRows); + Assert.Contains("frontend", dataRows[0][1]); + } + + [Fact] + public async Task TelemetryTracesCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive)); + } + + [Fact] + public async Task TelemetryTracesCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + 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") + }; + } + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains("--api-key")); + } } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs index 3a9548f8b64..0f1d975cc32 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs @@ -398,8 +398,10 @@ private static ListStructuredLogsTool CreateTool( TestAuxiliaryBackchannelMonitor? monitor = null, IHttpClientFactory? httpClientFactory = null) { + var actualMonitor = monitor ?? new TestAuxiliaryBackchannelMonitor(); + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(actualMonitor, NullLogger.Instance); return new ListStructuredLogsTool( - monitor ?? new TestAuxiliaryBackchannelMonitor(), + dashboardInfoProvider, httpClientFactory ?? s_httpClientFactory, NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs index 038d929ccec..05c94791874 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs @@ -437,8 +437,10 @@ private static ListTracesTool CreateTool( TestAuxiliaryBackchannelMonitor? monitor = null, IHttpClientFactory? httpClientFactory = null) { + var actualMonitor = monitor ?? new TestAuxiliaryBackchannelMonitor(); + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(actualMonitor, NullLogger.Instance); return new ListTracesTool( - monitor ?? new TestAuxiliaryBackchannelMonitor(), + dashboardInfoProvider, httpClientFactory ?? s_httpClientFactory, NullLogger.Instance); } From e775b90ddb00a8d749a412cd1ca157c2bc0c0525 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 11:59:16 +0800 Subject: [PATCH 2/9] PR feedback --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 12 +++-- .../Commands/TelemetryCommandHelpers.cs | 24 +++++----- src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs | 20 ++++++--- .../TelemetryCommandStrings.Designer.cs | 6 +++ .../Resources/TelemetryCommandStrings.resx | 3 ++ .../xlf/TelemetryCommandStrings.cs.xlf | 5 +++ .../xlf/TelemetryCommandStrings.de.xlf | 5 +++ .../xlf/TelemetryCommandStrings.es.xlf | 5 +++ .../xlf/TelemetryCommandStrings.fr.xlf | 5 +++ .../xlf/TelemetryCommandStrings.it.xlf | 5 +++ .../xlf/TelemetryCommandStrings.ja.xlf | 5 +++ .../xlf/TelemetryCommandStrings.ko.xlf | 5 +++ .../xlf/TelemetryCommandStrings.pl.xlf | 5 +++ .../xlf/TelemetryCommandStrings.pt-BR.xlf | 5 +++ .../xlf/TelemetryCommandStrings.ru.xlf | 5 +++ .../xlf/TelemetryCommandStrings.tr.xlf | 5 +++ .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 5 +++ .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 5 +++ .../Commands/AgentMcpCommandTests.cs | 17 +++++++ .../Commands/TelemetryLogsCommandTests.cs | 44 +++++++++++++++++-- .../Mcp/McpToolHelpersTests.cs | 10 +++-- 21 files changed, 171 insertions(+), 30 deletions(-) diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index f652a634830..b1d3bf77fc5 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -102,10 +102,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (dashboardUrl is not null) { + if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out _)) + { + _logger.LogError("Invalid --dashboard-url: {DashboardUrl}", dashboardUrl); + return ExitCodeConstants.InvalidCommand; + } + _dashboardOnlyMode = true; - var uri = new Uri(dashboardUrl); - var baseUrl = $"{uri.Scheme}://{uri.Authority}"; - IDashboardInfoProvider staticProvider = new StaticDashboardInfoProvider(baseUrl, apiKey); + var staticProvider = new StaticDashboardInfoProvider(dashboardUrl, apiKey); _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); @@ -113,7 +117,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } else { - IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(_auxiliaryBackchannelMonitor, _logger); + var dashboardInfoProvider = new BackchannelDashboardInfoProvider(_auxiliaryBackchannelMonitor, _logger); _knownTools[KnownMcpTools.ListResources] = new ListResourcesTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); _knownTools[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 487861ac51a..0462c3cb33e 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -7,6 +7,7 @@ using System.Net.Http.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; +using Aspire.Cli.Mcp.Tools; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Aspire.Dashboard.Otlp.Model; @@ -134,13 +135,18 @@ public static bool HasJsonContentType(HttpResponseMessage response) return (false, null, null, null, ExitCodeConstants.InvalidCommand); } + // Validate dashboard URL format + if (dashboardUrl is not null && !Uri.TryCreate(dashboardUrl, UriKind.Absolute, out _)) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl)); + return (false, null, null, null, ExitCodeConstants.InvalidCommand); + } + // Direct dashboard URL mode — bypass AppHost discovery if (dashboardUrl is not null) { - var uri = new Uri(dashboardUrl); - var baseUrl = $"{uri.Scheme}://{uri.Authority}"; var token = apiKey ?? string.Empty; - return (true, baseUrl, token, baseUrl, 0); + return (true, dashboardUrl, token, dashboardUrl, 0); } var result = await connectionResolver.ResolveConnectionAsync( @@ -170,19 +176,11 @@ public static bool HasJsonContentType(HttpResponseMessage response) } /// - /// Extracts the base URL from a dashboard URL (removes /login?t=... path). + /// Strips the /login path segment from a dashboard URL returned by the AppHost. /// private static string? ExtractDashboardBaseUrl(string? dashboardUrlWithToken) { - if (string.IsNullOrEmpty(dashboardUrlWithToken)) - { - return null; - } - - // Dashboard URLs look like: http://localhost:18888/login?t=abcd1234 - // We want: http://localhost:18888 - var uri = new Uri(dashboardUrlWithToken); - return $"{uri.Scheme}://{uri.Authority}"; + return McpToolHelpers.StripLoginPath(dashboardUrlWithToken); } /// diff --git a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs index 20f520e173b..77992331918 100644 --- a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs +++ b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs @@ -25,17 +25,16 @@ internal static class McpToolHelpers throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); } - var dashboardBaseUrl = GetBaseUrl(dashboardInfo.DashboardUrls.FirstOrDefault()); + var dashboardBaseUrl = StripLoginPath(dashboardInfo.DashboardUrls.FirstOrDefault()); return (dashboardInfo.ApiToken, dashboardInfo.ApiBaseUrl, dashboardBaseUrl); } /// - /// Extracts the base URL (scheme, host, and port) from a URL, removing any path and query string. + /// Strips the /login path segment (and any query string) from a dashboard URL + /// returned by the AppHost. Other path segments are preserved. /// - /// The full URL that may contain path and query string. - /// The base URL with only scheme, host, and port, or null if the input is null or invalid. - internal static string? GetBaseUrl(string? url) + internal static string? StripLoginPath(string? url) { if (url is null) { @@ -44,7 +43,16 @@ internal static class McpToolHelpers if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) { - return $"{uri.Scheme}://{uri.Authority}"; + // Dashboard URLs from the AppHost look like: http://localhost:18888/login?t=abcd1234 + // or with a base path: http://localhost:18888/base/login?t=abcd1234 + // Strip the trailing /login segment but preserve any other path components. + var path = uri.AbsolutePath; + if (path.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + path = path[..^"/login".Length]; + } + + return $"{uri.Scheme}://{uri.Authority}{path.TrimEnd('/')}"; } return url; diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs index 709f11abc74..f8b3d5a1e19 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -290,5 +290,11 @@ internal static string DashboardConnectionFailed { return ResourceManager.GetString("DashboardConnectionFailed", resourceCulture); } } + + internal static string DashboardUrlInvalid { + get { + return ResourceManager.GetString("DashboardUrlInvalid", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index 01d1b8a2412..16b2f8b3b73 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -207,4 +207,7 @@ Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index d12f7eb72ec..f03ef08d914 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 91fbc716896..7acd1e53475 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index 3d5dbb28b2d..d477f78a441 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index f204cc07f1b..d748463c321 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 1fd9e49e2df..e4fee0ba008 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index baaec034002..4a99774ed56 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index fdfe7ffa5a9..10136410a86 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index edda69d59d7..28de904b257 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 68f06704e7f..528f1251509 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index a6c6d25006c..7dc472ae6fe 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 8f1c01efe09..56fe7e12163 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index 75091aae61f..1b525c8145f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 7efbb9118ae..3cb6c1b4ee0 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -32,6 +32,11 @@ The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs index b635baf6002..f43e2d34521 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -566,6 +566,23 @@ await ctx.Client.CallToolAsync( Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); } + [Fact] + public async Task McpServer_WithInvalidDashboardUrl_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var serviceProvider = services.BuildServiceProvider(); + await using var _ = serviceProvider; + + var agentMcpCommand = serviceProvider.GetRequiredService(); + var rootCommand = serviceProvider.GetRequiredService(); + var parseResult = rootCommand.Parse("agent mcp --dashboard-url not-a-url"); + + var exitCode = await agentMcpCommand.ExecuteCommandAsync(parseResult, CancellationToken.None).DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + private static string GetResultText(CallToolResult result) { if (result.Content?.FirstOrDefault() is TextContentBlock textContent) diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs index fe3926f2fce..86ba7588d62 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Net; using System.Text.Json; using Aspire.Cli.Commands; using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; @@ -250,12 +252,16 @@ public async Task TelemetryLogsCommand_WithDashboardUrlAndApiKey_SendsApiKeyHead public async Task TelemetryLogsCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + TestInteractionService? testInteractionService = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => + { + testInteractionService = new TestInteractionService(); + return testInteractionService; + }; }); var provider = services.BuildServiceProvider(); @@ -265,7 +271,37 @@ public async Task TelemetryLogsCommand_WithDashboardUrlAndAppHost_ReturnsInvalid var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive)); + Assert.NotNull(testInteractionService); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); + } + + [Fact] + public async Task TelemetryLogsCommand_WithInvalidDashboardUrl_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + TestInteractionService? testInteractionService = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => + { + testInteractionService = new TestInteractionService(); + return testInteractionService; + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url not-a-url"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.NotNull(testInteractionService); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, "not-a-url"), errorMessage); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs index 0480f5d1a21..100ff683dee 100644 --- a/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs @@ -14,11 +14,15 @@ public class McpToolHelpersTests [InlineData("http://localhost:18888/login", "http://localhost:18888")] [InlineData("http://localhost:18888/login?t=authtoken123", "http://localhost:18888")] [InlineData("https://localhost:16319/login?t=d8d8255df4c79aebcb5b7325828ccb20", "https://localhost:16319")] - [InlineData("https://example.com:8080/path/to/resource?param=value", "https://example.com:8080")] + [InlineData("https://example.com:8080/path/to/resource?param=value", "https://example.com:8080/path/to/resource")] + [InlineData("https://example.com:8080/dashboard", "https://example.com:8080/dashboard")] + [InlineData("http://localhost/base/login", "http://localhost/base")] + [InlineData("http://localhost/base/login?t=token123", "http://localhost/base")] + [InlineData("https://example.com:8080/app/deep/login?t=abc", "https://example.com:8080/app/deep")] [InlineData("invalid-url", "invalid-url")] // Falls back to returning the original string - public void GetBaseUrl_ExtractsBaseUrl_RemovingPathAndQueryString(string? input, string? expected) + public void StripLoginPath_RemovesOnlyLoginSegment(string? input, string? expected) { - var result = McpToolHelpers.GetBaseUrl(input); + var result = McpToolHelpers.StripLoginPath(input); Assert.Equal(expected, result); } } From c502d9a57d8f1be3546e0048b75e4058cebbaabf Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 12:33:25 +0800 Subject: [PATCH 3/9] Fix review issues: MCP error messages, URL scheme validation, deduplicate error handling - Add IsDirectConnection to IDashboardInfoProvider so MCP tools only show dashboard-url-specific error messages (e.g. --api-key hint) when actually using --dashboard-url, not in AppHost backchannel mode - Validate --dashboard-url accepts only http/https schemes in both AgentMcpCommand and TelemetryCommandHelpers - Extract duplicated if/else error handling from 5 catch blocks in otel commands into TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 3 ++- .../Commands/TelemetryCommandHelpers.cs | 26 +++++++++++++++++-- .../Commands/TelemetryLogsCommand.cs | 13 ++-------- .../Commands/TelemetrySpansCommand.cs | 13 ++-------- .../Commands/TelemetryTracesCommand.cs | 26 +++---------------- .../Mcp/Tools/IDashboardInfoProvider.cs | 9 +++++++ .../Mcp/Tools/ListStructuredLogsTool.cs | 4 ++- .../Mcp/Tools/ListTraceStructuredLogsTool.cs | 4 ++- src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs | 4 ++- 9 files changed, 52 insertions(+), 50 deletions(-) diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index b1d3bf77fc5..dd8fcc44b58 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -102,7 +102,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (dashboardUrl is not null) { - if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out _)) + if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var parsedUri) || + (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps)) { _logger.LogError("Invalid --dashboard-url: {DashboardUrl}", dashboardUrl); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 0462c3cb33e..e003d5670fb 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -135,8 +135,10 @@ public static bool HasJsonContentType(HttpResponseMessage response) return (false, null, null, null, ExitCodeConstants.InvalidCommand); } - // Validate dashboard URL format - if (dashboardUrl is not null && !Uri.TryCreate(dashboardUrl, UriKind.Absolute, out _)) + // Validate dashboard URL format and scheme + if (dashboardUrl is not null && + (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var parsedUri) || + (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps))) { interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl)); return (false, null, null, null, ExitCodeConstants.InvalidCommand); @@ -196,6 +198,26 @@ public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiT return client; } + /// + /// Formats an error message for a telemetry HTTP failure, using dashboard-specific diagnostics + /// when a direct dashboard URL was provided, or a generic message otherwise. + /// + public static async Task FormatTelemetryErrorMessageAsync( + HttpRequestException ex, + string baseUrl, + string? dashboardUrl, + IHttpClientFactory httpClientFactory, + ILogger logger, + CancellationToken cancellationToken) + { + if (dashboardUrl is not null) + { + return await GetDashboardApiErrorMessageAsync(ex, baseUrl, httpClientFactory, logger, cancellationToken); + } + + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message); + } + /// /// Produces a user-friendly error message for dashboard API failures when using --dashboard-url. /// diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index 6b2b0dd8a65..05238be3b75 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -167,17 +167,8 @@ private async Task FetchLogsAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch logs from Dashboard API"); - - if (dashboardUrl is not null) - { - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); - _interactionService.DisplayError(errorMessage); - } - else - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); - } - + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 90a9e0a501a..9a4e881a0e9 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -168,17 +168,8 @@ private async Task FetchSpansAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch spans from Dashboard API"); - - if (dashboardUrl is not null) - { - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); - _interactionService.DisplayError(errorMessage); - } - else - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); - } - + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index dc31a589da6..61ab6b14865 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -164,17 +164,8 @@ private async Task FetchSingleTraceAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch trace from Dashboard API"); - - if (dashboardUrl is not null) - { - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); - _interactionService.DisplayError(errorMessage); - } - else - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); - } - + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } } @@ -249,17 +240,8 @@ private async Task FetchTracesAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - - if (dashboardUrl is not null) - { - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, baseUrl, _httpClientFactory, _logger, cancellationToken); - _interactionService.DisplayError(errorMessage); - } - else - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); - } - + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs b/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs index 9718fdedc31..de2c6b215ed 100644 --- a/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs +++ b/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs @@ -11,6 +11,11 @@ namespace Aspire.Cli.Mcp.Tools; /// internal interface IDashboardInfoProvider { + /// + /// Whether the dashboard URL was provided directly (e.g. via --dashboard-url) rather than discovered through an AppHost. + /// + bool IsDirectConnection { get; } + /// /// Gets dashboard connection info for telemetry API access. /// @@ -25,6 +30,8 @@ internal sealed class BackchannelDashboardInfoProvider( IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger) : IDashboardInfoProvider { + public bool IsDirectConnection => false; + public Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken) { return McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken); @@ -36,6 +43,8 @@ internal sealed class BackchannelDashboardInfoProvider( /// internal sealed class StaticDashboardInfoProvider(string dashboardUrl, string? apiKey) : IDashboardInfoProvider { + public bool IsDirectConnection => true; + public Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken) { // For unsecured dashboards, apiToken is empty string (no X-API-Key header will be sent) diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index 61420613d46..df628bbc7c3 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -100,7 +100,9 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch structured logs from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken); + var errorMessage = dashboardInfoProvider.IsDirectConnection + ? await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken) + : $"Failed to fetch structured logs: {ex.Message}"; throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 39f3eb1b496..c066066267e 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -100,7 +100,9 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch structured logs for trace from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken); + var errorMessage = dashboardInfoProvider.IsDirectConnection + ? await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken) + : $"Failed to fetch structured logs for trace: {ex.Message}"; throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 71ca34dbf57..586788c05d4 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -100,7 +100,9 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken); + var errorMessage = dashboardInfoProvider.IsDirectConnection + ? await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken) + : $"Failed to fetch traces: {ex.Message}"; throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } From 5d370c2c313836094a05414504ddb1d76c90b0ba Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 12:59:07 +0800 Subject: [PATCH 4/9] Move IsHttpUrl to UrlHelper, simplify TestInteractionService setup in tests --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 3 +- .../Commands/TelemetryCommandHelpers.cs | 15 +++---- src/Aspire.Cli/Packaging/PackagingService.cs | 4 +- src/Aspire.Cli/Utils/UrlHelper.cs | 18 ++++++++ .../Commands/TelemetryLogsCommandTests.cs | 45 ++++++++----------- .../Commands/TelemetrySpansCommandTests.cs | 19 ++++---- .../Commands/TelemetryTracesCommandTests.cs | 19 ++++---- 7 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 src/Aspire.Cli/Utils/UrlHelper.cs diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index dd8fcc44b58..a3dd7ca9a5d 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -102,8 +102,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (dashboardUrl is not null) { - if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var parsedUri) || - (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps)) + if (!UrlHelper.IsHttpUrl(dashboardUrl)) { _logger.LogError("Invalid --dashboard-url: {DashboardUrl}", dashboardUrl); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index e003d5670fb..587d4abe551 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -135,18 +135,15 @@ public static bool HasJsonContentType(HttpResponseMessage response) return (false, null, null, null, ExitCodeConstants.InvalidCommand); } - // Validate dashboard URL format and scheme - if (dashboardUrl is not null && - (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var parsedUri) || - (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps))) - { - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl)); - return (false, null, null, null, ExitCodeConstants.InvalidCommand); - } - // Direct dashboard URL mode — bypass AppHost discovery if (dashboardUrl is not null) { + if (!UrlHelper.IsHttpUrl(dashboardUrl)) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl)); + return (false, null, null, null, ExitCodeConstants.InvalidCommand); + } + var token = apiKey ?? string.Empty; return (true, dashboardUrl, token, dashboardUrl, 0); } diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index ddb0b6dae7a..8b619fd253f 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; +using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using System.Reflection; @@ -109,8 +110,7 @@ public Task> GetChannelsAsync(CancellationToken canc if (!string.IsNullOrEmpty(overrideFeed)) { // Validate that the override URL is well-formed - if (Uri.TryCreate(overrideFeed, UriKind.Absolute, out var uri) && - (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + if (UrlHelper.IsHttpUrl(overrideFeed)) { return overrideFeed; } diff --git a/src/Aspire.Cli/Utils/UrlHelper.cs b/src/Aspire.Cli/Utils/UrlHelper.cs new file mode 100644 index 00000000000..4586606deb8 --- /dev/null +++ b/src/Aspire.Cli/Utils/UrlHelper.cs @@ -0,0 +1,18 @@ +// 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.Cli.Utils; + +internal static class UrlHelper +{ + /// + /// Returns when is an absolute HTTP or HTTPS URL. + /// + internal static bool IsHttpUrl([NotNullWhen(true)] string? url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var parsed) && + (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs index 86ba7588d62..fa206724a8b 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs @@ -253,15 +253,11 @@ public async Task TelemetryLogsCommand_WithDashboardUrlAndAppHost_ReturnsInvalid { using var workspace = TemporaryWorkspace.Create(outputHelper); - TestInteractionService? testInteractionService = null; + var testInteractionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => - { - testInteractionService = new TestInteractionService(); - return testInteractionService; - }; + options.InteractionServiceFactory = _ => testInteractionService; }); var provider = services.BuildServiceProvider(); @@ -271,7 +267,6 @@ public async Task TelemetryLogsCommand_WithDashboardUrlAndAppHost_ReturnsInvalid var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.NotNull(testInteractionService); var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); } @@ -281,15 +276,11 @@ public async Task TelemetryLogsCommand_WithInvalidDashboardUrl_ReturnsInvalidCom { using var workspace = TemporaryWorkspace.Create(outputHelper); - TestInteractionService? testInteractionService = null; + var testInteractionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => - { - testInteractionService = new TestInteractionService(); - return testInteractionService; - }; + options.InteractionServiceFactory = _ => testInteractionService; }); var provider = services.BuildServiceProvider(); @@ -299,7 +290,6 @@ public async Task TelemetryLogsCommand_WithInvalidDashboardUrl_ReturnsInvalidCom var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.NotNull(testInteractionService); var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, "not-a-url"), errorMessage); } @@ -308,7 +298,8 @@ public async Task TelemetryLogsCommand_WithInvalidDashboardUrl_ReturnsInvalidCom public async Task TelemetryLogsCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var handler = new MockHttpMessageHandler(request => { @@ -325,8 +316,7 @@ public async Task TelemetryLogsCommand_WithDashboardUrl_401_DisplaysAuthFailedMe var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); services.AddSingleton(handler); services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); @@ -338,14 +328,16 @@ public async Task TelemetryLogsCommand_WithDashboardUrl_401_DisplaysAuthFailedMe var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains("--api-key")); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage); } [Fact] public async Task TelemetryLogsCommand_WithDashboardUrl_404WithReachableBase_DisplaysApiNotEnabledMessage() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var handler = new MockHttpMessageHandler(request => { @@ -367,8 +359,7 @@ public async Task TelemetryLogsCommand_WithDashboardUrl_404WithReachableBase_Dis var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); services.AddSingleton(handler); services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); @@ -380,14 +371,16 @@ public async Task TelemetryLogsCommand_WithDashboardUrl_404WithReachableBase_Dis var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains("Dashboard:Api:Enabled")); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardApiNotEnabled, "http://localhost:18888"), errorMessage); } [Fact] public async Task TelemetryLogsCommand_WithDashboardUrl_ConnectionRefused_DisplaysConnectionFailedMessage() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var handler = new MockHttpMessageHandler(request => { @@ -405,8 +398,7 @@ public async Task TelemetryLogsCommand_WithDashboardUrl_ConnectionRefused_Displa var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); services.AddSingleton(handler); services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); @@ -418,6 +410,7 @@ public async Task TelemetryLogsCommand_WithDashboardUrl_ConnectionRefused_Displa var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains("Could not connect")); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardConnectionFailed, "http://localhost:18888"), errorMessage); } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs index 726ba985f1f..e601bab9883 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Aspire.Cli.Commands; using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; @@ -202,12 +203,12 @@ public async Task TelemetrySpansCommand_WithDashboardUrl_FetchesSpansDirectly() public async Task TelemetrySpansCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); var provider = services.BuildServiceProvider(); @@ -217,14 +218,16 @@ public async Task TelemetrySpansCommand_WithDashboardUrlAndAppHost_ReturnsInvali var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive)); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); } [Fact] public async Task TelemetrySpansCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var handler = new MockHttpMessageHandler(request => { @@ -241,8 +244,7 @@ public async Task TelemetrySpansCommand_WithDashboardUrl_401_DisplaysAuthFailedM var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); services.AddSingleton(handler); services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); @@ -254,6 +256,7 @@ public async Task TelemetrySpansCommand_WithDashboardUrl_401_DisplaysAuthFailedM var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains("--api-key")); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage); } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs index 1161a9bf5cb..19cbc41678a 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Aspire.Cli.Commands; using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; @@ -250,12 +251,12 @@ public async Task TelemetryTracesCommand_WithDashboardUrl_FetchesTracesDirectly( public async Task TelemetryTracesCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); var provider = services.BuildServiceProvider(); @@ -265,14 +266,16 @@ public async Task TelemetryTracesCommand_WithDashboardUrlAndAppHost_ReturnsInval var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive)); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); } [Fact] public async Task TelemetryTracesCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); + + var testInteractionService = new TestInteractionService(); var handler = new MockHttpMessageHandler(request => { @@ -289,8 +292,7 @@ public async Task TelemetryTracesCommand_WithDashboardUrl_401_DisplaysAuthFailed var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.OutputTextWriter = outputWriter; - options.DisableAnsi = true; + options.InteractionServiceFactory = _ => testInteractionService; }); services.AddSingleton(handler); services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); @@ -302,6 +304,7 @@ public async Task TelemetryTracesCommand_WithDashboardUrl_401_DisplaysAuthFailed var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); - Assert.Contains(outputWriter.Logs, l => l.Contains("--api-key")); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage); } } From 27fd2e17574156e503321f0ac6b9be781c53c079 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 18:40:01 +0800 Subject: [PATCH 5/9] Add --dashboard-url and --api-key options to export command - ExportCommand supports --dashboard-url and --api-key for direct dashboard access - Refactor GetDashboardApiAsync to return DashboardApiResult record with Connection - Add requireDashboard parameter to GetDashboardApiAsync for optional dashboard - Update ExportCommand to gracefully degrade when dashboard is unavailable - Add DisplayedMessages tracking to TestInteractionService - Add ExportCommand_DashboardUnavailable test using TestInteractionService - Simplify CreateExportTestServices: remove outputWriter param, add interactionService --- src/Aspire.Cli/Commands/ExportCommand.cs | 196 ++++++++++-------- .../Commands/TelemetryCommandHelpers.cs | 66 ++++-- .../Commands/TelemetryLogsCommand.cs | 14 +- .../Commands/TelemetrySpansCommand.cs | 14 +- .../Commands/TelemetryTracesCommand.cs | 122 +++++------ .../Commands/ExportCommandTests.cs | 41 ++-- .../TestServices/TestInteractionService.cs | 2 + 7 files changed, 255 insertions(+), 200 deletions(-) diff --git a/src/Aspire.Cli/Commands/ExportCommand.cs b/src/Aspire.Cli/Commands/ExportCommand.cs index 44dc69d4c22..d2a2ef58403 100644 --- a/src/Aspire.Cli/Commands/ExportCommand.cs +++ b/src/Aspire.Cli/Commands/ExportCommand.cs @@ -38,6 +38,9 @@ internal sealed class ExportCommand : BaseCommand Description = ExportCommandStrings.OutputOptionDescription }; + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); + private static readonly Argument s_resourceArgument = new("resource") { Description = ExportCommandStrings.ResourceOptionDescription, @@ -65,6 +68,8 @@ public ExportCommand( Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); Options.Add(s_outputOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -74,35 +79,16 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var resourceName = parseResult.GetValue(s_resourceArgument); var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var outputPath = parseResult.GetValue(s_outputOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); - // Resolve the AppHost connection for backchannel access - var connectionResult = await _connectionResolver.ResolveConnectionAsync( - passedAppHostProjectFile, - SharedCommandStrings.ScanningForRunningAppHosts, - string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ExportCommandStrings.SelectAppHostAction), - SharedCommandStrings.AppHostNotRunning, - cancellationToken); - - if (!connectionResult.Success) + // Validate mutual exclusivity of --apphost and --dashboard-url + if (passedAppHostProjectFile is not null && dashboardUrl is not null) { - _interactionService.DisplayMessage(KnownEmojis.Information, connectionResult.ErrorMessage); - return ExitCodeConstants.Success; - } - - var connection = connectionResult.Connection!; - - // Get dashboard API info for telemetry data - var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken); - var isDashboardAvailable = dashboardInfo?.ApiBaseUrl is not null && dashboardInfo.ApiToken is not null; - - if (!isDashboardAvailable) - { - _interactionService.DisplayMessage(KnownEmojis.Warning, ExportCommandStrings.DashboardNotAvailable); + _interactionService.DisplayError(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive); + return ExitCodeConstants.InvalidCommand; } - var baseUrl = dashboardInfo?.ApiBaseUrl; - var apiToken = dashboardInfo?.ApiToken; - // Default file name if not specified if (string.IsNullOrEmpty(outputPath)) { @@ -117,89 +103,127 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell Directory.CreateDirectory(directory); } + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: false, cancellationToken); + + if (!dashboardApi.Success) + { + return dashboardApi.ExitCode; + } + + if (dashboardApi.BaseUrl is null) + { + _interactionService.DisplayMessage(KnownEmojis.Warning, ExportCommandStrings.DashboardNotAvailable); + } + try { - using var client = isDashboardAvailable ? TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken!) : null; + return await ExportDataAsync(resourceName, dashboardApi.Connection, dashboardApi.BaseUrl, dashboardApi.ApiToken, outputPath, cancellationToken); + } + catch (HttpRequestException ex) when (dashboardUrl is not null) + { + _logger.LogError(ex, "Failed to export telemetry data from dashboard"); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, dashboardApi.BaseUrl!, true, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + return ExitCodeConstants.DashboardFailure; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export telemetry data"); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.FailedToExport, ex.Message)); + return ExitCodeConstants.DashboardFailure; + } + } - // Get telemetry resources and resource snapshots - var (telemetryResources, snapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () => - { - var resources = isDashboardAvailable - ? await TelemetryCommandHelpers.GetAllResourcesAsync(client!, baseUrl!, cancellationToken).ConfigureAwait(false) - : []; - var snaps = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); - return (resources, snaps); - }); + private async Task ExportDataAsync( + string? resourceName, + IAppHostAuxiliaryBackchannel? connection, + string? baseUrl, + string? apiToken, + string outputPath, + CancellationToken cancellationToken) + { + var isDashboardAvailable = baseUrl is not null && apiToken is not null; - // Validate resource name exists (match by Name or DisplayName since users may pass either) - if (resourceName is not null) - { - if (!ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Any()) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ResourceNotFound, resourceName)); - return ExitCodeConstants.InvalidCommand; - } - } - else - { - if (snapshots.Count == 0) - { - _interactionService.DisplayMessage(KnownEmojis.Information, ExportCommandStrings.NoResourcesFound); - return ExitCodeConstants.Success; - } - } + using var client = isDashboardAvailable ? TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken!) : null; - // Resolve which telemetry resources match the filter - List? resolvedTelemetryResources = null; - var hasTelemetryData = true; - if (resourceName is not null) + // Get telemetry resources and resource snapshots + var (telemetryResources, snapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () => + { + var resources = isDashboardAvailable + ? await TelemetryCommandHelpers.GetAllResourcesAsync(client!, baseUrl!, cancellationToken).ConfigureAwait(false) + : []; + IReadOnlyList snaps = connection is not null + ? await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false) + : []; + return (resources, snaps); + }); + + // Validate resource name exists (match by Name or DisplayName since users may pass either) + if (resourceName is not null && snapshots.Count > 0) + { + if (!ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Any()) { - hasTelemetryData = TelemetryCommandHelpers.TryResolveResourceNames(resourceName, telemetryResources, out resolvedTelemetryResources); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ResourceNotFound, resourceName)); + return ExitCodeConstants.InvalidCommand; } + } + else if (resourceName is null && connection is not null && snapshots.Count == 0) + { + _interactionService.DisplayMessage(KnownEmojis.Information, ExportCommandStrings.NoResourcesFound); + return ExitCodeConstants.Success; + } - var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(telemetryResources); + // Resolve which telemetry resources match the filter + List? resolvedTelemetryResources = null; + var hasTelemetryData = true; + if (resourceName is not null) + { + hasTelemetryData = TelemetryCommandHelpers.TryResolveResourceNames(resourceName, telemetryResources, out resolvedTelemetryResources); + } - var exportArchive = new ExportArchive(); + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(telemetryResources); - // 1. Export resource details (filtered when a resource name is specified) + var exportArchive = new ExportArchive(); + + // Export resource details (filtered when a resource name is specified) + if (snapshots.Count > 0) + { AddResources(exportArchive, snapshots, resourceName); + } - // 2. Export console logs from backchannel + // Export console logs from backchannel + if (connection is not null) + { await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringConsoleLogs, async () => { await AddConsoleLogsAsync(exportArchive, connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); return true; }); + } - if (isDashboardAvailable && hasTelemetryData) + if (isDashboardAvailable && hasTelemetryData) + { + // Export structured logs from Dashboard API + await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringStructuredLogs, async () => { - // 3. Export structured logs from Dashboard API (skip if resource has no telemetry data or dashboard is unavailable) - await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringStructuredLogs, async () => - { - await AddStructuredLogsAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); - return true; - }); + await AddStructuredLogsAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); + return true; + }); - // 4. Export traces from Dashboard API (skip if resource has no telemetry data or dashboard is unavailable) - await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringTraces, async () => - { - await AddTracesAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); - return true; - }); - } + // Export traces from Dashboard API + await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringTraces, async () => + { + await AddTracesAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); + return true; + }); + } - var fullPath = Path.GetFullPath(outputPath); - exportArchive.WriteToFile(fullPath); + var fullPath = Path.GetFullPath(outputPath); + exportArchive.WriteToFile(fullPath); - _interactionService.DisplayMessage(KnownEmojis.CheckMark, string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ExportComplete, fullPath)); - return ExitCodeConstants.Success; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to export telemetry data"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.FailedToExport, ex.Message)); - return ExitCodeConstants.DashboardFailure; - } + _interactionService.DisplayMessage(KnownEmojis.CheckMark, string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ExportComplete, fullPath)); + return ExitCodeConstants.Success; } private static void AddResources(ExportArchive exportArchive, IReadOnlyList snapshots, string? resourceName) diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 587d4abe551..5206b8e8812 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -119,20 +119,31 @@ public static bool HasJsonContentType(HttpResponseMessage response) /// /// Resolves an AppHost connection and gets Dashboard API info. /// - /// A tuple with success status, base URL, API token, dashboard UI URL, and exit code if failed. - public static async Task<(bool Success, string? BaseUrl, string? ApiToken, string? DashboardUrl, int ExitCode)> GetDashboardApiAsync( + /// The connection resolver for AppHost discovery. + /// The interaction service for displaying messages. + /// The optional AppHost project file. + /// The optional direct dashboard URL (mutually exclusive with ). + /// The optional API key for dashboard authentication. + /// + /// When true, a missing Dashboard API is a hard error. + /// When false, a missing Dashboard API is non-fatal and the method returns success with null base URL and token. + /// + /// The cancellation token. + /// A with the resolved connection and dashboard API info. + public static async Task GetDashboardApiAsync( AppHostConnectionResolver connectionResolver, IInteractionService interactionService, FileInfo? projectFile, string? dashboardUrl, string? apiKey, + bool requireDashboard, CancellationToken cancellationToken) { // Validate mutual exclusivity of --apphost and --dashboard-url if (projectFile is not null && dashboardUrl is not null) { interactionService.DisplayError(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive); - return (false, null, null, null, ExitCodeConstants.InvalidCommand); + return DashboardApiResult.Failure(ExitCodeConstants.InvalidCommand); } // Direct dashboard URL mode — bypass AppHost discovery @@ -141,11 +152,11 @@ public static bool HasJsonContentType(HttpResponseMessage response) if (!UrlHelper.IsHttpUrl(dashboardUrl)) { interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl)); - return (false, null, null, null, ExitCodeConstants.InvalidCommand); + return DashboardApiResult.Failure(ExitCodeConstants.InvalidCommand); } var token = apiKey ?? string.Empty; - return (true, dashboardUrl, token, dashboardUrl, 0); + return new DashboardApiResult(true, null, dashboardUrl, token, dashboardUrl, 0); } var result = await connectionResolver.ResolveConnectionAsync( @@ -158,20 +169,27 @@ public static bool HasJsonContentType(HttpResponseMessage response) if (!result.Success) { interactionService.DisplayMessage(KnownEmojis.Information, result.ErrorMessage); - return (false, null, null, null, ExitCodeConstants.Success); + return DashboardApiResult.Failure(ExitCodeConstants.Success); } - var dashboardInfo = await result.Connection!.GetDashboardInfoV2Async(cancellationToken); + var connection = result.Connection!; + var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken); if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null) { - interactionService.DisplayError(TelemetryCommandStrings.DashboardApiNotAvailable); - return (false, null, null, null, ExitCodeConstants.DashboardFailure); + if (requireDashboard) + { + interactionService.DisplayError(TelemetryCommandStrings.DashboardApiNotAvailable); + return DashboardApiResult.Failure(ExitCodeConstants.DashboardFailure); + } + + // Dashboard is optional — return success with null API info + return new DashboardApiResult(true, connection, null, null, null, 0); } // Extract dashboard base URL (without /login path) for hyperlinks var extractedDashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault()); - return (true, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, extractedDashboardUrl, 0); + return new DashboardApiResult(true, connection, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, extractedDashboardUrl, 0); } /// @@ -202,12 +220,12 @@ public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiT public static async Task FormatTelemetryErrorMessageAsync( HttpRequestException ex, string baseUrl, - string? dashboardUrl, + bool dashboardOnly, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken cancellationToken) { - if (dashboardUrl is not null) + if (dashboardOnly) { return await GetDashboardApiErrorMessageAsync(ex, baseUrl, httpClientFactory, logger, cancellationToken); } @@ -456,3 +474,27 @@ public static string ResolveResourceName(OtlpResourceJson? resource, IReadOnlyLi return OtlpHelpers.GetResourceName(otlpResource, allResources); } } + +/// +/// Result of resolving the Dashboard API connection via . +/// +/// Whether the resolution succeeded. +/// The AppHost backchannel connection, if resolved via an AppHost. +/// The Dashboard API base URL, or null if the dashboard is unavailable. +/// The Dashboard API authentication token, or null if the dashboard is unavailable. +/// The Dashboard UI base URL for hyperlinks, or null if unavailable. +/// The exit code to return when is false. +internal sealed record DashboardApiResult( + bool Success, + IAppHostAuxiliaryBackchannel? Connection, + string? BaseUrl, + string? ApiToken, + string? DashboardUrl, + int ExitCode) +{ + /// + /// Creates a failed result with the specified exit code. + /// + public static DashboardApiResult Failure(int exitCode) + => new(false, null, null, null, null, exitCode); +} diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index 05238be3b75..de6d4725d5f 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -97,15 +97,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, cancellationToken); + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: true, cancellationToken); - if (!success) + if (!dashboardApi.Success) { - return exitCode; + return dashboardApi.ExitCode; } - return await FetchLogsAsync(baseUrl!, apiToken!, resourceName, traceId, severity, limit, follow, format, dashboardUrl, cancellationToken); + return await FetchLogsAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, traceId, severity, limit, follow, format, dashboardOnly: dashboardUrl is not null, cancellationToken); } private async Task FetchLogsAsync( @@ -117,7 +117,7 @@ private async Task FetchLogsAsync( int? limit, bool follow, OutputFormat format, - string? dashboardUrl, + bool dashboardOnly, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -167,7 +167,7 @@ private async Task FetchLogsAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch logs from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardOnly, _httpClientFactory, _logger, cancellationToken); _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 9a4e881a0e9..e0cb3c51c3a 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -93,15 +93,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, cancellationToken); + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: true, cancellationToken); - if (!success) + if (!dashboardApi.Success) { - return exitCode; + return dashboardApi.ExitCode; } - return await FetchSpansAsync(baseUrl!, apiToken!, resourceName, traceId, hasError, limit, follow, format, dashboardUrl, cancellationToken); + return await FetchSpansAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, traceId, hasError, limit, follow, format, dashboardOnly: dashboardUrl is not null, cancellationToken); } private async Task FetchSpansAsync( @@ -113,7 +113,7 @@ private async Task FetchSpansAsync( int? limit, bool follow, OutputFormat format, - string? dashboardUrl, + bool dashboardOnly, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -168,7 +168,7 @@ private async Task FetchSpansAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch spans from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardOnly, _httpClientFactory, _logger, cancellationToken); _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index 61ab6b14865..e1ec700fc8a 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -90,21 +90,31 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, cancellationToken); + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: true, cancellationToken); - if (!success) + if (!dashboardApi.Success) { - return exitCode; + return dashboardApi.ExitCode; } - if (!string.IsNullOrEmpty(traceId)) + try { - return await FetchSingleTraceAsync(baseUrl!, apiToken!, traceId, format, dashboardUrl, cancellationToken); + if (!string.IsNullOrEmpty(traceId)) + { + return await FetchSingleTraceAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, traceId, format, cancellationToken); + } + else + { + return await FetchTracesAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, hasError, limit, format, cancellationToken); + } } - else + catch (HttpRequestException ex) { - return await FetchTracesAsync(baseUrl!, apiToken!, resourceName, hasError, limit, format, dashboardUrl, cancellationToken); + _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, dashboardApi.BaseUrl!, dashboardUrl is not null, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + return ExitCodeConstants.DashboardFailure; } } @@ -113,7 +123,6 @@ private async Task FetchSingleTraceAsync( string apiToken, string traceId, OutputFormat format, - string? dashboardUrl, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -129,45 +138,35 @@ private async Task FetchSingleTraceAsync( _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url); - try - { - var response = await client.GetAsync(url, cancellationToken); + var response = await client.GetAsync(url, cancellationToken); - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.TraceNotFound, traceId)); - return ExitCodeConstants.InvalidCommand; - } + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.TraceNotFound, traceId)); + return ExitCodeConstants.InvalidCommand; + } - response.EnsureSuccessStatusCode(); + response.EnsureSuccessStatusCode(); - if (!TelemetryCommandHelpers.HasJsonContentType(response)) - { - _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); - return ExitCodeConstants.DashboardFailure; - } + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } - var json = await response.Content.ReadAsStringAsync(cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); - if (format == OutputFormat.Json) - { - // Structured output always goes to stdout. - _interactionService.DisplayRawText(json, ConsoleOutput.Standard); - } - else - { - DisplayTraceDetails(json, traceId, allOtlpResources); - } - - return ExitCodeConstants.Success; + if (format == OutputFormat.Json) + { + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } - catch (HttpRequestException ex) + else { - _logger.LogError(ex, "Failed to fetch trace from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); - _interactionService.DisplayError(errorMessage); - return ExitCodeConstants.DashboardFailure; + DisplayTraceDetails(json, traceId, allOtlpResources); } + + return ExitCodeConstants.Success; } private async Task FetchTracesAsync( @@ -177,7 +176,6 @@ private async Task FetchTracesAsync( bool? hasError, int? limit, OutputFormat format, - string? dashboardUrl, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -212,38 +210,28 @@ private async Task FetchTracesAsync( _logger.LogDebug("Fetching traces from {Url}", url); - try - { - var response = await client.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); + var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); - if (!TelemetryCommandHelpers.HasJsonContentType(response)) - { - _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); - return ExitCodeConstants.DashboardFailure; - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } - if (format == OutputFormat.Json) - { - // Structured output always goes to stdout. - _interactionService.DisplayRawText(json, ConsoleOutput.Standard); - } - else - { - DisplayTracesTable(json, allOtlpResources); - } + var json = await response.Content.ReadAsStringAsync(cancellationToken); - return ExitCodeConstants.Success; + if (format == OutputFormat.Json) + { + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } - catch (HttpRequestException ex) + else { - _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardUrl, _httpClientFactory, _logger, cancellationToken); - _interactionService.DisplayError(errorMessage); - return ExitCodeConstants.DashboardFailure; + DisplayTracesTable(json, allOtlpResources); } + + return ExitCodeConstants.Success; } private void DisplayTracesTable(string json, IReadOnlyList allResources) diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index 3f223da2d1c..e8d5e2dd322 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -23,7 +24,6 @@ public class ExportCommandTests(ITestOutputHelper outputHelper) public async Task ExportCommand_WritesZipWithExpectedData() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); var resources = new[] @@ -39,7 +39,7 @@ public async Task ExportCommand_WritesZipWithExpectedData() var tracesJson = BuildTracesJson( ("apiservice", null, "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, @@ -112,11 +112,10 @@ public async Task ExportCommand_WritesZipWithExpectedData() public async Task ExportCommand_OutputOption_ConfiguresArchiveOutputLocation() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var customDir = Path.Combine(workspace.WorkspaceRoot.FullName, "custom", "nested"); var outputPath = Path.Combine(customDir, "my-export.zip"); - var provider = CreateExportTestServices(workspace, outputWriter, + var provider = CreateExportTestServices(workspace, resources: [new ResourceInfoJson { Name = "redis", InstanceId = null }], telemetryEndpoints: new Dictionary { @@ -299,7 +298,6 @@ public async Task ExportCommand_SingleInScopeConnection_ExportsCorrectData() public async Task ExportCommand_ReplicaResources_GroupsDataByResolvedResourceName() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); // 3 telemetry resources: redis (singleton) + apiservice with 2 replicas @@ -321,7 +319,7 @@ public async Task ExportCommand_ReplicaResources_GroupsDataByResolvedResourceNam ("apiservice", "abc", "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false), ("apiservice", "def", "span002", "GET /api/orders", s_testTime.AddSeconds(1), s_testTime.AddSeconds(1).AddMilliseconds(80), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, @@ -426,7 +424,6 @@ public async Task ExportCommand_ReplicaResources_GroupsDataByResolvedResourceNam public async Task ExportCommand_ResourceFilter_ExportsOnlyFilteredResource() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); var resources = new[] @@ -442,7 +439,7 @@ public async Task ExportCommand_ResourceFilter_ExportsOnlyFilteredResource() var filteredTracesJson = BuildTracesJson( ("redis", null, "span001", "SET mykey", s_testTime, s_testTime.AddMilliseconds(10), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = filteredLogsJson, @@ -485,7 +482,6 @@ public async Task ExportCommand_ResourceFilter_ExportsOnlyFilteredResource() public async Task ExportCommand_ResourceFilter_NoTelemetryData_SkipsStructuredLogsAndTraces() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); // Telemetry resources do NOT include "webfrontend" - it hasn't sent any telemetry yet @@ -500,7 +496,7 @@ public async Task ExportCommand_ResourceFilter_NoTelemetryData_SkipsStructuredLo var tracesJson = BuildTracesJson( ("apiservice", null, "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, @@ -540,7 +536,6 @@ public async Task ExportCommand_ResourceFilter_NoTelemetryData_SkipsStructuredLo public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllReplicas() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); var resources = new[] @@ -559,7 +554,7 @@ public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllR ("apiservice", "abc", "span002", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false), ("apiservice", "def", "span003", "GET /api/orders", s_testTime.AddSeconds(1), s_testTime.AddSeconds(1).AddMilliseconds(80), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = filteredLogsJson, @@ -606,10 +601,9 @@ public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllR public async Task ExportCommand_ResourceFilter_NonExistentResource_ReturnsError() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); - var provider = CreateExportTestServices(workspace, outputWriter, + var provider = CreateExportTestServices(workspace, resources: [new ResourceInfoJson { Name = "redis", InstanceId = null }], telemetryEndpoints: new Dictionary { @@ -635,10 +629,11 @@ public async Task ExportCommand_ResourceFilter_NonExistentResource_ReturnsError( public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleLogs() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); - var provider = CreateExportTestServices(workspace, outputWriter, + var testInteractionService = new TestInteractionService(); + + var provider = CreateExportTestServices(workspace, resources: [], telemetryEndpoints: new Dictionary(), resourceSnapshots: @@ -651,7 +646,8 @@ public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleL new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis is starting" }, new ResourceLogLine { ResourceName = "apiservice", LineNumber = 1, Content = "Now listening on: https://localhost:5001" }, ], - dashboardAvailable: false); + dashboardAvailable: false, + interactionService: testInteractionService); var command = provider.GetRequiredService(); var result = command.Parse($"export --output {outputPath}"); @@ -676,7 +672,7 @@ public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleL Assert.Contains("Redis is starting", redisConsoleLog); // Verify warning was displayed - Assert.Contains(outputWriter.Logs, line => line.Contains(ExportCommandStrings.DashboardNotAvailable)); + Assert.Contains(testInteractionService.DisplayedMessages, m => m.Message.Contains(ExportCommandStrings.DashboardNotAvailable)); } /// @@ -685,12 +681,12 @@ public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleL /// private ServiceProvider CreateExportTestServices( TemporaryWorkspace workspace, - TestOutputTextWriter outputWriter, ResourceInfoJson[] resources, Dictionary telemetryEndpoints, List resourceSnapshots, List logLines, - bool dashboardAvailable = true) + bool dashboardAvailable = true, + IInteractionService? interactionService = null) { var resourcesJson = JsonSerializer.Serialize(resources, OtlpJsonSerializerContext.Default.ResourceInfoJsonArray); @@ -743,7 +739,10 @@ private ServiceProvider CreateExportTestServices( var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - options.OutputTextWriter = outputWriter; + if (interactionService is not null) + { + options.InteractionServiceFactory = _ => interactionService; + } options.DisableAnsi = true; }); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index befce116ddf..0ff856e2bf3 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -35,6 +35,7 @@ internal sealed class TestInteractionService : IInteractionService public List StringPromptCalls { get; } = []; public List BooleanPromptCalls { get; } = []; public List DisplayedErrors { get; } = []; + public List<(KnownEmoji Emoji, string Message)> DisplayedMessages { get; } = []; // Response queue setup methods public void SetupStringPromptResponse(string response) => _responses.Enqueue((response, ResponseType.String)); @@ -148,6 +149,7 @@ public void DisplayError(string errorMessage) public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { + DisplayedMessages.Add((emoji, message)); } public void DisplaySuccess(string message, bool allowMarkup = false) From 7b3dedf8fbf809013ea7967d44a5ca171e3602d6 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 18:44:53 +0800 Subject: [PATCH 6/9] Add ExportCommand_DashboardUrl_ExportsTelemetryData test Verifies export command works with --dashboard-url and --api-key options, bypassing AppHost backchannel discovery and fetching telemetry data directly from the dashboard API. --- .../Commands/ExportCommandTests.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index e8d5e2dd322..aca7343132a 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -625,6 +625,98 @@ public async Task ExportCommand_ResourceFilter_NonExistentResource_ReturnsError( Assert.False(File.Exists(outputPath), "No zip should be created when the resource doesn't exist"); } + [Fact] + public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); + + var resources = new[] + { + new ResourceInfoJson { Name = "redis", InstanceId = null }, + new ResourceInfoJson { Name = "apiservice", InstanceId = null }, + }; + + var resourcesJson = JsonSerializer.Serialize(resources, OtlpJsonSerializerContext.Default.ResourceInfoJsonArray); + + var logsJson = BuildLogsJson( + ("redis", null, 9, "Information", "Ready to accept connections", s_testTime), + ("apiservice", null, 9, "Information", "Request received", s_testTime.AddSeconds(1))); + + var tracesJson = BuildTracesJson( + ("apiservice", null, "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false)); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(resourcesJson, System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/logs")) + { + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(logsJson, System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/traces")) + { + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(tracesJson, System.Text.Encoding.UTF8, "application/json") + }; + } + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DisableAnsi = true; + }); + + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"export --dashboard-url http://localhost:18888 --api-key test-token --output {outputPath}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(outputPath), "Export zip file should be created"); + + using var archive = ZipFile.OpenRead(outputPath); + var entryNames = archive.Entries.Select(e => e.FullName).OrderBy(n => n).ToList(); + + // With --dashboard-url there is no backchannel, so no resources or console logs + Assert.Collection(entryNames, + entry => Assert.Equal("structuredlogs/apiservice.json", entry), + entry => Assert.Equal("structuredlogs/redis.json", entry), + entry => Assert.Equal("traces/apiservice.json", entry)); + + // Verify structured log content + var redisLogs = JsonSerializer.Deserialize( + ReadEntryText(archive, "structuredlogs/redis.json"), + OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); + Assert.NotNull(redisLogs?.ResourceLogs); + Assert.Single(redisLogs.ResourceLogs); + + // Verify trace content + var apiTraces = JsonSerializer.Deserialize( + ReadEntryText(archive, "traces/apiservice.json"), + OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); + Assert.NotNull(apiTraces?.ResourceSpans); + Assert.Single(apiTraces.ResourceSpans); + var span = Assert.Single(apiTraces.ResourceSpans[0].ScopeSpans![0].Spans!); + Assert.Equal("GET /api/products", span.Name); + } + [Fact] public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleLogs() { From 60ffd2a46b44f8e1307b62431e36f02747e1d446 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 26 Mar 2026 19:26:20 +0800 Subject: [PATCH 7/9] Fix ExportCommand_DashboardUrl test to include traces endpoint --- .../Commands/ExportCommandTests.cs | 70 +++++-------------- 1 file changed, 16 insertions(+), 54 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index aca7343132a..09360059ed0 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -634,54 +634,26 @@ public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() var resources = new[] { new ResourceInfoJson { Name = "redis", InstanceId = null }, - new ResourceInfoJson { Name = "apiservice", InstanceId = null }, }; - var resourcesJson = JsonSerializer.Serialize(resources, OtlpJsonSerializerContext.Default.ResourceInfoJsonArray); - var logsJson = BuildLogsJson( - ("redis", null, 9, "Information", "Ready to accept connections", s_testTime), - ("apiservice", null, 9, "Information", "Request received", s_testTime.AddSeconds(1))); - - var tracesJson = BuildTracesJson( - ("apiservice", null, "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false)); + ("redis", null, 9, "Information", "Ready to accept connections", s_testTime)); - var handler = new MockHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.Contains("/api/telemetry/resources")) - { - return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent(resourcesJson, System.Text.Encoding.UTF8, "application/json") - }; - } - if (url.Contains("/api/telemetry/logs")) - { - return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent(logsJson, System.Text.Encoding.UTF8, "application/json") - }; - } - if (url.Contains("/api/telemetry/traces")) + // CreateExportTestServices sets up a backchannel, but --dashboard-url bypasses it entirely + var provider = CreateExportTestServices(workspace, resources, + telemetryEndpoints: new Dictionary { - return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent(tracesJson, System.Text.Encoding.UTF8, "application/json") - }; - } - return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.NotFound); - }); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.DisableAnsi = true; - }); - - services.AddSingleton(handler); - services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); - - var provider = services.BuildServiceProvider(); + ["/api/telemetry/logs"] = logsJson, + ["/api/telemetry/traces"] = "{}", + }, + resourceSnapshots: + [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + ], + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis is starting" }, + ]); var command = provider.GetRequiredService(); var result = command.Parse($"export --dashboard-url http://localhost:18888 --api-key test-token --output {outputPath}"); @@ -696,9 +668,8 @@ public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() // With --dashboard-url there is no backchannel, so no resources or console logs Assert.Collection(entryNames, - entry => Assert.Equal("structuredlogs/apiservice.json", entry), entry => Assert.Equal("structuredlogs/redis.json", entry), - entry => Assert.Equal("traces/apiservice.json", entry)); + entry => Assert.Equal("traces/redis.json", entry)); // Verify structured log content var redisLogs = JsonSerializer.Deserialize( @@ -706,15 +677,6 @@ public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); Assert.NotNull(redisLogs?.ResourceLogs); Assert.Single(redisLogs.ResourceLogs); - - // Verify trace content - var apiTraces = JsonSerializer.Deserialize( - ReadEntryText(archive, "traces/apiservice.json"), - OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); - Assert.NotNull(apiTraces?.ResourceSpans); - Assert.Single(apiTraces.ResourceSpans); - var span = Assert.Single(apiTraces.ResourceSpans[0].ScopeSpans![0].Spans!); - Assert.Equal("GET /api/products", span.Name); } [Fact] From f2f39b7f8f4119075a7532786b8b2998142e5cbf Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 27 Mar 2026 07:36:11 +0800 Subject: [PATCH 8/9] Fix DashboardUrl test: use BuildTracesJson instead of empty JSON --- tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index 09360059ed0..1abef1951e6 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -639,12 +639,14 @@ public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() var logsJson = BuildLogsJson( ("redis", null, 9, "Information", "Ready to accept connections", s_testTime)); + var tracesJson = BuildTracesJson(); + // CreateExportTestServices sets up a backchannel, but --dashboard-url bypasses it entirely var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, - ["/api/telemetry/traces"] = "{}", + ["/api/telemetry/traces"] = tracesJson, }, resourceSnapshots: [ From a89e9d868eb62269bacb67573c886fa29da28ba5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 27 Mar 2026 07:42:30 +0800 Subject: [PATCH 9/9] Remove trace data from DashboardUrl export test --- tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index 1abef1951e6..31a28616245 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -639,14 +639,12 @@ public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() var logsJson = BuildLogsJson( ("redis", null, 9, "Information", "Ready to accept connections", s_testTime)); - var tracesJson = BuildTracesJson(); - // CreateExportTestServices sets up a backchannel, but --dashboard-url bypasses it entirely var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, - ["/api/telemetry/traces"] = tracesJson, + ["/api/telemetry/traces"] = BuildTracesJson(), }, resourceSnapshots: [ @@ -670,8 +668,7 @@ public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() // With --dashboard-url there is no backchannel, so no resources or console logs Assert.Collection(entryNames, - entry => Assert.Equal("structuredlogs/redis.json", entry), - entry => Assert.Equal("traces/redis.json", entry)); + entry => Assert.Equal("structuredlogs/redis.json", entry)); // Verify structured log content var redisLogs = JsonSerializer.Deserialize(