Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import org.apache.catalina.connector.ClientAbortException;
import org.slf4j.Logger;
import org.slf4j.MDC;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
Expand Down Expand Up @@ -44,6 +45,7 @@
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

private static final Logger RUNTIME_ERROR_LOGGER = LoggerFactory.getLogger("runtime.error");
private static final String REQUEST_ID_MDC_KEY = "requestId";
private static final String MASKED_HEADER_VALUE = "***";
private static final List<String> SENSITIVE_HEADER_NAMES = List.of(
"authorization",
Expand Down Expand Up @@ -203,29 +205,39 @@ protected ResponseEntity<Object> handleHttpMessageNotReadable(

@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleException(HttpServletRequest request, Exception e) {
StackTraceElement origin = e.getStackTrace()[0];

String uri = String.format("%s %s", request.getMethod(), request.getRequestURI());
String exception = e.getClass().getSimpleName();
String location = String.format("%s:%d", origin.getFileName(), origin.getLineNumber());
String location = getExceptionLocation(e);
String message = e.getMessage();
String requestId = getRequestId();

String slackMessage = String.format(
"""
Request ID: `%s`
URI: `%s`
Location: `%s`
Exception: `%s`
```%s```
""",
uri, location, exception, message
requestId, uri, location, exception, message
);

RUNTIME_ERROR_LOGGER.error(slackMessage);
requestDebugLogging(request);
requestDebugLogging(request, requestId);

return buildErrorResponse(ApiResponseCode.UNEXPECTED_SERVER_ERROR);
}

private String getExceptionLocation(Exception e) {
StackTraceElement[] stackTrace = e.getStackTrace();
if (stackTrace == null || stackTrace.length == 0) {
return "unknown:0";
}

StackTraceElement origin = stackTrace[0];
return String.format("%s:%d", origin.getFileName(), origin.getLineNumber());
}

private ResponseEntity<Object> buildErrorResponse(ApiResponseCode errorCode) {
String errorTraceId = UUID.randomUUID().toString();

Expand Down Expand Up @@ -272,17 +284,25 @@ private void requestLogging(
String errorTraceId
) {
log.warn("[{}] {} | errorTraceId={}", httpStatus, errorMessage, errorTraceId);
requestDebugLogging(request);
requestDebugLogging(request, getRequestId());
}

private void requestDebugLogging(HttpServletRequest request) {
private void requestDebugLogging(HttpServletRequest request, String requestId) {
if (!log.isDebugEnabled()) {
return;
}
log.debug("Request: {} {}", request.getMethod(), request.getRequestURI());
log.debug("Headers: {}", getLoggableHeaders(request));
log.debug("Query String: {}", getQueryString(request));
log.debug("Body: {}", getRequestBody(request));
log.debug("Request [requestId: {}]: {} {}", requestId, request.getMethod(), request.getRequestURI());
log.debug("Headers [requestId: {}]: {}", requestId, getLoggableHeaders(request));
log.debug("Query String [requestId: {}]: {}", requestId, getQueryString(request));
log.debug("Body [requestId: {}]: {}", requestId, getRequestBody(request));
}

private String getRequestId() {
String requestId = MDC.get(REQUEST_ID_MDC_KEY);
if (requestId == null) {
return " - ";
}
return requestId;
}

private Map<String, Object> getLoggableHeaders(HttpServletRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.HttpStatus;
Expand All @@ -34,12 +35,14 @@ void setUp() {
@AfterEach
void tearDown() {
exceptionHandlerLogger.setLevel(originalLevel);
MDC.clear();
}

@Test
@DisplayName("예상하지 못한 예외도 디버그 로그에서 요청 본문을 확인할 수 있다")
void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) {
// given
MDC.put("requestId", "request-123");
GlobalExceptionHandler handler = new GlobalExceptionHandler();
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs");
request.setContentType("application/json");
Expand All @@ -61,21 +64,23 @@ void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) {
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(output)
.contains("Request: POST /clubs")
.contains("Request ID: `request-123`")
.contains("Request [requestId: request-123]: POST /clubs")
.contains("Authorization=***")
.contains("Cookie=***")
.contains("X-Request-ID=request-1")
.doesNotContain("secret-token")
.doesNotContain("secret-cookie")
.contains(
"Query String: access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***"
"Query String [requestId: request-123]: "
+ "access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***"
)
.doesNotContain("encoded-query-secret")
.doesNotContain("query-secret")
.doesNotContain("oauth-code")
.doesNotContain("repeat-secret-one")
.doesNotContain("repeat-secret-two")
.contains("Body: {\"name\":\"KONECT\"}");
.contains("Body [requestId: request-123]: {\"name\":\"KONECT\"}");
}

@Test
Expand All @@ -97,7 +102,51 @@ void skipsRequestDetailLoggingWhenDebugIsDisabled(CapturedOutput output) {
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(output)
.doesNotContain("Request: POST /clubs")
.doesNotContain("Body: {\"name\":\"KONECT\"}");
.doesNotContain("Request [requestId:")
.doesNotContain("Body [requestId:");
}

@Test
@DisplayName("스택트레이스가 비어 있는 예외도 2차 예외 없이 처리한다")
void handlesUnexpectedExceptionWithoutStackTrace(CapturedOutput output) {
// given
MDC.put("requestId", "request-empty-stack");
GlobalExceptionHandler handler = new GlobalExceptionHandler();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs");
RuntimeException exception = new RuntimeException("boom");
exception.setStackTrace(new StackTraceElement[0]);

// when
var response = handler.handleException(request, exception);

// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(output)
.contains("Request ID: `request-empty-stack`")
.contains("Location: `unknown:0`");
}

@Test
@DisplayName("스택트레이스가 null인 예외도 2차 예외 없이 처리한다")
void handlesUnexpectedExceptionWithNullStackTrace(CapturedOutput output) {
// given
MDC.put("requestId", "request-null-stack");
GlobalExceptionHandler handler = new GlobalExceptionHandler();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs");
RuntimeException exception = new RuntimeException("boom") {
@Override
public StackTraceElement[] getStackTrace() {
return null;
}
};

// when
var response = handler.handleException(request, exception);

// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(output)
.contains("Request ID: `request-null-stack`")
.contains("Location: `unknown:0`");
}
}
Loading