From 645591777bac97c7846397c0249fb4deea8794e6 Mon Sep 17 00:00:00 2001 From: Seokwoo Date: Tue, 23 Dec 2025 15:42:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(monitoring):=20Sentry=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sentry SDK 및 Gradle 플러그인 추가 - JwtAuthenticationFilter 및 ExceptionAdvice 리팩토링 (에러 마스킹 이슈 해결) - 환경 변수(${SENTRY_DSN}) 추가 - GitHub Actions 빌드 단계에 SENTRY_AUTH_TOKEN 주입 설정 추가 --- .github/workflows/github-actions.yml | 3 +- build.gradle | 10 ++++ .../common/response/code/ErrorReasonDTO.java | 6 +++ .../error/exception/ExceptionAdvice.java | 46 +++++++++++++++++-- src/main/resources/application-prod.yml | 7 +++ 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 5c37feb4..6d3e02cb 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -39,7 +39,8 @@ jobs: - name: Build with Gradle run: ./gradlew bootJar --no-daemon - + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} diff --git a/build.gradle b/build.gradle index 28771bb2..7c9ea890 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.1' id 'io.spring.dependency-management' version '1.1.7' + id "io.sentry.jvm.gradle" version "5.12.2" } group = 'umc' @@ -19,6 +20,12 @@ dependencyManagement { } } +sentry { + includeSourceContext = true + org = "catchy" + projectName = "java-spring-boot" +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -86,6 +93,9 @@ dependencies { // OpenFeign implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // Sentry + implementation 'io.sentry:sentry-spring-boot-starter:8.22.0' } tasks.named('test') { diff --git a/src/main/java/umc/catchy/global/common/response/code/ErrorReasonDTO.java b/src/main/java/umc/catchy/global/common/response/code/ErrorReasonDTO.java index d7833b39..2a564958 100644 --- a/src/main/java/umc/catchy/global/common/response/code/ErrorReasonDTO.java +++ b/src/main/java/umc/catchy/global/common/response/code/ErrorReasonDTO.java @@ -11,4 +11,10 @@ public class ErrorReasonDTO { private final boolean isSuccess; private final String code; private final String message; + + @Override + public String toString() { + return String.format("ErrorReason{code='%s', message='%s', status=%s}", + code, message, httpStatus); + } } diff --git a/src/main/java/umc/catchy/global/error/exception/ExceptionAdvice.java b/src/main/java/umc/catchy/global/error/exception/ExceptionAdvice.java index 9fa7347c..4fb9ab70 100644 --- a/src/main/java/umc/catchy/global/error/exception/ExceptionAdvice.java +++ b/src/main/java/umc/catchy/global/error/exception/ExceptionAdvice.java @@ -1,5 +1,6 @@ package umc.catchy.global.error.exception; +import io.sentry.Sentry; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -28,14 +29,36 @@ public class ExceptionAdvice extends ResponseEntityExceptionHandler { private ResponseEntity createErrorResponse( Exception e, ErrorStatus errorStatus, String errorMessage, HttpHeaders headers, WebRequest request) { - log.error("Exception: {}, Status: {}, Message: {}", e.getClass().getSimpleName(), errorStatus, errorMessage); + log.error("Exception: {}, Code: {}, Message: {}", + e.getClass().getSimpleName(), errorStatus.getCode(), errorMessage); BaseResponse body = BaseResponse.onFailure(errorStatus, errorMessage); return super.handleExceptionInternal(e, body, headers, errorStatus.getHttpStatus(), request); } private ResponseEntity createErrorResponse( Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) { - log.error("Exception: {}, Reason: {}", e.getClass().getSimpleName(), reason); + + // 상세 로그 출력 (Sentry가 읽기 쉽게) + log.error("Exception: {}, Code: {}, Message: {}, Status: {}", + e.getClass().getSimpleName(), + reason.getCode(), + reason.getMessage(), + reason.getHttpStatus().value()); + + // Sentry에 상세 정보 추가 + Sentry.configureScope(scope -> { + scope.setTag("error_code", reason.getCode()); + scope.setTag("http_status", String.valueOf(reason.getHttpStatus().value())); + scope.setTag("endpoint", request.getRequestURI()); + scope.setTag("method", request.getMethod()); + scope.setExtra("error_message", reason.getMessage()); + }); + + // 500 에러만 Sentry에 전송 + if (reason.getHttpStatus().is5xxServerError()) { + Sentry.captureException(e); + } + BaseResponse body = BaseResponse.onFailure(reason, null); WebRequest webRequest = new ServletWebRequest(request); return super.handleExceptionInternal(e, body, headers, reason.getHttpStatus(), webRequest); @@ -43,6 +66,15 @@ private ResponseEntity createErrorResponse( @ExceptionHandler public ResponseEntity handleGlobalException(Exception e, WebRequest request) { + // 예상치 못한 에러는 무조건 Sentry에 + log.error("Unexpected exception occurred", e); + + Sentry.configureScope(scope -> { + scope.setTag("error_type", e.getClass().getSimpleName()); + scope.setExtra("error_message", e.getMessage()); + }); + Sentry.captureException(e); + return createErrorResponse(e, ErrorStatus._INTERNAL_SERVER_ERROR, e.getMessage(), HttpHeaders.EMPTY, request); } @@ -52,6 +84,9 @@ public ResponseEntity handleValidationException(ConstraintViolationExcep .map(ConstraintViolation::getMessage) .reduce((first, second) -> first + ", " + second) .orElse("Validation error occurred"); + + log.warn("Validation error: {}", errorMessage); + return createErrorResponse(e, ErrorStatus.VALIDATION_ERROR, errorMessage, HttpHeaders.EMPTY, request); } @@ -70,13 +105,18 @@ protected ResponseEntity handleMethodArgumentNotValid( e.getBindingResult().getFieldErrors() .forEach(fieldError -> errors.put(fieldError.getField(), fieldError.getDefaultMessage())); String errorMessage = String.join(", ", errors.values()); + + log.warn("Method argument validation failed: {}", errorMessage); + return createErrorResponse(e, ErrorStatus._BAD_REQUEST, errorMessage, headers, request); } @ExceptionHandler(ResultEmptyListException.class) public ResponseEntity handleResultEmptyListException(ResultEmptyListException e, WebRequest request) { + log.info("Empty result list: {}", e.getErrorStatus().getCode()); + return ResponseEntity .status(e.getErrorStatus().getHttpStatus()) .body(BaseResponse.onFailureWithEmptyList(e.getErrorStatus())); } -} \ No newline at end of file +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 94f0a996..05fa85ed 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -88,5 +88,12 @@ cache: fcm: certification: ${FCM_CERTIFICATION:} +sentry: + dsn: ${SENTRY_DSN} + environment: production + send-default-pii: true + attach-stacktrace: true + traces-sample-rate: 1.0 + server: port: 8081 \ No newline at end of file From 6f2114ad1798355a4437919427e14b54ea11de01 Mon Sep 17 00:00:00 2001 From: Seokwoo Date: Tue, 23 Dec 2025 17:08:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(monitoring):=20Prometheus=20=EB=B0=8F?= =?UTF-8?q?=20Grafana=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-prod.yml: Prometheus 메트릭 노출을 위한 Actuator 설정 추가 - docker-compose.yml: Prometheus, Grafana 서비스 및 전용 네트워크 추가 - monitoring/prometheus.yml: 애플리케이션 메트릭 수집을 위한 스크레이핑 설정 추가 - github-actions.yml: 다중 컨테이너 배포를 위해 docker-compose up 명령어 수정 --- .github/workflows/github-actions.yml | 2 +- build.gradle | 4 +++ docker-compose.yml | 35 ++++++++++++++++++- monitoring/prometheus.yml | 8 +++++ .../config/security/SecurityConfig.java | 2 +- src/main/resources/application-prod.yml | 11 ++++++ 6 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 monitoring/prometheus.yml diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 6d3e02cb..7f3644ee 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -63,6 +63,6 @@ jobs: sudo docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} sudo docker image rm ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }} || true sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }} - sudo docker-compose up -d --no-deps app + sudo docker-compose up -d sudo docker-compose logs sudo docker image prune -f \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7c9ea890..e2e2bda1 100644 --- a/build.gradle +++ b/build.gradle @@ -96,6 +96,10 @@ dependencies { // Sentry implementation 'io.sentry:sentry-spring-boot-starter:8.22.0' + + // 성능 모니터링 + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index eaf2ed6e..c4421b00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - "8081:8081" volumes: - ./src/main/resources/application-prod.yml:/config/application-prod.yml + networks: + - catchy-network osrm-routed: image: osrm/osrm-backend:latest @@ -18,4 +20,35 @@ services: volumes: - .:/data command: > - osrm-routed --algorithm mld /data/OSM/south-korea-latest.osrm \ No newline at end of file + osrm-routed --algorithm mld /data/OSM/south-korea-latest.osrm + networks: + - catchy-network + + prometheus: + image: prom/prometheus:latest + container_name: catchy-prometheus + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + ports: + - "9090:9090" + networks: + - catchy-network + + grafana: + image: grafana/grafana:latest + container_name: catchy-grafana + ports: + - "3000:3000" + volumes: + - grafana-storage:/var/lib/grafana + networks: + - catchy-network + +networks: + catchy-network: + driver: bridge + +volumes: + grafana-storage: \ No newline at end of file diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 00000000..caf6e78a --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'catchy-prod-app' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['catchy-server:8081'] \ No newline at end of file diff --git a/src/main/java/umc/catchy/global/config/security/SecurityConfig.java b/src/main/java/umc/catchy/global/config/security/SecurityConfig.java index 1f97276d..b1da46f6 100644 --- a/src/main/java/umc/catchy/global/config/security/SecurityConfig.java +++ b/src/main/java/umc/catchy/global/config/security/SecurityConfig.java @@ -44,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { , "/member/reissue", "/member/callback/**" ,"/course/generate-ai", "/health" ,"/course/place/current", "/course/place/region" - , "/member/mypage/nickname").permitAll() + , "/member/mypage/nickname", "/actuator/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling(exception -> exception diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 05fa85ed..4a6451f0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -95,5 +95,16 @@ sentry: attach-stacktrace: true traces-sample-rate: 1.0 +management: + endpoints: + web: + exposure: + include: prometheus, health, info + endpoint: + prometheus: + enabled: true + health: + show-details: always + server: port: 8081 \ No newline at end of file