도입한 이유
업무를 진행하던 중 로깅과 오류 모니터링을 해야 했다.
ELK를 도입하고 싶지만 주어진 시간이 너무 없어서 도입하고 공부할 시간이 너무 없었다.
나는 도입하는 것도 중요하지만 어떻게 효율적으로 사용하고 고도화가 가능한지를 생각해보고 도입하는 것이 중요하다 생각돼서 간단한 Sentry를 이용해 로깅과 오류 모니터링을 구축하였다.
Sentry는 여러 언어와 프레임워크를 지원하고 구축 방법도 매우 쉬워서 쉽게 로깅과 모니터링이 가능하다.
가격 정보를 간단하게 정리하면 아래와 같다. 더 자세한 정보는 홈페이지를 참고 바란다.
- Developer : 1개 계정 사용, 30일 데이터 보존, 오류 5,000개 제한, 트랜잭션 10,000개 제한
- Team, Business : 무제한 계정 사용, 90일 데이터 보존, 오류 50,000개 제한, 트랜잭션 100,000개 제한
- Enterprise : 맞춤형 비용
지원하는 언어, 프레임워크, 라이브러리는 아래와 같다.
Spring Boot + Spring AOP + Sentry 구축
프로젝트는 Spring Boot로 생성했고 AOP를 활용해 API를 사용할 때 Sentry로 로그를 보내려고 한다.
1. 프로젝트 생성
먼저 Sentry 홈페이지에 접속해 회원가입을 하고 프로젝트를 생성한다.
Spring boot를 선택하고 아래 프로젝트명을 입력한다.
프로젝트를 생성하면 Spring Boot에서 Sentry를 사용하는 방법과 logback을 이용해 Sentry를 사용하는 방법을 자세히 알려준다.
처음에는 logback을 이용해 사용하려 했지만 뭔가 내가 원하는 대로 컨트롤이 가능해야 고도화가 가능할 것 같아 그냥 Sentry SDK만 사용하기로 했다.
정상적으로 프로젝트가 등록되었다면 프로젝트 설정에 들어가서 DSN을 복사한다.
2. dependency 추가, application.yml 등록
(해당 Spring Boot 프로젝트는 JDK는 11를 사용했고 gradle 기반이다.)
build.gradle 파일에 두가지 라이브러리를 추가한 후 빌드한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'io.sentry:sentry-spring-boot-starter:6.2.1'
application.yml에 Sentry에 대한 설정을 추가한다.
...
sentry:
dsn: {복사한 DSN}
traces-sample-rate: 1.0 # 전송할 트랜잭션의 양 1 = 100%
logging:
minimum-event-level: info # 최소 이벤트 레벨
minimum-breadcrumb-level: info # 최소 브래드크럼 레벨
3. AOP 설정
먼저 API를 호출했을때 여러 정보들을 추출할 클래스를 만들었다. 허접한 코드라 다른 방식으로 구현해도 아주 상관없다.
@ToString
@Getter
public class RequestApiInfo {
private UUID userId = null;
private String userName = null;
private String method = null;
private String url = null;
private String name = null;
private Map<String, String> header = new HashMap<>();
private Map<String, String> parameters = new HashMap<>();
private Map<String, String> body = new HashMap<>();
private String ipAddress = null;
private final String dateTime = LocalDateTime.now(ZoneId.of("Asia/Seoul")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
// Token에서 회원정보 추출
private void setUser() {
final UserPrincipal userPrincipal = SecurityContextUtil.getUserPrincipal();
this.userId = userPrincipal.getUserId();
this.userName = userPrincipal.getUserLoginId();
}
// Request에서 Header 추출
private void setHeader(HttpServletRequest request) {
final Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
final String headerName = headerNames.nextElement();
this.header.put(headerName, request.getHeader(headerName));
}
}
// Request에서 ipAddress 추출
private void setIpAddress(HttpServletRequest request) {
this.ipAddress = Optional.of(request)
.map(httpServletRequest -> Optional.ofNullable(request.getHeader("X-Forwarded-For"))
.orElse(Optional.ofNullable(request.getHeader("Proxy-Client-IP"))
.orElse(Optional.ofNullable(request.getHeader("WL-Proxy-Client-IP"))
.orElse(Optional.ofNullable(request.getHeader("HTTP_CLIENT_IP"))
.orElse(Optional.ofNullable(request.getHeader("HTTP_X_FORWARDED_FOR"))
.orElse(request.getRemoteAddr())))))).orElse(null);
}
// API 정보 추출
private void setApiInfo(JoinPoint joinPoint, Class clazz) {
final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
final Method method = methodSignature.getMethod();
final RequestMapping requestMapping = (RequestMapping) clazz.getAnnotation(RequestMapping.class);
final String baseUrl = requestMapping.value()[0];
Stream.of(GetMapping.class, PutMapping.class, PostMapping.class, DeleteMapping.class, RequestMapping.class)
.filter(method::isAnnotationPresent)
.findFirst()
.ifPresent(mappingClass -> {
final Annotation annotation = method.getAnnotation(mappingClass);
try {
final String[] methodUrl = (String[])mappingClass.getMethod("value").invoke(annotation);
this.method = (mappingClass.getSimpleName().replace("Mapping", "")).toUpperCase();
this.url = String.format("%s%s", baseUrl, methodUrl.length > 0 ? "/" + methodUrl[0] : "");
this.name = (String)mappingClass.getMethod("name").invoke(annotation);
} catch (Exception e) {
e.printStackTrace();
}
});
}
// Body와 Parameters 추출
private void setInputStream(JoinPoint joinPoint, ObjectMapper objectMapper) {
try {
final CodeSignature codeSignature = (CodeSignature) joinPoint.getSignature();
final String[] parameterNames = codeSignature.getParameterNames();
final Object[] args = joinPoint.getArgs();
for (int i = 0; i < parameterNames.length; i++) {
if (parameterNames[i].equals("request")) {
this.body = objectMapper.convertValue(args[i], new TypeReference<Map<String, String>>(){});
} else {
this.parameters.put(parameterNames[i], objectMapper.writeValueAsString(args[i]));
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public RequestApiInfo(JoinPoint joinPoint, Class clazz, ObjectMapper objectMapper) {
try {
final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
setHeader(request);
setIpAddress(request);
} catch (Exception e) {
e.printStackTrace();
}
try {
setUser();
} catch (Exception e) {
e.printStackTrace();
}
try {
setApiInfo(joinPoint, clazz);
} catch (Exception e) {
e.printStackTrace();
}
try {
setInputStream(joinPoint, objectMapper);
} catch (Exception e) {
e.printStackTrace();
}
}
}
AOP를 이용해 API를 호출했을 때 위에서 만든 클래스를 이용해 SentryTransaction 또는 SentryEvent를 적용해주자.
AOP에 관한 설명은 너무 길어지기 때문에 여기서 따로 설명하진 않겠다.
@Aspect
@Component
@RequiredArgsConstructor
public class LoggingAspect {
private final ObjectMapper objectMapper;
@Pointcut("within(com.sample.project.controller..*)") // 패키지 범위 설정
public void onRequest() {}
@Around("onRequest()")
public Object requestLogging(ProceedingJoinPoint joinPoint) throws Throwable {
// API의 정보 담는 클래스
final RequestApiInfo apiInfo = new RequestApiInfo(joinPoint, joinPoint.getTarget().getClass(), objectMapper);
final String requestMessage = String.format("%s %s", apiInfo.getMethod(), apiInfo.getUrl());
final String body = objectMapper.writeValueAsString(apiInfo.getBody());
final String parameters = objectMapper.writeValueAsString(apiInfo.getParameters());
// Request 설정
final Request request = new Request();
request.setUrl(apiInfo.getUrl());
request.setMethod(apiInfo.getMethod());
request.setData(apiInfo.getBody());
request.setQueryString(apiInfo.getParameters().keySet().stream()
.map(key -> key + "=" + apiInfo.getParameters().get(key))
.reduce("", (a, b) -> a + "&" + b)
);
// User 설정
final User user = new User();
user.setId(Optional.ofNullable(apiInfo.getUserId()).map(UUID::toString).orElse("Unknown"));
user.setUsername(Optional.ofNullable(apiInfo.getUserName()).orElse("Unknown"));
user.setIpAddress(apiInfo.getIpAddress());
// 트랜잭션 설정
final ITransaction transaction = Sentry.startTransaction(requestMessage, "request-api");
Sentry.configureScope(scope -> {
scope.setRequest(request);
scope.setUser(user);
});
try {
final Object result = joinPoint.proceed(joinPoint.getArgs());
return result;
} catch (Exception e) {
final StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
final String exceptionAsString = sw.toString();
// Sentry Event 생성 및 설정
final SentryEvent event = new SentryEvent();
event.setRequest(request);
event.setUser(user);
event.setLevel(SentryLevel.ERROR);
event.setTransaction(transaction.getName());
// Event Message 설정
final Message message = new Message();
message.setMessage(requestMessage);
event.setMessage(message);
// Exception 설정
final SentryException exception = new SentryException();
exception.setType(e.getClass().getSimpleName());
exception.setValue(exceptionAsString);
event.setExceptions(List.of(exception));
Sentry.captureEvent(event);
throw e;
} finally {
// 트랜잭션 close
transaction.finish();
}
}
}
Request 정보들을 담아 Transaction을 진행하고, Exception이 발생한다면 SentryEvent에 정보를 담아 발송했다.
하지만 이대로 배포했을때 로드밸런서에서 헬스체크나 잡다한 트랜잭션들이 많이 등록된다.
4. 트랜잭션 및 이벤트 필터링
이럴 때는 Sentry에 EventProcessor를 구현해 필터링이 가능하다.
@Component
public class SentryEventProcessor implements EventProcessor {
public static final String TAG_KEY = "type";
public static final String TAG_VALUE = "request-api";
@Override
public SentryEvent process(SentryEvent event, Hint hint) {
if (TAG_VALUE.equals(event.getTag(TAG_KEY))) {
return event;
} else {
return null;
}
}
@Override
public SentryTransaction process(SentryTransaction event, Hint hint) {
if (Objects.equals(event.getStatus(), SpanStatus.OK) && TAG_VALUE.equals(event.getTag(TAG_KEY))) {
return event;
} else {
return null;
}
}
}
태그를 설정해 해당 태그의 트랜잭션과 이벤트, 그리고 트랜잭션의 상태가 OK인 것들만 등록되도록 필터링을 해주었다.
다시 LoggingAspect 클래스로 돌아가 Tag를 지정하고 트랜잭션의 상태를 설정해야 한다.
@Aspect
@Component
@RequiredArgsConstructor
public class LoggingAspect {
....
@Around("onRequest()")
public Object requestLogging(ProceedingJoinPoint joinPoint) throws Throwable {
....
final ITransaction transaction = Sentry.startTransaction(requestMessage, "request-api");
transaction.setTag(SentryEventProcessor.TAG_KEY, SentryEventProcessor.TAG_VALUE); // 추가
transaction.setStatus(SpanStatus.OK); // 추가
Sentry.configureScope(scope -> {
scope.setRequest(request);
scope.setUser(user);
});
try {
final Object result = joinPoint.proceed(joinPoint.getArgs());
return result;
} catch (Exception e) {
final StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
final String exceptionAsString = sw.toString();
final SentryEvent event = new SentryEvent();
event.setTag(SentryEventProcessor.TAG_KEY, SentryEventProcessor.TAG_VALUE); // 추가
event.setRequest(request);
event.setUser(user);
event.setLevel(SentryLevel.ERROR);
event.setTransaction(transaction.getName());
final Message message = new Message();
message.setMessage(requestMessage);
event.setMessage(message);
final SentryException exception = new SentryException();
exception.setType(e.getClass().getSimpleName());
exception.setValue(exceptionAsString);
event.setExceptions(List.of(exception));
Sentry.captureEvent(event);
transaction.setStatus(SpanStatus.CANCELLED); // 추가
throw e;
} finally {
transaction.finish();
}
}
}
트랜잭션과 이벤트를 모두 수정했으면 API를 호출해보자.
5. Test
트랜잭션 테스트와 Exception 테스트 API를 만든 후 호출하고 Sentry에 접속해 이벤트를 확인해보자.
@GetMapping(name = "Sentry 트랜잭션 테스트", value = "transaction")
public void sentryTransactionTest() {
System.out.println("sentryTransactionTest");
}
@GetMapping(name = "Sentry Exception 테스트", value = "exception")
public void sentryExceptionTest() throws Exception {
System.out.println("sentryExceptionTest");
throw new Exception("test");
}
Discover > All Events에 접속해보면 로깅에 성공한 것을 확인할 수 있다.
지금은 User와 Parameters, Body가 비어있지만 @RequestParam, @RequestBody를 이용하고 토큰을 이용해 인증한다면 누가 어떻게 API를 요청했는지 쉽게 알 수 있고 User별로 검색도 가능하다.
더욱 자세한 Java 사용법은 https://docs.sentry.io/platforms/java/에서 확인할 수 있다.
Sentry는 많은 정보를 쉽게 로깅할 수 있었고 사용하는 방법이 너무나 쉽다.
처음 써봐서 많은 기능을 아직 모르지만 계속 사용해보면서 더 좋은 기능이 있다면 정리해서 다시 올리도록 하겠다.
PS.
TImestamp를 한국 시간으로 변경하고 싶다면 UserSetting에서 변경할 수 있다.
'Spring' 카테고리의 다른 글
SlackAppender를 이용한 Logback Slack 알림 받기 (0) | 2022.12.01 |
---|---|
플러그인을 사용해 라이선스 보고서 생성하기 (0) | 2022.09.06 |
Jenkins + Docker + Spring Cloud Config 적용기 (4) | 2022.05.24 |
Spring Docs + Swagger 설정하기 (Spring Rest Docs 비교) (2) | 2022.05.02 |
스프링 배치(Spring Batch)를 이용한 데이터 마이그레이션 (2) | 2022.02.24 |