When developing ASP.NET APIs, most of the time we want the same base functionality such as logging requests/responses and handling exceptions errors. Other functionality may include enforcing CorrelationId in the request header (X-Correlation-ID). Enriching the logs with the correlationId. Ability to enable or disable Swagger from configuration.
This article is focused on Request/Response and Exception Middleware.
There are numerous blog posts out there for request and response middleware as well as exception handling middleware. The sample code here does both.
Startup
The request/response middleware wraps around the exception middleware. Any additional middleware would go after/below these.
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// Add any correlationId enforcement.
app.ConfigureRequestResponseLoggingMiddleware(_logger);
app.ConfigureExceptionHandlingMiddleware(_logger);
app.UseHttpsRedirection();
// If needed:
// app.AddAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
Request/Response Middleware
The request is logged and requires httpContext.Request.EnableBuffering() so that the request body stream can be read multiple times.
The most tricky part of this middleware is handling the response body stream. The response body stream is not seekable or readable. It is writable. We initially save a reference to it as originalResponseBody. A memory stream is used which is seekable, readable, and writable. When the middleware is invoked (await _next(httpContext)), the httpContext.ResponseBody is populated with the response from the Controller. We can then log the response with or without sanitization and reset the stream's position to 0 so it can be copied to the originalResponseBody.
public async Task InvokeAsync(HttpContext httpContext)
{
var start = Stopwatch.GetTimestamp();
if (httpContext == null) { return; }
Stream originalRequestBody = httpContext.Request?.Body;
Stream originalResponseBody = httpContext.Response?.Body;
using var responseBody = new MemoryStream();
try
{
if (originalRequestBody == null || originalResponseBody == null)
{
return;
}
double elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp());
// The actual logging of request and responses should be done
// in controller if destructuring of objects is needed.
httpContext.Request.EnableBuffering();
await LogRequestAsync(httpContext, elapsedMs)
.ConfigureAwait(false);
httpContext.Response.Body = responseBody;
// End of incoming request pipeline
await _next(httpContext);
// Start outgoing response pipeline
await LogResponseAsync(httpContext, start);
}
catch (Exception ex)
{
// Exception middleware to handle unexpected exception previously.
// An exception here would be internal to logging.
double elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp());
string requestMethod = httpContext.Request.Method;
int? statusCode = httpContext.Response.StatusCode;
_logger.Error(ExceptionInRequestResponseMiddleware, ex);
_logger.Error(ResponseErrorMessageTemplate, requestMethod, GetPath(httpContext)
, statusCode, elapsedMs);
}
finally
{
if (originalResponseBody != null)
{
await responseBody.CopyToAsync(originalResponseBody).ConfigureAwait(false);
httpContext.Response.Body = originalResponseBody;
}
}
}
Exception Middleware
This middleware will handle any exception thrown by the API and underlying code. There is a sense that this somewhat control flow by exception and perhaps using Channels might be preferable. In any case, it's worked fine for me in production services. Underlying code can return a custom exception that inherits from BaseException. This exception can return an HttpStatus code along with message, and inner exception. The ErrorResponse just contains and error code and message.
internal ErrorResponse GetErrorResponse(Exception exception)
{
var errorResponse = new ErrorResponse(((int)HttpStatusCode.InternalServerError).ToString()
, HttpStatusCode.InternalServerError.ToString());
// All exceptions used should derive from BaseException.
// If needed, handle all other exceptions here.
if (exception is BaseException baseException)
{
int? errorCode = null;
if (int.TryParse(baseException.ErrorCode, out int code) && code != 0)
{
errorCode = code;
}
return new ErrorResponse(errorCode.ToString(), exception.Message);
}
else
{
errorResponse.Message = exception.Message;
_logger.Warning(ExceptionDidNotInheritFromBaseException, exception);
}
return errorResponse;
}
Formatter
The formatter could be specified as compact json instead of the usual Serilog JSON formatter in the appsettings.*.json.
"formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog",
A custom formatter can be written that will conduct the JSON formatting, but also conduct any custom logic, such as running filtering/regular expression patterns on the logs to output/format.
Logging
Structured logging through Serilog is my preference. It's useful to have the correlationId of each request logged for each write. This is where a CorrelationId enrichment class comes into play.