Support Blazor Server
Problem Statement
We should investigate better support for Blazor Server
Solution Brainstorm
Could include a new package, or might just need improved guidance docs.
Also related: #1685
Hi,
is there any progress on this topic? Or maybe a best practice to implement a custom tracing?
I tried the following approach, but I'm not sure if this is correct. Every Page I want to include inherits from this page.
public abstract partial class AbstractPage
{
private ITransaction _transaction;
private ISpan _span;
private string _currentUri;
public override Task SetParametersAsync(ParameterView parameters)
{
_currentUri = NavigationManager.Uri;
Console.Write($@"SetParameters {_currentUri}");
_transaction = SentrySdk.StartTransaction(
NavigationManager.Uri, // name
NavigationManager.Uri // operation
);
// Validate the cart
_span = _transaction.StartChild(
GetType().Name, // operation
"init" // description
);
// Set transaction on scope to associate with errors and get included span instrumentation
// If there's currently an unfinished transaction, it may be dropped
SentrySdk.ConfigureScope(scope => scope.Transaction = _transaction);
return base.SetParametersAsync(parameters);
}
protected override Task OnInitializedAsync()
{
Console.Write($@"OnInitialized {_currentUri}");
return base.OnInitializedAsync();
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if(firstRender)
{
Console.Write($@"OnAfterRender first {_currentUri}");
_span?.Finish();
_transaction?.Finish();
}
else
{
Console.Write($@"OnAfterRender {_currentUri} ");
}
return base.OnAfterRenderAsync(firstRender);
}
}
Any updates on this?
Blazor is not so new and not directly supported within sentry tracing its a big downside. And also the world is changing pretty fast while Blazor Server is not the way to go. So this might be renamend/rewritten to support the Blazor Web App with the flexible render modes for page/components.
Hi @Alfieri,
Thanks for the feedback.
How would you see this working? When would a transaction start and stop when using either Blazor Server or when dealing with SignalR connections in a Blazor Web App?
At first there was also another issue which was related to the SignalR directly, and this could maybe easily done by the SignalR filter.
Second for Blazor, I also don't know the best solution I tried something by myself to integrated into sentry and get more insides about my app. But for Blazor there are also different aspects what this really means. We have the new Blazor Web App with the different render modes and all needs maybe different implementations.
Generally what I wanted to see in Sentry or what is missing for me:
- Navigating to a page/route (like we already see for endpoints)
- Timing for page loads
- DB Queries which are related to pages/components
Currently I tried the same approach like above. I start the transaction when component initialized and finished it after the component is renders. But what is missing is any interaction on component level and I also don't know how to implement it.
Thanks @Alfieri for your scenarios.
How could we move forward with this?
E.g. first, add some scenarios about performance/transactions/tracing to our Blazor samples.
And then either add to docs, or add integrations to the existing Blazor-WASM SDK, or create a new Blazor-Server SDK, where we may extract a SignalR-Integration-Package from.
And then there is also Blazor Hybrid with MAUI and WPF/WindowsForms.
@Alfieri, would you mind providing some sample code, or add scenarios to our Blazor samples through a PR, which we could work on together?
I only built two smaller apps with Blazor WASM, but don't have much experience with Blazor Server yet.
(relates to: https://github.com/getsentry/sentry-dotnet/issues/1685#issuecomment-2830905012)
Sentry works great across most ASP.NET scenarios (Razor Pages, MVC, Web API, Blazor WebAssembly) but falls short with Blazor Server, which is frustrating.
The error reporting is decent, you still get line numbers, file names, and other debugging details. The downside is that instead of showing the actual URL the user was on, everything shows up as "GET /_blazor". You can usually figure out which page caused the issue from the other information, but it's not ideal.
The bigger problem is with the transactions and performance monitoring. It shows your database queries and execution times fine, but the "Found In" section just displays "GET /_blazor" and "CONNECT /_blazor" for everything. This makes it impossible to trace which page or component actually triggered a specific query, which really sucks when you're trying to optimise performance.
This is especially annoying because Sentry works so well with every other ASP.NET project type right out of the box. The issue comes from how Blazor Server uses SignalR - all the communication goes through the /_blazor endpoint instead of hitting different page URLs like traditional web apps.
It would be great if there was better integration that could provide more context for Blazor Server apps to match the monitoring experience you get with other ASP.NET projects.
It looks like this can be done using an IHubFilter... so it would be possible to build something "Sentry Native" to do this.
That said, there is already a NuGet package that does exactly this via OpenTelemetry:
- https://www.nuget.org/packages/AspNetCore.SignalR.OpenTelemetry
So that could be used in conjunction with Sentry's OpenTelemetry Support.
I tried wiring up the IHubFilter from AspNetCore.SignalR.OpenTelemetry... it was a bit of a battle to get it going but even once I did, I'm not sure the results are much more useful than what we have currently.
So instead of GET /_blazor and CONNECT /_blazor for everything you get OnLocationChanged , and OnRenderComplete etc. for everything (these are the methods in the SignalR Hub that get used for everything over the RPC channel).
All in all, I don't think SignalR lends itself very well to tracing. There might not be any good solution to this problem.
@jamescrosswell I'm attempting to write some IHubFilter middleware to see if I can get anything useful out of these transactions.
How did you get to that page with the span samples? I don't think I've ever seen that before. Looks useful
Ok, I've got... something. Here's a janky IHubFilter that leaks memory.
public class AutomaticBlazorTelemetryHubFilter : IHubFilter
{
private static readonly ConcurrentDictionary<string, string> ConnectionPages = new();
private static readonly ConcurrentDictionary<string, ITransactionTracer> PageTransactions = new();
private readonly ILogger<AutomaticBlazorTelemetryHubFilter> _logger;
public AutomaticBlazorTelemetryHubFilter(ILogger<AutomaticBlazorTelemetryHubFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext,
Func<HubInvocationContext, ValueTask<object?>> next)
{
var hubName = invocationContext.Hub.GetType().Name;
var methodName = invocationContext.HubMethodName;
var connectionId = invocationContext.Context.ConnectionId;
// Skip if not the ComponentHub, I don't care about anything else
if (hubName != "ComponentHub")
{
return await next(invocationContext);
}
try
{
// Grab current page from connection context
var currentPage = ConnectionPages.TryGetValue(connectionId, out var page) ? page : "unknown";
if (methodName == "OnLocationChanged")
{
_logger.LogInformation("OnLocationChanged called with {ArgCount} arguments",
invocationContext.HubMethodArguments.Count);
for (int i = 0; i < invocationContext.HubMethodArguments.Count; i++)
{
var arg = invocationContext.HubMethodArguments[i];
_logger.LogInformation("Arg[{Index}]: Type={Type}, Value={Value}",
i, arg?.GetType().Name ?? "null", arg?.ToString() ?? "null");
}
// Try to extract the URL from arguments
string? newLocation = null;
foreach (var arg in invocationContext.HubMethodArguments)
{
if (arg is string str && (str.StartsWith("http") || str.StartsWith("/")))
{
newLocation = str;
break;
}
}
if (!string.IsNullOrEmpty(newLocation))
{
var uri = new Uri(newLocation, UriKind.RelativeOrAbsolute);
var path = uri.IsAbsoluteUri ? uri.PathAndQuery : newLocation;
_logger.LogInformation("Navigation detected to: {Path}", path);
ConnectionPages[connectionId] = path;
currentPage = path;
// End previous transaction
if (PageTransactions.TryRemove(connectionId, out var oldTransaction))
{
oldTransaction.Finish();
}
// Start new transaction with the actual name
var transaction = SentrySdk.StartTransaction(
$"Page Load: {path}",
"navigation"
);
transaction.SetTag("page.path", path);
transaction.SetTag("page.url", newLocation);
PageTransactions[connectionId] = transaction;
// Set on scope
SentrySdk.ConfigureScope(scope =>
{
scope.Transaction = transaction;
scope.SetTag("current.page", path);
});
}
}
// For BeginInvokeDotNetFromJS, create a child span or transaction with page context
if (methodName == "BeginInvokeDotNetFromJS" && currentPage != "unknown")
{
// Try to extract the method being invoked from arguments
string? invokedMethod = null;
if (invocationContext.HubMethodArguments.Count > 1)
{
// The second agrument *should* be the method name
invokedMethod = invocationContext.HubMethodArguments[1]?.ToString();
}
var operationName = !string.IsNullOrEmpty(invokedMethod)
? $"{invokedMethod} on {currentPage}"
: $"DotNet Invocation on {currentPage}";
_logger.LogInformation("Creating child span for {OperationName}", operationName);
// Create a child span using Sentry
var childSpan = SentrySdk.GetSpan()?.StartChild(
"blazor.interaction",
operationName
);
if (childSpan != null)
{
childSpan.SetTag("blazor.page", currentPage);
childSpan.SetTag("blazor.method", invokedMethod ?? "unknown");
// Store the span in HttpContext
var context = invocationContext.Context.GetHttpContext();
if (context != null)
{
context.Items["sentry.span"] = childSpan;
}
}
// Update the current activity for OpenTelemetry
var activity = Activity.Current;
if (activity != null)
{
activity.DisplayName = operationName;
activity.SetTag("blazor.page", currentPage);
activity.SetTag("blazor.invoked_method", invokedMethod ?? "unknown");
}
}
// Add page context to all operations
if (currentPage != "unknown")
{
var activity = Activity.Current;
if (activity != null)
{
activity.SetTag("blazor.page", currentPage);
// Rename certain operations for clarity
switch (methodName)
{
case "BeginInvokeDotNetFromJS":
// Already handled above
break;
case "EndInvokeJSFromDotNet":
activity.DisplayName = $".NET → JS on {currentPage}";
break;
case "DispatchBrowserEvent":
activity.DisplayName = $"Browser Event on {currentPage}";
break;
case "UpdateRootComponents":
activity.DisplayName = $"Update Components on {currentPage}";
break;
case "OnRenderCompleted":
activity.DisplayName = $"Render Completed on {currentPage}";
break;
}
}
// Also add to Sentry scope
SentrySdk.ConfigureScope(scope =>
{
scope.SetTag("blazor.current_page", currentPage);
scope.SetTag("blazor.hub_method", methodName);
});
}
// Execute the hub method
var result = await next(invocationContext);
// Finish any span we created
var ctx = invocationContext.Context.GetHttpContext();
if (ctx?.Items.TryGetValue("sentry.span", out var spanObj) == true && spanObj is ISpan sentrySpan)
{
sentrySpan.Finish();
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Blazor telemetry filter");
// Finish any span we created even on error
var ctx = invocationContext.Context.GetHttpContext();
if (ctx?.Items.TryGetValue("sentry.span", out var spanObj) == true && spanObj is ISpan sentrySpan)
{
sentrySpan.Finish(SpanStatus.InternalError);
}
throw;
}
}
public Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
{
_logger.LogInformation("SignalR connected: {ConnectionId}", context.Context.ConnectionId);
// Initialize with the root page
var httpContext = context.Context.GetHttpContext();
if (httpContext != null)
{
var referer = httpContext.Request.Headers["Referer"].FirstOrDefault();
if (!string.IsNullOrEmpty(referer))
{
var uri = new Uri(referer);
ConnectionPages[context.Context.ConnectionId] = uri.PathAndQuery;
_logger.LogInformation("Initial page set to: {Path}", uri.PathAndQuery);
}
}
// Add breadcrumb
SentrySdk.AddBreadcrumb(
"SignalR Connected",
"signalr",
level: BreadcrumbLevel.Info
);
return next(context);
}
public Task OnDisconnectedAsync(
HubLifetimeContext context,
Exception? exception,
Func<HubLifetimeContext, Exception?, Task> next)
{
var connectionId = context.Context.ConnectionId;
// Cleanup
ConnectionPages.TryRemove(connectionId, out _);
if (PageTransactions.TryRemove(connectionId, out var transaction))
{
transaction.Finish();
}
_logger.LogInformation("SignalR disconnected: {ConnectionId}", connectionId);
return next(context, exception);
}
}
It's not perfect at this point, but things are improving.
For service methods that are called OnInitialized, we can now see the transaction as GET Page Load: /weather. Cool!
I'm still working on stuff that's executed on user interaction (button click, etc.). It seems to call BeginInvokeDotNetFromJS, which still appears as GET /_blazor as you see above. BUT:
If I view the Trace Details page, we've got some extra context! You can see that I interacted with buttons on /Blah2 and /Blah3!
There's still more messing around to do at this point, but we already have a lot more info.
@msuddaby nice - so maybe with some effort it could be done!
@jamescrosswell how did you get your Traces page working with Blazor Server?
@msuddaby I pushed my fiddling up as a PR, so you can see exactly what I did:
- https://github.com/getsentry/sentry-dotnet/pull/4443
I think the critical lines to get things showing as traces in Sentry are these: https://github.com/getsentry/sentry-dotnet/blob/50d246560ceef5d5c6fe767f6f07ab7206963886/samples/Sentry.Samples.AspNetCore.Blazor.Server/Program.cs#L25-L29 https://github.com/getsentry/sentry-dotnet/blob/50d246560ceef5d5c6fe767f6f07ab7206963886/samples/Sentry.Samples.AspNetCore.Blazor.Server/Program.cs#L42
That's all per Sentry's OpenTelemetry setup (just adding the relevant sources). If you were creating your own Activity instances from some other ActivitySource, you'd want to add the name of that source to ensure those Activities showed up in the Traces area of Sentry.
+1 through User Feedback via WASM docs. As a quick win, we could add a Guide in the docs, mirroring our current WASM Sample, until we got a dedicated package.
Hey,
It seems that the release of .NET 10 will bring "comprehensive metrics and tracing capabilities for Blazor apps, providing detailed observability of the component lifecycle, navigation, event handling, and circuit management." (Source)
They also link this article: https://learn.microsoft.com/en-ca/aspnet/core/blazor/performance/?view=aspnetcore-10.0#metrics-and-tracing
When I get some time I'll play around with it.
Thank you very much in advance @msuddaby! We'd appreciate any further insights.
Hey all, sorry for the delay. I've created a proof-of-concept repository using the new .NET features I linked above. You can check it out here: https://github.com/msuddaby/SentryBlazorServerExample/tree/master
There's a detailed breakdown in the README, but it does require using OpenTelemetry packages to work. However, the results make Sentry significantly more usable with Blazor Server! The route name takes the place of GET /_blazor, there's improved tagging, and automatic breadcrumbs for navigation and UI interactions.
I have some additional functionality I want to experiment with here, but for now I hope this helps someone else or even the Sentry team!
@msuddaby this is fantastic!
Ideally we'd have something like BlazorEventProcessor and BlazorSentryIntegration be part of a Sentry.Samples.AspNetCore.Blazor.Server package so that people wouldn't need to copy/paste to reuse these in their solutions and the Sentry.Samples.AspNetCore.Blazor.Server Sample project would demonstrate how to wire these up (like you've done in your sample).
How would you feel about making a PR to do that? Or if you don't have the time, would you be OK with one of us doing that?
Sure, I'd love to! I'll get a PR going ASAP.
If anyone finds this from Google as I originally did, please see the Blazor Server example for how to make Sentry work a bit better for you :)