Skip to content

Fix Blazor error logging to telemetry #9304

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions src/Aspire.Dashboard/Components/Controls/TelemetryErrorBoundary.cs

This file was deleted.

30 changes: 14 additions & 16 deletions src/Aspire.Dashboard/Components/Routes.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,20 @@
return;
}

<TelemetryErrorBoundary>
<CascadingValue Value="@_viewportInformation">
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<!-- AuthorizeRouteView protects pages from being accessed when authorization is required -->
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
</Found>
<NotFound>
<PageTitle>@Loc[nameof(Resources.Routes.NotFoundPageTitle)]</PageTitle>
<LayoutView Layout="@typeof(Layout.MainLayout)">
<Aspire.Dashboard.Components.Pages.NotFound />
</LayoutView>
</NotFound>
</Router>
</CascadingValue>
</TelemetryErrorBoundary>
<CascadingValue Value="@_viewportInformation">
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<!-- AuthorizeRouteView protects pages from being accessed when authorization is required -->
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
</Found>
<NotFound>
<PageTitle>@Loc[nameof(Resources.Routes.NotFoundPageTitle)]</PageTitle>
<LayoutView Layout="@typeof(Layout.MainLayout)">
<Aspire.Dashboard.Components.Pages.NotFound />
</LayoutView>
</NotFound>
</Router>
</CascadingValue>

@code {
private ViewportInformation? _viewportInformation;
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ public DashboardWebApplication(
builder.Services.TryAddScoped<ComponentTelemetryContextProvider>();
builder.Services.TryAddSingleton<DashboardTelemetryService>();
builder.Services.TryAddSingleton<IDashboardTelemetrySender, DashboardTelemetrySender>();
builder.Services.AddSingleton<ILoggerProvider, TelemetryLoggerProvider>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this usage pattern adds a further ILoggerProvider rather than replacing the one that would already have been in place, so you still get normal logging output.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It's what happens when you call LoggerFactory.AddProvider: https://github.com/dotnet/runtime/blob/f76d75739482939fcf2661010bf490a3d81163ed/src/libraries/Microsoft.Extensions.Logging/src/LoggingBuilderExtensions.cs#L37

Other methods to add customer loggers use TryAddEnumerable. Example. I think that's to avoid double registration of a provider which isn't a problem in our case because we control all registrations in the dashboard app.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SteveSandersonMS I added a test.


// OTLP services.
builder.Services.AddGrpc();
Expand Down
73 changes: 73 additions & 0 deletions src/Aspire.Dashboard/Telemetry/TelemetryLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Log an error to dashboard telemetry when there is an unhandled Blazor error.
/// </summary>
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>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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<DashboardTelemetryService>();

telemetryService.PostFault(
TelemetryEventKeys.Error,
$"{exception.GetType().FullName}: {exception.Message}",
FaultSeverity.Critical,
new Dictionary<string, AspireTelemetryProperty>
{
[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.
}
}
}
}
}
16 changes: 16 additions & 0 deletions tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<ILoggerProvider>();
var loggerProviderTypes = loggerProviders.Select(p => p.GetType()).ToList();

// Assert
Assert.Contains(typeof(TelemetryLoggerProvider), loggerProviderTypes);
Assert.Contains(typeof(ConsoleLoggerProvider), loggerProviderTypes);
}

private static void AssertIPv4OrIPv6Endpoint(Func<EndpointInfo> endPointAccessor)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DashboardTelemetryService>()
.AddSingleton<IDashboardTelemetrySender>(telemetrySender)
.AddLogging()
.AddSingleton<ILoggerProvider, TelemetryLoggerProvider>()
.BuildServiceProvider();

var loggerProvider = serviceProvider.GetRequiredService<ILoggerFactory>();

// 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);
}
}