ASP.NET Core
Preserve exception detail behind a handled 503 in ASP.NET Core 10
An exception handler can return an error response while its middleware stops logging the exception. Choose which failures should retain diagnostic detail, and test the signals your alerts actually consume.
Imagine an order API whose exception handler turns an unavailable dependency into a 503 response. After a .NET upgrade, its exception-log chart falls. Customers can still receive the same 503. This is a hypothetical application, not a measured customer incident.
- Keep unexpected operational failures visible to the people responding to them.
- Avoid duplicate error noise for named, expected validation outcomes.
- Prove what the deployed alert observes before using a smaller chart to accept the upgrade.
Start with services whose registered IExceptionHandler returns true and whose alert uses middleware exception logs or the request-duration exception tag. A service already monitored adequately through HTTP status or the collected exception counter may need no policy change. For a VP of Engineering, this identifies where upgrade verification belongs. For the engineer implementing it, two mechanisms matter: the handler’s diagnostics policy and the distinction between request-duration tags and the separate exception counter.
Handling the response does not establish recovery
ASP.NET Core 10 changes the default when a registered IExceptionHandler returns true: the exception middleware suppresses its diagnostic branch. That return value reports that the exception was handled; it does not verify that the handler wrote a response or that the business operation succeeded. The example handler below explicitly writes its response. Microsoft documents the change in the ASP.NET Core 10 compatibility note.
In the tested 10.0.1 implementation, suppression skips the middleware error log and its exception error.type enrichment on http.server.request.duration. The HTTP response status is still recorded when request metrics are collected. The middleware and hosting metric implementation identify these separate paths.
A dashboard based on that error log or duration error.type can therefore show less exception detail without fewer failed HTTP responses. That is an inference about those query choices, not a claim that every telemetry product or alert loses failures.
The separate aspnetcore.diagnostics.exceptions counter is an important counterexample: when enabled, 10.0.1 records it outside the suppression branch, including error.type and aspnetcore.diagnostics.exception.result=handled. Subscribe to the Microsoft.AspNetCore.Diagnostics meter to collect it. Its tags do not include HTTP status or route; a useful query must distinguish the relevant exception class, or use a request signal for HTTP outcomes. The pinned counter source explains why suppressing diagnostics does not make every exception metric disappear.
Choose the signal policy before suppressing it
First name the expected condition. In this isolated example, ValidationRejectedException produces 400; DependencyUnavailableException produces 503. These are deliberately thrown test exceptions. No database or dependency failure was reproduced.
Then choose the policy. The default leaves SuppressDiagnosticsCallback unset. Returning false retains middleware diagnostics for both conditions. A selective callback returns true only for the named validation exception, leaving the unexpected dependency failure visible. The 10.0.1 options define true as suppression, not successful recovery.
var options = new ExceptionHandlerOptions();
if (mode == "retain-all")
options.SuppressDiagnosticsCallback = _ => false;
if (mode == "selective")
options.SuppressDiagnosticsCallback = context =>
context.Exception is ValidationRejectedException;
app.UseExceptionHandler(options);Here mode is default, retain-all or selective. UseExceptionHandler must precede the downstream middleware and endpoint execution whose exceptions it should catch. The complete inline harness below supplies both exception types, the response handler and the observers; this policy is the central part of the executed program.
Use retain-all if the existing alert contract depends on middleware exception detail and that contract has not yet been replaced. Use selective only after agreeing which expected outcomes have adequate signals elsewhere. Exception classification is part of that agreement; a catch-all handler returning true is too broad to prove it.
Send the same failures through all three policies
The harness ran six real HTTP requests against Kestrel on loopback: one 400 and one 503 under each policy. An ILogger provider counted middleware event 1; a MeterListener collected request-duration and diagnostics-counter tags. The following is a projection of the retained raw output, not a production benchmark:
policy input HTTP middleware error logs duration error.type exception counter error.type
default validation 400 0 absent ValidationRejectedException
default dependency 503 0 absent DependencyUnavailableException
retain-all validation 400 1 present ValidationRejectedException
retain-all dependency 503 1 present DependencyUnavailableException
selective validation 400 0 absent ValidationRejectedException
selective dependency 503 1 present DependencyUnavailableExceptionThe selective policy retains the 503’s middleware error log and duration error.type while suppressing those two signals for the 400. Both responses keep their status. The diagnostics exception-counter measurement retains its exception type in all six cases.
That last column matters when interpreting an upgrade. If an existing alert already uses this counter or HTTP 5xx outcomes correctly, the default suppression may leave it useful. Verify the actual collector, query and notification path; the in-process listener cannot establish that a remote alert is configured or delivered.
Run the complete inline example
Use SDK 10.0.101 and ASP.NET Core/.NET runtime 10.0.1 to reproduce this exact boundary. Create these four files in an isolated directory. global.json selects the SDK; runtime.json binds both shared frameworks at 10.0.1 with runtime roll-forward disabled for this test. The source prints and checks the loaded framework versions. The project uses the installed shared framework and no added package. The empty builder keeps the experiment independent of application configuration and file watchers; Kestrel and the exception middleware are real.
{"sdk":{"version":"10.0.101","rollForward":"disable"}}<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>{
"runtimeOptions": {
"tfm": "net10.0",
"rollForward": "Disable",
"frameworks": [
{"name": "Microsoft.NETCore.App", "version": "10.0.1"},
{"name": "Microsoft.AspNetCore.App", "version": "10.0.1"}
]
}
}using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using System.Runtime.InteropServices;
using System.Reflection;
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
var aspnetAssembly = typeof(WebApplication).Assembly;
var aspnetPatch = Path.GetFileName(Path.GetDirectoryName(aspnetAssembly.Location));
var aspnetProduct = aspnetAssembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion;
Console.WriteLine($"runtime={RuntimeInformation.FrameworkDescription}; aspnetSharedFramework={aspnetPatch}; aspnetProduct={aspnetProduct}");
if (RuntimeInformation.FrameworkDescription != ".NET 10.0.1" || aspnetPatch != "10.0.1" || !aspnetProduct.StartsWith("10.0.1+"))
throw new InvalidOperationException("This reproduction requires both shared frameworks at 10.0.1.");
foreach (var mode in new[] { "default", "retain-all", "selective" })
{
Console.WriteLine($"preparing={mode}");
var samples = new ConcurrentQueue<Sample>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, owner) =>
{
if (instrument.Name is "http.server.request.duration" or "aspnetcore.diagnostics.exceptions")
owner.EnableMeasurementEvents(instrument);
};
void Record(Instrument instrument, ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
var values = new Dictionary<string, string>();
foreach (var tag in tags) values[tag.Key] = tag.Value?.ToString() ?? "<null>";
samples.Enqueue(new Sample(instrument.Name, values));
}
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) => Record(instrument, tags));
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) => Record(instrument, tags));
Console.WriteLine("starting-listener");
listener.Start();
Console.WriteLine("creating-builder");
var logs = new CaptureLogs();
var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions { EnvironmentName = "Production" });
builder.WebHost.UseKestrel();
builder.Services.AddRouting();
Console.WriteLine("builder-created");
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.Error);
builder.Logging.AddProvider(logs);
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddExceptionHandler<DemoHandler>();
builder.Services.AddProblemDetails();
await using var app = builder.Build();
Console.WriteLine("app-built");
var options = new ExceptionHandlerOptions();
if (mode == "retain-all") options.SuppressDiagnosticsCallback = _ => false;
if (mode == "selective") options.SuppressDiagnosticsCallback = context => context.Exception is ValidationRejectedException;
app.UseExceptionHandler(options);
app.MapGet("/failure/{kind}", (string kind) => Fail(kind));
Console.WriteLine($"starting={mode}");
using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await app.StartAsync(startup.Token);
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
Console.WriteLine($"listening={address}");
using var client = new HttpClient(new HttpClientHandler { UseProxy = false }) { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(5) };
foreach (var kind in new[] { "validation", "dependency" })
{
while (samples.TryDequeue(out _)) { }
logs.Entries.Clear();
using var response = await client.GetAsync($"/failure/{kind}");
await response.Content.ReadAsStringAsync();
// A response body may be delivered before request-finalization metrics.
for (var attempt = 0; attempt < 100 && !samples.Any(x => x.Instrument == "http.server.request.duration"); attempt++)
await Task.Delay(10);
var observed = samples.ToArray();
var duration = observed.Single(x => x.Instrument == "http.server.request.duration");
var exceptions = observed.Single(x => x.Instrument == "aspnetcore.diagnostics.exceptions");
var middlewareLogs = logs.Entries.Count(x => x.Category == "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware" && x.EventId == 1);
var expectedRetain = mode == "retain-all" || (mode == "selective" && kind == "dependency");
if ((int)response.StatusCode != (kind == "validation" ? 400 : 503) ||
middlewareLogs != (expectedRetain ? 1 : 0) ||
duration.Tags.ContainsKey("error.type") != expectedRetain ||
!exceptions.Tags.ContainsKey("error.type") || exceptions.Tags.GetValueOrDefault("aspnetcore.diagnostics.exception.result") != "handled")
throw new InvalidOperationException($"Unexpected behavior: {mode}/{kind}");
Console.WriteLine(JsonSerializer.Serialize(new { mode, kind, status = (int)response.StatusCode, middlewareErrorLogs = middlewareLogs, duration = duration.Tags, exceptions = exceptions.Tags }));
}
await app.StopAsync();
}
Console.WriteLine("PASS: six real HTTP requests; status, middleware log, duration tag and diagnostics counter assertions passed.");
static IResult Fail(string kind)
{
if (kind == "validation") throw new ValidationRejectedException();
throw new DependencyUnavailableException();
}
sealed class ValidationRejectedException : Exception { }
sealed class DependencyUnavailableException : Exception { }
sealed record Sample(string Instrument, Dictionary<string, string> Tags);
sealed record LogEntry(string Category, int EventId);
sealed class CaptureLogs : ILoggerProvider
{
public ConcurrentBag<LogEntry> Entries { get; } = [];
public ILogger CreateLogger(string categoryName) => new CaptureLogger(categoryName, Entries);
public void Dispose() { }
private sealed class CaptureLogger(string category, ConcurrentBag<LogEntry> entries) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{ if (IsEnabled(logLevel)) entries.Add(new LogEntry(category, eventId.Id)); }
}
}
sealed class DemoHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken token)
{
context.Response.StatusCode = exception is ValidationRejectedException ? 400 : 503;
await context.Response.WriteAsJsonAsync(new { error = exception.GetType().Name }, token);
return true;
}
}dotnet --version
dotnet build Harness.csproj --configuration Release
dotnet exec --runtimeconfig runtime.json bin/Release/net10.0/Harness.dllThe program binds only to 127.0.0.1, disables the HTTP client proxy and sends requests to its own ephemeral port. It asserts status, middleware error-log count, duration error.type presence and handled counter tags. Allow local socket access in the authorized isolated environment; a build alone does not execute these checks.
Keep the counterexample in the release decision
Expected validation exceptions already counted by an application may not need a second middleware error log. Retaining every diagnostic unconditionally can increase noise and cost. Keep the default where independent signals already meet the operational need, or select a named-exception policy when those outcomes should be treated differently. Neither choice establishes recovery of the failed order.
This advice stops at exceptions fully handled by the middleware. In the 10.0.1 source, exceptions after the response starts and unsuccessful error handling take different paths. The callback is not a universal switch for every application log or server failure. Those paths were source-inspected, not exercised by the six-request harness.
If your handler writes its own error log, suppression does not remove that application log. If your monitoring uses only reliable HTTP status signals, restoring middleware logs may add no decision value. Either observation can overturn a blanket recommendation to retain everything.
Accept the upgrade against an explicit observation
- Name the operations and error responses that should still alert; distinguish expected validation from operational failure.
- Run a representative request through the actual registered handlers and check status, logs and the specific metric tags the alert consumes.
- Keep the selected policy and its failure test with the release; verify the real exporter/query/notification route separately before accepting monitoring continuity.
The leader’s useful evidence is a demonstrated failure still reaching the agreed signal, plus an explained suppression policy for expected conditions. A drop in one exception chart alone establishes neither fewer customer failures nor successful recovery.
Record the following with the service owner before approving monitoring continuity. This is an illustrative decision record to fill from the actual environment; it is not an executed collector or alert test.
Context: target runtime, registered handler and selected suppression policy
Signal: actual collected instrument/log plus the alert’s classification filter
Alert condition: the team’s existing threshold, window and response route
Failure control: representative operational 503 reaches the selected query
Expected control: named validation 400 has the agreed non-paging behavior
Evidence: timestamped query results and notification/response-route observation
Owner and decision: accountable service owner; accept or hold with the evidenceDrive enough controlled traffic to meet the existing alert condition; one request need not cross its threshold. If the exporter removes error.type, a locally retained tag cannot prove that a query filtering on it still works. Missing query evidence or missing response-route evidence leaves continuity unproved. Keep the release decision open until the actual observation resolves it.
Verification boundary
Executed on October 7, 2026: SDK 10.0.101, .NET and ASP.NET Core runtime 10.0.1, six loopback HTTP requests with an in-process logger and metric listener. All checks across the six requests passed. This is an exact runtime experiment, not advice to select that patch for a new deployment. No external dependency, telemetry exporter, production workload or live customer system was tested.
The test-only runtime configuration uses the documented dotnet exec runtimeconfig option and Disable roll-forward setting. Keep that exact binding confined to reproduction; choose an appropriately supported and patched runtime for deployment.
The loaded ASP.NET assembly reports product version 10.0.1+fad253f51b461736dfd3cd9c15977bb7493becef. Its .NET build source manifest maps ASP.NET Core to commit 373d4826c58ac2f1c8d49fdaf31bc9eddf9e7437, which pins the code references above. Tag names described here belong to that tested release; check the target runtime and collector when applying the result.
Sources
- ASP.NET Core 10 exception-diagnostics breaking change
- ASP.NET Core 10.0.1 exception-handler implementation
- ASP.NET Core 10.0.1 diagnostics counter
- ASP.NET Core 10.0.1 hosting metrics
- ASP.NET Core 10.0.1 suppression options
- .NET CLI runtime configuration and test-only Disable roll-forward
- .NET build source manifest for the loaded ASP.NET informational version