diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 5c37feb4..7f3644ee 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 }} @@ -62,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 28771bb2..e2e2bda1 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,13 @@ dependencies { // OpenFeign implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // 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/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/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/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..4a6451f0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -88,5 +88,23 @@ cache: fcm: certification: ${FCM_CERTIFICATION:} +sentry: + dsn: ${SENTRY_DSN} + environment: production + send-default-pii: true + 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