View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.job;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.time.Instant;
21  import java.time.ZoneId;
22  import java.time.format.DateTimeFormatter;
23  import java.util.ArrayList;
24  import java.util.List;
25  import java.util.Map;
26  import java.util.stream.Collectors;
27  
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.codelibs.core.lang.StringUtil;
31  import org.codelibs.fess.helper.LogNotificationHelper.LogNotificationEvent;
32  import org.codelibs.fess.helper.NotificationHelper;
33  import org.codelibs.fess.helper.SystemHelper;
34  import org.codelibs.fess.mylasta.direction.FessConfig;
35  import org.codelibs.fess.mylasta.mail.LogNotificationPostcard;
36  import org.codelibs.fess.opensearch.client.SearchEngineClient;
37  import org.codelibs.fess.util.ComponentUtil;
38  import org.dbflute.mail.send.hook.SMailCallbackContext;
39  import org.lastaflute.core.mail.Postbox;
40  import org.opensearch.action.bulk.BulkRequestBuilder;
41  import org.opensearch.action.search.SearchResponse;
42  import org.opensearch.index.query.QueryBuilders;
43  import org.opensearch.search.SearchHit;
44  import org.opensearch.search.sort.SortOrder;
45  
46  /**
47   * Job for sending log notifications.
48   */
49  public class LogNotificationJob {
50  
51      /**
52       * Default constructor.
53       */
54      public LogNotificationJob() {
55          // Default constructor
56      }
57  
58      private static final Logger logger = LogManager.getLogger(LogNotificationJob.class);
59  
60      private static final DateTimeFormatter TIMESTAMP_FORMATTER =
61              DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS").withZone(ZoneId.systemDefault());
62  
63      /**
64       * Executes the log notification job.
65       *
66       * @return the execution result
67       */
68      public String execute() {
69          final FessConfig fessConfig = ComponentUtil.getFessConfig();
70  
71          if (!fessConfig.isLogNotificationEnabled()) {
72              return "Log notification disabled.";
73          }
74  
75          if (!fessConfig.hasNotification()) {
76              return "No notification targets configured.";
77          }
78  
79          final SearchEngineClient client = ComponentUtil.getSearchEngineClient();
80          final String indexName = fessConfig.getIndexLogIndex() + ".notification_queue";
81  
82          if (!client.existsIndex(indexName)) {
83              return "No log notifications.";
84          }
85  
86          final int searchSize = fessConfig.getLogNotificationSearchSizeAsInteger();
87          final String hostname = ComponentUtil.getSystemHelper().getHostname();
88          final SearchResponse searchResponse = client.prepareSearch(indexName)
89                  .setQuery(QueryBuilders.termQuery("hostname", hostname))
90                  .setSize(searchSize)
91                  .addSort("timestamp", SortOrder.ASC)
92                  .execute()
93                  .actionGet(fessConfig.getIndexSearchTimeout());
94  
95          final SearchHit[] hits = searchResponse.getHits().getHits();
96          if (hits.length == 0) {
97              return "No log notifications.";
98          }
99  
100         final List<LogNotificationEvent> events = new ArrayList<>();
101         final List<String> docIds = new ArrayList<>();
102         for (final SearchHit hit : hits) {
103             final Map<String, Object> source = hit.getSourceAsMap();
104             events.add(new LogNotificationEvent(((Number) source.get("timestamp")).longValue(), (String) source.get("level"),
105                     (String) source.get("loggerName"), (String) source.get("message"), (String) source.get("throwable")));
106             docIds.add(hit.getId());
107         }
108 
109         final int maxDetailsLength = fessConfig.getLogNotificationMaxDetailsLengthAsInteger();
110         final int maxDisplayEvents = fessConfig.getLogNotificationMaxDisplayEventsAsInteger();
111         final int maxMessageLength = fessConfig.getLogNotificationMaxMessageLengthAsInteger();
112 
113         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
114         final Map<String, List<LogNotificationEvent>> eventsByLevel =
115                 events.stream().collect(Collectors.groupingBy(LogNotificationEvent::getLevel));
116 
117         for (final Map.Entry<String, List<LogNotificationEvent>> entry : eventsByLevel.entrySet()) {
118             final String level = entry.getKey();
119             final List<LogNotificationEvent> levelEvents = entry.getValue();
120             final String details = formatDetails(levelEvents, maxDetailsLength, maxDisplayEvents, maxMessageLength);
121 
122             final String toStrs = fessConfig.getNotificationTo();
123             final String[] toAddresses;
124             if (StringUtil.isNotBlank(toStrs)) {
125                 toAddresses = toStrs.split(",");
126             } else {
127                 toAddresses = StringUtil.EMPTY_STRINGS;
128             }
129 
130             final Postbox postbox = ComponentUtil.getComponent(Postbox.class);
131             final NotificationHelper notificationHelper = ComponentUtil.getNotificationHelper();
132             try {
133                 SMailCallbackContext.setPreparedMessageHookOnThread(notificationHelper::send);
134                 LogNotificationPostcard.droppedInto(postbox, postcard -> {
135                     postcard.setFrom(fessConfig.getMailFromAddress(), fessConfig.getMailFromName());
136                     postcard.addReplyTo(fessConfig.getMailReturnPath());
137                     if (toAddresses.length > 0) {
138                         stream(toAddresses).of(stream -> stream.map(String::trim).forEach(address -> {
139                             postcard.addTo(address);
140                         }));
141                     } else {
142                         postcard.addTo(fessConfig.getMailFromAddress());
143                         postcard.dryrun();
144                     }
145                     postcard.setHostname(systemHelper.getHostname());
146                     postcard.setLevel(level);
147                     postcard.setCount(String.valueOf(levelEvents.size()));
148                     postcard.setInterval(String.valueOf(fessConfig.getLogNotificationIntervalAsInteger()));
149                     postcard.setDetails(details);
150                 });
151             } catch (final Exception e) {
152                 logger.warn("Failed to send log notification.", e);
153             } finally {
154                 SMailCallbackContext.clearPreparedMessageHookOnThread();
155             }
156         }
157 
158         final BulkRequestBuilder bulkDelete = client.prepareBulk();
159         for (final String docId : docIds) {
160             bulkDelete.add(client.prepareDelete().setIndex(indexName).setId(docId));
161         }
162         bulkDelete.execute().actionGet(fessConfig.getIndexSearchTimeout());
163 
164         // Delete any remaining events beyond the search size limit (discard overflow)
165         try {
166             client.deleteByQuery(indexName, QueryBuilders.termQuery("hostname", hostname));
167         } catch (final Exception e) {
168             logger.debug("Failed to delete remaining log notifications.", e);
169         }
170 
171         return "Sent log notifications: " + events.size() + " events.";
172     }
173 
174     /**
175      * Formats a list of log notification events into a human-readable summary string.
176      *
177      * @param events the list of log notification events
178      * @param maxDetailsLength the maximum length of the details string
179      * @param maxDisplayEvents the maximum number of events to display
180      * @param maxMessageLength the maximum length of each log message
181      * @return the formatted details string with summary header and truncated entries
182      */
183     protected String formatDetails(final List<LogNotificationEvent> events, final int maxDetailsLength, final int maxDisplayEvents,
184             final int maxMessageLength) {
185         final int totalCount = events.size();
186         final int displayCount = Math.min(totalCount, maxDisplayEvents);
187         final StringBuilder sb = new StringBuilder();
188         sb.append("Total: ").append(totalCount).append(" event(s)");
189         if (totalCount > displayCount) {
190             sb.append(" (showing ").append(displayCount).append(')');
191         }
192         sb.append("\n\n");
193 
194         for (int i = 0; i < displayCount; i++) {
195             final LogNotificationEvent event = events.get(i);
196             final String timestamp = TIMESTAMP_FORMATTER.format(Instant.ofEpochMilli(event.getTimestamp()));
197             String message = event.getMessage();
198             if (message != null && message.length() > maxMessageLength) {
199                 message = message.substring(0, maxMessageLength) + "...";
200             }
201             sb.append('[')
202                     .append(timestamp)
203                     .append("] ")
204                     .append(event.getLevel())
205                     .append(' ')
206                     .append(event.getLoggerName())
207                     .append(" - ")
208                     .append(message)
209                     .append('\n');
210             if (sb.length() > maxDetailsLength) {
211                 sb.setLength(maxDetailsLength);
212                 break;
213             }
214         }
215 
216         if (totalCount > displayCount) {
217             sb.append("... and ").append(totalCount - displayCount).append(" more\n");
218         }
219 
220         return sb.toString();
221     }
222 }