diff --git a/src/Aspire.Dashboard/Components/Controls/TelemetryErrorBoundary.cs b/src/Aspire.Dashboard/Components/Controls/TelemetryErrorBoundary.cs deleted file mode 100644 index 9fd4d74c36a..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/TelemetryErrorBoundary.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Dashboard.Telemetry; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; - -namespace Aspire.Dashboard.Components.Controls; - -public class TelemetryErrorBoundary : ErrorBoundary -{ - [Inject] - public required DashboardTelemetryService TelemetryService { get; init; } - - protected override Task OnErrorAsync(Exception ex) - { - TelemetryService.PostFault( - TelemetryEventKeys.Error, - $"{ex.GetType().FullName}: {ex.Message}", - FaultSeverity.Critical, - new Dictionary - { - [TelemetryPropertyKeys.ExceptionType] = new AspireTelemetryProperty(ex.GetType().FullName!), - [TelemetryPropertyKeys.ExceptionMessage] = new AspireTelemetryProperty(ex.Message), - [TelemetryPropertyKeys.ExceptionStackTrace] = new AspireTelemetryProperty(ex.StackTrace ?? string.Empty) - } - ); - - return Task.CompletedTask; - } -} diff --git a/src/Aspire.Dashboard/Components/Routes.razor b/src/Aspire.Dashboard/Components/Routes.razor index 17d2a0afa0e..1b2af60faf4 100644 --- a/src/Aspire.Dashboard/Components/Routes.razor +++ b/src/Aspire.Dashboard/Components/Routes.razor @@ -10,22 +10,20 @@ return; } - - - - - - - - - @Loc[nameof(Resources.Routes.NotFoundPageTitle)] - - - - - - - + + + + + + + + @Loc[nameof(Resources.Routes.NotFoundPageTitle)] + + + + + + @code { private ViewportInformation? _viewportInformation; diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 16cfceecbb7..cb98554ccec 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -240,6 +240,7 @@ public DashboardWebApplication( builder.Services.TryAddScoped(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.AddSingleton(); // OTLP services. builder.Services.AddGrpc(); diff --git a/src/Aspire.Dashboard/Telemetry/TelemetryLoggerProvider.cs b/src/Aspire.Dashboard/Telemetry/TelemetryLoggerProvider.cs new file mode 100644 index 00000000000..b66dbf79327 --- /dev/null +++ b/src/Aspire.Dashboard/Telemetry/TelemetryLoggerProvider.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Telemetry; + +/// +/// Log an error to dashboard telemetry when there is an unhandled Blazor error. +/// +public sealed class TelemetryLoggerProvider : ILoggerProvider +{ + // Log when an unhandled error is caught by Blazor. + // https://github.com/dotnet/aspnetcore/blob/0230498dfccaef6f782a5e37c60ea505081b72bf/src/Components/Server/src/Circuits/CircuitHost.cs#L695 + public const string CircuitHostLogCategory = "Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost"; + public static readonly EventId CircuitUnhandledExceptionEventId = new EventId(111, "CircuitUnhandledException"); + + private readonly IServiceProvider _serviceProvider; + + public TelemetryLoggerProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public ILogger CreateLogger(string categoryName) => new TelemetryLogger(_serviceProvider, categoryName); + + public void Dispose() + { + } + + private sealed class TelemetryLogger : ILogger + { + private readonly IServiceProvider _serviceProvider; + private readonly bool _isCircuitHostLogger; + + public TelemetryLogger(IServiceProvider serviceProvider, string categoryName) + { + _serviceProvider = serviceProvider; + _isCircuitHostLogger = categoryName == CircuitHostLogCategory; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (_isCircuitHostLogger && eventId == CircuitUnhandledExceptionEventId && exception != null) + { + try + { + // Get the telemetry service lazily to avoid a circular reference between resolving telemetry service and logging. + var telemetryService = _serviceProvider.GetRequiredService(); + + telemetryService.PostFault( + TelemetryEventKeys.Error, + $"{exception.GetType().FullName}: {exception.Message}", + FaultSeverity.Critical, + new Dictionary + { + [TelemetryPropertyKeys.ExceptionType] = new AspireTelemetryProperty(exception.GetType().FullName!), + [TelemetryPropertyKeys.ExceptionMessage] = new AspireTelemetryProperty(exception.Message), + [TelemetryPropertyKeys.ExceptionStackTrace] = new AspireTelemetryProperty(exception.StackTrace ?? string.Empty) + } + ); + } + catch + { + // We should never throw an error out of logging. + // Logging the error to telemetry shouldn't throw. But, for extra safety, send error to telemetry is inside a try/catch. + } + } + } + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index fe39bdfc5ce..5f089eb7ce9 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -6,6 +6,7 @@ using System.Text.Json.Nodes; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Otlp.Http; +using Aspire.Dashboard.Telemetry; using Aspire.Hosting; using Aspire.Tests.Shared.Telemetry; using Google.Protobuf; @@ -15,6 +16,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -765,6 +767,20 @@ public async Task Configuration_DisableResourceGraph_EnsureValueSetOnOptions(boo // Assert Assert.Equal(value, app.DashboardOptionsMonitor.CurrentValue.UI.DisableResourceGraph); } + [Fact] + public async Task ServiceProvider_AppCreated_LoggerProvidersRegistered() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper); + + // Act + var loggerProviders = app.Services.GetServices(); + var loggerProviderTypes = loggerProviders.Select(p => p.GetType()).ToList(); + + // Assert + Assert.Contains(typeof(TelemetryLoggerProvider), loggerProviderTypes); + Assert.Contains(typeof(ConsoleLoggerProvider), loggerProviderTypes); + } private static void AssertIPv4OrIPv6Endpoint(Func endPointAccessor) { diff --git a/tests/Aspire.Dashboard.Tests/Telemetry/TelemetryLoggerProviderTests.cs b/tests/Aspire.Dashboard.Tests/Telemetry/TelemetryLoggerProviderTests.cs new file mode 100644 index 00000000000..40cc4551e74 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Telemetry/TelemetryLoggerProviderTests.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Aspire.Dashboard.Tests.Telemetry; + +public class TelemetryLoggerProviderTests +{ + [Fact] + public async Task Log_DifferentCategoryAndEventIds_WriteTelemetryForBlazorUnhandedErrorAsync() + { + // Arrange + var telemetrySender = new TestDashboardTelemetrySender { IsTelemetryEnabled = true }; + await telemetrySender.TryStartTelemetrySessionAsync(); + + var serviceProvider = new ServiceCollection() + .AddSingleton() + .AddSingleton(telemetrySender) + .AddLogging() + .AddSingleton() + .BuildServiceProvider(); + + var loggerProvider = serviceProvider.GetRequiredService(); + + // Act & assert 1 + var testLogger = loggerProvider.CreateLogger("testLogger"); + testLogger.Log(LogLevel.Error, TelemetryLoggerProvider.CircuitUnhandledExceptionEventId, "Test message"); + Assert.False(telemetrySender.ContextChannel.Reader.TryPeek(out _)); + + // Act & assert 2 + var circuitHostLogger = loggerProvider.CreateLogger(TelemetryLoggerProvider.CircuitHostLogCategory); + circuitHostLogger.LogInformation("Test log message"); + Assert.False(telemetrySender.ContextChannel.Reader.TryPeek(out _)); + + // Act & assert 3 + circuitHostLogger.Log(LogLevel.Error, TelemetryLoggerProvider.CircuitUnhandledExceptionEventId, "Test message"); + Assert.False(telemetrySender.ContextChannel.Reader.TryPeek(out _)); + + // Act & assert 4 + circuitHostLogger.Log(LogLevel.Error, TelemetryLoggerProvider.CircuitUnhandledExceptionEventId, new InvalidOperationException("Exception message"), "Test message"); + Assert.True(telemetrySender.ContextChannel.Reader.TryPeek(out var context)); + Assert.Equal("/telemetry/fault - $aspire/dashboard/error", context.Name); + } +}