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 @@
+