diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index e07d1459036..f2f06c0a53f 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -41,6 +41,7 @@ namespace Aspire.Dashboard.ServiceClient; internal sealed class DashboardClient : IDashboardClient { private const string ApiKeyHeaderName = "x-resource-service-api-key"; + private const string TroubleshootingUrl = "https://aka.ms/aspire/dashboard-apphost-connection-failed"; private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); private readonly InteractionCollection _pendingInteractionCollection = new(); @@ -339,7 +340,7 @@ await Task.WhenAll( } catch (Exception ex) { - _logger.LogError(ex, "Error loading data from the resource service."); + _logger.LogError(ex, "Error loading data from the resource service. For troubleshooting, see {TroubleshootingUrl}", TroubleshootingUrl); throw; } } @@ -411,7 +412,7 @@ private async Task ConnectWithRetryAsync(CancellationToken cancellationToken) catch (Exception ex) { errorCount++; - _logger.LogError(ex, "Error #{ErrorCount} connecting to the resource service.", errorCount); + _logger.LogError(ex, "Error #{ErrorCount} connecting to the resource service. For troubleshooting, see {TroubleshootingUrl}", errorCount, TroubleshootingUrl); } } } @@ -500,7 +501,7 @@ private async Task WatchWithRecoveryAsync(Func= 3, $"Expected at least 3 Disconnected events but got {disconnectedCount}."); } + [Fact] + public async Task ConnectWithRetry_LogsErrorWithTroubleshootingLink() + { + var testSink = new TestSink(); + var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new TestLoggerProvider(testSink))); + + await using var instance = new DashboardClient(loggerFactory, _configuration, _dashboardOptions, new MockKnownPropertyLookup()); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { FailOnGetApplicationInformation = true }); + + IDashboardClient client = instance; + var disconnectedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.ConnectionStateChanged += state => + { + if (state == DashboardConnectionState.Disconnected) + { + disconnectedTcs.TrySetResult(); + } + }; + + // Trigger the connection attempt which will fail on GetApplicationInformationAsync. + _ = client.WhenConnected; + + // Wait for the first Disconnected event which means the error has been logged. + await disconnectedTcs.Task.DefaultTimeout(); + + var errorLog = testSink.Writes.FirstOrDefault(w => w.LogLevel == LogLevel.Error); + Assert.NotNull(errorLog); + Assert.Contains("https://aka.ms/aspire/dashboard-apphost-connection-failed", errorLog.Message); + } + private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient { public bool FailOnWatchResources { get; init; } + public bool FailOnGetApplicationInformation { get; init; } public override AsyncDuplexStreamingCall WatchInteractions(CallOptions options) { @@ -382,6 +415,16 @@ public override AsyncDuplexStreamingCall GetApplicationInformationAsync(ApplicationInformationRequest request, CallOptions options) { + if (FailOnGetApplicationInformation) + { + return new AsyncUnaryCall( + Task.FromException(new RpcException(new Status(StatusCode.Unavailable, "Service unavailable"))), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.Unavailable, "Service unavailable"), + () => new Metadata(), + () => { }); + } + return new AsyncUnaryCall( Task.FromResult(new ApplicationInformationResponse {