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
24 changes: 14 additions & 10 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
id "io.sentry.jvm.gradle" version "5.9.0"
}

group = 'akuma'
Expand All @@ -13,6 +14,14 @@ java {
}
}

sentry {
includeSourceContext = true

org = "akuma-ir"
projectName = "nuntteo_dev"
authToken = System.getenv("SENTRY_AUTH_TOKEN")
}
Comment on lines +17 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Sentry Gradle 플러그인 토큰/프로젝트 설정을 환경별로 유연하게 처리하세요.

로컬/CI 환경에 따라 SENTRY_AUTH_TOKEN이 없을 수 있습니다. 토큰이 없을 때 업로드 관련 태스크가 안전하게 스킵되도록 가드하거나, 프로젝트명을 환경 변수로 파라미터화하면 운영/개발 프로젝트 분리가 쉬워집니다.

아래처럼 조건부로 토큰을 적용하고, 프로젝트명을 환경 변수로 오버라이드 가능하게 하는 것을 제안합니다.

 sentry {
   includeSourceContext = true
-  org = "akuma-ir"
-  projectName = "nuntteo_dev"
-  authToken = System.getenv("SENTRY_AUTH_TOKEN")
+  org = "akuma-ir"
+  projectName = System.getenv("SENTRY_PROJECT") ?: "nuntteo_dev"
+  def token = System.getenv("SENTRY_AUTH_TOKEN")
+  if (token) {
+    authToken = token
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sentry {
includeSourceContext = true
org = "akuma-ir"
projectName = "nuntteo_dev"
authToken = System.getenv("SENTRY_AUTH_TOKEN")
}
sentry {
includeSourceContext = true
org = "akuma-ir"
projectName = System.getenv("SENTRY_PROJECT") ?: "nuntteo_dev"
def token = System.getenv("SENTRY_AUTH_TOKEN")
if (token) {
authToken = token
}
}
🤖 Prompt for AI Agents
In build.gradle around lines 17-23, the Sentry plugin hardcodes org/projectName
and unconditionally reads SENTRY_AUTH_TOKEN which can be missing in local/CI;
change it to read auth token and project name from environment with sensible
fallbacks (e.g., System.getenv("SENTRY_AUTH_TOKEN") and
System.getenv("SENTRY_PROJECT") or default), and guard upload tasks so they are
no-ops when the token is absent (skip configuring or running Sentry upload tasks
if auth token is null/empty) so builds don’t fail in environments without
credentials.


configurations {
compileOnly {
extendsFrom annotationProcessor
Expand All @@ -30,6 +39,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webflux' // 버전 명시 X (BOM 관리)
implementation 'org.springframework.boot:spring-boot-starter-aop'

// --- Swagger/OpenAPI ---
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
Expand All @@ -52,6 +62,10 @@ dependencies {
implementation 'com.google.apis:google-api-services-sheets:v4-rev612-1.25.0'
implementation 'com.google.firebase:firebase-admin:9.2.0'

// Sentry BOM 추가하여 버전 관리 일원화
implementation platform("io.sentry:sentry-bom:8.19.0")
implementation "io.sentry:sentry-spring-boot-starter-jakarta"

// --- Lombok ---
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand All @@ -73,15 +87,5 @@ dependencies {
testImplementation platform('org.testcontainers:testcontainers-bom:1.21.3')
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'

// (선택) 일부 CI 환경에서 필요한 경우만 사용
testRuntimeOnly 'net.bytebuddy:byte-buddy-agent:1.14.17'
}

tasks.withType(Test).configureEach {
useJUnitPlatform()
// Mockito가 ByteBuddy 에이전트를 self-attach 할 수 있게 허용 (CI에서 static/final 모킹 이슈 방지)
jvmArgs += ['-Djdk.attach.allowAttachSelf=true']
// JDK 21+를 쓰는 CI라면 아래 한 줄도 고려
// jvmArgs += ['-XX:+EnableDynamicAgentLoading']
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package akuma.whiplash.global.config.sentry;

import io.sentry.SentryOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SentryConfig {

@Bean
public SentryOptions.BeforeSendCallback beforeSendCallback() {
return (event, hint) -> {
if (event.getExceptions() != null && !event.getExceptions().isEmpty()) {
var ex = event.getExceptions().get(0);
if (ex.getType() != null && ex.getType().endsWith("ApplicationException")) {
// 전역 핸들러에서 미리 심어둔 태그
String code = event.getTag("error.code");
if (code != null) {
String originalMessage = ex.getValue(); // 기존 사람이 읽는 메시지
// 타이틀에 반영되는 'type'을 에러코드로 교체
ex.setType(code);
// 부제목(value)은 원래 메시지를 유지(또는 필요 시 축약)
ex.setValue(originalMessage);
// 참고용으로 원본도 extra에 보존
event.setExtra("original.message", originalMessage);
Comment on lines +17 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

컴파일 오류: event.getTag(...)는 존재하지 않습니다. getTags().get(...)을 사용하세요.

SentryEvent에는 getTag(String)가 없고, Map 형태의 getTags()만 제공합니다. 현재 코드는 컴파일에 실패합니다.

아래처럼 수정하세요(Null 안전 포함):

-                    String code = event.getTag("error.code");
+                    var tags = event.getTags();
+                    String code = (tags != null) ? tags.get("error.code") : null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String code = event.getTag("error.code");
if (code != null) {
String originalMessage = ex.getValue(); // 기존 사람이 읽는 메시지
// 타이틀에 반영되는 'type'을 에러코드로 교체
ex.setType(code);
// 부제목(value)은 원래 메시지를 유지(또는 필요 시 축약)
ex.setValue(originalMessage);
// 참고용으로 원본도 extra에 보존
event.setExtra("original.message", originalMessage);
var tags = event.getTags();
String code = (tags != null) ? tags.get("error.code") : null;
if (code != null) {
String originalMessage = ex.getValue(); // 기존 사람이 읽는 메시지
// 타이틀에 반영되는 'type'을 에러코드로 교체
ex.setType(code);
// 부제목(value)은 원래 메시지를 유지(또는 필요 시 축약)
ex.setValue(originalMessage);
// 참고용으로 원본도 extra에 보존
event.setExtra("original.message", originalMessage);
🤖 Prompt for AI Agents
In src/main/java/akuma/whiplash/global/config/sentry/SentryConfig.java around
lines 17 to 25, replace the non-existent event.getTag("error.code") call with a
null-safe lookup from the tags map (e.g., getTags() != null ?
getTags().get("error.code") : null); then proceed only if the retrieved code is
non-null, preserving originalMessage from ex.getValue(), setting
ex.setType(code), ex.setValue(originalMessage), and
event.setExtra("original.message", originalMessage). Ensure you guard against a
null tags map and a null originalMessage to avoid NPEs.

}
}
}
return event;
};
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package akuma.whiplash.global.exception;

import akuma.whiplash.global.log.LogUtils;
import akuma.whiplash.global.response.ApplicationResponse;
import akuma.whiplash.global.response.code.BaseErrorCode;
import akuma.whiplash.global.response.code.CommonErrorCode;
import io.sentry.Sentry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
Comment on lines 4 to 16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Sentry 연동 시 유용 태그/유틸 의존성 추가 필요

requestId 태그 및 쿼리 마스킹을 위해 MDC, LogConst, LogUtils 임포트가 필요합니다.

 import akuma.whiplash.global.response.ApplicationResponse;
 import akuma.whiplash.global.response.code.BaseErrorCode;
 import akuma.whiplash.global.response.code.CommonErrorCode;
+import akuma.whiplash.global.log.LogConst;
+import akuma.whiplash.global.log.LogUtils;
 import io.sentry.Sentry;
 import jakarta.servlet.http.HttpServletRequest;
...
 import java.util.stream.Collectors;
+import org.slf4j.MDC;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import akuma.whiplash.global.response.ApplicationResponse;
import akuma.whiplash.global.response.code.BaseErrorCode;
import akuma.whiplash.global.response.code.CommonErrorCode;
import io.sentry.Sentry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import akuma.whiplash.global.response.ApplicationResponse;
import akuma.whiplash.global.response.code.BaseErrorCode;
import akuma.whiplash.global.response.code.CommonErrorCode;
import akuma.whiplash.global.log.LogConst;
import akuma.whiplash.global.log.LogUtils;
import io.sentry.Sentry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.slf4j.MDC;
import lombok.extern.slf4j.Slf4j;
🤖 Prompt for AI Agents
In src/main/java/akuma/whiplash/global/exception/GlobalExceptionHandler.java
around lines 3 to 15, the Sentry integration needs additional imports for
requestId tagging and query masking; add imports for org.slf4j.MDC and the
project's LogConst and LogUtils (or their correct packages) so the handler can
read the MDC requestId and use LogUtils/LogConst to mask sensitive query
parameters before sending to Sentry; ensure the import statements use the
correct fully-qualified package names used elsewhere in the codebase.

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -45,6 +49,14 @@ public ResponseEntity<Object> handleMethodArgumentNotValid(
(existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage);
});

sendErrorToSentry(
e,
extractRequestUri(request),
LogUtils.maskSensitiveQuery(extractQueryString(request)),
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getCustomCode(),
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getHttpStatus()
);

return handleExceptionInternalArgs(
e,
request,
Expand All @@ -59,6 +71,18 @@ public ResponseEntity<Object> validation(ConstraintViolationException e, WebRequ
.findFirst()
.orElseThrow(() -> new RuntimeException("ConstraintViolationException Error"));

sendErrorToSentry(
e,
extractRequestUri(request),
LogUtils.maskSensitiveQuery(
e.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + "=" + v.getInvalidValue())
.collect(Collectors.joining(", "))
),
errorMessage,
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getHttpStatus()
);

return handleExceptionInternalConstraint(e, CommonErrorCode.valueOf(errorMessage), request);
}

Expand All @@ -74,13 +98,30 @@ protected ResponseEntity<Object> handleHttpMessageNotReadable(
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getCustomCode(),
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage()
);

sendErrorToSentry(
ex,
extractRequestUri(request),
LogUtils.maskSensitiveQuery(extractQueryString(request)),
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getCustomCode(),
CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getHttpStatus()
);

return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler
public ResponseEntity<Object> exception(Exception e, WebRequest request) {
log.error("Unexpected error: ", e);

sendErrorToSentry(
e,
request.getDescription(false),
request.getParameterMap().toString(),
CommonErrorCode.INTERNAL_SERVER_ERROR.getCustomCode(),
CommonErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus()
);

return handleExceptionInternalFalse(
e,
CommonErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus(),
Expand All @@ -90,10 +131,18 @@ public ResponseEntity<Object> exception(Exception e, WebRequest request) {
}

@ExceptionHandler(value = ApplicationException.class)
public ResponseEntity<Object> onThrowException(ApplicationException applicationException, HttpServletRequest request) {
BaseErrorCode baseErrorCode = applicationException.getCode();
public ResponseEntity<Object> onThrowException(ApplicationException ex, HttpServletRequest request) {
BaseErrorCode baseErrorCode = ex.getCode();

return handleExceptionInternal(applicationException, baseErrorCode, null, request);
sendErrorToSentry(
ex,
request.getRequestURI(),
LogUtils.maskSensitiveQuery(request.getQueryString()),
baseErrorCode.getCustomCode(),
baseErrorCode.getHttpStatus()
);

return handleExceptionInternal(ex, baseErrorCode, null, request);
}

private ResponseEntity<Object> handleExceptionInternal(
Expand Down Expand Up @@ -179,4 +228,40 @@ private ResponseEntity<Object> handleExceptionInternalConstraint(
request
);
}

private static String extractRequestUri(WebRequest request) {
if (request instanceof ServletWebRequest servletWebRequest) {
return servletWebRequest.getRequest().getRequestURI();
}
String desc = request.getDescription(false); // ex: "uri=/api/alarms/100/checkin"
if (desc != null && desc.startsWith("uri=")) return desc.substring(4);
return desc;
}

private static String extractQueryString(WebRequest request) {
if (request instanceof ServletWebRequest servletWebRequest) {
return servletWebRequest.getRequest().getQueryString();
}
return null;
}

private static void sendErrorToSentry(Exception ex, String requestUri, String queryString, String errorCode, HttpStatus status) {
if (status.is5xxServerError()) {
Sentry.withScope(scope -> {
scope.setTransaction(requestUri);
scope.setTag("path", requestUri);

if (errorCode != null && !errorCode.isBlank()) {
scope.setTag("error.code", errorCode);
scope.setFingerprint(List.of(errorCode));
}

if (queryString != null && !queryString.isBlank()) {
scope.setExtra("query", queryString);
}

Sentry.captureException(ex);
});
}
}
}
Loading