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 }