Error Detail by Environment
Show full exception detail only in Development, return generic problems with a trace id in production, and trace any error back to its logs.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler(); // generic problem + traceId
}
// Development: WebApplication adds the developer exception page automaticallyIn the Development environment WebApplication adds the developer exception page as the outermost middleware without being asked. A browser gets an HTML page with the stack trace, query, cookies, headers and route values. Other clients get plain text or, when AddProblemDetails() is registered, a problem whose title is the exception type, detail is the exception message and exception extension holds the stack trace, every request header (including Authorization and Cookie), the path, the endpoint and the route values. That is what a developer wants and exactly what must never leave a production server.
What each setup returns
| Setup | Response to an unhandled exception |
|---|---|
| Production, no handler | 500 with an empty body; the server logs the exception |
Production, UseExceptionHandler() + AddProblemDetails() | 500 problem: generic title and traceId, no message, no stack |
| Development, no exception handler | Developer page, or a problem with stack and headers |
Development, UseExceptionHandler() registered unconditionally | The handler catches first; developers see the generic production body |
The environment comes from ASPNETCORE_ENVIRONMENT (or DOTNET_ENVIRONMENT) and is Production when unset. The classic leak is a container image or Kubernetes manifest that sets ASPNETCORE_ENVIRONMENT=Development to get a settings file loaded: every unhandled exception then publishes stack traces, SQL fragments and host names from exception messages, and echoes the caller's bearer token back in the headers block.
The decision
Two setups are common. Register UseExceptionHandler() only outside Development, so developers keep the rich page; or register it everywhere and add exception details in CustomizeProblemDetails when IHostEnvironment.IsDevelopment() is true. The second keeps one pipeline, so integration tests see production behaviour; the first gives better local debugging. Either way, gate on the environment only, never on a request header or a configuration flag a deployment can flip by accident.
Correlation ids instead of detail
Production bodies stay generic, so the link between a client's error and your logs is the traceId member. ASP.NET Core creates an Activity per request when logging is enabled for the hosting category, a DiagnosticListener is subscribed, or an ActivityListener or OpenTelemetry is attached, which is the case in a default app with console logging. traceId is then Activity.Current.Id in W3C format, 00-<trace-id>-<span-id>-<flags>; the flags are 01 only when the activity is sampled (recorded) and 00 otherwise. The 32 hex characters of the trace id are what your tracing backend and log scopes index. Only with every log provider cleared and no listener does it fall back to HttpContext.TraceIdentifier, such as 0HNPFVEAQKP8I:00000001, which means something only to that one server's logs. Make sure log entries carry the same trace id, show it on client error screens, and consider returning it as a response header on every response so a wrong but successful answer can be traced too.
Failure modes
- Custom error code that copies
exception.Messageorexception.ToString()intodetailfor a quick diagnosis and never removes it. - Logging full request headers on errors, which stores tokens and cookies in the log system.
- A trace id in the body that no log line contains, because logging was configured without trace context.
Example
When to use it
- Let a developer see the stack trace for a failing invoice PDF locally while the same error in production returns only a generic 500 and a traceId.
- Find the exact failed request in the tracing backend from the traceId a customer copied out of the checkout error dialog.
- Catch a staging deployment that set ASPNETCORE_ENVIRONMENT=Development before it starts echoing bearer tokens in error bodies.
More examples
One pipeline, details only in Development
The exception handler passes the exception into <code>ProblemDetailsContext.Exception</code>, so one callback can enrich the body when, and only when, the host environment is Development. Production gets the generic 500 with a <code>traceId</code>. The trade-off against the automatic developer page is a plainer local view in exchange for identical behaviour in tests and production.
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
var environment = context.HttpContext.RequestServices.GetRequiredService<IHostEnvironment>();
if (environment.IsDevelopment() && context.Exception is { } exception)
{
context.ProblemDetails.Detail = exception.Message;
context.ProblemDetails.Extensions["exceptionType"] = exception.GetType().FullName;
context.ProblemDetails.Extensions["stackTrace"] = exception.StackTrace;
}
};
});
var app = builder.Build();
// Registered in every environment, so local runs and integration tests use the production pipeline.
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapGet("/orders/{id:int}/label", (int id) =>
{
throw new InvalidOperationException($"Carrier account not configured for order {id}.");
});
app.Run();The trace id on every response
A trace id in the problem body only helps on errors. A header on every response lets support trace a request that succeeded with wrong data. The exception handler resets the response, headers included, before writing its problem, so setting the header directly would lose it on exactly the responses that matter; an <code>OnStarting</code> callback runs after the reset.
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
// Before UseExceptionHandler: the handler clears response headers, but OnStarting callbacks survive.
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Trace-Id"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
return Task.CompletedTask;
});
await next(context);
});
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapGet("/customers/{id:int}", (int id, ILogger<Program> logger) =>
{
// The log entry carries the same TraceId when logging includes trace context.
logger.LogInformation("Loading customer {CustomerId}", id);
return Results.Ok(new { id, name = "Ada Lovelace", tier = "gold" });
});
app.Run();Pin the environment in the deployment
Production is already the default, but setting it explicitly in the manifest stops a base image, a shared ConfigMap or a copied <code>launchSettings.json</code> value from turning on the developer exception page. For environment-specific settings in staging, use a name such as <code>Staging</code> with an <code>appsettings.Staging.json</code>, never <code>Development</code>.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: api
image: registry.example.com/shop/orders-api:1.14.2
ports:
- containerPort: 8080
env:
# Explicit, so a base image or shared ConfigMap cannot switch on the developer page.
- name: ASPNETCORE_ENVIRONMENT
value: Production
- name: OTEL_SERVICE_NAME
value: orders-api
Discussion