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.helper;
17  
18  import java.util.Date;
19  import java.util.LinkedHashMap;
20  import java.util.Locale;
21  import java.util.concurrent.TimeUnit;
22  import java.util.concurrent.atomic.AtomicInteger;
23  
24  import org.apache.logging.log4j.LogManager;
25  import org.apache.logging.log4j.Logger;
26  import org.codelibs.fess.crawler.entity.UrlQueue;
27  import org.codelibs.fess.taglib.FessFunctions;
28  import org.codelibs.fess.util.ComponentUtil;
29  import org.dbflute.optional.OptionalThing;
30  
31  import com.google.common.cache.CacheBuilder;
32  import com.google.common.cache.CacheLoader;
33  import com.google.common.cache.LoadingCache;
34  
35  import jakarta.annotation.PostConstruct;
36  import jakarta.annotation.PreDestroy;
37  
38  /**
39   * Helper class for managing crawler statistics and performance metrics.
40   * This class provides functionality to track, record, and report statistics
41   * about crawler operations including timing data, performance metrics, and
42   * operational events. It uses an internal cache to maintain statistics
43   * objects and provides methods to begin tracking, record events, and
44   * finalize statistics collection.
45   *
46   */
47  public class CrawlerStatsHelper {
48  
49      /**
50       * Creates a new instance of CrawlerStatsHelper.
51       */
52      public CrawlerStatsHelper() {
53          // Default constructor
54      }
55  
56      /** Logger instance for this class. */
57      private static final Logger logger = LogManager.getLogger(CrawlerStatsHelper.class);
58  
59      /** Key used to store the begin timestamp in statistics objects. */
60      private static final String BEGIN_KEY = "begin";
61  
62      /** Logger instance specifically for outputting crawler statistics. */
63      protected Logger statsLogger = null;
64  
65      /** Name of the logger used for statistics output. */
66      protected String loggerName = "fess.log.crawler.stats";
67  
68      /** Maximum number of statistics objects to cache. */
69      protected long maxCacheSize = 1000;
70  
71      /** Time in milliseconds after which cache entries expire after write. */
72      protected long cacheExpireAfterWrite = 10 * 60 * 1000L;
73  
74      /** Cache for storing statistics objects keyed by crawler object identifiers. */
75      protected LoadingCache<String, StatsObject> statsCache;
76  
77      /**
78       * Initializes the crawler statistics helper.
79       * Sets up the statistics logger and creates the cache for storing
80       * statistics objects with the configured size and expiration settings.
81       */
82      @PostConstruct
83      public void init() {
84          statsLogger = LogManager.getLogger(loggerName);
85          statsCache = CacheBuilder.newBuilder()
86                  .maximumSize(maxCacheSize)
87                  .expireAfterWrite(cacheExpireAfterWrite, TimeUnit.MILLISECONDS)
88                  .build(new CacheLoader<String, StatsObject>() {
89                      @Override
90                      public StatsObject load(final String key) {
91                          return new StatsObject();
92                      }
93                  });
94      }
95  
96      /**
97       * Cleanup method called when the helper is being destroyed.
98       * Logs cache statistics and processes any remaining statistics
99       * objects in the cache before shutdown.
100      */
101     @PreDestroy
102     public void destroy() {
103         if (logger.isDebugEnabled()) {
104             logger.debug("cache stats: {}", statsCache.stats());
105         }
106         statsCache.asMap().entrySet().stream().forEach(e -> {
107             final StatsObject data = e.getValue();
108             final Long begin = data.remove(BEGIN_KEY);
109             if (begin != null) {
110                 printStats(e.getKey(), data, begin, false);
111             }
112         });
113 
114     }
115 
116     /**
117      * Begins statistics tracking for the specified crawler object.
118      * Creates a new statistics object in the cache and starts timing.
119      *
120      * @param keyObj the crawler object to track (UrlQueue, StatsKeyObject, String, or Number)
121      */
122     public void begin(final Object keyObj) {
123         getCacheKey(keyObj).ifPresent(key -> {
124             try {
125                 statsCache.get(key);
126             } catch (final Exception e) {
127                 final StringBuilder buf = createStringBuffer(keyObj, getCurrentTimeMillis());
128                 buf.append('\t').append("action:begin");
129                 buf.append('\t').append("error:").append(escapeValue(e.getLocalizedMessage()).replaceAll("\\s", " "));
130                 log(buf);
131             }
132         });
133     }
134 
135     /**
136      * Records a statistics action for the specified crawler object.
137      *
138      * @param keyObj the crawler object being tracked
139      * @param action the statistics action to record
140      */
141     public void record(final Object keyObj, final StatsAction action) {
142         record(keyObj, action.name().toLowerCase(Locale.ENGLISH));
143     }
144 
145     /**
146      * Records a custom statistics action for the specified crawler object.
147      *
148      * @param keyObj the crawler object being tracked
149      * @param action the custom action name to record
150      */
151     public void record(final Object keyObj, final String action) {
152         getCacheKey(keyObj).ifPresent(key -> {
153             try {
154                 final StatsObject data = statsCache.getIfPresent(key);
155                 if (data != null) {
156                     data.put(escapeValue(action), getCurrentTimeMillis());
157                 }
158             } catch (final Exception e) {
159                 final StringBuilder buf = createStringBuffer(keyObj, getCurrentTimeMillis());
160                 buf.append('\t').append("action:record");
161                 buf.append('\t').append("error:").append(escapeValue(e.getLocalizedMessage()).replaceAll("\\s", " "));
162                 log(buf);
163             }
164         });
165     }
166 
167     /**
168      * Marks statistics tracking as complete for the specified crawler object.
169      * Decrements the reference count and if it reaches zero, removes the
170      * statistics object from cache and outputs the final statistics.
171      *
172      * @param keyObj the crawler object to complete tracking for
173      */
174     public void done(final Object keyObj) {
175         getCacheKey(keyObj).ifPresent(key -> {
176             try {
177                 final StatsObject data = statsCache.getIfPresent(key);
178                 if (data != null && data.decrement() <= 0) {
179                     statsCache.invalidate(key);
180                     final Long begin = data.remove(BEGIN_KEY);
181                     if (begin != null) {
182                         printStats(keyObj, data, begin, true);
183                     }
184                 }
185             } catch (final Exception e) {
186                 final StringBuilder buf = createStringBuffer(keyObj, getCurrentTimeMillis());
187                 buf.append('\t').append("action:done");
188                 buf.append('\t').append("error:").append(escapeValue(e.getLocalizedMessage()).replaceAll("\\s", " "));
189                 log(buf);
190             }
191         });
192     }
193 
194     /**
195      * Discards statistics tracking for the specified crawler object.
196      * Removes the statistics object from cache without outputting statistics.
197      *
198      * @param keyObj the crawler object to discard tracking for
199      */
200     public void discard(final Object keyObj) {
201         getCacheKey(keyObj).ifPresent(key -> {
202             try {
203                 final StatsObject data = statsCache.getIfPresent(key);
204                 if (data != null) {
205                     statsCache.invalidate(key);
206                 }
207             } catch (final Exception e) {
208                 final StringBuilder buf = createStringBuffer(keyObj, getCurrentTimeMillis());
209                 buf.append('\t').append("action:done");
210                 buf.append('\t').append("error:").append(escapeValue(e.getLocalizedMessage()).replaceAll("\\s", " "));
211                 log(buf);
212             }
213         });
214     }
215 
216     /**
217      * Outputs statistics information for a crawler object.
218      *
219      * @param keyObj the crawler object the statistics relate to
220      * @param data the statistics data to output
221      * @param begin the timestamp when tracking began
222      * @param done whether tracking was completed normally
223      */
224     protected void printStats(final Object keyObj, final StatsObject data, final long begin, final boolean done) {
225         final StringBuilder buf = createStringBuffer(keyObj, begin);
226         if (done) {
227             buf.append('\t').append("done:").append(getCurrentTimeMillis() - begin);
228         }
229         data.entrySet()
230                 .stream()
231                 .map(e -> escapeValue(e.getKey()) + ":" + (e.getValue().longValue() - begin))
232                 .map(s -> "\t" + s)
233                 .forEach(s -> buf.append(s));
234         log(buf);
235     }
236 
237     /**
238      * Increments the thread reference count for the specified crawler object.
239      * Used when the same object is being processed on multiple threads.
240      *
241      * @param keyObj the crawler object running on an additional thread
242      */
243     public void runOnThread(final Object keyObj) {
244         getCacheKey(keyObj).ifPresent(key -> {
245             try {
246                 final StatsObject data = statsCache.getIfPresent(key);
247                 if (data != null) {
248                     data.increment();
249                 }
250             } catch (final Exception e) {
251                 final StringBuilder buf = createStringBuffer(keyObj, getCurrentTimeMillis());
252                 buf.append('\t').append("action:record");
253                 buf.append('\t').append("error:").append(escapeValue(e.getLocalizedMessage()).replaceAll("\\s", " "));
254                 log(buf);
255             }
256         });
257     }
258 
259     /**
260      * Gets the current system time in milliseconds.
261      *
262      * @return current time in milliseconds
263      */
264     protected long getCurrentTimeMillis() {
265         return ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
266     }
267 
268     /**
269      * Creates a string buffer for logging statistics information.
270      *
271      * @param keyObj the crawler object
272      * @param time the timestamp to include
273      * @return a StringBuilder with basic log information
274      */
275     private StringBuilder createStringBuffer(final Object keyObj, final long time) {
276         final StringBuilder buf = new StringBuilder(1000);
277         buf.append("url:").append(getUrl(keyObj));
278         buf.append('\t');
279         buf.append("time:").append(FessFunctions.formatDate(new Date(time)));
280         return buf;
281     }
282 
283     /**
284      * Extracts the URL string from a crawler object for logging purposes.
285      *
286      * @param keyObj the crawler object to extract URL from
287      * @return the URL string or a default value if not extractable
288      */
289     protected String getUrl(final Object keyObj) {
290         if (keyObj instanceof final UrlQueue<?> urlQueue) {
291             return escapeValue(urlQueue.getUrl());
292         }
293         if (keyObj instanceof final StatsKeyObject statsKey) {
294             return escapeValue(statsKey.getUrl());
295         }
296         if (keyObj instanceof final String key) {
297             return escapeValue(key);
298         }
299         if (keyObj instanceof final Number key) {
300             return key.toString();
301         }
302         return "-";
303     }
304 
305     /**
306      * Generates a cache key from a crawler object.
307      *
308      * @param keyObj the crawler object to generate key for
309      * @return Optional cache key string, empty if object type not supported
310      */
311     protected OptionalThing<String> getCacheKey(final Object keyObj) {
312         if (keyObj instanceof final UrlQueue<?> urlQueue) {
313             return OptionalThing.of(urlQueue.getId().toString());
314         }
315         if (keyObj instanceof final StatsKeyObject statsKey) {
316             return OptionalThing.of(statsKey.getId());
317         }
318         if (keyObj instanceof final String key) {
319             return OptionalThing.of(key);
320         }
321         if (keyObj instanceof final Number key) {
322             return OptionalThing.of(key.toString());
323         }
324         return OptionalThing.empty();
325     }
326 
327     /**
328      * Escapes special characters in a value string for safe logging.
329      *
330      * @param action the string value to escape
331      * @return the escaped string with tabs replaced by spaces
332      */
333     protected String escapeValue(final String action) {
334         return action.replace('\t', ' ');
335     }
336 
337     /**
338      * Outputs a log message using the statistics logger.
339      *
340      * @param buf the string buffer containing the log message
341      */
342     protected void log(final StringBuilder buf) {
343         statsLogger.info(buf.toString());
344     }
345 
346     /**
347      * Sets the name of the logger used for statistics output.
348      *
349      * @param loggerName the logger name to use
350      */
351     public void setLoggerName(final String loggerName) {
352         this.loggerName = loggerName;
353     }
354 
355     /**
356      * Sets the maximum number of statistics objects to cache.
357      *
358      * @param maxCacheSize the maximum cache size
359      */
360     public void setMaxCacheSize(final long maxCacheSize) {
361         this.maxCacheSize = maxCacheSize;
362     }
363 
364     /**
365      * Sets the cache expiration time after write in milliseconds.
366      *
367      * @param cacheExpireAfterWrite the expiration time in milliseconds
368      */
369     public void setCacheExpireAfterWrite(final long cacheExpireAfterWrite) {
370         this.cacheExpireAfterWrite = cacheExpireAfterWrite;
371     }
372 
373     /**
374      * Key object for statistics tracking that contains an identifier and optional URL.
375      * Used when tracking statistics for objects that don't have built-in URL extraction.
376      */
377     public static class StatsKeyObject {
378 
379         /** Unique identifier for this statistics key object. */
380         private final String id;
381 
382         /** Optional URL associated with this statistics key object. */
383         private String url;
384 
385         /**
386          * Creates a new statistics key object with the specified identifier.
387          *
388          * @param id the unique identifier for this object
389          */
390         public StatsKeyObject(final String id) {
391             this.id = id;
392         }
393 
394         /**
395          * Gets the unique identifier for this statistics key object.
396          *
397          * @return the identifier
398          */
399         public String getId() {
400             return id;
401         }
402 
403         /**
404          * Sets the URL associated with this statistics key object.
405          *
406          * @param url the URL to associate with this object
407          */
408         public void setUrl(final String url) {
409             this.url = url;
410         }
411 
412         /**
413          * Gets the URL associated with this statistics key object.
414          *
415          * @return the URL if set, otherwise returns the identifier
416          */
417         protected String getUrl() {
418             if (url != null) {
419                 return url;
420             }
421             return id;
422         }
423     }
424 
425     /**
426      * Statistics data object that stores timestamped events and maintains reference counting.
427      * Extends LinkedHashMap to store event names mapped to their timestamps.
428      * Includes reference counting for multi-threaded access tracking.
429      */
430     public static class StatsObject extends LinkedHashMap<String, Long> {
431         /** Serial version UID for serialization. */
432         private static final long serialVersionUID = 1L;
433 
434         /** Atomic counter for tracking reference count across multiple threads. */
435         protected final AtomicInteger count;
436 
437         /**
438          * Creates a new statistics object with the current timestamp as the begin time.
439          * Initializes the reference count to 1.
440          */
441         public StatsObject() {
442             put(BEGIN_KEY, ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
443             count = new AtomicInteger(1);
444         }
445 
446         /**
447          * Increments the reference count for this statistics object.
448          *
449          * @return the new reference count after incrementing
450          */
451         public int increment() {
452             return count.incrementAndGet();
453         }
454 
455         /**
456          * Decrements the reference count for this statistics object.
457          *
458          * @return the new reference count after decrementing
459          */
460         public int decrement() {
461             return count.decrementAndGet();
462         }
463     }
464 
465     /**
466      * Enumeration of predefined statistics actions that can be recorded
467      * during crawler operations. Each action represents a specific event
468      * or milestone in the crawling process.
469      */
470     public enum StatsAction {
471         /** Indicates that a URL was successfully accessed. */
472         ACCESSED,
473         /** Indicates that an exception occurred during URL access. */
474         ACCESS_EXCEPTION,
475         /** Indicates that a child URL was discovered. */
476         CHILD_URL,
477         /** Indicates that multiple child URLs were discovered. */
478         CHILD_URLS,
479         /** Indicates that a URL was evaluated for crawling eligibility. */
480         EVALUATED,
481         /** Indicates that a general exception occurred during processing. */
482         EXCEPTION,
483         /** Indicates that processing of a URL has finished. */
484         FINISHED,
485         /** Indicates that content was successfully parsed. */
486         PARSED,
487         /** Indicates that a URL was prepared for crawling. */
488         PREPARED,
489         /** Indicates that a URL redirect was encountered. */
490         REDIRECTED,
491         /** Indicates that a URL was processed completely. */
492         PROCESSED
493     }
494 }