Guide Errors ~9 min read Updated April 25, 2026

.NET error monitoring (ASP.NET Core + Worker Services).

.NET applications spread errors across several distinct surfaces: the ASP.NET Core middleware pipeline, hosted services, Worker Services, background job loops, and — if you target desktop or mobile — MAUI clients. This guide covers how to wire up Sentry.AspNetCore so every failure reaches your error tracker, what the SDK captures without extra code, and the three gotchas that catch .NET teams on their first day with any error monitoring setup.

TL;DR

20 seconds. Run dotnet add package Sentry.AspNetCore. Call builder.WebHost.UseSentry() in Program.cs with your DSN, environment, release, and TracesSampleRate. Swap the DSN to your urgentry DSN and nothing else changes.

60 seconds. The Sentry.AspNetCore middleware hooks into the ASP.NET Core pipeline and captures unhandled exceptions, model binding failures, and the global exception handler path without extra code. What it does not capture automatically: exceptions thrown inside a BackgroundService.ExecuteAsync loop, async void event handlers, and any exception that fires before the host finishes initializing (which means before Sentry itself initializes). All three gaps are covered below, with fixes.

Two .NET-specific traps are worth knowing before you read on. First: the default FailureBehavior for BackgroundService is Ignore, so a crashed background worker leaves the host running silently — no log, no event, no alert unless you add handling. Second: async void methods cannot propagate exceptions to their caller; exceptions thrown inside them go straight to the thread pool unhandled exception handler, completely bypassing the Sentry middleware. Both are common patterns in .NET code and both produce invisible failures.

Where .NET errors come from in 2026

A modern .NET application is not just an HTTP server. It is a host that runs multiple services simultaneously: the Kestrel web server, one or more IHostedService or BackgroundService implementations, health check endpoints, and optionally a gRPC server. Errors can originate in any of these, and each one has different propagation behavior.

ASP.NET Core middleware pipeline. HTTP requests flow through a chain of middleware registered in Program.cs. When a middleware or endpoint throws, the exception propagates back up the chain. If nothing catches it, ASP.NET Core’s exception handling middleware converts it to a 500 response and logs the traceback. The event appears in stderr or your logging sink but reaches no error tracker unless you instrument it.

Hosted services and IHostApplicationLifetime. IHostedService implementations run on the same host as the web server. Their StartAsync and StopAsync methods run during host startup and shutdown. An exception in StartAsync propagates to the host and terminates it — this is the correct behavior, but the exception fires before most logging infrastructure is fully wired. Exceptions in StopAsync are swallowed by the host by default.

Worker Services. A Worker Service is a hosted application with no HTTP layer. It runs one or more BackgroundService subclasses that loop indefinitely, processing queues, polling external systems, or running scheduled tasks. The error behavior here is the most surprising: the default BackgroundServiceExceptionBehavior in .NET 6 and later is Ignore, which means an unhandled exception in ExecuteAsync stops the service silently while the host process keeps running. Your Worker Service is dead; your process monitoring shows it alive.

EF Core. Database operations raise exceptions of several types: DbUpdateException for constraint violations and concurrency conflicts, DbUpdateConcurrencyException for optimistic concurrency failures, and SqlException or driver-specific exceptions for connection and query failures. Retry policies configured with EnableRetryOnFailure silently swallow transient failures and only surface the exception after all retries are exhausted.

MAUI clients. If your .NET solution includes a MAUI client, the Sentry.Maui package covers unhandled exceptions on the UI thread and the background thread. This guide focuses on the server side; MAUI setup is a separate topic.

Install Sentry.AspNetCore

The Sentry .NET SDK ships as separate NuGet packages for different host types. For ASP.NET Core web applications, the package is Sentry.AspNetCore:

dotnet add package Sentry.AspNetCore

For a Worker Service project with no HTTP layer, use the base package:

dotnet add package Sentry.Extensions.Logging

Version compatibility: Sentry.AspNetCore 4.x supports .NET 6, .NET 7, .NET 8, and .NET 9. .NET 8 is an LTS release; .NET 9 is a standard-term release. Both work without additional configuration beyond the version constraint. .NET Framework projects require a different package (Sentry directly, not Sentry.AspNetCore) and the setup differs from what this guide covers.

Pin to the current 4.x series in your .csproj:

<PackageReference Include="Sentry.AspNetCore" Version="4.*" />

The wildcard 4.* resolves to the latest 4.x release and will not pull a future 5.x that might change the API. Restore packages after adding:

dotnet restore

Wire up in Program.cs

Call UseSentry on the WebApplicationBuilder before builder.Build(). This registers the Sentry middleware and the SDK’s logging integration in one call.

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.UseSentry(options =>
{
    options.Dsn = builder.Configuration["Sentry:Dsn"]
        ?? Environment.GetEnvironmentVariable("SENTRY_DSN");

    options.Environment = builder.Environment.EnvironmentName; // "Production", "Staging"

    // Set from your CI pipeline: a git SHA or a semantic version string.
    options.Release = Environment.GetEnvironmentVariable("APP_VERSION");

    // Fraction of requests that generate a performance transaction.
    // Start at 0.1 (10%) in production. Use 1.0 during initial validation.
    options.TracesSampleRate = builder.Environment.IsProduction() ? 0.1 : 1.0;

    // When true, attaches the request body, cookies, and user IP to events.
    // Review your data retention policy before enabling.
    options.SendDefaultPii = false;

    // Attach the full server exception to events. On by default in Debug;
    // set explicitly to keep behavior consistent across environments.
    options.AttachStacktrace = true;
});

builder.Services.AddControllers();
// ... other service registrations

var app = builder.Build();

app.UseRouting();
app.UseSentryTracing(); // optional: creates performance spans per request
app.MapControllers();

app.Run();

A few notes on each option.

Dsn: read from configuration or environment, never hardcoded. The DSN grants write access to your project. Check it into source control and rotate it.

Environment: ASP.NET Core exposes the environment through IWebHostEnvironment.EnvironmentName, which reads from the ASPNETCORE_ENVIRONMENT variable. Use distinct values for production, staging, and development. Events from different environments group separately in urgentry, which prevents staging noise from triggering production alerts.

Release: ties events to a specific deployment. Set it from a git SHA or semantic version injected by your CI pipeline. With a release set, urgentry can show you which deployment introduced a regression.

TracesSampleRate: controls what fraction of requests generate a performance transaction. At 1.0, every request is traced and you accumulate data fast. At 0.1, one in ten requests is traced. Start low. Errors are always captured regardless of the sample rate.

UseSentryTracing() adds a middleware that creates an OpenTelemetry-compatible span for each HTTP request. It is separate from UseSentry() so you can capture errors without performance tracing if you prefer.

What Sentry.AspNetCore captures automatically

After the UseSentry call, the SDK instruments the ASP.NET Core pipeline without further configuration. Here is what it covers.

Unhandled exceptions through the middleware. Any exception that propagates out of a controller action, a minimal API handler, or a middleware and is not caught by a higher-order middleware generates an event. The event includes the full stack trace, the HTTP method, the URL, the route values, and the request headers (minus cookies and auth tokens unless SendDefaultPii = true).

The global exception handler. ASP.NET Core’s UseExceptionHandler middleware catches unhandled exceptions before they reach the HTTP client. The Sentry middleware runs before UseExceptionHandler in the default registration order, so it sees the original exception before the exception handler rewrites the response. If you register middleware in a non-default order, verify that UseSentry appears before UseExceptionHandler in your pipeline.

Model binding failures. ASP.NET Core’s model binding raises InvalidOperationException subtypes when required parameters cannot be bound or when JSON deserialization fails on a request body. These propagate through the middleware and are captured with the request details attached.

The BackgroundService unhandled exception path. If you set BackgroundServiceExceptionBehavior to StopHost (which causes an unhandled exception in a background service to stop the entire host), the Sentry SDK captures the exception during host teardown via the logging integration. In the default Ignore behavior, the exception is only captured if you add explicit handling inside ExecuteAsync — covered in the next section.

The SDK also integrates with Microsoft.Extensions.Logging. Log entries at Error level and above generate breadcrumbs attached to subsequent events. Log entries at Critical generate events directly.

Worker Services and IHostedService

Worker Services expose the most error-tracking surface area that the middleware does not cover automatically. The root cause is the BackgroundServiceExceptionBehavior.Ignore default.

Here is what happens when a BackgroundService throws in ExecuteAsync with the default behavior: the ExecuteAsync task faults; the BackgroundService base class catches the exception, logs it at Error level, and marks the service as stopped. The host does not stop. The service does not restart. From the outside, the process looks healthy.

The correct pattern is to wrap your ExecuteAsync loop in a try/catch, capture the exception, and then decide what to do:

public class OrderProcessingWorker : BackgroundService
{
    private readonly ILogger<OrderProcessingWorker> _logger;

    public OrderProcessingWorker(ILogger<OrderProcessingWorker> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ProcessPendingOrdersAsync(stoppingToken);
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
            catch (OperationCanceledException)
            {
                // Normal shutdown path. Do not capture.
                break;
            }
            catch (Exception ex)
            {
                SentrySdk.CaptureException(ex);
                _logger.LogError(ex, "OrderProcessingWorker loop failed");

                // Back off before retrying to avoid tight failure loops.
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
        }
    }
}

The OperationCanceledException path is important. When the host shuts down, it signals the cancellation token. Task.Delay throws TaskCanceledException (a subclass of OperationCanceledException) in response. Do not capture that as an error.

The IHostApplicationLifetime.ApplicationStopping gotcha: if your StopAsync implementation calls lifetime.ApplicationStopping.Register and the registered callback throws, the exception is silently swallowed by the host. The host shuts down normally from its perspective. Add try/catch inside any ApplicationStopping callback and call SentrySdk.CaptureException explicitly:

public MyService(IHostApplicationLifetime lifetime)
{
    lifetime.ApplicationStopping.Register(() =>
    {
        try
        {
            FlushInternalBuffer();
        }
        catch (Exception ex)
        {
            SentrySdk.CaptureException(ex);
            SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult();
        }
    });
}

The SentrySdk.FlushAsync call inside the stopping callback is not optional. The host is shutting down; the SDK buffers events asynchronously and without an explicit flush they may not reach the server before the process exits.

Capturing handled exceptions and context

The SDK captures exceptions that escape your code. For exceptions you catch and convert to user-facing error messages or business outcomes, call SentrySdk.CaptureException yourself.

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        try
        {
            var order = await _orderService.CreateAsync(request);
            return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
        }
        catch (PaymentDeclinedException)
        {
            // Expected business outcome. Do not capture.
            return UnprocessableEntity(new { error = "Payment declined" });
        }
        catch (PaymentProviderException ex)
        {
            // Our integration is broken. Capture it.
            SentrySdk.CaptureException(ex);
            return StatusCode(503, new { error = "Payment service unavailable" });
        }
    }
}

For attaching context specific to one capture, use SentrySdk.ConfigureScope inside a local scope:

catch (ExportFailedException ex)
{
    using (SentrySdk.PushScope())
    {
        SentrySdk.ConfigureScope(scope =>
        {
            scope.SetTag("export.format", request.Format ?? "unknown");
            scope.SetExtra("order_id", orderId);
            scope.User = new SentryUser
            {
                Id = User.FindFirstValue(ClaimTypes.NameIdentifier),
                Email = User.FindFirstValue(ClaimTypes.Email)
            };
        });
        SentrySdk.CaptureException(ex);
    }
    return StatusCode(500, new { error = "Export failed" });
}

SentrySdk.PushScope() returns an IDisposable. The using block ensures the scope is popped after the capture, so the tags and extras set inside it do not attach to subsequent events on the same request. Use SetTag for indexed, filterable values. Use SetExtra for arbitrary context you want visible in the event detail but do not need to filter by.

EF Core query failures

Entity Framework Core surfaces database errors in two distinct ways, and the difference matters for error tracking.

DbUpdateException. This exception fires when SaveChangesAsync fails. It wraps the underlying database driver exception. The most common causes are constraint violations (unique index conflict, foreign key violation), concurrency conflicts (DbUpdateConcurrencyException, a subclass), and connection failures. DbUpdateException propagates out of SaveChangesAsync and, if not caught, continues up through the controller action and into the Sentry middleware, where it is captured automatically.

When you catch DbUpdateException to return a user-facing error, capture it explicitly:

try
{
    await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // Optimistic concurrency conflict. Return 409 Conflict.
    SentrySdk.CaptureException(ex);
    return Conflict(new { error = "Record was modified by another user" });
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
    // Expected for duplicate submissions. Do not capture.
    return Conflict(new { error = "Record already exists" });
}

Retry policies and what they hide. EF Core’s SQL Server provider supports automatic retry via EnableRetryOnFailure on the DbContextOptions. When enabled, transient failures (connection timeouts, transient SQL errors) are retried silently. The exception is only surfaced after all retry attempts are exhausted.

This is generally the correct behavior: a transient connection blip that resolves on retry is not worth an error event. But if your retry count is high and the underlying problem is persistent (the database is down), you lose visibility into the retry storm — each attempt logs at Warning level but generates no Sentry event until the final failure.

Add a before-retry callback if you want visibility into individual retry attempts:

options.UseSqlServer(connectionString, sqlOptions =>
{
    sqlOptions.EnableRetryOnFailure(
        maxRetryCount: 5,
        maxRetryDelay: TimeSpan.FromSeconds(30),
        errorNumbersToAdd: null);
});

For the SQL parameter capture concern: EF Core does not attach SQL parameter values to exceptions by default. If you enable detailed error logging (EnableSensitiveDataLogging), parameter values appear in logs and can flow into the breadcrumbs the Sentry SDK captures from the logging integration. This is useful for debugging but carries data exposure risk in production. Keep EnableSensitiveDataLogging off by default and enable it only on non-production environments.

Point the DSN at urgentry

Create a project in urgentry and copy its DSN. It has the format:

https://<public_key>@errors.example.com/<project_id>

Set SENTRY_DSN to this value in your production environment, or update your appsettings.Production.json:

{
  "Sentry": {
    "Dsn": "https://<public_key>@errors.example.com/<project_id>"
  }
}

The UseSentry call in Program.cs reads from builder.Configuration["Sentry:Dsn"] as written above. No code change is needed. Every SDK feature — middleware capture, SentrySdk.CaptureException, scope configuration, breadcrumbs, performance tracing — sends the same Sentry envelope protocol it always has. urgentry receives that envelope and stores it. The DSN is the only difference between pointing at Sentry and pointing at urgentry.

Three .NET gotchas

1. async void event handlers swallow exceptions

async void methods are a special case in .NET’s async model. When an async void method throws, the exception goes to the thread pool’s unhandled exception handler (AppDomain.CurrentDomain.UnhandledException), not to any awaiter. There is no awaiter; the caller of an async void method cannot observe the result.

The Sentry middleware only sees exceptions that propagate through the middleware pipeline. An exception from an async void handler never reaches the pipeline. It goes to the unhandled exception handler and typically crashes the process, with the crash logged to the Windows Event Log or to stderr, but with no Sentry event.

The fix is to avoid async void entirely outside of event handlers where the signature is forced on you (Blazor event callbacks, for example). For those forced cases, add an explicit try/catch inside the method:

// Bad: exception escapes to the thread pool, never reaches Sentry middleware
private async void OnDataReceived(object sender, DataReceivedEventArgs e)
{
    await ProcessDataAsync(e.Data); // if this throws, no event
}

// Good: exception is captured explicitly
private async void OnDataReceived(object sender, DataReceivedEventArgs e)
{
    try
    {
        await ProcessDataAsync(e.Data);
    }
    catch (Exception ex)
    {
        SentrySdk.CaptureException(ex);
    }
}

If you control the event subscription pattern, prefer async Task methods invoked through an awaitable adapter rather than async void directly.

2. BackgroundService FailureBehavior defaults to Ignore

.NET 6 changed the default BackgroundServiceExceptionBehavior from StopHost to Ignore. The motivation was to prevent a single background service failure from taking down the entire host. The cost is that the failure is silent unless you add explicit handling.

To change the default for all background services in your host, set it in your service registration:

builder.Services.Configure<HostOptions>(options =>
{
    options.BackgroundServiceExceptionBehavior =
        BackgroundServiceExceptionBehavior.StopHost;
});

With StopHost, an unhandled exception in any background service terminates the host process. This is the safer default for services where background job health is critical to the overall application. The Sentry SDK captures the exception during host teardown through the logging integration.

For services where you want the host to survive individual background service failures, keep Ignore but add the explicit try/catch pattern shown in the Worker Services section above. Do not rely on the default behavior to surface failures to your error tracker.

3. Configuration binding throws before Sentry initializes

UseSentry is called during host configuration in Program.cs. If your configuration throws an exception before UseSentry is called — a missing required configuration key, a JSON parse error in appsettings.json, or a custom IConfiguration provider that fails to connect — the Sentry SDK is never initialized and the exception reaches no error tracker.

The exception goes to the process’s unhandled exception handler. In a containerized environment, this shows up as a non-zero exit code and whatever logging your container runtime captures.

The mitigation is to call UseSentry as early in Program.cs as possible, before any configuration binding that can throw. The DSN itself must be available before this call, but the DSN comes from environment variables or a simple configuration value that is available before the rest of your application configuration loads:

var builder = WebApplication.CreateBuilder(args);

// Initialize Sentry before any configuration binding that can throw.
builder.WebHost.UseSentry(options =>
{
    options.Dsn = Environment.GetEnvironmentVariable("SENTRY_DSN");
    options.Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
});

// Configuration binding that might throw happens after Sentry is live.
var myConfig = builder.Configuration.GetSection("MyService").Get<MyServiceOptions>()
    ?? throw new InvalidOperationException("MyService configuration is missing");

For exceptions that happen before even the environment variable read is possible, your only option is process-level monitoring: container restart policies, systemd service restarts, and alerting on non-zero exit codes from your process supervisor.

FAQ

Does Sentry.AspNetCore capture all unhandled exceptions automatically?

It captures exceptions that propagate through the ASP.NET Core middleware pipeline. Exceptions from BackgroundService loops and async void handlers are not captured automatically. Add explicit try/catch blocks with SentrySdk.CaptureException in background services, and avoid async void outside of contexts where the signature is forced on you.

Does the Sentry .NET SDK work with .NET 8 and .NET 9?

Yes. Sentry.AspNetCore 4.x supports .NET 6, .NET 7, .NET 8, and .NET 9. .NET 8 is an LTS release and is the recommended target for new projects. .NET 9 is a standard-term release. Both work without additional SDK configuration.

How do I capture exceptions from a BackgroundService without crashing the host?

Wrap your ExecuteAsync loop in a try/catch. Call SentrySdk.CaptureException(ex) in the catch block, log the error, and add a backoff delay before the loop continues. The default FailureBehavior.Ignore keeps the host running; your try/catch prevents the loop from dying silently. Always check for OperationCanceledException separately and do not capture it.

Does urgentry work with the Sentry .NET SDK without code changes?

The only change is the DSN. Set the Dsn option in UseSentry (or the SENTRY_DSN environment variable) to your urgentry project DSN. Every SDK feature, every SentrySdk.CaptureException call, and every middleware integration works without modification. urgentry implements the same Sentry envelope ingest endpoints the SDK targets.

Can I use Sentry.AspNetCore alongside OpenTelemetry .NET?

Yes. Use Sentry.AspNetCore for error grouping and alerting, and OpenTelemetry.Exporter.OpenTelemetryProtocol for distributed traces. The two SDKs do not conflict. urgentry accepts both the Sentry envelope protocol and OTLP/HTTP in the same binary, so both can point at the same urgentry instance.

Sources

Ready to wire up your .NET app?

urgentry runs as a single binary, accepts the Sentry SDK out of the box, and takes under ten minutes to start receiving events. Your UseSentry call stays exactly as written above. Only the DSN changes.

Read the setup docs Run the switch proof