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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ CREATE DATABASE IF NOT EXISTS quespot
## 모니터링

- Spring Boot Actuator가 요청 수·응답 시간·상태 코드, JVM, 시스템, HikariCP 메트릭을 Prometheus 형식으로 제공합니다.
- 공통 예외 응답은 `quespot_api_errors_total`에 Quespot 에러 코드와 HTTP 상태별로 집계됩니다.
- 공통 예외 응답은 `quespot_api_errors_total`에 Quespot 에러 코드, HTTP 상태, 요청 메서드, 템플릿화된 API 경로별로 집계됩니다.
- 운영 환경의 Grafana Alloy는 내부 Docker 네트워크에서 애플리케이션 메트릭을 수집해 Grafana Cloud Hosted Prometheus로 전송합니다.
- `/actuator/prometheus`는 Spring Security에서 Alloy 수집을 허용하지만 Nginx에서는 차단하므로 외부 API 도메인에 공개되지 않습니다.
- 운영 EC2의 `.env`에는 Grafana Cloud 연결 화면에서 확인한 `GRAFANA_CLOUD_PROMETHEUS_URL`, `GRAFANA_CLOUD_PROMETHEUS_USERNAME`, `GRAFANA_CLOUD_API_TOKEN`을 등록합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.quespot.global.apiPayload.exception.GeneralException;
import com.quespot.global.monitoring.ApiErrorMetrics;
import jakarta.validation.ConstraintViolationException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
Expand All @@ -29,72 +30,89 @@ public class GeneralExceptionAdvice {
private final ApiErrorMetrics apiErrorMetrics;

@ExceptionHandler(GeneralException.class)
protected ResponseEntity<ApiResponse<Void>> handleGeneralException(GeneralException exception) {
return handleExceptionInternal(exception.getErrorCode());
protected ResponseEntity<ApiResponse<Void>> handleGeneralException(
GeneralException exception,
HttpServletRequest request
) {
return handleExceptionInternal(exception.getErrorCode(), request);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException exception
MethodArgumentNotValidException exception,
HttpServletRequest request
) {
Map<String, String> errors = new LinkedHashMap<>();

for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage());
}

return handleExceptionInternal(GeneralErrorCode.COMMON_400_002, errors);
return handleExceptionInternal(GeneralErrorCode.COMMON_400_002, errors, request);
}

@ExceptionHandler(ConstraintViolationException.class)
protected ResponseEntity<ApiResponse<Void>> handleConstraintViolationException(
ConstraintViolationException exception
ConstraintViolationException exception,
HttpServletRequest request
) {
return handleExceptionInternal(
GeneralErrorCode.COMMON_400_002,
Map.of("message", exception.getMessage())
Map.of("message", exception.getMessage()),
request
);
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
protected ResponseEntity<ApiResponse<Void>> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException exception
MethodArgumentTypeMismatchException exception,
HttpServletRequest request
) {
return handleExceptionInternal(
GeneralErrorCode.COMMON_400_002,
Map.of(exception.getName(), "요청 파라미터 형식이 올바르지 않습니다.")
Map.of(exception.getName(), "요청 파라미터 형식이 올바르지 않습니다."),
request
);
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ApiResponse<Void>> handleHttpRequestMethodNotSupportedException() {
return handleExceptionInternal(GeneralErrorCode.COMMON_405_001);
protected ResponseEntity<ApiResponse<Void>> handleHttpRequestMethodNotSupportedException(
HttpServletRequest request
) {
return handleExceptionInternal(GeneralErrorCode.COMMON_405_001, request);
}

@ExceptionHandler(DataAccessException.class)
protected ResponseEntity<ApiResponse<Void>> handleDataAccessException() {
return handleExceptionInternal(GeneralErrorCode.COMMON_503_001);
protected ResponseEntity<ApiResponse<Void>> handleDataAccessException(HttpServletRequest request) {
return handleExceptionInternal(GeneralErrorCode.COMMON_503_001, request);
}

// 처리되지 않은 예외는 응답이 COMMON_500_001로만 나가서 원인을 알 수 없다(#67에서
// LazyInitializationException이 서버 로그에도 안 남아 코드 추적으로만 찾았다).
// 스택트레이스를 반드시 남긴다.
@ExceptionHandler(Exception.class)
protected ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
protected ResponseEntity<ApiResponse<Void>> handleException(
Exception exception,
HttpServletRequest request
) {
log.error("처리되지 않은 예외", exception);
return handleExceptionInternal(GeneralErrorCode.COMMON_500_001);
return handleExceptionInternal(GeneralErrorCode.COMMON_500_001, request);
}

private ResponseEntity<ApiResponse<Void>> handleExceptionInternal(BaseErrorCode errorCode) {
return handleExceptionInternal(errorCode, Map.of());
private ResponseEntity<ApiResponse<Void>> handleExceptionInternal(
BaseErrorCode errorCode,
HttpServletRequest request
) {
return handleExceptionInternal(errorCode, Map.of(), request);
}

private ResponseEntity<ApiResponse<Void>> handleExceptionInternal(
BaseErrorCode errorCode,
Object errorDetail
Object errorDetail,
HttpServletRequest request
) {
ErrorReasonDTO reason = errorCode.getReasonHttpStatus();
apiErrorMetrics.increment(errorCode);
apiErrorMetrics.increment(errorCode, request);

return ResponseEntity
.status(reason.getHttpStatus())
Expand Down
22 changes: 21 additions & 1 deletion src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,44 @@
import com.quespot.global.apiPayload.code.BaseErrorCode;
import com.quespot.global.apiPayload.code.ErrorReasonDTO;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.HandlerMapping;

@Component
@RequiredArgsConstructor
public class ApiErrorMetrics {

private static final String METRIC_NAME = "quespot.api.errors";
private static final String UNKNOWN_METHOD = "UNKNOWN";
private static final String UNMATCHED_URI = "UNMATCHED";

private final MeterRegistry meterRegistry;

public void increment(BaseErrorCode errorCode) {
increment(errorCode, UNKNOWN_METHOD, UNMATCHED_URI);
}

public void increment(BaseErrorCode errorCode, HttpServletRequest request) {
Object matchedPattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String uri = matchedPattern == null ? UNMATCHED_URI : matchedPattern.toString();
RequestMethod requestMethod = RequestMethod.resolve(request.getMethod());
String method = requestMethod == null ? UNKNOWN_METHOD : requestMethod.name();

increment(errorCode, method, uri);
}
Comment thread
woo6629058 marked this conversation as resolved.

private void increment(BaseErrorCode errorCode, String method, String uri) {
ErrorReasonDTO reason = errorCode.getReasonHttpStatus();

meterRegistry.counter(
METRIC_NAME,
"code", reason.getCode(),
"status", String.valueOf(reason.getHttpStatus().value())
"status", String.valueOf(reason.getHttpStatus().value()),
"method", method,
"uri", uri
).increment();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -20,7 +21,10 @@ void mapsMethodArgumentTypeMismatchExceptionTo400() {
MethodArgumentTypeMismatchException exception = mock(MethodArgumentTypeMismatchException.class);
when(exception.getName()).thenReturn("size");

ResponseEntity<ApiResponse<Void>> response = advice.handleMethodArgumentTypeMismatchException(exception);
ResponseEntity<ApiResponse<Void>> response = advice.handleMethodArgumentTypeMismatchException(
exception,
new MockHttpServletRequest("GET", "/api/missions")
);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().getIsSuccess()).isFalse();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,66 @@
import com.quespot.global.apiPayload.code.GeneralErrorCode;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.servlet.HandlerMapping;

import static org.assertj.core.api.Assertions.assertThat;

class ApiErrorMetricsTest {

@Test
void incrementsCounterByErrorCodeAndStatus() {
void incrementsCounterWithFallbackTagsWithoutRequest() {
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry);

apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001);

assertCounter(meterRegistry, "UNKNOWN", "UNMATCHED");
}

@Test
void incrementsCounterWithUnmatchedUriWithoutMatchedPattern() {
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/unmatched");

apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request);

assertCounter(meterRegistry, "POST", "UNMATCHED");
}

@Test
void incrementsCounterWithUnknownMethodForUnrecognizedHttpMethod() {
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry);
MockHttpServletRequest request = new MockHttpServletRequest("CUSTOM", "/api/missions");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/missions");

apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request);

assertCounter(meterRegistry, "UNKNOWN", "/api/missions");
}

@Test
void incrementsCounterByErrorCodeStatusMethodAndMatchedUri() {
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/missions/1");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/missions/{missionId}");

apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request);

assertCounter(meterRegistry, "GET", "/api/missions/{missionId}");
}

private void assertCounter(SimpleMeterRegistry meterRegistry, String method, String uri) {
assertThat(meterRegistry.get("quespot.api.errors")
.tags("code", "COMMON_503_001", "status", "503")
.tags(
"code", "COMMON_503_001",
"status", "503",
"method", method,
"uri", uri
)
Comment thread
woo6629058 marked this conversation as resolved.
.counter()
.count()).isEqualTo(1.0);
}
Expand Down
Loading