diff --git a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java index 5cf2b1f..9bdff49 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -3,11 +3,18 @@ import dev.faststats.bukkit.BukkitMetrics; import dev.faststats.core.Metrics; import dev.faststats.core.chart.Chart; +import dev.faststats.core.ErrorTracker; import org.bukkit.plugin.java.JavaPlugin; import java.net.URI; public class ExamplePlugin extends JavaPlugin { + // context-aware error tracker, automatically tracks errors in the same class loader + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); + + // context-unaware error tracker, does not automatically track errors + public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware(); + private final Metrics metrics = BukkitMetrics.factory() .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only @@ -20,6 +27,10 @@ public class ExamplePlugin extends JavaPlugin { .addChart(Chart.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) .addChart(Chart.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) + // Attach an error tracker + // This must be enabled in the project settings + .errorTracker(ERROR_TRACKER) + .debug(true) // Enable debug mode for development and testing .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project @@ -29,4 +40,13 @@ public class ExamplePlugin extends JavaPlugin { public void onDisable() { metrics.shutdown(); } + + public void doSomethingWrong() { + try { + // Do something that might throw an error + throw new RuntimeException("Something went wrong!"); + } catch (Exception e) { + CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); + } + } } diff --git a/core/src/main/java/dev/faststats/core/ErrorTracker.java b/core/src/main/java/dev/faststats/core/ErrorTracker.java new file mode 100644 index 0000000..4cd62c2 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/ErrorTracker.java @@ -0,0 +1,119 @@ +package dev.faststats.core; + +import dev.faststats.core.concurrent.TrackingExecutors; +import dev.faststats.core.concurrent.TrackingBase; +import dev.faststats.core.concurrent.TrackingThreadFactory; +import dev.faststats.core.concurrent.TrackingThreadPoolExecutor; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +/** + * An error tracker. + * + * @since 0.10.0 + */ +public sealed interface ErrorTracker permits SimpleErrorTracker { + /** + * Create and attach a new context-aware error tracker. + *

+ * This tracker will automatically track errors that occur in the same class loader as the tracker itself. + *

+ * You can still manually track errors using {@code #trackError}. + * + * @return the error tracker + * @see #contextUnaware() + * @see #trackError(String) + * @see #trackError(Throwable) + * @since 0.10.0 + */ + @Contract(value = " -> new") + static ErrorTracker contextAware() { + var tracker = new SimpleErrorTracker(); + tracker.attachErrorContext(ErrorTracker.class.getClassLoader()); + return tracker; + } + + /** + * Create a new context-unaware error tracker. + *

+ * This tracker will not automatically track any errors. + *

+ * You have to manually track errors using {@code #trackError}. + * + * @return the error tracker + * @see #contextAware() + * @see #trackError(String) + * @see #trackError(Throwable) + * @since 0.10.0 + */ + @Contract(value = " -> new") + static ErrorTracker contextUnaware() { + return new SimpleErrorTracker(); + } + + /** + * Tracks an error. + * + * @param message the error message + * @see #trackError(Throwable) + * @since 0.10.0 + */ + @Contract(mutates = "this") + void trackError(String message); + + /** + * Tracks an error. + * + * @param error the error + * @since 0.10.0 + */ + @Contract(mutates = "this") + void trackError(Throwable error); + + /** + * Attaches an error context to the tracker. + * + * @param loader the class loader + * @since 0.10.0 + */ + void attachErrorContext(@Nullable ClassLoader loader); + + /** + * Returns the tracking base. + * + * @return the tracking base + * @since 0.10.0 + */ + @Contract(pure = true) + TrackingBase base(); + + /** + * Returns the tracking equivalent to {@link java.util.concurrent.Executors}. + * + * @return the tracking executors + * @see java.util.concurrent.Executors + * @since 0.10.0 + */ + @Contract(pure = true) + TrackingExecutors executors(); + + /** + * Returns the tracking equivalent to {@link java.util.concurrent.ThreadFactory}. + * + * @return the tracking thread factory + * @see java.util.concurrent.ThreadFactory + * @since 0.10.0 + */ + @Contract(pure = true) + TrackingThreadFactory threadFactory(); + + /** + * Returns the tracking equivalent to {@link java.util.concurrent.ThreadPoolExecutor}. + * + * @return the tracking thread pool executor + * @see java.util.concurrent.ThreadPoolExecutor + * @since 0.10.0 + */ + @Contract(pure = true) + TrackingThreadPoolExecutor threadPoolExecutor(); +} diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/core/Metrics.java index 0ec7b77..e0eb365 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/core/Metrics.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.Contract; import java.net.URI; +import java.util.Optional; import java.util.UUID; /** @@ -23,6 +24,15 @@ public interface Metrics { @Contract(pure = true) String getToken(); + /** + * Get the error tracker for this metrics instance. + * + * @return the error tracker + * @since 0.10.0 + */ + @Contract(pure = true) + Optional getErrorTracker(); + /** * Get the metrics configuration. * @@ -60,6 +70,16 @@ interface Factory { @Contract(mutates = "this") Factory addChart(Chart chart) throws IllegalArgumentException; + /** + * Sets the error tracker for this metrics instance. + * + * @param tracker the error tracker + * @return the metrics factory + * @since 0.10.0 + */ + @Contract(mutates = "this") + Factory errorTracker(ErrorTracker tracker); + /** * Enables or disabled debug mode for this metrics instance. *

diff --git a/core/src/main/java/dev/faststats/core/MurmurHash3.java b/core/src/main/java/dev/faststats/core/MurmurHash3.java new file mode 100644 index 0000000..b26c9bc --- /dev/null +++ b/core/src/main/java/dev/faststats/core/MurmurHash3.java @@ -0,0 +1,182 @@ +package dev.faststats.core; + +import org.jetbrains.annotations.Contract; + +import java.nio.charset.StandardCharsets; + +/** + * Implementation of the MurmurHash3 128-bit hash algorithm. + *

+ * MurmurHash is a non-cryptographic hash function suitable for general hash-based lookup. + * It provides excellent distribution and performance while minimizing collisions. + *

+ *

+ * This implementation follows the MurmurHash3_x64_128 variant as described at: + * https://en.wikipedia.org/wiki/MurmurHash + *

+ *

+ * Original algorithm by Austin Appleby. The name comes from the two elementary operations + * it uses: multiply (MU) and rotate (R). + *

+ */ +final class MurmurHash3 { + /** + * Computes the 128-bit MurmurHash3 hash of the input string. + *

+ * The string is encoded to UTF-8 bytes before hashing. The result is returned + * as an array of two long values (64 bits each), combined they form a 128-bit hash. + *

+ * + * @param data the input string to hash + * @return a 2-element array containing the lower 64 bits at index 0 and upper 64 bits at index 1 + * @see MurmurHash on Wikipedia + */ + @Contract(value = "_ -> new", pure = true) + public static long[] hash(String data) { + var bytes = data.getBytes(StandardCharsets.UTF_8); + var h1 = 0L; + var h2 = 0L; + final var c1 = 0x87c37b91114253d5L; + final var c2 = 0x4cf5ad432745937fL; + var length = bytes.length; + var blocks = length / 16; + + // Process 128-bit blocks + for (int i = 0; i < blocks; i++) { + var k1 = getInt(bytes, i * 16); + var k2 = getInt(bytes, i * 16 + 4); + var k3 = getInt(bytes, i * 16 + 8); + var k4 = getInt(bytes, i * 16 + 12); + + k1 *= (int) c1; + k1 = Integer.rotateLeft(k1, 31); + k1 *= (int) c2; + h1 ^= k1; + + h1 = Long.rotateLeft(h1, 27); + h1 += h2; + h1 = h1 * 5 + 0x52dce729; + + k2 *= (int) c2; + k2 = Integer.rotateLeft(k2, 33); + k2 *= (int) c1; + h2 ^= k2; + + h2 = Long.rotateLeft(h2, 31); + h2 += h1; + h2 = h2 * 5 + 0x38495ab5; + } + + // Tail + var k1 = 0; + var k2 = 0; + var k3 = 0; + var k4 = 0; + var tail = blocks * 16; + + switch (length & 15) { + case 15: + k4 ^= (bytes[tail + 14] & 0xff) << 16; + case 14: + k4 ^= (bytes[tail + 13] & 0xff) << 8; + case 13: + k4 ^= (bytes[tail + 12] & 0xff); + k4 *= (int) c2; + k4 = Integer.rotateLeft(k4, 33); + k4 *= (int) c1; + h2 ^= k4; + case 12: + k3 ^= (bytes[tail + 11] & 0xff) << 24; + case 11: + k3 ^= (bytes[tail + 10] & 0xff) << 16; + case 10: + k3 ^= (bytes[tail + 9] & 0xff) << 8; + case 9: + k3 ^= (bytes[tail + 8] & 0xff); + k3 *= (int) c1; + k3 = Integer.rotateLeft(k3, 31); + k3 *= (int) c2; + h1 ^= k3; + case 8: + k2 ^= (bytes[tail + 7] & 0xff) << 24; + case 7: + k2 ^= (bytes[tail + 6] & 0xff) << 16; + case 6: + k2 ^= (bytes[tail + 5] & 0xff) << 8; + case 5: + k2 ^= (bytes[tail + 4] & 0xff); + k2 *= (int) c2; + k2 = Integer.rotateLeft(k2, 33); + k2 *= (int) c1; + h2 ^= k2; + case 4: + k1 ^= (bytes[tail + 3] & 0xff) << 24; + case 3: + k1 ^= (bytes[tail + 2] & 0xff) << 16; + case 2: + k1 ^= (bytes[tail + 1] & 0xff) << 8; + case 1: + k1 ^= (bytes[tail] & 0xff); + k1 *= (int) c1; + k1 = Integer.rotateLeft(k1, 31); + k1 *= (int) c2; + h1 ^= k1; + } + + // Finalization + h1 ^= length; + h2 ^= length; + + h1 += h2; + h2 += h1; + + h1 = fmix64(h1); + h2 = fmix64(h2); + + h1 += h2; + h2 += h1; + + return new long[]{h1, h2}; + } + + /** + * Finalization mix function to avalanche the bits in the hash. + *

+ * This function improves the distribution of the hash by XORing and multiplying + * with carefully chosen constants, ensuring that similar inputs produce very + * different outputs (avalanche effect). + *

+ * + * @param k the 64-bit value to mix + * @return the mixed 64-bit value + * @see MurmurHash Algorithm on Wikipedia + */ + @Contract(pure = true) + private static long fmix64(long k) { + k ^= k >>> 33; + k *= 0xff51afd7ed558ccdL; + k ^= k >>> 33; + k *= 0xc4ceb9fe1a85ec53L; + k ^= k >>> 33; + return k; + } + + /** + * Reads a 32-bit little-endian integer from the byte array at the specified offset. + *

+ * This helper method extracts four consecutive bytes and combines them into a + * single integer using little-endian byte order. + *

+ * + * @param bytes the byte array to read from + * @param offset the starting index in the byte array (must have at least 4 bytes from offset) + * @return the 32-bit integer value read in little-endian order + */ + @Contract(pure = true) + private static int getInt(byte[] bytes, int offset) { + return (bytes[offset] & 0xff) | + ((bytes[offset + 1] & 0xff) << 8) | + ((bytes[offset + 2] & 0xff) << 16) | + ((bytes[offset + 3] & 0xff) << 24); + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java new file mode 100644 index 0000000..2349b26 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java @@ -0,0 +1,231 @@ +package dev.faststats.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.faststats.core.concurrent.TrackingBase; +import dev.faststats.core.concurrent.TrackingExecutors; +import dev.faststats.core.concurrent.TrackingThreadFactory; +import dev.faststats.core.concurrent.TrackingThreadPoolExecutor; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +final class SimpleErrorTracker implements ErrorTracker { + private final int stackTraceLimit = Integer.getInteger("faststats.stack-trace-limit", 15); + private final Map collected = new ConcurrentHashMap<>(); + private final Map reports = new ConcurrentHashMap<>(); + + private final TrackingBase base = new SimpleTrackingBase(this); + private final TrackingExecutors executors = new SimpleTrackingExecutors(this); + private final TrackingThreadFactory threadFactory = new SimpleTrackingThreadFactory(this); + private final TrackingThreadPoolExecutor threadPoolExecutor = new SimpleTrackingThreadPoolExecutor(this); + + @Override + public void trackError(String message) { + trackError(new RuntimeException(message)); + } + + @Override + public void trackError(Throwable error) { + var compile = compile(error, null); + var hashed = hash(compile); + if (collected.compute(hashed, (k, v) -> { + return v == null ? 1 : v + 1; + }) > 1) return; + reports.put(hashed, compile); + } + + private String hash(JsonObject report) { + long[] hash = MurmurHash3.hash(report.toString()); + return Long.toHexString(hash[0]) + Long.toHexString(hash[1]); + } + + private JsonObject compile(Throwable error, @Nullable List suppress) { + var stack = Arrays.asList(error.getStackTrace()); + var list = new ArrayList<>(stack); + if (suppress != null) list.removeAll(suppress); + + var traces = Math.min(list.size(), stackTraceLimit); + + var report = new JsonObject(); + var stacktrace = new JsonArray(traces); + + for (var i = 0; i < traces; i++) { + stacktrace.add(list.get(i).toString()); + } + if (traces > 0 && traces < list.size()) { + stacktrace.add("and " + (list.size() - traces) + " more..."); + } else { + var i = stack.size() - list.size(); + if (i > 0) stacktrace.add("Omitted " + i + " duplicate stack frame" + (i == 1 ? "" : "s")); + } + + report.addProperty("error", error.getClass().getName()); + if (error.getMessage() != null) { + report.addProperty("message", anonymize(error.getMessage())); + } + if (!stacktrace.isEmpty()) { + report.add("stack", stacktrace); + } + if (error.getCause() != null) { + var toSuppress = new ArrayList<>(stack); + if (suppress != null) toSuppress.addAll(suppress); + report.add("cause", compile(error.getCause(), toSuppress)); + } + + return report; + } + + private static final String IPV4_PATTERN = + "\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b"; + private static final String IPV6_PATTERN = + "(?i)\\b([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\\b|" + // Full form + "(?i)\\b([0-9a-f]{1,4}:){1,7}:\\b|" + // Trailing :: + "(?i)\\b([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\\b|" + // :: in middle (1 group after) + "(?i)\\b([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\\b|" + // :: in middle (2 groups after) + "(?i)\\b([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\\b|" + // :: in middle (3 groups after) + "(?i)\\b([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\\b|" + // :: in middle (4 groups after) + "(?i)\\b([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\\b|" + // :: in middle (5 groups after) + "(?i)\\b[0-9a-f]{1,4}:(:[0-9a-f]{1,4}){1,6}\\b|" + // :: in middle (6 groups after) + "(?i)\\b:(:[0-9a-f]{1,4}){1,7}\\b|" + // Leading :: + "(?i)\\b::([0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\\b|" + // :: at start + "(?i)\\b::\\b"; // Just :: + private static final String USER_HOME_PATH_PATTERN = + "(/home/)[^/\\s]+" + // Linux: /home/username + "|(/Users/)[^/\\s]+" + // macOS: /Users/username + "|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"; // Windows: A-Z:\\Users\\username + + private String anonymize(String message) { + message = message.replaceAll(IPV4_PATTERN, "[IP hidden]"); + message = message.replaceAll(IPV6_PATTERN, "[IP hidden]"); + message = message.replaceAll(USER_HOME_PATH_PATTERN, "$1$2$3[username hidden]"); + var username = System.getProperty("user.name"); + if (username != null) message = message.replace(username, "[username hidden]"); + return message; + } + + public JsonArray getData() { + var report = new JsonArray(reports.size()); + + reports.forEach((hash, object) -> { + var copy = object.deepCopy(); + copy.addProperty("hash", hash); + var count = collected.getOrDefault(hash, 1); + if (count > 1) copy.addProperty("count", count); + report.add(copy); + }); + + collected.forEach((hash, count) -> { + if (count <= 0 || reports.containsKey(hash)) return; + var entry = new JsonObject(); + + entry.addProperty("hash", hash); + if (count > 1) entry.addProperty("count", count); + + report.add(entry); + }); + + return report; + } + + public void clear() { + collected.replaceAll((k, v) -> 0); + reports.clear(); + } + + @Override + public void attachErrorContext(@Nullable ClassLoader loader) { + var handler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { + if (handler != null) handler.uncaughtException(thread, error); + if (loader != null && !isSameLoader(loader, error)) return; + trackError(error); + }); + } + + @Override + public TrackingBase base() { + return base; + } + + @Override + public TrackingExecutors executors() { + return executors; + } + + @Override + public TrackingThreadFactory threadFactory() { + return threadFactory; + } + + @Override + public TrackingThreadPoolExecutor threadPoolExecutor() { + return threadPoolExecutor; + } + + private boolean isSameLoader(final ClassLoader loader, final Throwable error) { + var stackTrace = error.getStackTrace(); + if (stackTrace == null || stackTrace.length == 0) { + return false; + } + + var firstNonLibraryIndex = findFirstNonLibraryFrameIndex(stackTrace); + if (firstNonLibraryIndex == -1) { + return false; + } + + var framesToCheck = Math.min(5, stackTrace.length - firstNonLibraryIndex); + + for (var i = 0; i < framesToCheck; i++) { + var frame = stackTrace[firstNonLibraryIndex + i]; + if (isLibraryClass(frame.getClassName())) { + continue; + } + if (!isFromLoader(frame, loader)) { + return false; + } + } + + return true; + } + + private int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { + for (var i = 0; i < stackTrace.length; i++) { + if (!isLibraryClass(stackTrace[i].getClassName())) { + return i; + } + } + return -1; + } + + private boolean isLibraryClass(final String className) { + return className.startsWith("java.") + || className.startsWith("javax.") + || className.startsWith("sun.") + || className.startsWith("com.sun.") + || className.startsWith("jdk."); + } + + private boolean isFromLoader(final StackTraceElement frame, final ClassLoader loader) { + try { + var clazz = Class.forName(frame.getClassName(), false, loader); + return isSameClassLoader(clazz.getClassLoader(), loader); + } catch (ClassNotFoundException e) { + return false; + } + } + + private boolean isSameClassLoader(final ClassLoader classLoader, final ClassLoader loader) { + if (classLoader == loader) return true; + + var current = classLoader; + while (current != null && current != loader) { + current = current.getParent(); + } + return loader == current; + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 9f0e857..a713133 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -17,7 +17,6 @@ import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -33,6 +32,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.GZIPOutputStream; +import static java.nio.charset.StandardCharsets.UTF_8; + public abstract class SimpleMetrics implements Metrics { protected static final String ONBOARDING_MESSAGE = """ This plugin uses FastStats to collect anonymous usage statistics. @@ -52,23 +53,34 @@ public abstract class SimpleMetrics implements Metrics { private final Set> charts; private final Config config; private final @Token String token; + private final @Nullable ErrorTracker tracker; private final URI url; private final boolean debug; @Contract(mutates = "io") @SuppressWarnings("PatternValidation") - protected SimpleMetrics(SimpleMetrics.Factory factory, Path config) throws IllegalStateException { + protected SimpleMetrics(Factory factory, Path config) throws IllegalStateException { if (factory.token == null) throw new IllegalStateException("Token must be specified"); this.charts = Set.copyOf(factory.charts); this.config = new Config(config); this.debug = factory.debug || Boolean.getBoolean("faststats.debug") || this.config.debug(); this.token = factory.token; + this.tracker = factory.tracker; this.url = factory.url; + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + shutdown(); + submitAsync().join(); + } catch (Exception e) { + error("Failed to submit metrics on shutdown", e); + } + }, "metrics-shutdown-thread " + getClass().getName())); } @VisibleForTesting - protected SimpleMetrics(Config config, Set> charts, @Token String token, URI url, boolean debug) { + protected SimpleMetrics(Config config, Set> charts, @Token String token, @Nullable ErrorTracker tracker, URI url, boolean debug) { if (!token.matches(Token.PATTERN)) { throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); } @@ -77,6 +89,7 @@ protected SimpleMetrics(Config config, Set> charts, @Token String token this.config = config; this.debug = debug; this.token = token; + this.tracker = tracker; this.url = url; } @@ -144,7 +157,7 @@ protected boolean isSubmitting() { protected CompletableFuture submitAsync() throws IOException { var data = createData().toString(); - var bytes = data.getBytes(StandardCharsets.UTF_8); + var bytes = data.getBytes(UTF_8); info("Uncompressed data: " + data); @@ -168,12 +181,13 @@ protected CompletableFuture submitAsync() throws IOException { .build(); info("Sending metrics to: " + url); - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)).thenApply(response -> { + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(UTF_8)).thenApply(response -> { var statusCode = response.statusCode(); var body = response.body(); if (statusCode >= 200 && statusCode < 300) { info("Metrics submitted with status code: " + statusCode + " (" + body + ")"); + getErrorTracker().map(SimpleErrorTracker.class::cast).ifPresent(SimpleErrorTracker::clear); return true; } else if (statusCode >= 300 && statusCode < 400) { warn("Received redirect response from metrics server: " + statusCode + " (" + body + ")"); @@ -212,15 +226,25 @@ protected JsonObject createData() { this.charts.forEach(chart -> { try { chart.getData().ifPresent(chartData -> charts.add(chart.getId(), chartData)); - } catch (Throwable e) { - error("Failed to build chart data: " + chart.getId(), e); + } catch (Throwable t) { + error("Failed to build chart data: " + chart.getId(), t); + getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); } }); - appendDefaultData(charts); + try { + appendDefaultData(charts); + } catch (Throwable t) { + error("Failed to append default data", t); + getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); + } - data.addProperty("server_id", config.serverId().toString()); + data.addProperty("identifier", config.serverId().toString()); data.add("data", charts); + + getErrorTracker().map(SimpleErrorTracker.class::cast) + .map(SimpleErrorTracker::getData) + .ifPresent(errors -> data.add("errors", errors)); return data; } @@ -229,6 +253,11 @@ protected JsonObject createData() { return token; } + @Override + public Optional getErrorTracker() { + return Optional.ofNullable(tracker); + } + @Override public Metrics.Config getConfig() { return config; @@ -270,6 +299,7 @@ public void shutdown() { public abstract static class Factory implements Metrics.Factory { private final Set> charts = new HashSet<>(0); private URI url = URI.create("https://metrics.faststats.dev/v1/collect"); + private @Nullable ErrorTracker tracker; private @Nullable String token; private boolean debug = false; @@ -279,6 +309,12 @@ public Metrics.Factory addChart(Chart chart) throws IllegalArgumentExcepti return this; } + @Override + public Metrics.Factory errorTracker(ErrorTracker tracker) { + this.tracker = tracker; + return this; + } + @Override public Metrics.Factory debug(boolean enabled) { this.debug = enabled; @@ -360,7 +396,7 @@ public boolean debug() { private static Optional readOrEmpty(Path file) { if (!Files.isRegularFile(file)) return Optional.empty(); - try (var reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + try (var reader = Files.newBufferedReader(file, UTF_8)) { var properties = new Properties(); properties.load(reader); return Optional.of(properties); @@ -372,7 +408,7 @@ private static Optional readOrEmpty(Path file) { private static void save(Path file, UUID serverId, boolean enabled, boolean debug) throws IOException { Files.createDirectories(file.getParent()); try (var out = Files.newOutputStream(file); - var writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { + var writer = new OutputStreamWriter(out, UTF_8)) { var properties = new Properties(); properties.setProperty("serverId", serverId.toString()); diff --git a/core/src/main/java/dev/faststats/core/SimpleTrackingBase.java b/core/src/main/java/dev/faststats/core/SimpleTrackingBase.java new file mode 100644 index 0000000..00da8da --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleTrackingBase.java @@ -0,0 +1,50 @@ +package dev.faststats.core; + +import dev.faststats.core.concurrent.TrackingBase; + +import java.security.PrivilegedAction; +import java.security.PrivilegedExceptionAction; + +final class SimpleTrackingBase implements TrackingBase { + private final ErrorTracker tracker; + + public SimpleTrackingBase(ErrorTracker tracker) { + this.tracker = tracker; + } + + @Override + public Runnable tracked(Runnable runnable) { + return () -> { + try { + runnable.run(); + } catch (Throwable error) { + tracker.trackError(error); + throw error; + } + }; + } + + @Override + public PrivilegedAction tracked(PrivilegedAction action) { + return () -> { + try { + return action.run(); + } catch (Throwable error) { + tracker.trackError(error); + throw error; + } + }; + } + + @Override + public PrivilegedExceptionAction tracked(PrivilegedExceptionAction action) { + return () -> { + try { + return action.run(); + } catch (Throwable error) { + tracker.trackError(error); + throw error; + } + }; + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleTrackingExecutors.java b/core/src/main/java/dev/faststats/core/SimpleTrackingExecutors.java new file mode 100644 index 0000000..e51dc00 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleTrackingExecutors.java @@ -0,0 +1,127 @@ +package dev.faststats.core; + +import dev.faststats.core.concurrent.TrackingExecutors; + +import java.security.PrivilegedAction; +import java.security.PrivilegedExceptionAction; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +final class SimpleTrackingExecutors implements TrackingExecutors { + private final ErrorTracker tracker; + + public SimpleTrackingExecutors(ErrorTracker tracker) { + this.tracker = tracker; + } + + @Override + public ExecutorService newFixedThreadPool(int threads) { + return tracker.threadPoolExecutor().create(threads, threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); + } + + @Override + public ExecutorService newWorkStealingPool(int parallelism) { + return Executors.newWorkStealingPool(parallelism); // todo + } + + @Override + public ExecutorService newWorkStealingPool() { + return Executors.newWorkStealingPool(); // todo + } + + @Override + public ExecutorService newFixedThreadPool(int threads, ThreadFactory factory) { + return Executors.newFixedThreadPool(threads, WrappedTrackingThreadFactory.wrap(tracker, factory)); + } + + @Override + public ExecutorService newSingleThreadExecutor() { + return newSingleThreadExecutor(defaultThreadFactory()); + } + + @Override + public ExecutorService newSingleThreadExecutor(ThreadFactory factory) { + return Executors.newSingleThreadExecutor(WrappedTrackingThreadFactory.wrap(tracker, factory)); + } + + @Override + public ExecutorService newCachedThreadPool() { + return tracker.threadPoolExecutor().create(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + } + + @Override + public ExecutorService newCachedThreadPool(ThreadFactory factory) { + return Executors.newCachedThreadPool(WrappedTrackingThreadFactory.wrap(tracker, factory)); + } + + @Override + public ExecutorService newThreadPerTaskExecutor(ThreadFactory factory) { + return Executors.newSingleThreadExecutor(WrappedTrackingThreadFactory.wrap(tracker, factory)); + } + + @Override + public ExecutorService newVirtualThreadPerTaskExecutor() { + return newThreadPerTaskExecutor(Thread.ofVirtual().factory()); + } + + @Override + public ScheduledExecutorService newSingleThreadScheduledExecutor() { + return Executors.newSingleThreadScheduledExecutor(); // todo + } + + @Override + public ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory factory) { + return Executors.newSingleThreadScheduledExecutor(WrappedTrackingThreadFactory.wrap(tracker, factory)); + } + + @Override + public ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { + return Executors.newScheduledThreadPool(corePoolSize); // todo + } + + @Override + public ScheduledExecutorService newScheduledThreadPool(int corePoolSize, ThreadFactory factory) { + return Executors.newScheduledThreadPool(corePoolSize, WrappedTrackingThreadFactory.wrap(tracker, factory)); + } + + @Override + public ExecutorService unconfigurableExecutorService(ExecutorService executor) { + return Executors.unconfigurableExecutorService(executor); // todo + } + + @Override + public ScheduledExecutorService unconfigurableScheduledExecutorService(ScheduledExecutorService executor) { + return Executors.unconfigurableScheduledExecutorService(executor); // todo + } + + @Override + public ThreadFactory defaultThreadFactory() { + return new SimpleTrackingThreadFactory(tracker); + } + + @Override + public Callable callable(Runnable task, T result) { + return Executors.callable(tracker.base().tracked(task), result); + } + + @Override + public Callable callable(Runnable task) { + return Executors.callable(tracker.base().tracked(task)); + } + + @Override + public Callable callable(PrivilegedAction action) { + return Executors.callable(tracker.base().tracked(action)); + } + + @Override + public Callable callable(PrivilegedExceptionAction action) { + return Executors.callable(tracker.base().tracked(action)); + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleTrackingThreadFactory.java b/core/src/main/java/dev/faststats/core/SimpleTrackingThreadFactory.java new file mode 100644 index 0000000..f641971 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleTrackingThreadFactory.java @@ -0,0 +1,32 @@ +package dev.faststats.core; + +import dev.faststats.core.concurrent.TrackingThreadFactory; + +import java.util.concurrent.atomic.AtomicInteger; + +final class SimpleTrackingThreadFactory implements TrackingThreadFactory { + private static final AtomicInteger poolNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final String namePrefix; + private final ErrorTracker tracker; + + public SimpleTrackingThreadFactory(ErrorTracker tracker) { + this.tracker = tracker; + this.group = Thread.currentThread().getThreadGroup(); + this.namePrefix = "tracking-pool-" + poolNumber.getAndIncrement() + "-thread-"; + } + + @Override + public Thread newThread(Runnable runnable) { + return newThread(namePrefix + threadNumber.getAndIncrement(), runnable); + } + + @Override + public Thread newThread(String name, Runnable runnable) { + var thread = new Thread(this.group, tracker.base().tracked(runnable), name, 0L); + if (thread.isDaemon()) thread.setDaemon(false); + if (thread.getPriority() != 5) thread.setPriority(5); + return thread; + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleTrackingThreadPoolExecutor.java b/core/src/main/java/dev/faststats/core/SimpleTrackingThreadPoolExecutor.java new file mode 100644 index 0000000..89f0a3c --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleTrackingThreadPoolExecutor.java @@ -0,0 +1,62 @@ +package dev.faststats.core; + +import dev.faststats.core.concurrent.TrackingThreadPoolExecutor; +import org.jetbrains.annotations.Range; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +final class SimpleTrackingThreadPoolExecutor implements TrackingThreadPoolExecutor { + private final ErrorTracker tracker; + + public SimpleTrackingThreadPoolExecutor(ErrorTracker tracker) { + this.tracker = tracker; + } + + @Override + public ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) { + return new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, tracker.threadFactory()) { + @Override + protected void afterExecute(Runnable r, Throwable t) { + super.afterExecute(r, t); + tracker.trackError(t); + } + }; + } + + @Override + public ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory) { + return new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory) { + @Override + protected void afterExecute(Runnable r, Throwable t) { + super.afterExecute(r, t); + tracker.trackError(t); + } + }; + } + + @Override + public ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler) { + return new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler) { + @Override + protected void afterExecute(Runnable r, Throwable t) { + super.afterExecute(r, t); + tracker.trackError(t); + } + }; + } + + @Override + public ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { + return new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler) { + @Override + protected void afterExecute(Runnable r, Throwable t) { + super.afterExecute(r, t); + tracker.trackError(t); + } + }; + } +} diff --git a/core/src/main/java/dev/faststats/core/WrappedTrackingThreadFactory.java b/core/src/main/java/dev/faststats/core/WrappedTrackingThreadFactory.java new file mode 100644 index 0000000..469f5a3 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/WrappedTrackingThreadFactory.java @@ -0,0 +1,25 @@ +package dev.faststats.core; + +import dev.faststats.core.concurrent.TrackingThreadFactory; + +import java.util.concurrent.ThreadFactory; + +final class WrappedTrackingThreadFactory implements ThreadFactory { + private final ErrorTracker tracker; + private final ThreadFactory factory; + + public WrappedTrackingThreadFactory(ErrorTracker tracker, ThreadFactory factory) { + this.tracker = tracker; + this.factory = factory; + } + + @Override + public Thread newThread(Runnable runnable) { + return factory.newThread(tracker.base().tracked(runnable)); + } + + public static ThreadFactory wrap(ErrorTracker tracker, ThreadFactory factory) { + return factory instanceof TrackingThreadFactory || factory instanceof WrappedTrackingThreadFactory + ? factory : new WrappedTrackingThreadFactory(tracker, factory); + } +} diff --git a/core/src/main/java/dev/faststats/core/concurrent/TrackingBase.java b/core/src/main/java/dev/faststats/core/concurrent/TrackingBase.java new file mode 100644 index 0000000..e20c9c3 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/concurrent/TrackingBase.java @@ -0,0 +1,43 @@ +package dev.faststats.core.concurrent; + +import org.jetbrains.annotations.Contract; + +import java.security.PrivilegedAction; +import java.security.PrivilegedExceptionAction; + +/** + * Provides tracking for various concurrency-related operations. + * + * @since 0.10.0 + */ +public interface TrackingBase { + /** + * Creates a tracked runnable. + * + * @param runnable the runnable + * @return the tracked runnable + * @since 0.10.0 + */ + @Contract(value = "_ -> new", pure = true) + Runnable tracked(Runnable runnable); + + /** + * Creates a tracked action. + * + * @param action the action + * @return the tracked action + * @since 0.10.0 + */ + @Contract(value = "_ -> new", pure = true) + PrivilegedAction tracked(PrivilegedAction action); + + /** + * Creates a tracked exception action. + * + * @param action the exception action + * @return the tracked exception action + * @since 0.10.0 + */ + @Contract(value = "_ -> new", pure = true) + PrivilegedExceptionAction tracked(PrivilegedExceptionAction action); +} diff --git a/core/src/main/java/dev/faststats/core/concurrent/TrackingExecutors.java b/core/src/main/java/dev/faststats/core/concurrent/TrackingExecutors.java new file mode 100644 index 0000000..d7dd437 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/concurrent/TrackingExecutors.java @@ -0,0 +1,143 @@ +package dev.faststats.core.concurrent; + +import org.jetbrains.annotations.ApiStatus; + +import java.security.PrivilegedAction; +import java.security.PrivilegedExceptionAction; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; + +/** + * @see java.util.concurrent.Executors + * @since 0.10.0 + */ +@ApiStatus.NonExtendable +public interface TrackingExecutors { + /** + * @see java.util.concurrent.Executors#newFixedThreadPool(int) + * @since 0.10.0 + */ + ExecutorService newFixedThreadPool(int threads); + + /** + * @see java.util.concurrent.Executors#newWorkStealingPool(int) + * @since 0.10.0 + */ + ExecutorService newWorkStealingPool(int parallelism); + + /** + * @see java.util.concurrent.Executors#newWorkStealingPool() + * @since 0.10.0 + */ + ExecutorService newWorkStealingPool(); + + /** + * @see java.util.concurrent.Executors#newFixedThreadPool(int, ThreadFactory) + * @since 0.10.0 + */ + ExecutorService newFixedThreadPool(int threads, ThreadFactory factory); + + /** + * @see java.util.concurrent.Executors#newSingleThreadExecutor() + * @since 0.10.0 + */ + ExecutorService newSingleThreadExecutor(); + + /** + * @see java.util.concurrent.Executors#newSingleThreadExecutor(ThreadFactory) + * @since 0.10.0 + */ + ExecutorService newSingleThreadExecutor(ThreadFactory factory); + + /** + * @see java.util.concurrent.Executors#newCachedThreadPool() + * @since 0.10.0 + */ + ExecutorService newCachedThreadPool(); + + /** + * @see java.util.concurrent.Executors#newCachedThreadPool(ThreadFactory) + * @since 0.10.0 + */ + ExecutorService newCachedThreadPool(ThreadFactory factory); + + /** + * @see java.util.concurrent.Executors#newThreadPerTaskExecutor(ThreadFactory) + * @since 0.10.0 + */ + ExecutorService newThreadPerTaskExecutor(ThreadFactory factory); + + /** + * @see java.util.concurrent.Executors#newVirtualThreadPerTaskExecutor() + * @since 0.10.0 + */ + ExecutorService newVirtualThreadPerTaskExecutor(); + + /** + * @see java.util.concurrent.Executors#newSingleThreadScheduledExecutor() + * @since 0.10.0 + */ + ScheduledExecutorService newSingleThreadScheduledExecutor(); + + /** + * @see java.util.concurrent.Executors#newSingleThreadScheduledExecutor(ThreadFactory) + * @since 0.10.0 + */ + ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory factory); + + /** + * @see java.util.concurrent.Executors#newScheduledThreadPool(int) + * @since 0.10.0 + */ + ScheduledExecutorService newScheduledThreadPool(int corePoolSize); + + /** + * @see java.util.concurrent.Executors#newScheduledThreadPool(int, ThreadFactory) + * @since 0.10.0 + */ + ScheduledExecutorService newScheduledThreadPool(int corePoolSize, ThreadFactory factory); + + /** + * @see java.util.concurrent.Executors#unconfigurableExecutorService(ExecutorService) + * @since 0.10.0 + */ + ExecutorService unconfigurableExecutorService(ExecutorService executor); + + /** + * @see java.util.concurrent.Executors#unconfigurableScheduledExecutorService(ScheduledExecutorService) + * @since 0.10.0 + */ + ScheduledExecutorService unconfigurableScheduledExecutorService(ScheduledExecutorService executor); + + /** + * @see java.util.concurrent.Executors#defaultThreadFactory() + * @since 0.10.0 + */ + ThreadFactory defaultThreadFactory(); + + /** + * @see java.util.concurrent.Executors#callable(Runnable, Object) + * @since 0.10.0 + */ + Callable callable(Runnable task, T result); + + /** + * @see java.util.concurrent.Executors#callable(Runnable) + * @since 0.10.0 + */ + Callable callable(Runnable task); + + /** + * @see java.util.concurrent.Executors#callable(PrivilegedAction) + * @since 0.10.0 + */ + Callable callable(final PrivilegedAction action); + + /** + * @see java.util.concurrent.Executors#callable(PrivilegedExceptionAction) + * @since 0.10.0 + */ + Callable callable(final PrivilegedExceptionAction action); +} diff --git a/core/src/main/java/dev/faststats/core/concurrent/TrackingThreadFactory.java b/core/src/main/java/dev/faststats/core/concurrent/TrackingThreadFactory.java new file mode 100644 index 0000000..ad0b7f7 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/concurrent/TrackingThreadFactory.java @@ -0,0 +1,42 @@ +package dev.faststats.core.concurrent; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NonNls; + +import java.util.concurrent.ThreadFactory; + +/** + * Creates threads that are tracked by the error tracker. + * + * @see java.util.concurrent.ThreadFactory + * @since 0.10.0 + */ +@ApiStatus.NonExtendable +public interface TrackingThreadFactory extends ThreadFactory { + /** + * Creates a new thread for the given runnable. + *

+ * The resulting thread will be tracked by the error tracker. + * + * @param runnable The runnable to execute in the new thread. + * @return The newly created thread. + * @see ThreadFactory#newThread(Runnable) + * @since 0.10.0 + */ + @Contract(value = "_ -> new", pure = true) + Thread newThread(Runnable runnable); + + /** + * Creates a new named thread for the given runnable. + *

+ * The resulting thread will be tracked by the error tracker. + * + * @param name The name of the new thread. + * @param runnable The runnable to execute in the new thread. + * @return The newly created named thread. + * @since 0.10.0 + */ + @Contract(value = "_, _ -> new", pure = true) + Thread newThread(@NonNls String name, Runnable runnable); +} diff --git a/core/src/main/java/dev/faststats/core/concurrent/TrackingThreadPoolExecutor.java b/core/src/main/java/dev/faststats/core/concurrent/TrackingThreadPoolExecutor.java new file mode 100644 index 0000000..b804037 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/concurrent/TrackingThreadPoolExecutor.java @@ -0,0 +1,83 @@ +package dev.faststats.core.concurrent; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Range; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Creates thread pool executors that are tracked by the error tracker. + * + * @see java.util.concurrent.ThreadPoolExecutor + * @since 0.10.0 + */ +@ApiStatus.NonExtendable +public interface TrackingThreadPoolExecutor { + /** + * Creates a new thread pool executor. + *

+ * The resulting executor will be tracked by the error tracker. + * + * @param corePoolSize The core pool size. + * @param maximumPoolSize The maximum pool size. + * @param keepAliveTime The keep alive time. + * @param unit The time unit. + * @param workQueue The work queue. + * @return The newly created thread pool executor. + * @see ThreadPoolExecutor#ThreadPoolExecutor(int, int, long, TimeUnit, BlockingQueue) + * @since 0.10.0 + */ + ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue); + + /** + * Creates a new thread pool executor. + * + * @param corePoolSize The core pool size. + * @param maximumPoolSize The maximum pool size. + * @param keepAliveTime The keep alive time. + * @param unit The time unit. + * @param workQueue The work queue. + * @param threadFactory The thread factory. + * @return The newly created thread pool executor. + * @see ThreadPoolExecutor#ThreadPoolExecutor(int, int, long, TimeUnit, BlockingQueue, ThreadFactory) + * @since 0.10.0 + */ + ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory); + + /** + * Creates a new thread pool executor. + * + * @param corePoolSize The core pool size. + * @param maximumPoolSize The maximum pool size. + * @param keepAliveTime The keep alive time. + * @param unit The time unit. + * @param workQueue The work queue. + * @param handler The rejected execution handler. + * @return The newly created thread pool executor. + * @see ThreadPoolExecutor#ThreadPoolExecutor(int, int, long, TimeUnit, BlockingQueue, RejectedExecutionHandler) + * @since 0.10.0 + */ + ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler); + + /** + * Creates a new thread pool executor. + *

+ * The resulting executor will be tracked by the error tracker. + * + * @param corePoolSize The core pool size. + * @param maximumPoolSize The maximum pool size. + * @param keepAliveTime The keep alive time. + * @param unit The time unit. + * @param workQueue The work queue. + * @param threadFactory The thread factory. + * @param handler The rejected execution handler. + * @return The newly created thread pool executor. + * @see ThreadPoolExecutor#ThreadPoolExecutor(int, int, long, TimeUnit, BlockingQueue, ThreadFactory, RejectedExecutionHandler) + * @since 0.10.0 + */ + ThreadPoolExecutor create(@Range(from = 0, to = Integer.MAX_VALUE) int corePoolSize, @Range(from = 0, to = Integer.MAX_VALUE) int maximumPoolSize, @Range(from = 0, to = Integer.MAX_VALUE) long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler); +} diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index fd689ee..d9e56a3 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -3,6 +3,7 @@ @NullMarked module dev.faststats.core { exports dev.faststats.core.chart; + exports dev.faststats.core.concurrent; exports dev.faststats.core; requires com.google.gson; diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java new file mode 100644 index 0000000..0387af1 --- /dev/null +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -0,0 +1,44 @@ +package dev.faststats; + +import dev.faststats.core.ErrorTracker; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.CompletionException; + +public class ErrorTrackerTest { + // todo: add redaction tests + // todo: add nesting tests + // todo: add duplicate tests + + @Test + // todo: fix this mess + public void testCompile() throws InterruptedException { + var tracker = ErrorTracker.contextUnaware(); + tracker.attachErrorContext(null); + tracker.trackError("Test error"); + var nestedError = new RuntimeException("Nested error"); + var error = new RuntimeException(null, nestedError); + tracker.trackError(error); + + tracker.trackError("hello my name is david"); + tracker.trackError("/home/MyName/Documents/MyFile.txt"); + tracker.trackError("C:\\Users\\MyName\\AppData\\Local\\Temp"); + tracker.trackError("/Users/MyName/AppData/Local/Temp"); + tracker.trackError("my ipv4 address is 215.223.110.131"); + tracker.trackError("my ipv6 address is f833:be65:65da:975b:4896:88f7:6964:44c0"); + + var deepAsyncError = new RuntimeException("deep async error"); + + var thisIsANiceError = new Thread(() -> { + var nestedAsyncError = new RuntimeException("nested async error", deepAsyncError); + throw new CompletionException("async error", nestedAsyncError); + }); + thisIsANiceError.start(); + thisIsANiceError.join(Duration.ofSeconds(1)); + + Thread.sleep(1000); + + tracker.trackError("Test error"); + } +} diff --git a/core/src/test/java/dev/faststats/MetricsTest.java b/core/src/test/java/dev/faststats/MetricsTest.java index 0f5ca2b..4a29017 100644 --- a/core/src/test/java/dev/faststats/MetricsTest.java +++ b/core/src/test/java/dev/faststats/MetricsTest.java @@ -10,7 +10,7 @@ public class MetricsTest { @Test public void testCreateData() throws IOException { - var mock = new MockMetrics(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", true); + var mock = new MockMetrics(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", null, true); assumeTrue(mock.submitAsync().join(), "For this test to run, the server must be running"); } } diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index cbc0047..377b43e 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -3,6 +3,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.SimpleMetrics; import dev.faststats.core.Token; +import dev.faststats.core.ErrorTracker; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -14,8 +15,8 @@ @NullMarked public class MockMetrics extends SimpleMetrics { - public MockMetrics(UUID serverId, @Token String token, boolean debug) { - super(new SimpleMetrics.Config(serverId, true, debug), Set.of(), token, URI.create("http://localhost:5000/v1/collect"), debug); + public MockMetrics(UUID serverId, @Token String token, @Nullable ErrorTracker tracker, boolean debug) { + super(new SimpleMetrics.Config(serverId, true, debug), Set.of(), token, tracker, URI.create("http://localhost:5000/v1/collect"), debug); } @Override diff --git a/gradle.properties b/gradle.properties index 68b492f..825e6cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=0.9.0 +version=0.10.0