Skip to content

Commit 927f820

Browse files
committed
SImplified log forwarding by using webhook lib
1 parent c6006f4 commit 927f820

File tree

6 files changed

+52
-223
lines changed

6 files changed

+52
-223
lines changed

application/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ dependencies {
5050
implementation 'org.apache.logging.log4j:log4j-core:2.19.0'
5151
runtimeOnly 'org.apache.logging.log4j:log4j-slf4j18-impl:2.18.0'
5252

53+
implementation 'club.minnced:discord-webhooks:0.8.2'
54+
5355
implementation 'org.jooq:jooq:3.17.2'
5456

5557
implementation 'io.mikael:urlbuilder:2.0.9'
Lines changed: 50 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
11
package org.togetherjava.tjbot.logging.discord;
22

3-
import com.fasterxml.jackson.core.JsonProcessingException;
4-
import com.fasterxml.jackson.databind.ObjectMapper;
3+
import club.minnced.discord.webhook.WebhookClient;
4+
import club.minnced.discord.webhook.send.WebhookEmbed;
5+
import club.minnced.discord.webhook.send.WebhookEmbedBuilder;
6+
import club.minnced.discord.webhook.send.WebhookMessage;
7+
import org.apache.logging.log4j.Level;
58
import org.apache.logging.log4j.core.LogEvent;
69
import org.jetbrains.annotations.NotNull;
710
import org.slf4j.Logger;
811
import org.slf4j.LoggerFactory;
912
import org.togetherjava.tjbot.logging.LogMarkers;
10-
import org.togetherjava.tjbot.logging.discord.api.DiscordLogBatch;
11-
import org.togetherjava.tjbot.logging.discord.api.DiscordLogMessageEmbed;
1213

13-
import javax.annotation.Nullable;
14-
import java.io.IOException;
1514
import java.net.URI;
16-
import java.net.http.HttpClient;
17-
import java.net.http.HttpRequest;
18-
import java.net.http.HttpResponse;
1915
import java.time.Instant;
20-
import java.util.List;
21-
import java.util.PriorityQueue;
22-
import java.util.Queue;
16+
import java.util.*;
2317
import java.util.concurrent.Executors;
2418
import java.util.concurrent.ScheduledExecutorService;
2519
import java.util.concurrent.TimeUnit;
@@ -31,62 +25,50 @@
3125
* Logs are forwarded in correct order, based on their timestamp. They are not forwarded
3226
* immediately, but at a fixed schedule in batches of {@value MAX_BATCH_SIZE} logs.
3327
* <p>
34-
* Failed logs are repeated {@value MAX_RETRIES_UNTIL_DISCARD} times until eventually discarded.
35-
* <p>
3628
* Although unlikely to hit, the class maximally buffers {@value MAX_PENDING_LOGS} logs until
3729
* discarding further logs. Under normal circumstances, the class can easily handle high loads of
3830
* logs.
31+
* <p>
32+
* The class is thread-safe.
3933
*/
4034
final class DiscordLogForwarder {
4135
private static final Logger logger = LoggerFactory.getLogger(DiscordLogForwarder.class);
4236

4337
private static final int MAX_PENDING_LOGS = 10_000;
44-
private static final int MAX_BATCH_SIZE = 10;
45-
private static final int MAX_RETRIES_UNTIL_DISCARD = 3;
46-
private static final int HTTP_STATUS_TOO_MANY_REQUESTS = 429;
47-
private static final int HTTP_STATUS_OK_START = 200;
48-
private static final int HTTP_STATUS_OK_END = 300;
49-
50-
private static final HttpClient CLIENT = HttpClient.newHttpClient();
51-
private static final ObjectMapper JSON = new ObjectMapper();
38+
private static final int MAX_BATCH_SIZE = WebhookMessage.MAX_EMBEDS;
5239
private static final ScheduledExecutorService SERVICE =
5340
Executors.newSingleThreadScheduledExecutor();
54-
5541
/**
56-
* The Discord webhook to send logs to.
42+
* Has to be small enough for fitting all {@value MAX_BATCH_SIZE} embeds contained in a batch
43+
* into the total character length of ~6000.
5744
*/
58-
private final URI webhook;
45+
private static final int MAX_EMBED_DESCRIPTION = 1_000;
46+
private static final Map<Level, Integer> LEVEL_TO_AMBIENT_COLOR =
47+
Map.of(Level.TRACE, 0x00B362, Level.DEBUG, 0x00A5CE, Level.INFO, 0xAC59FF, Level.WARN,
48+
0xDFDF00, Level.ERROR, 0xBF2200, Level.FATAL, 0xFF8484);
49+
50+
private final WebhookClient webhookClient;
5951
/**
6052
* Internal buffer of logs that still have to be forwarded to Discord. Actions are synchronized
6153
* using {@link #pendingLogsLock} to ensure thread safety.
6254
*/
6355
private final Queue<LogMessage> pendingLogs = new PriorityQueue<>();
6456
private final Object pendingLogsLock = new Object();
6557

66-
/**
67-
* If present, a rate limit has been hit and further requests must be made only after this
68-
* moment.
69-
*/
70-
@Nullable
71-
private Instant rateLimitExpiresAt;
72-
/**
73-
* The amount of subsequent failed requests. Requests are tried
74-
* {@value MAX_RETRIES_UNTIL_DISCARD} times until discarded. Resets to 0 once a request was
75-
* successful.
76-
*/
77-
private int currentRetries;
78-
7958
DiscordLogForwarder(URI webhook) {
80-
this.webhook = webhook;
59+
webhookClient = WebhookClient.withUrl(webhook.toString());
8160

8261
SERVICE.scheduleWithFixedDelay(this::processPendingLogs, 5, 5, TimeUnit.SECONDS);
8362
}
8463

64+
8565
/**
8666
* Forwards the given log message to Discord.
8767
* <p>
8868
* Logs are not immediately forwarded, but on a schedule. If the maximal buffer size of
8969
* {@value MAX_PENDING_LOGS} is exceeded, logs are discarded.
70+
* <p>
71+
* This method is thread-safe.
9072
*
9173
* @param event the log to forward
9274
*/
@@ -108,129 +90,65 @@ void forwardLogEvent(LogEvent event) {
10890

10991
private void processPendingLogs() {
11092
try {
111-
if (handleIsRateLimitActive()) {
112-
// Try again later
113-
return;
114-
}
115-
11693
// Process batch
11794
List<LogMessage> logsToProcess = pollLogsToProcessBatch();
11895
if (logsToProcess.isEmpty()) {
11996
return;
12097
}
12198

122-
List<DiscordLogMessageEmbed> embeds =
123-
logsToProcess.stream().map(LogMessage::embed).toList();
124-
DiscordLogBatch logBatch = new DiscordLogBatch(embeds);
125-
126-
LogSendResult result = sendLogBatch(logBatch);
127-
if (result == LogSendResult.SUCCESS) {
128-
currentRetries = 0;
129-
} else {
130-
currentRetries++;
131-
if (currentRetries <= MAX_RETRIES_UNTIL_DISCARD) {
132-
synchronized (pendingLogsLock) {
133-
// Try again later
134-
pendingLogs.addAll(logsToProcess);
135-
}
136-
} else {
137-
logger.warn(LogMarkers.NO_DISCORD, """
138-
A log batch keeps failing forwarding to Discord. \
139-
Discarding the problematic batch.""");
140-
}
141-
}
99+
List<WebhookEmbed> logBatch = logsToProcess.stream().map(LogMessage::embed).toList();
100+
101+
webhookClient.send(logBatch);
142102
} catch (Exception e) {
143103
logger.warn(LogMarkers.NO_DISCORD,
144104
"Unknown error when forwarding pending logs to Discord.", e);
145105
}
146106
}
147107

148-
private boolean handleIsRateLimitActive() {
149-
if (rateLimitExpiresAt == null) {
150-
return false;
151-
}
152-
153-
if (Instant.now().isAfter(rateLimitExpiresAt)) {
154-
// Rate limit has expired, reset
155-
rateLimitExpiresAt = null;
156-
}
157-
158-
return true;
159-
}
160-
161108
private List<LogMessage> pollLogsToProcessBatch() {
162109
int batchSize = Math.min(pendingLogs.size(), MAX_BATCH_SIZE);
163110
synchronized (pendingLogsLock) {
164111
return Stream.generate(pendingLogs::remove).limit(batchSize).toList();
165112
}
166113
}
167114

168-
private LogSendResult sendLogBatch(DiscordLogBatch logBatch) {
169-
String rawPayload;
170-
try {
171-
rawPayload = JSON.writeValueAsString(logBatch);
172-
} catch (JsonProcessingException e) {
173-
throw new IllegalArgumentException("Unable to write JSON for log batch.", e);
174-
}
115+
private record LogMessage(WebhookEmbed embed,
116+
Instant timestamp) implements Comparable<LogMessage> {
175117

176-
HttpRequest request = HttpRequest.newBuilder(webhook)
177-
.header("Content-Type", "application/json")
178-
.POST(HttpRequest.BodyPublishers.ofString(rawPayload))
179-
.build();
118+
private static LogMessage ofEvent(LogEvent event) {
119+
String authorName = event.getLoggerName();
120+
String title = event.getLevel().name();
121+
int colorDecimal = Objects.requireNonNull(LEVEL_TO_AMBIENT_COLOR.get(event.getLevel()));
122+
String description =
123+
abbreviate(event.getMessage().getFormattedMessage(), MAX_EMBED_DESCRIPTION);
124+
Instant timestamp = Instant.ofEpochMilli(event.getInstant().getEpochMillisecond());
180125

181-
try {
182-
HttpResponse<String> response =
183-
CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
184-
185-
if (response.statusCode() == HTTP_STATUS_TOO_MANY_REQUESTS) {
186-
response.headers()
187-
.firstValueAsLong("Retry-After")
188-
.ifPresent(retryAfterSeconds -> rateLimitExpiresAt =
189-
Instant.now().plusSeconds(retryAfterSeconds));
190-
logger.debug(LogMarkers.NO_DISCORD,
191-
"Hit rate limits when trying to forward log batch to Discord.");
192-
return LogSendResult.RATE_LIMIT;
193-
}
194-
if (response.statusCode() < HTTP_STATUS_OK_START
195-
|| response.statusCode() >= HTTP_STATUS_OK_END) {
196-
if (logger.isWarnEnabled()) {
197-
logger.warn(LogMarkers.NO_DISCORD,
198-
"Discord webhook API responded with {} when forwarding log batch. Body: {}",
199-
response.statusCode(), response.body());
200-
}
201-
return LogSendResult.ERROR;
202-
}
126+
WebhookEmbed embed = new WebhookEmbedBuilder()
127+
.setAuthor(new WebhookEmbed.EmbedAuthor(authorName, null, null))
128+
.setTitle(new WebhookEmbed.EmbedTitle(title, null))
129+
.setDescription(description)
130+
.setColor(colorDecimal)
131+
.setTimestamp(timestamp)
132+
.build();
203133

204-
return LogSendResult.SUCCESS;
205-
} catch (IOException e) {
206-
logger.warn(LogMarkers.NO_DISCORD, "Unknown error when sending log batch to Discord.",
207-
e);
208-
return LogSendResult.ERROR;
209-
} catch (InterruptedException e) {
210-
Thread.currentThread().interrupt();
211-
return LogSendResult.ERROR;
134+
return new LogMessage(embed, timestamp);
212135
}
213-
}
214136

215-
private record LogMessage(DiscordLogMessageEmbed embed,
216-
Instant timestamp) implements Comparable<LogMessage> {
217-
private static LogMessage ofEvent(LogEvent event) {
218-
DiscordLogMessageEmbed embed = DiscordLogMessageEmbed.ofEvent(event);
219-
Instant timestamp = Instant.ofEpochMilli(event.getInstant().getEpochMillisecond());
137+
private static String abbreviate(String text, int maxLength) {
138+
if (text.length() < maxLength) {
139+
return text;
140+
}
220141

221-
return new LogMessage(embed, timestamp);
142+
if (maxLength < 3) {
143+
return text.substring(0, maxLength);
144+
}
145+
146+
return text.substring(0, maxLength - 3) + "...";
222147
}
223148

224149
@Override
225150
public int compareTo(@NotNull LogMessage o) {
226151
return timestamp.compareTo(o.timestamp);
227152
}
228153
}
229-
230-
231-
private enum LogSendResult {
232-
SUCCESS,
233-
RATE_LIMIT,
234-
ERROR
235-
}
236154
}

application/src/main/java/org/togetherjava/tjbot/logging/discord/api/DiscordLogBatch.java

Lines changed: 0 additions & 11 deletions
This file was deleted.

application/src/main/java/org/togetherjava/tjbot/logging/discord/api/DiscordLogMessageEmbed.java

Lines changed: 0 additions & 59 deletions
This file was deleted.

application/src/main/java/org/togetherjava/tjbot/logging/discord/api/DiscordLogMessageEmbedAuthor.java

Lines changed: 0 additions & 9 deletions
This file was deleted.

application/src/main/java/org/togetherjava/tjbot/logging/discord/api/package-info.java

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)