Skip to content

Commit 2de4317

Browse files
[release/9.3] Fix Blazor error logging to telemetry (#9308)
* Fix Blazor error logging to telemetry * Add logger * Add test * Fix build * Add test for logging registration, add try/catch around sending error to telemetry --------- Co-authored-by: James Newton-King <[email protected]>
1 parent 69d4e75 commit 2de4317

File tree

6 files changed

+152
-47
lines changed

6 files changed

+152
-47
lines changed

src/Aspire.Dashboard/Components/Controls/TelemetryErrorBoundary.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/Aspire.Dashboard/Components/Routes.razor

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,20 @@
1010
return;
1111
}
1212

13-
<TelemetryErrorBoundary>
14-
<CascadingValue Value="@_viewportInformation">
15-
<Router AppAssembly="@typeof(App).Assembly">
16-
<Found Context="routeData">
17-
<!-- AuthorizeRouteView protects pages from being accessed when authorization is required -->
18-
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
19-
</Found>
20-
<NotFound>
21-
<PageTitle>@Loc[nameof(Resources.Routes.NotFoundPageTitle)]</PageTitle>
22-
<LayoutView Layout="@typeof(Layout.MainLayout)">
23-
<Aspire.Dashboard.Components.Pages.NotFound />
24-
</LayoutView>
25-
</NotFound>
26-
</Router>
27-
</CascadingValue>
28-
</TelemetryErrorBoundary>
13+
<CascadingValue Value="@_viewportInformation">
14+
<Router AppAssembly="@typeof(App).Assembly">
15+
<Found Context="routeData">
16+
<!-- AuthorizeRouteView protects pages from being accessed when authorization is required -->
17+
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
18+
</Found>
19+
<NotFound>
20+
<PageTitle>@Loc[nameof(Resources.Routes.NotFoundPageTitle)]</PageTitle>
21+
<LayoutView Layout="@typeof(Layout.MainLayout)">
22+
<Aspire.Dashboard.Components.Pages.NotFound />
23+
</LayoutView>
24+
</NotFound>
25+
</Router>
26+
</CascadingValue>
2927

3028
@code {
3129
private ViewportInformation? _viewportInformation;

src/Aspire.Dashboard/DashboardWebApplication.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ public DashboardWebApplication(
240240
builder.Services.TryAddScoped<ComponentTelemetryContextProvider>();
241241
builder.Services.TryAddSingleton<DashboardTelemetryService>();
242242
builder.Services.TryAddSingleton<IDashboardTelemetrySender, DashboardTelemetrySender>();
243+
builder.Services.AddSingleton<ILoggerProvider, TelemetryLoggerProvider>();
243244

244245
// OTLP services.
245246
builder.Services.AddGrpc();
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Dashboard.Telemetry;
5+
6+
/// <summary>
7+
/// Log an error to dashboard telemetry when there is an unhandled Blazor error.
8+
/// </summary>
9+
public sealed class TelemetryLoggerProvider : ILoggerProvider
10+
{
11+
// Log when an unhandled error is caught by Blazor.
12+
// https://github.com/dotnet/aspnetcore/blob/0230498dfccaef6f782a5e37c60ea505081b72bf/src/Components/Server/src/Circuits/CircuitHost.cs#L695
13+
public const string CircuitHostLogCategory = "Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost";
14+
public static readonly EventId CircuitUnhandledExceptionEventId = new EventId(111, "CircuitUnhandledException");
15+
16+
private readonly IServiceProvider _serviceProvider;
17+
18+
public TelemetryLoggerProvider(IServiceProvider serviceProvider)
19+
{
20+
_serviceProvider = serviceProvider;
21+
}
22+
23+
public ILogger CreateLogger(string categoryName) => new TelemetryLogger(_serviceProvider, categoryName);
24+
25+
public void Dispose()
26+
{
27+
}
28+
29+
private sealed class TelemetryLogger : ILogger
30+
{
31+
private readonly IServiceProvider _serviceProvider;
32+
private readonly bool _isCircuitHostLogger;
33+
34+
public TelemetryLogger(IServiceProvider serviceProvider, string categoryName)
35+
{
36+
_serviceProvider = serviceProvider;
37+
_isCircuitHostLogger = categoryName == CircuitHostLogCategory;
38+
}
39+
40+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
41+
42+
public bool IsEnabled(LogLevel logLevel) => true;
43+
44+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
45+
{
46+
if (_isCircuitHostLogger && eventId == CircuitUnhandledExceptionEventId && exception != null)
47+
{
48+
try
49+
{
50+
// Get the telemetry service lazily to avoid a circular reference between resolving telemetry service and logging.
51+
var telemetryService = _serviceProvider.GetRequiredService<DashboardTelemetryService>();
52+
53+
telemetryService.PostFault(
54+
TelemetryEventKeys.Error,
55+
$"{exception.GetType().FullName}: {exception.Message}",
56+
FaultSeverity.Critical,
57+
new Dictionary<string, AspireTelemetryProperty>
58+
{
59+
[TelemetryPropertyKeys.ExceptionType] = new AspireTelemetryProperty(exception.GetType().FullName!),
60+
[TelemetryPropertyKeys.ExceptionMessage] = new AspireTelemetryProperty(exception.Message),
61+
[TelemetryPropertyKeys.ExceptionStackTrace] = new AspireTelemetryProperty(exception.StackTrace ?? string.Empty)
62+
}
63+
);
64+
}
65+
catch
66+
{
67+
// We should never throw an error out of logging.
68+
// Logging the error to telemetry shouldn't throw. But, for extra safety, send error to telemetry is inside a try/catch.
69+
}
70+
}
71+
}
72+
}
73+
}

tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text.Json.Nodes;
77
using Aspire.Dashboard.Configuration;
88
using Aspire.Dashboard.Otlp.Http;
9+
using Aspire.Dashboard.Telemetry;
910
using Aspire.Hosting;
1011
using Aspire.Tests.Shared.Telemetry;
1112
using Google.Protobuf;
@@ -15,6 +16,7 @@
1516
using Microsoft.Extensions.DependencyInjection;
1617
using Microsoft.Extensions.Logging;
1718
using Microsoft.Extensions.Logging.Abstractions;
19+
using Microsoft.Extensions.Logging.Console;
1820
using Microsoft.Extensions.Logging.Testing;
1921
using Microsoft.Extensions.Options;
2022
using Microsoft.Net.Http.Headers;
@@ -765,6 +767,20 @@ public async Task Configuration_DisableResourceGraph_EnsureValueSetOnOptions(boo
765767
// Assert
766768
Assert.Equal(value, app.DashboardOptionsMonitor.CurrentValue.UI.DisableResourceGraph);
767769
}
770+
[Fact]
771+
public async Task ServiceProvider_AppCreated_LoggerProvidersRegistered()
772+
{
773+
// Arrange
774+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper);
775+
776+
// Act
777+
var loggerProviders = app.Services.GetServices<ILoggerProvider>();
778+
var loggerProviderTypes = loggerProviders.Select(p => p.GetType()).ToList();
779+
780+
// Assert
781+
Assert.Contains(typeof(TelemetryLoggerProvider), loggerProviderTypes);
782+
Assert.Contains(typeof(ConsoleLoggerProvider), loggerProviderTypes);
783+
}
768784

769785
private static void AssertIPv4OrIPv6Endpoint(Func<EndpointInfo> endPointAccessor)
770786
{
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Dashboard.Telemetry;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
using Xunit;
8+
9+
namespace Aspire.Dashboard.Tests.Telemetry;
10+
11+
public class TelemetryLoggerProviderTests
12+
{
13+
[Fact]
14+
public async Task Log_DifferentCategoryAndEventIds_WriteTelemetryForBlazorUnhandedErrorAsync()
15+
{
16+
// Arrange
17+
var telemetrySender = new TestDashboardTelemetrySender { IsTelemetryEnabled = true };
18+
await telemetrySender.TryStartTelemetrySessionAsync();
19+
20+
var serviceProvider = new ServiceCollection()
21+
.AddSingleton<DashboardTelemetryService>()
22+
.AddSingleton<IDashboardTelemetrySender>(telemetrySender)
23+
.AddLogging()
24+
.AddSingleton<ILoggerProvider, TelemetryLoggerProvider>()
25+
.BuildServiceProvider();
26+
27+
var loggerProvider = serviceProvider.GetRequiredService<ILoggerFactory>();
28+
29+
// Act & assert 1
30+
var testLogger = loggerProvider.CreateLogger("testLogger");
31+
testLogger.Log(LogLevel.Error, TelemetryLoggerProvider.CircuitUnhandledExceptionEventId, "Test message");
32+
Assert.False(telemetrySender.ContextChannel.Reader.TryPeek(out _));
33+
34+
// Act & assert 2
35+
var circuitHostLogger = loggerProvider.CreateLogger(TelemetryLoggerProvider.CircuitHostLogCategory);
36+
circuitHostLogger.LogInformation("Test log message");
37+
Assert.False(telemetrySender.ContextChannel.Reader.TryPeek(out _));
38+
39+
// Act & assert 3
40+
circuitHostLogger.Log(LogLevel.Error, TelemetryLoggerProvider.CircuitUnhandledExceptionEventId, "Test message");
41+
Assert.False(telemetrySender.ContextChannel.Reader.TryPeek(out _));
42+
43+
// Act & assert 4
44+
circuitHostLogger.Log(LogLevel.Error, TelemetryLoggerProvider.CircuitUnhandledExceptionEventId, new InvalidOperationException("Exception message"), "Test message");
45+
Assert.True(telemetrySender.ContextChannel.Reader.TryPeek(out var context));
46+
Assert.Equal("/telemetry/fault - $aspire/dashboard/error", context.Name);
47+
}
48+
}

0 commit comments

Comments
 (0)