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.
+ *
+ * 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