diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 39eaa9c4a95..0267fefee94 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -420,19 +420,7 @@ 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; - 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; - - LoggingHelpers.WriteDashboardUrl(_logger, frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), options.Frontend.BrowserToken, isContainer); - } - } + PrintSummary(frontendEndpointInfo); // One-off async initialization of telemetry service. var telemetryService = _app.Services.GetRequiredService(); @@ -535,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(); 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 bbbb4d89a6d..d5c2f0b5f3e 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -1,28 +1,85 @@ // 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.Diagnostics; +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? dashboardUrl, string? otlpGrpcUrl, string? otlpHttpUrl, string? token, bool isContainer = false) { - if (string.IsNullOrEmpty(token)) + // 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) + ? uri.GetLeftPart(UriPartial.Authority) + : null; + } + + 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) { - throw new InvalidOperationException("Token must be provided."); + return; } - if (StringUtils.TryGetUriFromDelimitedString(dashboardUrls, ";", out var firstDashboardUrl)) + var loginUrl = !string.IsNullOrEmpty(token) && dashboardAuthority is not null + ? $"{dashboardAuthority}/login?t={token}" + : null; + + var templateBuilder = new StringBuilder(); + var parameters = new List(); + + templateBuilder + .Append("Aspire Dashboard").Append('\n') + .Append('\n'); + + if (dashboardAuthority is not null) { - 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."; + templateBuilder.Append("Dashboard: {DashboardUrl}").Append('\n'); + parameters.Add(dashboardAuthority); + } - var dashboardUrl = $"{firstDashboardUrl.GetLeftPart(UriPartial.Authority)}/login?t={token}"; - logger.LogInformation(message, dashboardUrl); + if (loginUrl is not null) + { + templateBuilder.Append("Login URL: {LoginUrl}").Append('\n'); + parameters.Add(loginUrl); } + + if (otlpGrpcAuthority is not null) + { + templateBuilder.Append("OTLP/gRPC: {OtlpGrpcUrl}").Append('\n'); + parameters.Add(otlpGrpcAuthority); + } + + if (otlpHttpAuthority is not null) + { + templateBuilder.Append("OTLP/HTTP: {OtlpHttpUrl}").Append('\n'); + parameters.Add(otlpHttpAuthority); + } + + 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()); + } + + [Conditional("DEBUG")] + private static void AssertSingleUrl(string? url, string paramName) + { + Debug.Assert(url is null || !url.Contains(';'), $"{paramName} should not contain ';': {url}"); } } 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 new file mode 100644 index 00000000000..f26d9fcbd47 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs @@ -0,0 +1,212 @@ +// 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 WriteDashboardSummary_WithTokenAndOtlpEndpoints_LogsSummaryAndStructuredUrls() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + 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.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 WriteDashboardSummary_WithoutToken_DoesNotIncludeLoginUrl() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: 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.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 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"); + + 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("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_NullDashboardUrl_LogsOtlpEndpoints() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardSummary( + logger, + 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("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, "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")); + } + + [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] + public void WriteDashboardSummary_WithoutOtlpEndpoints_DoesNotIncludeOtlpLines() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888", + otlpGrpcUrl: null, + otlpHttpUrl: null, + token: "abc123"); + + 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 WriteDashboardSummary_IsContainer_IncludesContainerMessage() + { + var sink = new TestSink(); + var logger = new TestLogger("TestLogger", sink, enabled: true); + + LoggingHelpers.WriteDashboardSummary( + logger, + "http://localhost:18888", + otlpGrpcUrl: null, + otlpHttpUrl: null, + token: "abc123", + isContainer: true); + + 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); + } + + private static string[] GetMessageLines(string message) + { + return message.Replace("\r", string.Empty).Split('\n'); + } +} 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");