Skip to content

Commit

Permalink
[feat] 디스코드 로깅 봇 추가 (#73)
Browse files Browse the repository at this point in the history
* [chore] #62 add discord appender dependency

* [chore] #62 add discord logback dependency

* [chore] #62 create logback yml

* [feat] #62 create Wrapper to cache RequestBody

* [feat] #62 create ServletWrappingFilter to cache requestBody

* [feat] #62 create MDCFilter to save info in MDC

* [feat] #62 apply filters with order

* [feat] #62 create HttpRequestUtil to parse info from HttpRequest

* [feat] #62 create MDCUtil to set MDC

* [feat] #62 create model to send discord message prettier

* [feat] #62 create discordWebHook & Appender to send message in jsonformat

* [feat] #62 create ApiCallUtil to send discord message send API

* [feat] #62 add Discord ErrorCode in BusinessErrorCode

* [feat] #62 pass exception over log
  • Loading branch information
tkdwns414 authored Jul 18, 2024
1 parent 5ea7cb6 commit 700c076
Show file tree
Hide file tree
Showing 25 changed files with 980 additions and 3 deletions.
2 changes: 1 addition & 1 deletion SERVER_YML
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ configurations {

repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}

dependencies {
Expand Down Expand Up @@ -68,6 +69,9 @@ dependencies {

// Firebase
implementation 'com.google.firebase:firebase-admin:9.2.0'

// Logback Discord Appender
implementation('com.github.napstr:logback-discord-appender:1.0.0')
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,7 @@ public ResponseEntity<BusinessErrorCode> handleMaxSizeException(MaxUploadSizeExc
// 기본 예외
@ExceptionHandler(value = {Exception.class})
public ResponseEntity<BusinessErrorCode> handleException(Exception e) {
log.error("GlobalExceptionHandler catch Exception : {}", e.getMessage());
e.printStackTrace();
log.error("GlobalExceptionHandler catch Exception : {}", e.getMessage(), e);
return ResponseEntity
.status(BusinessErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus())
.body(BusinessErrorCode.INTERNAL_SERVER_ERROR);
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/org/kkumulkkum/server/config/FilterConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.kkumulkkum.server.config;

import org.kkumulkkum.server.log.filter.ServletWrappingFilter;
import org.kkumulkkum.server.log.filter.MDCFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<ServletWrappingFilter> secondFilter() {
FilterRegistrationBean<ServletWrappingFilter> filterRegistrationBean = new FilterRegistrationBean<>(
new ServletWrappingFilter());
filterRegistrationBean.setOrder(0);
return filterRegistrationBean;
}

@Bean
public FilterRegistrationBean<MDCFilter> thirdFilter() {
FilterRegistrationBean<MDCFilter> filterRegistrationBean = new FilterRegistrationBean<>(
new MDCFilter());
filterRegistrationBean.setOrder(1);
return filterRegistrationBean;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public enum BusinessErrorCode implements DefaultErrorCode {
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED,40500, "지원하지 않는 메소드입니다."),
// 500 INTERNAL_SEVER_ERROR
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,50000, "서버 내부 오류입니다."),
DISCORD_WEBHOOK_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,50001,"디스코드 웹훅 전송에 실패했습니다."),
;

private HttpStatus httpStatus;
Expand Down
118 changes: 118 additions & 0 deletions src/main/java/org/kkumulkkum/server/log/discord/DiscordAppender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.kkumulkkum.server.log.discord;

import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import ch.qos.logback.classic.spi.ThrowableProxyUtil;
import ch.qos.logback.core.UnsynchronizedAppenderBase;
import io.micrometer.core.instrument.util.StringEscapeUtils;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.kkumulkkum.server.exception.BusinessException;
import org.kkumulkkum.server.exception.code.BusinessErrorCode;
import org.kkumulkkum.server.log.model.EmbedObject;
import org.kkumulkkum.server.log.util.MDCUtil;
import org.kkumulkkum.server.log.util.StringUtil;

import java.awt.*;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;

@Slf4j
@Setter
public class DiscordAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {

private String discordWebhookUrl;
private String username;
private String avatarUrl;

private static Color getLevelColor(ILoggingEvent eventObject) {
String level = eventObject.getLevel().levelStr;
if (level.equals("WARN")) {
return Color.yellow;
} else if (level.equals("ERROR")) {
return Color.red;
}

return Color.blue;
}

@Override
protected void append(ILoggingEvent eventObject) {
DiscordWebHook discordWebhook = new DiscordWebHook(discordWebhookUrl, username, avatarUrl, false);
Map<String, String> mdcPropertyMap = eventObject.getMDCPropertyMap();
Color messageColor = getLevelColor(eventObject);

String level = eventObject.getLevel().levelStr;
String exceptionBrief = "";
String exceptionDetail = "";
IThrowableProxy throwable = eventObject.getThrowableProxy();

if (throwable != null) {
exceptionBrief = throwable.getClassName() + ": " + throwable.getMessage();
}

if (exceptionBrief.equals("")) {
exceptionBrief = "EXCEPTION 정보가 남지 않았습니다.";
}

discordWebhook.addEmbed(new EmbedObject()
.setTitle("[" + level + " - 문제 간략 내용]")
.setColor(messageColor)
.setDescription(exceptionBrief)
.addField("[" + "Exception Level" + "]",
StringEscapeUtils.escapeJson(level),
true)
.addField("[문제 발생 시각]",
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
false)
.addField(
"[" + MDCUtil.REQUEST_URI_MDC + "]",
StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.REQUEST_URI_MDC)),
false)
.addField(
"[" + MDCUtil.USER_IP_MDC + "]",
StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.USER_IP_MDC)),
false)
.addField(
"[" + MDCUtil.USER_INFO + "]",
StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.USER_INFO)),
false)
.addField(
"[" + MDCUtil.HEADER_MAP_MDC + "]",
StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.HEADER_MAP_MDC).replaceAll("[\\{\\{\\}]", "")),
true)
// .addField(
// "[" + MDCUtil.USER_REQUEST_COOKIES + "]",
// StringEscapeUtils.escapeJson(
// mdcPropertyMap.get(MDCUtil.USER_REQUEST_COOKIES).replaceAll("[\\{\\{\\}]", "")),
// false)
.addField(
"[" + MDCUtil.PARAMETER_MAP_MDC + "]",
StringEscapeUtils.escapeJson(
mdcPropertyMap.get(MDCUtil.PARAMETER_MAP_MDC).replaceAll("[\\{\\{\\}]", "")),
false)
.addField("[" + MDCUtil.BODY_MDC + "]",
StringEscapeUtils.escapeJson(StringUtil.translateEscapes(mdcPropertyMap.get(MDCUtil.BODY_MDC))),
false)
);

if (throwable != null) {
exceptionDetail = ThrowableProxyUtil.asString(throwable);
String exception = exceptionDetail.substring(0, 4000);
discordWebhook.addEmbed(
new EmbedObject()
.setTitle("[Exception 상세 내용]")
.setColor(messageColor)
.setDescription(StringEscapeUtils.escapeJson(exception))
);
}

try {
discordWebhook.execute();
} catch (IOException ioException) {
throw new BusinessException(BusinessErrorCode.DISCORD_WEBHOOK_ERROR);
}
}
}
146 changes: 146 additions & 0 deletions src/main/java/org/kkumulkkum/server/log/discord/DiscordWebHook.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package org.kkumulkkum.server.log.discord;

import org.kkumulkkum.server.exception.BusinessException;
import org.kkumulkkum.server.exception.code.BusinessErrorCode;
import org.kkumulkkum.server.log.model.*;
import org.kkumulkkum.server.log.model.Image;
import org.kkumulkkum.server.log.util.ApiCallUtil;

import java.awt.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class DiscordWebHook {

private final String urlString;
private final List<EmbedObject> embeds = new ArrayList<>();
private String username;
private String avatarUrl;
private boolean tts;

public DiscordWebHook(String urlString, String username, String avatarUrl, boolean tts) {
this.urlString = urlString;
this.username = username;
this.avatarUrl = avatarUrl;
this.tts = tts;
}

public void addEmbed(EmbedObject embed) {
this.embeds.add(embed);
}

public void execute() throws IOException {
if (this.embeds.isEmpty()) {
throw new RuntimeException("컨텐츠를 설정하거나 하나 이상의 Embed Object를 추가해야 합니다.");
}

try {
ApiCallUtil.callDiscordAppenderPostAPI(
this.urlString, createDiscordEmbedObject(
this.embeds, initializerDiscordSendForJsonObject(new JsonObject())
));

} catch (IOException ioException) {
throw ioException;
}
}

private JsonObject initializerDiscordSendForJsonObject(JsonObject json) {
json.put("username", this.username);
json.put("avatar_url", this.avatarUrl);
json.put("tts", this.tts);
return json;
}

private JsonObject createDiscordEmbedObject(List<EmbedObject> embeds, JsonObject json) {
if (embeds.isEmpty()) {
throw new BusinessException(BusinessErrorCode.DISCORD_WEBHOOK_ERROR);
}

List<JsonObject> embedObjects = new ArrayList<>();

for (EmbedObject embed : embeds) {
JsonObject jsonEmbed = new JsonObject();

jsonEmbed.put("title", embed.getTitle());
jsonEmbed.put("description", embed.getDescription());
jsonEmbed.put("url", embed.getUrl());

processDiscordEmbedColor(embed, jsonEmbed);
processDiscordEmbedFooter(embed.getFooter(), jsonEmbed);
processDiscordEmbedImage(embed.getImage(), jsonEmbed);
processDiscordEmbedThumbnail(embed.getThumbnail(), jsonEmbed);
processDiscordEmbedAuthor(embed.getAuthor(), jsonEmbed);
processDiscordEmbedMessageFields(embed.getFields(), jsonEmbed);

embedObjects.add(jsonEmbed);
}
json.put("embeds", embedObjects.toArray());

return json;
}

private void processDiscordEmbedColor(EmbedObject embed, JsonObject jsonEmbed) {
if (embed.getColor() != null) {
Color color = embed.getColor();
int rgb = color.getRed();
rgb = (rgb << 8) + color.getGreen();
rgb = (rgb << 8) + color.getBlue();

jsonEmbed.put("color", rgb);
}
}

private void processDiscordEmbedFooter(Footer footer, JsonObject jsonEmbed) {
if (footer != null) {
JsonObject jsonFooter = new JsonObject();
jsonFooter.put("text", footer.getText());
jsonFooter.put("icon_url", footer.getIconUrl());
jsonEmbed.put("footer", jsonFooter);
}
}

private void processDiscordEmbedImage(Image image, JsonObject jsonEmbed) {
if (image != null) {
JsonObject jsonImage = new JsonObject();
jsonImage.put("url", image.getUrl());
jsonEmbed.put("image", jsonImage);
}
}

private void processDiscordEmbedThumbnail(Thumbnail thumbnail, JsonObject jsonEmbed) {
if (thumbnail != null) {
JsonObject jsonThumbnail = new JsonObject();
jsonThumbnail.put("url", thumbnail.getUrl());
jsonEmbed.put("thumbnail", jsonThumbnail);
}
}

private void processDiscordEmbedAuthor(Author author, JsonObject jsonEmbed) {
if (author != null) {
JsonObject jsonAuthor = new JsonObject();
jsonAuthor.put("name", author.getName());
jsonAuthor.put("url", author.getUrl());
jsonAuthor.put("icon_url", author.getIconUrl());
jsonEmbed.put("author", jsonAuthor);
}
}

private void processDiscordEmbedMessageFields(List<Field> fields, JsonObject jsonEmbed) {
List<JsonObject> jsonFields = new ArrayList<>();

for (Field field : fields) {
JsonObject jsonField = new JsonObject();

jsonField.put("name", field.getName());
jsonField.put("value", field.getValue());
jsonField.put("inline", field.isInline());

jsonFields.add(jsonField);
}

jsonEmbed.put("fields", jsonFields.toArray());
}
}

42 changes: 42 additions & 0 deletions src/main/java/org/kkumulkkum/server/log/filter/MDCFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.kkumulkkum.server.log.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kkumulkkum.server.log.util.HttpRequestUtil;
import org.kkumulkkum.server.log.util.MDCUtil;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.WebUtils;

import java.io.IOException;
import java.util.Objects;

@RequiredArgsConstructor
@Slf4j
@Component
public class MDCFilter extends OncePerRequestFilter {


@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

HttpServletRequest httpReq = WebUtils.getNativeRequest(request, HttpServletRequest.class);

MDCUtil.setJsonValue(MDCUtil.REQUEST_URI_MDC, HttpRequestUtil.getRequestUri(Objects.requireNonNull(httpReq)));
MDCUtil.setJsonValue(MDCUtil.USER_IP_MDC, HttpRequestUtil.getUserIP(Objects.requireNonNull(httpReq)));
MDCUtil.setJsonValue(MDCUtil.HEADER_MAP_MDC, HttpRequestUtil.getHeaderMap(httpReq));
MDCUtil.setJsonValue(MDCUtil.USER_INFO, SecurityContextHolder.getContext().getAuthentication().getPrincipal());
// MDCUtil.setJsonValue(MDCUtil.USER_REQUEST_COOKIES, HttpRequestUtil.getUserCookies(httpReq));
MDCUtil.setJsonValue(MDCUtil.PARAMETER_MAP_MDC, HttpRequestUtil.getParamMap(httpReq));
MDCUtil.setJsonValue(MDCUtil.BODY_MDC, HttpRequestUtil.getBody(httpReq));

filterChain.doFilter(request, response);

}
}
Loading

0 comments on commit 700c076

Please sign in to comment.