11package  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 ;
58import  org .apache .logging .log4j .core .LogEvent ;
69import  org .jetbrains .annotations .NotNull ;
710import  org .slf4j .Logger ;
811import  org .slf4j .LoggerFactory ;
912import  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 ;
1514import  java .net .URI ;
16- import  java .net .http .HttpClient ;
17- import  java .net .http .HttpRequest ;
18- import  java .net .http .HttpResponse ;
1915import  java .time .Instant ;
20- import  java .util .List ;
21- import  java .util .PriorityQueue ;
22- import  java .util .Queue ;
16+ import  java .util .*;
2317import  java .util .concurrent .Executors ;
2418import  java .util .concurrent .ScheduledExecutorService ;
2519import  java .util .concurrent .TimeUnit ;
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 */ 
4034final  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}
0 commit comments