M. Justin
M. Justin

Reputation: 21353

Log Spring Boot controller exceptions without changing the error response body or status code

Thanks to DefaultErrorAttributes, Spring Boot by default returns a response body for exceptions that meets my needs out of the box:

{
  "timestamp": 1588022957431,
  "status": 400,
  "error": "Bad Request",
  "exception": "org.springframework.web.bind.MethodArgumentNotValidException",
  "errors": [
    {
      "codes": [
        "NotEmpty.myResource.label",
        "NotEmpty.label",
        "NotEmpty.java.lang.String",
        "NotEmpty"
      ],
      "arguments": [
        {
          "codes": [
            "myResource.label",
            "label"
          ],
          "arguments": null,
          "defaultMessage": "label",
          "code": "label"
        }
      ],
      "defaultMessage": "must not be empty",
      "objectName": "myResource",
      "field": "label",
      "rejectedValue": null,
      "bindingFailure": false,
      "code": "NotEmpty"
    }
  ],
  "message": "Validation failed for object='myResource'. Error count: 1"
}

Furthermore, between the built-in exception handling provided by DefaultHandlerExceptionResolver and my own custom error handling, I have the exceptions returning exactly the response codes I want my REST API to be exposing.

What I would like to do is log the exceptions thrown for debugging purposes. If the exception results in a 4xx response, I'd like to log it as debug level, since that's a problem with the client's request, and not something I'll generally need logged in production (but which might be useful to have around when debugging specific issues or testing the correctness of the code). If it results in a 5xx exception, I would like to log it at warn level, since this indicates an unexpected problem with the server that I would likely want to be made aware of.

I do not see a good mechanism for adding in this logging while retaining both the default Spring Boot response body, as well as my customized status code mappings. How can I do this conditional logging based on status code while keeping my current response body & status codes? Even better, is there a way to do this that will also work with the few endpoints I have that use their own customized @ExceptionHandler methods to return a different response body?

Upvotes: 0

Views: 1838

Answers (2)

M. Justin
M. Justin

Reputation: 21353

I solved this using a javax.servlet.Filter, which retrieves the thrown exception from the ErrorAttributes:

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.filter.OrderedFilter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Optional;

@Slf4j
@Component
public class ExceptionLoggingFilter extends OncePerRequestFilter implements OrderedFilter {
    @Autowired
    private ErrorAttributes errorAttributes;

    @Override
    public int getOrder() {
        return OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        filterChain.doFilter(request, response);

        logError(request, response);
    }

    private void logError(HttpServletRequest request, HttpServletResponse response) {
        Throwable error = errorAttributes.getError(new ServletWebRequest(request));
        if (error == null) {
            return;
        }

        int statusCode = response.getStatus();
        String reasonPhrase = Optional.ofNullable(HttpStatus.resolve(statusCode))
                .map(HttpStatus::getReasonPhrase).orElse("<nonstandard status code>");

        String uriString = request.getRequestURI()
                + Optional.ofNullable(request.getQueryString()).map(q -> "?" + q).orElse("");

        HttpStatus.Series series = HttpStatus.Series.resolve(statusCode);
        if (HttpStatus.Series.SERVER_ERROR.equals(series)) {
            log.warn("{} {} response for {}", statusCode, reasonPhrase, uriString, error);
        } else if (HttpStatus.Series.CLIENT_ERROR.equals(series)){
            log.debug("{} {} response for {}", statusCode, reasonPhrase, uriString, error);
        }
    }
}

Upvotes: 1

M. Justin
M. Justin

Reputation: 21353

One solution I've come up with which handles the DefaultErrorAttributes case, but not the custom @ExceptionHandler one, is to override the built-in Spring Boot error controller (as defined by ErrorMvcAutoConfiguration.basicErrorController()) to log the exceptions prior to returning the result:

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController;
import org.springframework.boot.autoconfigure.web.servlet.error.ErrorViewResolver;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;

@Slf4j
public class LoggingErrorController extends BasicErrorController {
    private final ErrorAttributes errorAttributes;

    public LoggingErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties,
                                  List<ErrorViewResolver> errorViewResolvers) {
        super(errorAttributes, errorProperties, errorViewResolvers);
        this.errorAttributes = errorAttributes;
    }

    @Override
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
        logError(request, isIncludeStackTrace(request, MediaType.TEXT_HTML));
        return super.errorHtml(request, response);
    }

    @Override
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        logError(request, isIncludeStackTrace(request, MediaType.ALL));
        return super.error(request);
    }

    private void logError(HttpServletRequest request, boolean includeStackTrace) {
        Throwable error = errorAttributes.getError(new ServletWebRequest(request));
        Map<String, Object> attributesMap = getErrorAttributes(request, includeStackTrace);

        Object path = attributesMap.getOrDefault("path", "<unknown>");

        HttpStatus status = getStatus(request);
        if (status.is4xxClientError()) {
            log.debug("{} {} error for path {}", status.value(), status.getReasonPhrase(), path, error);
        } else {
            log.warn("{} {} error for path {}", status.value(), status.getReasonPhrase(), path, error);
        }
    }
}

I feel it's a bit hacky, and very dependent on the current Spring Boot implementation, but it otherwise seems to work and feels pretty solid.

Upvotes: 0

Related Questions