From 00682b5c3e9217545e92d6dd8a877336eae316a3 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 18 May 2026 18:22:51 +0800 Subject: [PATCH 1/7] Always log dashboard URL at startup regardless of auth mode Previously the dashboard login URL was only logged when FrontendAuthMode was BrowserToken. When running standalone without browser-token auth, no URL was printed at all. Now WriteDashboardUrl handles a null/empty token gracefully by logging the base dashboard URL instead of the login link. The auth mode guard in DashboardWebApplication is removed so a URL is always printed at startup. Fixes #16095 --- .../DashboardWebApplication.cs | 13 +- src/Shared/LoggingHelpers.cs | 15 ++- .../LoggingHelpersTests.cs | 118 ++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 39eaa9c4a95..6ae039398ea 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -424,14 +424,13 @@ public DashboardWebApplication( if (frontendEndpointInfo != null) { var options = _app.Services.GetRequiredService>().CurrentValue; - if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken) - { - // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. - // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers - var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; + // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. + // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers + var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; - LoggingHelpers.WriteDashboardUrl(_logger, frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), options.Frontend.BrowserToken, isContainer); - } + // Always print the dashboard URL. When using browser token auth, include the login token. + var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + LoggingHelpers.WriteDashboardUrl(_logger, frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), token, isContainer); } // One-off async initialization of telemetry service. diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs index bbbb4d89a6d..2ac1dad08c3 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -10,12 +10,12 @@ internal static class LoggingHelpers { public static void WriteDashboardUrl(ILogger logger, string? dashboardUrls, string? token, bool isContainer) { - if (string.IsNullOrEmpty(token)) + if (!StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) { - throw new InvalidOperationException("Token must be provided."); + return; } - if (StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) + if (!string.IsNullOrEmpty(token)) { var message = !isContainer ? "Login to the dashboard at {DashboardLoginUrl}" @@ -24,5 +24,14 @@ public static void WriteDashboardUrl(ILogger logger, string? dashboardUrls, stri var dashboardUrl = $"{firstDashboardUrl.GetLeftPart(UriPartial.Authority)}/login?t={token}"; logger.LogInformation(message, dashboardUrl); } + else + { + var message = !isContainer + ? "The dashboard is available at {DashboardUrl}" + : "The dashboard is available at {DashboardUrl} . The URL may need changes depending on how network access to the container is configured."; + + var dashboardUrl = firstDashboardUrl.GetLeftPart(UriPartial.Authority); + logger.LogInformation(message, dashboardUrl); + } } } diff --git a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs new file mode 100644 index 00000000000..305f827548d --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class LoggingHelpersTests +{ + [Fact] + public void WriteDashboardUrl_WithToken_LogsLoginUrl() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", "abc123", isContainer: false); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); + Assert.Contains("/login?t=abc123", write.Message); + Assert.Contains("Login to the dashboard at", write.Message); + } + + [Fact] + public void WriteDashboardUrl_WithToken_IsContainer_LogsContainerMessage() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", "abc123", isContainer: true); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); + Assert.Contains("/login?t=abc123", write.Message); + Assert.Contains("URL may need changes depending on how network access to the container is configured", write.Message); + } + + [Fact] + public void WriteDashboardUrl_WithoutToken_LogsDashboardUrl() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", token: null, isContainer: false); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); + Assert.Contains("The dashboard is available at", write.Message); + Assert.Contains("http://localhost:18888", write.Message); + Assert.DoesNotContain("/login?t=", write.Message); + } + + [Fact] + public void WriteDashboardUrl_WithoutToken_IsContainer_LogsContainerMessage() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", token: null, isContainer: true); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); + Assert.Contains("The dashboard is available at", write.Message); + Assert.Contains("URL may need changes depending on how network access to the container is configured", write.Message); + } + + [Fact] + public void WriteDashboardUrl_EmptyToken_LogsDashboardUrlWithoutLogin() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", token: "", isContainer: false); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); + Assert.Contains("The dashboard is available at", write.Message); + Assert.DoesNotContain("/login?t=", write.Message); + } + + [Fact] + public void WriteDashboardUrl_InvalidUrl_DoesNotLog() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "not-a-url", token: "abc123", isContainer: false); + + Assert.Empty(sink.Writes); + } + + [Fact] + public void WriteDashboardUrl_NullUrl_DoesNotLog() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, dashboardUrls: null, token: "abc123", isContainer: false); + + Assert.Empty(sink.Writes); + } + + [Fact] + public void WriteDashboardUrl_SemicolonDelimitedUrls_UsesFirstUrl() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888;http://localhost:19999", "mytoken", isContainer: false); + + var write = Assert.Single(sink.Writes); + Assert.Contains("http://localhost:18888/login?t=mytoken", write.Message); + Assert.DoesNotContain("19999", write.Message); + } +} From 12bdff914c2cbb988e96a4f9d2c21d9193681f2d Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 18 May 2026 22:04:37 +0800 Subject: [PATCH 2/7] Always log dashboard URL regardless of launch profile settings --- .../DashboardWebApplication.cs | 14 +- .../Model/Assistant/AIContextProvider.cs | 2 +- .../Dashboard/DashboardEventHandlers.cs | 5 +- src/Shared/LoggingHelpers.cs | 57 +++++-- .../FrontendBrowserTokenAuthTests.cs | 18 ++- .../Integration/StartupTests.cs | 12 ++ .../LoggingHelpersTests.cs | 153 +++++++++++++----- 7 files changed, 191 insertions(+), 70 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 6ae039398ea..8759bb9366d 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -424,13 +424,21 @@ public DashboardWebApplication( if (frontendEndpointInfo != null) { var options = _app.Services.GetRequiredService>().CurrentValue; + // Always print a dashboard summary including dashboard and OTLP endpoints. + var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); + var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; - // Always print the dashboard URL. When using browser token auth, include the login token. - var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; - LoggingHelpers.WriteDashboardUrl(_logger, frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), token, isContainer); + LoggingHelpers.WriteDashboardSummary( + _logger, + frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), + otlpGrpcAddress, + otlpHttpAddress, + token, + isContainer); } // One-off async initialization of telemetry service. diff --git a/src/Aspire.Dashboard/Model/Assistant/AIContextProvider.cs b/src/Aspire.Dashboard/Model/Assistant/AIContextProvider.cs index d25e759513c..d2ace567fa1 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIContextProvider.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIContextProvider.cs @@ -281,7 +281,7 @@ private bool IsEnabled() // Explicitly disable AI in configuration. if (_dashboardOptions.CurrentValue.AI.Disabled.GetValueOrDefault()) { - _logger.LogInformation("AI is disabled in configuration."); + _logger.LogDebug("AI is disabled in configuration."); return false; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 26a2aa1735a..6aa9ed57773 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -400,10 +400,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) distributedApplicationLogger.LogInformation("Now listening on: {DashboardUrl}", dashboardUrl.TrimEnd('/')); - if (!string.IsNullOrEmpty(browserToken)) - { - LoggingHelpers.WriteDashboardUrl(distributedApplicationLogger, dashboardUrl, browserToken, isContainer: false); - } + LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcEndpointUrl, otlpHttpEndpointUrl, browserToken, isContainer: false); }); foreach (var d in dashboardUrls?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? []) diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs index 2ac1dad08c3..22bec5ee046 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -3,35 +3,66 @@ using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; +using System.Text; namespace Aspire.Hosting; internal static class LoggingHelpers { - public static void WriteDashboardUrl(ILogger logger, string? dashboardUrls, string? token, bool isContainer) + public static void WriteDashboardSummary(ILogger logger, string? dashboardUrls, string? otlpGrpcUrls, string? otlpHttpUrls, string? token, bool isContainer = false) { if (!StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) { return; } - if (!string.IsNullOrEmpty(token)) + static string? GetEndpointAuthority(string? urls) { - var message = !isContainer - ? "Login to the dashboard at {DashboardLoginUrl}" - : "Login to the dashboard at {DashboardLoginUrl} . The URL may need changes depending on how network access to the container is configured."; + return StringUtils.TryGetUriFromDelimitedString(urls, ";", out var firstUrl) + ? firstUrl.GetLeftPart(UriPartial.Authority) + : null; + } + + var dashboardUrl = firstDashboardUrl.GetLeftPart(UriPartial.Authority); + var otlpGrpcUrl = GetEndpointAuthority(otlpGrpcUrls); + var otlpHttpUrl = GetEndpointAuthority(otlpHttpUrls); + var loginUrl = !string.IsNullOrEmpty(token) + ? $"{dashboardUrl}/login?t={token}" + : null; + + var templateBuilder = new StringBuilder(); + var parameters = new List(); + + templateBuilder + .Append("Aspire Dashboard").Append('\n') + .Append('\n') + .Append("Dashboard: {DashboardUrl}").Append('\n'); + parameters.Add(dashboardUrl); + + if (loginUrl is not null) + { + templateBuilder.Append("Login URL: {LoginUrl}").Append('\n'); + parameters.Add(loginUrl); + } - var dashboardUrl = $"{firstDashboardUrl.GetLeftPart(UriPartial.Authority)}/login?t={token}"; - logger.LogInformation(message, dashboardUrl); + if (otlpGrpcUrl is not null) + { + templateBuilder.Append("OTLP/gRPC: {OtlpGrpcUrl}").Append('\n'); + parameters.Add(otlpGrpcUrl); } - else + + if (otlpHttpUrl is not null) { - var message = !isContainer - ? "The dashboard is available at {DashboardUrl}" - : "The dashboard is available at {DashboardUrl} . The URL may need changes depending on how network access to the container is configured."; + templateBuilder.Append("OTLP/HTTP: {OtlpHttpUrl}").Append('\n'); + parameters.Add(otlpHttpUrl); + } - var dashboardUrl = firstDashboardUrl.GetLeftPart(UriPartial.Authority); - logger.LogInformation(message, dashboardUrl); + if (isContainer) + { + templateBuilder.Append('\n'); + templateBuilder.Append("URLs may need changes depending on how network access to the container is configured.").Append('\n'); } + + logger.LogInformation(templateBuilder.ToString(), parameters.ToArray()); } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 0cb59718894..1553160bdc8 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -206,9 +206,10 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() }, w => { - Assert.Equal("Login to the dashboard at {DashboardLoginUrl}", LogTestHelpers.GetValue(w, "{OriginalFormat}")); + Assert.StartsWith("Aspire Dashboard", (string)LogTestHelpers.GetValue(w, "{OriginalFormat}")!); - var uri = new Uri((string)LogTestHelpers.GetValue(w, "DashboardLoginUrl")!, UriKind.Absolute); + var loginUrl = (string)LogTestHelpers.GetValue(w, "LoginUrl")!; + var uri = new Uri(loginUrl, UriKind.Absolute); var queryString = HttpUtility.ParseQueryString(uri.Query); Assert.NotNull(queryString["t"]); }); @@ -235,10 +236,11 @@ public async Task LogOutput_AnyIP_LoginLinkLocalhost(string frontendUrl, string // Assert var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); - // Testing via the log template is kind of hacky. If this becomes a problem then consider adding proper log definitions and match via ID. - var loginLinkLog = l.Single(w => "Login to the dashboard at {DashboardLoginUrl}" == (string?)LogTestHelpers.GetValue(w, "{OriginalFormat}")); + // The login URL is now part of the summary log message. + var summaryLog = l.Single(w => ((string?)LogTestHelpers.GetValue(w, "{OriginalFormat}"))?.StartsWith("Aspire Dashboard") == true); - var uri = new Uri((string)LogTestHelpers.GetValue(loginLinkLog, "DashboardLoginUrl")!, UriKind.Absolute); + var loginUrl = (string)LogTestHelpers.GetValue(summaryLog, "LoginUrl")!; + var uri = new Uri(loginUrl, UriKind.Absolute); var queryString = HttpUtility.ParseQueryString(uri.Query); Assert.NotNull(queryString["t"]); @@ -262,7 +264,9 @@ public async Task LogOutput_InContainer_LoginLinkContainerMessage() // Assert var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); - // Testing via the log template is kind of hacky. If this becomes a problem then consider adding proper log definitions and match via ID. - Assert.Single(l, w => (string?)LogTestHelpers.GetValue(w, "{OriginalFormat}") == "Login to the dashboard at {DashboardLoginUrl} . The URL may need changes depending on how network access to the container is configured."); + // The container message is now part of the summary log message. + var summaryLog = l.Single(w => ((string?)LogTestHelpers.GetValue(w, "{OriginalFormat}"))?.StartsWith("Aspire Dashboard") == true); + var containerMessage = "URLs may need changes depending on how network access to the container is configured."; + Assert.Contains(containerMessage, summaryLog.Message); } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 6c80a13a71e..f47ead788ed 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -660,6 +660,10 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() { Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.StartsWith("Aspire Dashboard", (string)LogTestHelpers.GetValue(w, "{OriginalFormat}")!); }); } @@ -696,6 +700,10 @@ public async Task LogOutput_NoOtlpEndpoints_NoOtlpLogs() { Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.StartsWith("Aspire Dashboard", (string)LogTestHelpers.GetValue(w, "{OriginalFormat}")!); }); } @@ -836,6 +844,10 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => { Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.StartsWith("Aspire Dashboard", (string)LogTestHelpers.GetValue(w, "{OriginalFormat}")!); }); } diff --git a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs index 305f827548d..b0dfa860629 100644 --- a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs @@ -11,108 +11,177 @@ namespace Aspire.Dashboard.Tests; public class LoggingHelpersTests { [Fact] - public void WriteDashboardUrl_WithToken_LogsLoginUrl() + public void WriteDashboardSummary_WithTokenAndOtlpEndpoints_LogsSummaryAndStructuredUrls() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", "abc123", isContainer: false); + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888", + "http://localhost:18889", + "http://localhost:18890", + "abc123"); var write = Assert.Single(sink.Writes); Assert.Equal(LogLevel.Information, write.LogLevel); - Assert.Contains("/login?t=abc123", write.Message); - Assert.Contains("Login to the dashboard at", write.Message); + Assert.NotNull(write.Message); + var lines = GetMessageLines(write.Message!); + + Assert.Collection(lines, + line => Assert.Equal("Aspire Dashboard", line), + line => Assert.Equal(string.Empty, line), + line => Assert.Equal("Dashboard: http://localhost:18888", line), + line => Assert.Equal("Login URL: http://localhost:18888/login?t=abc123", line), + line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(string.Empty, line)); + + Assert.Equal("http://localhost:18888", LogTestHelpers.GetValue(write, "DashboardUrl")); + Assert.Equal("http://localhost:18889", LogTestHelpers.GetValue(write, "OtlpGrpcUrl")); + Assert.Equal("http://localhost:18890", LogTestHelpers.GetValue(write, "OtlpHttpUrl")); + Assert.Equal("http://localhost:18888/login?t=abc123", LogTestHelpers.GetValue(write, "LoginUrl")); } [Fact] - public void WriteDashboardUrl_WithToken_IsContainer_LogsContainerMessage() + public void WriteDashboardSummary_WithoutToken_DoesNotIncludeLoginUrl() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", "abc123", isContainer: true); + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888", + "http://localhost:18889", + "http://localhost:18890", + token: null); var write = Assert.Single(sink.Writes); Assert.Equal(LogLevel.Information, write.LogLevel); - Assert.Contains("/login?t=abc123", write.Message); - Assert.Contains("URL may need changes depending on how network access to the container is configured", write.Message); + Assert.NotNull(write.Message); + var lines = GetMessageLines(write.Message!); + + Assert.Collection(lines, + line => Assert.Equal("Aspire Dashboard", line), + line => Assert.Equal(string.Empty, line), + line => Assert.Equal("Dashboard: http://localhost:18888", line), + line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(string.Empty, line)); + + Assert.Null(LogTestHelpers.GetValue(write, "LoginUrl")); } [Fact] - public void WriteDashboardUrl_WithoutToken_LogsDashboardUrl() + public void WriteDashboardSummary_InvalidDashboardUrl_DoesNotLog() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", token: null, isContainer: false); + LoggingHelpers.WriteDashboardSummary(logger, "not-a-url", "http://localhost:18889", "http://localhost:18890", token: "abc123"); - var write = Assert.Single(sink.Writes); - Assert.Equal(LogLevel.Information, write.LogLevel); - Assert.Contains("The dashboard is available at", write.Message); - Assert.Contains("http://localhost:18888", write.Message); - Assert.DoesNotContain("/login?t=", write.Message); + Assert.Empty(sink.Writes); } [Fact] - public void WriteDashboardUrl_WithoutToken_IsContainer_LogsContainerMessage() + public void WriteDashboardSummary_NullDashboardUrl_DoesNotLog() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", token: null, isContainer: true); + LoggingHelpers.WriteDashboardSummary( + logger, + dashboardUrls: null, + otlpGrpcUrls: "http://localhost:18889", + otlpHttpUrls: "http://localhost:18890", + token: "abc123"); - var write = Assert.Single(sink.Writes); - Assert.Equal(LogLevel.Information, write.LogLevel); - Assert.Contains("The dashboard is available at", write.Message); - Assert.Contains("URL may need changes depending on how network access to the container is configured", write.Message); + Assert.Empty(sink.Writes); } [Fact] - public void WriteDashboardUrl_EmptyToken_LogsDashboardUrlWithoutLogin() + public void WriteDashboardSummary_SemicolonDelimitedUrls_UsesFirstUrls() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888", token: "", isContainer: false); + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888;http://localhost:19999", + "http://localhost:18889;http://localhost:19998", + "http://localhost:18890;http://localhost:19997", + "mytoken"); var write = Assert.Single(sink.Writes); - Assert.Equal(LogLevel.Information, write.LogLevel); - Assert.Contains("The dashboard is available at", write.Message); - Assert.DoesNotContain("/login?t=", write.Message); + Assert.NotNull(write.Message); + var lines = GetMessageLines(write.Message!); + + Assert.Collection(lines, + line => Assert.Equal("Aspire Dashboard", line), + line => Assert.Equal(string.Empty, line), + line => Assert.Equal("Dashboard: http://localhost:18888", line), + line => Assert.Equal("Login URL: http://localhost:18888/login?t=mytoken", line), + line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(string.Empty, line)); + + Assert.Equal("http://localhost:18888", LogTestHelpers.GetValue(write, "DashboardUrl")); + Assert.Equal("http://localhost:18889", LogTestHelpers.GetValue(write, "OtlpGrpcUrl")); + Assert.Equal("http://localhost:18890", LogTestHelpers.GetValue(write, "OtlpHttpUrl")); + Assert.Equal("http://localhost:18888/login?t=mytoken", LogTestHelpers.GetValue(write, "LoginUrl")); } [Fact] - public void WriteDashboardUrl_InvalidUrl_DoesNotLog() + public void WriteDashboardSummary_WithoutOtlpEndpoints_DoesNotIncludeOtlpLines() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, "not-a-url", token: "abc123", isContainer: false); + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888", + otlpGrpcUrls: null, + otlpHttpUrls: null, + token: "abc123"); - Assert.Empty(sink.Writes); + var write = Assert.Single(sink.Writes); + Assert.NotNull(write.Message); + var lines = GetMessageLines(write.Message!); + + Assert.Collection(lines, + line => Assert.Equal("Aspire Dashboard", line), + line => Assert.Equal(string.Empty, line), + line => Assert.Equal("Dashboard: http://localhost:18888", line), + line => Assert.Equal("Login URL: http://localhost:18888/login?t=abc123", line), + line => Assert.Equal(string.Empty, line)); + + Assert.Null(LogTestHelpers.GetValue(write, "OtlpGrpcUrl")); + Assert.Null(LogTestHelpers.GetValue(write, "OtlpHttpUrl")); } [Fact] - public void WriteDashboardUrl_NullUrl_DoesNotLog() + public void WriteDashboardSummary_IsContainer_IncludesContainerMessage() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); - LoggingHelpers.WriteDashboardUrl(logger, dashboardUrls: null, token: "abc123", isContainer: false); + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888", + otlpGrpcUrls: null, + otlpHttpUrls: null, + token: "abc123", + isContainer: true); - Assert.Empty(sink.Writes); + var write = Assert.Single(sink.Writes); + Assert.NotNull(write.Message); + var containerMessage = "URLs may need changes depending on how network access to the container is configured."; + + Assert.Contains(containerMessage, write.Message); } - [Fact] - public void WriteDashboardUrl_SemicolonDelimitedUrls_UsesFirstUrl() + private static string[] GetMessageLines(string message) { - var sink = new TestSink(); - var logger = new TestLogger("TestLogger", sink, enabled: true); - - LoggingHelpers.WriteDashboardUrl(logger, "http://localhost:18888;http://localhost:19999", "mytoken", isContainer: false); - - var write = Assert.Single(sink.Writes); - Assert.Contains("http://localhost:18888/login?t=mytoken", write.Message); - Assert.DoesNotContain("19999", write.Message); + return message.Replace("\r", string.Empty).Split('\n'); } } From 6e8e9c8f0b598b15103f9d837e395efee502b1d0 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 18 May 2026 22:39:14 +0800 Subject: [PATCH 3/7] Update --- src/Aspire.Dashboard/DashboardWebApplication.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 8759bb9366d..37229a5bf0b 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -426,6 +426,7 @@ public DashboardWebApplication( var options = _app.Services.GetRequiredService>().CurrentValue; // Always print a dashboard summary including dashboard and OTLP endpoints. var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var frontendAddress = frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. @@ -434,7 +435,7 @@ public DashboardWebApplication( LoggingHelpers.WriteDashboardSummary( _logger, - frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), + frontendAddress, otlpGrpcAddress, otlpHttpAddress, token, From ebbb2726807cde35483d8020d5a105d2f9a39fc4 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 19 May 2026 07:25:42 +0800 Subject: [PATCH 4/7] Log dashboard summary even when frontend endpoint is null - WriteDashboardSummary now logs OTLP endpoints even without a valid dashboard URL - Moved WriteDashboardSummary call outside frontendEndpointInfo null check - Simplified URL parsing (single URLs, no delimiter handling) - Added tests for null/invalid dashboard URL scenarios --- .../DashboardWebApplication.cs | 39 ++++----- src/Shared/LoggingHelpers.cs | 45 +++++----- .../LoggingHelpersTests.cs | 85 ++++++++++++------- 3 files changed, 98 insertions(+), 71 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 37229a5bf0b..943cd37950c 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -420,27 +420,24 @@ public DashboardWebApplication( _logger.LogWarning("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data."); } - // Log frontend login URL last at startup so it's easy to find in the logs. - if (frontendEndpointInfo != null) - { - var options = _app.Services.GetRequiredService>().CurrentValue; - // Always print a dashboard summary including dashboard and OTLP endpoints. - var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; - var frontendAddress = frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true); - var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); - var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); - // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. - // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers - var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; - - LoggingHelpers.WriteDashboardSummary( - _logger, - frontendAddress, - otlpGrpcAddress, - otlpHttpAddress, - token, - isContainer); - } + // Always print a dashboard summary including dashboard and OTLP endpoints, + // even when the frontend endpoint isn't available. + var options = _app.Services.GetRequiredService>().CurrentValue; + var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var frontendAddress = frontendEndpointInfo?.GetResolvedAddress(replaceIPAnyWithLocalhost: true); + var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); + var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); + // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. + // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers + var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; + + LoggingHelpers.WriteDashboardSummary( + _logger, + frontendAddress, + otlpGrpcAddress, + otlpHttpAddress, + token, + isContainer); // One-off async initialization of telemetry service. var telemetryService = _app.Services.GetRequiredService(); diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs index 22bec5ee046..620bd5e52a4 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; using System.Text; @@ -9,25 +8,27 @@ namespace Aspire.Hosting; internal static class LoggingHelpers { - public static void WriteDashboardSummary(ILogger logger, string? dashboardUrls, string? otlpGrpcUrls, string? otlpHttpUrls, string? token, bool isContainer = false) + public static void WriteDashboardSummary(ILogger logger, string? dashboardUrl, string? otlpGrpcUrl, string? otlpHttpUrl, string? token, bool isContainer = false) { - if (!StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) + static string? GetAuthority(string? url) { - return; + return Uri.TryCreate(url, UriKind.Absolute, out var uri) + ? uri.GetLeftPart(UriPartial.Authority) + : null; } - static string? GetEndpointAuthority(string? urls) + var dashboardAuthority = GetAuthority(dashboardUrl); + var otlpGrpcAuthority = GetAuthority(otlpGrpcUrl); + var otlpHttpAuthority = GetAuthority(otlpHttpUrl); + + // Nothing to log if we have no URLs at all. + if (dashboardAuthority is null && otlpGrpcAuthority is null && otlpHttpAuthority is null) { - return StringUtils.TryGetUriFromDelimitedString(urls, ";", out var firstUrl) - ? firstUrl.GetLeftPart(UriPartial.Authority) - : null; + return; } - var dashboardUrl = firstDashboardUrl.GetLeftPart(UriPartial.Authority); - var otlpGrpcUrl = GetEndpointAuthority(otlpGrpcUrls); - var otlpHttpUrl = GetEndpointAuthority(otlpHttpUrls); - var loginUrl = !string.IsNullOrEmpty(token) - ? $"{dashboardUrl}/login?t={token}" + var loginUrl = !string.IsNullOrEmpty(token) && dashboardAuthority is not null + ? $"{dashboardAuthority}/login?t={token}" : null; var templateBuilder = new StringBuilder(); @@ -35,9 +36,13 @@ public static void WriteDashboardSummary(ILogger logger, string? dashboardUrls, templateBuilder .Append("Aspire Dashboard").Append('\n') - .Append('\n') - .Append("Dashboard: {DashboardUrl}").Append('\n'); - parameters.Add(dashboardUrl); + .Append('\n'); + + if (dashboardAuthority is not null) + { + templateBuilder.Append("Dashboard: {DashboardUrl}").Append('\n'); + parameters.Add(dashboardAuthority); + } if (loginUrl is not null) { @@ -45,16 +50,16 @@ public static void WriteDashboardSummary(ILogger logger, string? dashboardUrls, parameters.Add(loginUrl); } - if (otlpGrpcUrl is not null) + if (otlpGrpcAuthority is not null) { templateBuilder.Append("OTLP/gRPC: {OtlpGrpcUrl}").Append('\n'); - parameters.Add(otlpGrpcUrl); + parameters.Add(otlpGrpcAuthority); } - if (otlpHttpUrl is not null) + if (otlpHttpAuthority is not null) { templateBuilder.Append("OTLP/HTTP: {OtlpHttpUrl}").Append('\n'); - parameters.Add(otlpHttpUrl); + parameters.Add(otlpHttpAuthority); } if (isContainer) diff --git a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs index b0dfa860629..f26d9fcbd47 100644 --- a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs @@ -73,62 +73,87 @@ public void WriteDashboardSummary_WithoutToken_DoesNotIncludeLoginUrl() } [Fact] - public void WriteDashboardSummary_InvalidDashboardUrl_DoesNotLog() + public void WriteDashboardSummary_InvalidDashboardUrl_LogsOtlpEndpoints() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); LoggingHelpers.WriteDashboardSummary(logger, "not-a-url", "http://localhost:18889", "http://localhost:18890", token: "abc123"); - Assert.Empty(sink.Writes); - } - - [Fact] - public void WriteDashboardSummary_NullDashboardUrl_DoesNotLog() - { - var sink = new TestSink(); - var logger = new TestLogger("TestLogger", sink, enabled: true); - - LoggingHelpers.WriteDashboardSummary( - logger, - dashboardUrls: null, - otlpGrpcUrls: "http://localhost:18889", - otlpHttpUrls: "http://localhost:18890", - token: "abc123"); + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); + Assert.NotNull(write.Message); + var lines = GetMessageLines(write.Message!); - Assert.Empty(sink.Writes); + Assert.Collection(lines, + line => Assert.Equal("Aspire Dashboard", line), + line => Assert.Equal(string.Empty, line), + line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(string.Empty, line)); } [Fact] - public void WriteDashboardSummary_SemicolonDelimitedUrls_UsesFirstUrls() + public void WriteDashboardSummary_NullDashboardUrl_LogsOtlpEndpoints() { var sink = new TestSink(); var logger = new TestLogger("TestLogger", sink, enabled: true); LoggingHelpers.WriteDashboardSummary( logger, - "http://localhost:18888;http://localhost:19999", - "http://localhost:18889;http://localhost:19998", - "http://localhost:18890;http://localhost:19997", - "mytoken"); + dashboardUrl: null, + otlpGrpcUrl: "http://localhost:18889", + otlpHttpUrl: "http://localhost:18890", + token: "abc123"); var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Information, write.LogLevel); Assert.NotNull(write.Message); var lines = GetMessageLines(write.Message!); Assert.Collection(lines, line => Assert.Equal("Aspire Dashboard", line), line => Assert.Equal(string.Empty, line), - line => Assert.Equal("Dashboard: http://localhost:18888", line), - line => Assert.Equal("Login URL: http://localhost:18888/login?t=mytoken", line), line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), line => Assert.Equal(string.Empty, line)); - Assert.Equal("http://localhost:18888", LogTestHelpers.GetValue(write, "DashboardUrl")); + Assert.Null(LogTestHelpers.GetValue(write, "DashboardUrl")); + Assert.Null(LogTestHelpers.GetValue(write, "LoginUrl")); Assert.Equal("http://localhost:18889", LogTestHelpers.GetValue(write, "OtlpGrpcUrl")); Assert.Equal("http://localhost:18890", LogTestHelpers.GetValue(write, "OtlpHttpUrl")); - Assert.Equal("http://localhost:18888/login?t=mytoken", LogTestHelpers.GetValue(write, "LoginUrl")); + } + + [Fact] + public void WriteDashboardSummary_AllUrlsInvalid_DoesNotLog() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardSummary( + logger, + dashboardUrl: "not-a-url", + otlpGrpcUrl: "also-invalid", + otlpHttpUrl: "nope", + token: "abc123"); + + Assert.Empty(sink.Writes); + } + + [Fact] + public void WriteDashboardSummary_AllUrlsNull_DoesNotLog() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardSummary( + logger, + dashboardUrl: null, + otlpGrpcUrl: null, + otlpHttpUrl: null, + token: "abc123"); + + Assert.Empty(sink.Writes); } [Fact] @@ -140,8 +165,8 @@ public void WriteDashboardSummary_WithoutOtlpEndpoints_DoesNotIncludeOtlpLines() LoggingHelpers.WriteDashboardSummary( logger, "http://localhost:18888", - otlpGrpcUrls: null, - otlpHttpUrls: null, + otlpGrpcUrl: null, + otlpHttpUrl: null, token: "abc123"); var write = Assert.Single(sink.Writes); @@ -168,8 +193,8 @@ public void WriteDashboardSummary_IsContainer_IncludesContainerMessage() LoggingHelpers.WriteDashboardSummary( logger, "http://localhost:18888", - otlpGrpcUrls: null, - otlpHttpUrls: null, + otlpGrpcUrl: null, + otlpHttpUrl: null, token: "abc123", isContainer: true); From a47caabdb1df6411930e2fe86ca4044e743a5cbc Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 19 May 2026 07:27:20 +0800 Subject: [PATCH 5/7] Clean up --- .../DashboardWebApplication.cs | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 943cd37950c..0267fefee94 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -420,24 +420,7 @@ public DashboardWebApplication( _logger.LogWarning("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data."); } - // Always print a dashboard summary including dashboard and OTLP endpoints, - // even when the frontend endpoint isn't available. - var options = _app.Services.GetRequiredService>().CurrentValue; - var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; - var frontendAddress = frontendEndpointInfo?.GetResolvedAddress(replaceIPAnyWithLocalhost: true); - var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); - var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); - // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. - // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers - var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; - - LoggingHelpers.WriteDashboardSummary( - _logger, - frontendAddress, - otlpGrpcAddress, - otlpHttpAddress, - token, - isContainer); + PrintSummary(frontendEndpointInfo); // One-off async initialization of telemetry service. var telemetryService = _app.Services.GetRequiredService(); @@ -540,6 +523,27 @@ public DashboardWebApplication( _app.MapDashboardHealthChecks(); } + private void PrintSummary(ResolvedEndpointInfo? frontendEndpointInfo) + { + var options = _app.Services.GetRequiredService>().CurrentValue; + var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var frontendAddress = frontendEndpointInfo?.GetResolvedAddress(replaceIPAnyWithLocalhost: true); + var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); + var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); + + // DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images. + // https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers + var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false; + + LoggingHelpers.WriteDashboardSummary( + _logger, + frontendAddress, + otlpGrpcAddress, + otlpHttpAddress, + token, + isContainer); + } + private ILogger GetLogger() { return _app.Services.GetRequiredService().CreateLogger(); From 53faa6cbc2fc398e0b002486b57bdf6ab13d2b82 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 19 May 2026 07:34:07 +0800 Subject: [PATCH 6/7] Add Debug.Assert to validate single URL inputs in WriteDashboardSummary --- src/Shared/LoggingHelpers.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs index 620bd5e52a4..d5c2f0b5f3e 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.Text; namespace Aspire.Hosting; @@ -10,6 +11,11 @@ internal static class LoggingHelpers { public static void WriteDashboardSummary(ILogger logger, string? dashboardUrl, string? otlpGrpcUrl, string? otlpHttpUrl, string? token, bool isContainer = false) { + // Callers should pass a single resolved URL, not a semicolon-delimited list. + AssertSingleUrl(dashboardUrl, nameof(dashboardUrl)); + AssertSingleUrl(otlpGrpcUrl, nameof(otlpGrpcUrl)); + AssertSingleUrl(otlpHttpUrl, nameof(otlpHttpUrl)); + static string? GetAuthority(string? url) { return Uri.TryCreate(url, UriKind.Absolute, out var uri) @@ -70,4 +76,10 @@ public static void WriteDashboardSummary(ILogger logger, string? dashboardUrl, s logger.LogInformation(templateBuilder.ToString(), parameters.ToArray()); } + + [Conditional("DEBUG")] + private static void AssertSingleUrl(string? url, string paramName) + { + Debug.Assert(url is null || !url.Contains(';'), $"{paramName} should not contain ';': {url}"); + } } From c0f3c73127ea2fe85abd7cb98d661bbac31208a9 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 19 May 2026 09:38:35 +0800 Subject: [PATCH 7/7] Fix tests? --- tests/Shared/TemplatesTesting/AspireProject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Shared/TemplatesTesting/AspireProject.cs b/tests/Shared/TemplatesTesting/AspireProject.cs index 65b1a5ea757..32993b109c6 100644 --- a/tests/Shared/TemplatesTesting/AspireProject.cs +++ b/tests/Shared/TemplatesTesting/AspireProject.cs @@ -17,7 +17,7 @@ public partial class AspireProject : IAsyncDisposable { public const int DashboardAvailabilityTimeoutSecs = 60; private const int AppStartupWaitTimeoutSecs = 5 * 60; - private static readonly Regex s_dashboardUrlRegex = new(@"Login to the dashboard at (?.*)", RegexOptions.Compiled); + private static readonly Regex s_dashboardUrlRegex = new(@"Login URL:\s+(?.*)", RegexOptions.Compiled); public static string GetNuGetConfigPathFor(TestTargetFramework targetFramework) => Path.Combine(BuildEnvironment.TestAssetsPath, "nuget8.config");