diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 1b2092a50c..4b4ac6e63e 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -179,5 +179,6 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index 96de6b3c98..c43882a550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add navigation breadcrumbs for Blazor WebAssembly ([#4907](https://github.com/getsentry/sentry-dotnet/pull/4907)) + ### Dependencies - Bumped Xamarin.Kotlin.StdLib.Jdk8 to 2.2.20 ([#4876](https://github.com/getsentry/sentry-dotnet/pull/4876)) diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 666eb1f1ab..69921c2bcb 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -49,6 +49,7 @@ "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index c440f23f14..14ffc09833 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -56,6 +56,7 @@ "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.Android.AssemblyReader.Tests\\Sentry.Android.AssemblyReader.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index 30d7b36fd8..41d924ac5f 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -58,6 +58,7 @@ "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.AspNet.Tests\\Sentry.AspNet.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index 371f5a4b71..1b158939c9 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -58,6 +58,7 @@ "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.AspNet.Tests\\Sentry.AspNet.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index eaa6e56209..08b1295241 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -63,6 +63,7 @@ "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.Android.AssemblyReader.Tests\\Sentry.Android.AssemblyReader.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/Sentry.slnx b/Sentry.slnx index 1b2092a50c..4b4ac6e63e 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -179,5 +179,6 @@ + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index 8041add0d8..bbaecea920 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -24,6 +24,7 @@ "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index 3b19809db5..5284aab01f 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -52,6 +52,7 @@ "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.AspNet.Tests\\Sentry.AspNet.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/SentryNoSamples.slnf b/SentryNoSamples.slnf index 4aad8aa2b3..5ca7fb6d84 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -27,6 +27,7 @@ "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.Android.AssemblyReader.Tests\\Sentry.Android.AssemblyReader.Tests.csproj", "test\\Sentry.AspNet.Tests\\Sentry.AspNet.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", diff --git a/src/Sentry.AspNetCore.Blazor.WebAssembly/Internal/BlazorWasmOptionsSetup.cs b/src/Sentry.AspNetCore.Blazor.WebAssembly/Internal/BlazorWasmOptionsSetup.cs new file mode 100644 index 0000000000..ad1f5bbcc9 --- /dev/null +++ b/src/Sentry.AspNetCore.Blazor.WebAssembly/Internal/BlazorWasmOptionsSetup.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.Options; +using Sentry.Extensibility; + +namespace Sentry.AspNetCore.Blazor.WebAssembly.Internal; + +internal sealed class BlazorWasmOptionsSetup : IConfigureOptions +{ + private readonly NavigationManager _navigationManager; + private readonly IHub _hub; + private bool _initialized; + + public BlazorWasmOptionsSetup(NavigationManager navigationManager) + : this(navigationManager, HubAdapter.Instance) + { + } + + internal BlazorWasmOptionsSetup(NavigationManager navigationManager, IHub hub) + { + _navigationManager = navigationManager; + _hub = hub; + } + + public void Configure(SentryBlazorOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (_initialized) + { + return; + } + _initialized = true; + + var previousUrl = _navigationManager.Uri; + + // Set the initial scope request URL + _hub.ConfigureScope(scope => + { + scope.Request.Url = ToRelativePath(previousUrl); + }); + + _navigationManager.LocationChanged += (_, args) => + { + var from = ToRelativePath(previousUrl); + var to = ToRelativePath(args.Location); + + _hub.AddBreadcrumb( + new Breadcrumb( + type: "navigation", + category: "navigation", + data: new Dictionary + { + { "from", from }, + { "to", to } + })); + + _hub.ConfigureScope(scope => + { + scope.Request.Url = to; + }); + + previousUrl = args.Location; + }; + } + + private string ToRelativePath(string url) + { + var relative = _navigationManager.ToBaseRelativePath(url); + return "/" + relative; + } +} diff --git a/src/Sentry.AspNetCore.Blazor.WebAssembly/Sentry.AspNetCore.Blazor.WebAssembly.csproj b/src/Sentry.AspNetCore.Blazor.WebAssembly/Sentry.AspNetCore.Blazor.WebAssembly.csproj index b0635091c6..c02680154d 100644 --- a/src/Sentry.AspNetCore.Blazor.WebAssembly/Sentry.AspNetCore.Blazor.WebAssembly.csproj +++ b/src/Sentry.AspNetCore.Blazor.WebAssembly/Sentry.AspNetCore.Blazor.WebAssembly.csproj @@ -10,6 +10,10 @@ + + + + diff --git a/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs b/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs index ca208e0280..8985e9cc5e 100644 --- a/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs @@ -1,5 +1,8 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Sentry; +using Sentry.AspNetCore.Blazor.WebAssembly.Internal; using Sentry.Extensions.Logging; // ReSharper disable once CheckNamespace - Discoverability @@ -30,6 +33,8 @@ public static WebAssemblyHostBuilder UseSentry(this WebAssemblyHostBuilder build blazorOptions.IsGlobalModeEnabled = true; }); + builder.Services.AddSingleton, BlazorWasmOptionsSetup>(); + return builder; } } diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index a2b75db0ae..2fce203ef5 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -137,6 +137,7 @@ + diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/BlazorWasmOptionsSetupTests.cs b/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/BlazorWasmOptionsSetupTests.cs new file mode 100644 index 0000000000..d9960f3087 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/BlazorWasmOptionsSetupTests.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Sentry.AspNetCore.Blazor.WebAssembly.Internal; + +namespace Sentry.AspNetCore.Blazor.WebAssembly.Tests; + +public class BlazorWasmOptionsSetupTests +{ + private readonly FakeNavigationManager _navigationManager; + private readonly IHub _hub; + private readonly Scope _scope; + private readonly BlazorWasmOptionsSetup _sut; + + public BlazorWasmOptionsSetupTests() + { + _navigationManager = new FakeNavigationManager( + baseUri: "https://localhost:5001/", + initialUri: "https://localhost:5001/"); + + _hub = Substitute.For(); + _scope = new Scope(new SentryOptions()); + _hub.SubstituteConfigureScope(_scope); + + _sut = new BlazorWasmOptionsSetup(_navigationManager, _hub); + } + + [Fact] + public void Configure_SetsInitialRequestUrl() + { + // Act + _sut.Configure(new SentryBlazorOptions()); + + // Assert + _scope.Request.Url.Should().Be("/"); + } + + [Fact] + public void Configure_SetsInitialRequestUrl_WithPath() + { + // Arrange + var nav = new FakeNavigationManager( + baseUri: "https://localhost:5001/", + initialUri: "https://localhost:5001/counter"); + var sut = new BlazorWasmOptionsSetup(nav, _hub); + + // Act + sut.Configure(new SentryBlazorOptions()); + + // Assert + _scope.Request.Url.Should().Be("/counter"); + } + + [Fact] + public void Navigation_CreatesBreadcrumbWithCorrectTypeAndCategory() + { + // Arrange + _sut.Configure(new SentryBlazorOptions()); + + // Act + _navigationManager.NavigateTo("/dashboard"); + + // Assert + var crumb = _scope.Breadcrumbs.Should().ContainSingle().Subject; + crumb.Type.Should().Be("navigation"); + crumb.Category.Should().Be("navigation"); + } + + [Fact] + public void Navigation_BreadcrumbHasNoMessage() + { + // Arrange + _sut.Configure(new SentryBlazorOptions()); + + // Act + _navigationManager.NavigateTo("/dashboard"); + + // Assert + var crumb = _scope.Breadcrumbs.Should().ContainSingle().Subject; + crumb.Message.Should().BeNull(); + } + + [Fact] + public void Navigation_CreatesBreadcrumbWithRelativePaths() + { + // Arrange + _sut.Configure(new SentryBlazorOptions()); + + // Act + _navigationManager.NavigateTo("/dashboard"); + + // Assert + var crumb = _scope.Breadcrumbs.Should().ContainSingle().Subject; + crumb.Data.Should().ContainKey("from").WhoseValue.Should().Be("/"); + crumb.Data.Should().ContainKey("to").WhoseValue.Should().Be("/dashboard"); + } + + [Fact] + public void Navigation_UpdatesRequestUrl() + { + // Arrange + _sut.Configure(new SentryBlazorOptions()); + + // Act + _navigationManager.NavigateTo("/dashboard"); + + // Assert + _scope.Request.Url.Should().Be("/dashboard"); + } + + [Fact] + public void MultipleNavigations_TrackFromCorrectly() + { + // Arrange + _sut.Configure(new SentryBlazorOptions()); + + // Act + _navigationManager.NavigateTo("/page1"); + _navigationManager.NavigateTo("/page2"); + + // Assert + var breadcrumbs = _scope.Breadcrumbs.ToList(); + breadcrumbs.Should().HaveCount(2); + + var first = breadcrumbs[0]; + first.Data.Should().ContainKey("from").WhoseValue.Should().Be("/"); + first.Data.Should().ContainKey("to").WhoseValue.Should().Be("/page1"); + + var second = breadcrumbs[1]; + second.Data.Should().ContainKey("from").WhoseValue.Should().Be("/page1"); + second.Data.Should().ContainKey("to").WhoseValue.Should().Be("/page2"); + } + + [Fact] + public void Navigation_FromInitialPath_TracksCorrectFrom() + { + // Arrange - start on /login + var nav = new FakeNavigationManager( + baseUri: "https://localhost:5001/", + initialUri: "https://localhost:5001/login"); + var sut = new BlazorWasmOptionsSetup(nav, _hub); + sut.Configure(new SentryBlazorOptions()); + + // Act + nav.NavigateTo("/home"); + + // Assert + var crumb = _scope.Breadcrumbs.Should().ContainSingle().Subject; + crumb.Data.Should().ContainKey("from").WhoseValue.Should().Be("/login"); + crumb.Data.Should().ContainKey("to").WhoseValue.Should().Be("/home"); + } +} diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/FakeNavigationManager.cs b/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/FakeNavigationManager.cs new file mode 100644 index 0000000000..e0fddc9587 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/FakeNavigationManager.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components; + +namespace Sentry.AspNetCore.Blazor.WebAssembly.Tests; + +internal sealed class FakeNavigationManager : NavigationManager +{ + public FakeNavigationManager(string baseUri = "https://localhost/", string initialUri = "https://localhost/") + { + Initialize(baseUri, initialUri); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + var absoluteUri = ToAbsoluteUri(uri).ToString(); + Uri = absoluteUri; + NotifyLocationChanged(isInterceptedLink: false); + } +} diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj b/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj new file mode 100644 index 0000000000..d65aec285a --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.Tests/Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj @@ -0,0 +1,12 @@ + + + + $(CurrentTfms) + + + + + + + + diff --git a/test/Sentry.Testing/Sentry.Testing.csproj b/test/Sentry.Testing/Sentry.Testing.csproj index 4a5c86e2bd..66a5feab07 100644 --- a/test/Sentry.Testing/Sentry.Testing.csproj +++ b/test/Sentry.Testing/Sentry.Testing.csproj @@ -18,6 +18,7 @@ +