From 47e38344e1a7b4e9dd28374fea03a178ae4253e3 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 17 Apr 2026 23:20:39 +0200 Subject: [PATCH 001/140] Refactor feature flags --- .../main/java/com/example/ExamplePlugin.java | 55 +---- .../main/java/com/example/ExamplePlugin.java | 42 ++-- core/example/build.gradle.kts | 3 + .../example/ErrorTrackerExample.java | 29 +++ .../faststats/example/FeatureFlagExample.java | 66 +++++ .../faststats/example/MetricTypesExample.java | 14 ++ .../faststats/example/SettingsExample.java | 17 ++ .../java/dev/faststats/core/ErrorTracker.java | 2 +- .../main/java/dev/faststats/core/Metrics.java | 61 ++--- .../java/dev/faststats/core/Settings.java | 120 +++++++++ .../dev/faststats/core/SimpleMetrics.java | 74 +++--- .../dev/faststats/core/SimpleSettings.java | 44 ++++ .../dev/faststats/core/flags/Attributes.java | 113 +++++++++ .../dev/faststats/core/flags/FeatureFlag.java | 141 +++++++++++ .../core/flags/FeatureFlagService.java | 169 +++++++++++++ .../core/flags/SimpleAttributes.java | 34 +++ .../core/flags/SimpleFeatureFlag.java | 103 ++++++++ .../core/flags/SimpleFeatureFlagService.java | 227 ++++++++++++++++++ core/src/main/java/module-info.java | 1 + .../test/java/dev/faststats/MockMetrics.java | 7 +- .../src/main/java/com/example/ExampleMod.java | 37 +-- .../dev/faststats/fabric/FabricMetrics.java | 5 +- .../main/java/com/example/ExamplePlugin.java | 37 +-- settings.gradle.kts | 1 + .../main/java/com/example/ExamplePlugin.java | 38 +-- .../main/java/com/example/ExamplePlugin.java | 38 +-- .../velocity/VelocityMetricsImpl.java | 5 +- 27 files changed, 1195 insertions(+), 288 deletions(-) create mode 100644 core/example/build.gradle.kts create mode 100644 core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java create mode 100644 core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java create mode 100644 core/example/src/main/java/dev/faststats/example/MetricTypesExample.java create mode 100644 core/example/src/main/java/dev/faststats/example/SettingsExample.java create mode 100644 core/src/main/java/dev/faststats/core/Settings.java create mode 100644 core/src/main/java/dev/faststats/core/SimpleSettings.java create mode 100644 core/src/main/java/dev/faststats/core/flags/Attributes.java create mode 100644 core/src/main/java/dev/faststats/core/flags/FeatureFlag.java create mode 100644 core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java create mode 100644 core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java create mode 100644 core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java create mode 100644 core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java 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 2b1d9cf5..296adec6 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,54 +2,28 @@ import dev.faststats.bukkit.BukkitMetrics; import dev.faststats.core.ErrorTracker; +import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import org.bukkit.plugin.java.JavaPlugin; -import java.lang.reflect.InvocationTargetException; -import java.net.URI; -import java.nio.file.AccessDeniedException; import java.util.concurrent.atomic.AtomicInteger; -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() - // Ignore specific errors and messages - .ignoreError(InvocationTargetException.class, "Expected .* but got .*") // Ignored an error with a message - .ignoreError(AccessDeniedException.class); // Ignored a specific error type - - // context-unaware error tracker, does not automatically track errors - public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware() - // Anonymize error messages if required - .anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") // Email addresses - .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") // Bearer tokens in error messages - .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") // AWS access key IDs - .anonymize("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "[uuid hidden]") // UUIDs (e.g. session/user IDs) - .anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); // API keys in query strings - +public final class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BukkitMetrics metrics = BukkitMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first - .addMetric(Metric.number("example_metric", () -> 42)) + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .onFlush(() -> gameCount.set(0)) // Reset game count on flush + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project .create(this); @Override @@ -62,15 +36,6 @@ public void onDisable() { metrics.shutdown(); // safely shut down metrics submission } - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } - } - public void startGame() { gameCount.incrementAndGet(); } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 37d245a9..0c9a1f8b 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -3,37 +3,28 @@ import dev.faststats.bungee.BungeeMetrics; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import net.md_5.bungee.api.plugin.Plugin; -import java.net.URI; +import java.util.concurrent.atomic.AtomicInteger; public class ExamplePlugin extends Plugin { - // 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 AtomicInteger gameCount = new AtomicInteger(); private final Metrics metrics = BungeeMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first - .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .debug(true) // Enable debug mode for development and testing + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project .create(this); @Override @@ -41,12 +32,7 @@ public void onDisable() { metrics.shutdown(); // safely shut down metrics submission } - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } + public void startGame() { + gameCount.incrementAndGet(); } } diff --git a/core/example/build.gradle.kts b/core/example/build.gradle.kts new file mode 100644 index 00000000..eca1ab1c --- /dev/null +++ b/core/example/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":core")) +} diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java new file mode 100644 index 00000000..aa8d6439 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -0,0 +1,29 @@ +package dev.faststats.example; + +import dev.faststats.core.ErrorTracker; + +import java.lang.reflect.InvocationTargetException; +import java.nio.file.AccessDeniedException; + +public final class ErrorTrackerExample { + // Context-aware: automatically tracks uncaught errors from the same class loader + public static final ErrorTracker CONTEXT_AWARE = ErrorTracker.contextAware() + .ignoreError(InvocationTargetException.class, "Expected .* but got .*") + .ignoreError(AccessDeniedException.class); + + // Context-unaware: only tracks errors passed to trackError() manually + public static final ErrorTracker CONTEXT_UNAWARE = ErrorTracker.contextUnaware() + .anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") + .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") + .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") + .anonymize("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "[uuid hidden]") + .anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); + + public static void manualTracking() { + try { + throw new RuntimeException("Something went wrong!"); + } catch (final Exception e) { + CONTEXT_UNAWARE.trackError(e); + } + } +} diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java new file mode 100644 index 00000000..fb0c49de --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -0,0 +1,66 @@ +package dev.faststats.example; + +import dev.faststats.core.Settings; +import dev.faststats.core.flags.Attributes; +import dev.faststats.core.flags.FeatureFlag; +import dev.faststats.core.flags.FeatureFlagService; + +import java.time.Duration; +import java.time.Instant; + +public final class FeatureFlagExample { + private static final Settings SETTINGS = Settings.withToken("YOUR_TOKEN_HERE"); + + public static final FeatureFlagService SERVICE = FeatureFlagService.factory() + .settings(SETTINGS) + .ttl(Duration.ofMinutes(10)) + .attributes(Attributes.create() + .put("version", "1.2.3") + .put("java_version", System.getProperty("java.version")) + .put("java_vendor", System.getProperty("java.vendor"))) + .create(); + + // Define flags with default values + public static final FeatureFlag NEW_COMMANDS = SERVICE.define("new_commands", false); + public static final FeatureFlag COMPRESSION = SERVICE.define("compression", "zstd"); + + public static void usage() { + // Async: waits for the server value to be fetched + NEW_COMMANDS.whenReady().thenAccept(enabled -> { + if (enabled) { + // register new commands + } + }); + + // Non-blocking: returns the cached value if present without triggering a fetch + COMPRESSION.getCached().ifPresent(compression -> { + switch (compression) { + case "zstd": + // default compression + break; + case "lz4": + // experimental compression + break; + default: + break; + } + }); + + // Refresh stale values explicitly when your code decides it is needed + if (COMPRESSION.getExpiration().filter(Instant.now()::isAfter).isPresent()) { + COMPRESSION.fetch(); + } + + // Opt-in/out (requires allow_specific_opt_in on server) + NEW_COMMANDS.optIn().thenAccept(updatedValue -> { + if (updatedValue) { + // react to the updated server value + } + }); + NEW_COMMANDS.optOut().thenAccept(updatedValue -> { + if (!updatedValue) { + // react to the updated server value + } + }); + } +} diff --git a/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java b/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java new file mode 100644 index 00000000..066b5b4c --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java @@ -0,0 +1,14 @@ +package dev.faststats.example; + +import dev.faststats.core.data.Metric; + +public final class MetricTypesExample { + // Single value metrics + public static final Metric PLAYER_COUNT = Metric.number("player_count", () -> 42); + public static final Metric SERVER_VERSION = Metric.string("server_version", () -> "1.0.0"); + public static final Metric MAINTENANCE_MODE = Metric.bool("maintenance_mode", () -> false); + + // Array metrics + public static final Metric INSTALLED_PLUGINS = Metric.stringArray("installed_plugins", () -> new String[]{"WorldEdit", "Essentials"}); + public static final Metric WORLDS = Metric.stringArray("worlds", () -> new String[]{"city", "farmworld", "farmworld_nether", "famrworld_end"}); +} diff --git a/core/example/src/main/java/dev/faststats/example/SettingsExample.java b/core/example/src/main/java/dev/faststats/example/SettingsExample.java new file mode 100644 index 00000000..6dd9bd21 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/SettingsExample.java @@ -0,0 +1,17 @@ +package dev.faststats.example; + +import dev.faststats.core.Settings; + +import java.net.URI; + +public final class SettingsExample { + // Recommended: create settings with just a token + public static final Settings SETTINGS = Settings.withToken("YOUR_TOKEN_HERE"); + + // Or use the factory for full control + public static final Settings ALL_SETTINGS = Settings.factory() + .url(URI.create("https://metrics.example.com/v1/collect")) // only for different metrics servers (mainly for testing) + .debug(true) // Enable debug mode for development and testing + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .create(); +} diff --git a/core/src/main/java/dev/faststats/core/ErrorTracker.java b/core/src/main/java/dev/faststats/core/ErrorTracker.java index 1fe010d0..a02b9284 100644 --- a/core/src/main/java/dev/faststats/core/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/core/ErrorTracker.java @@ -47,7 +47,7 @@ static ErrorTracker contextAware() { * @see #trackError(Throwable) * @since 0.10.0 */ - @Contract(value = " -> new") + @Contract(value = " -> new", pure = true) static ErrorTracker contextUnaware() { return new SimpleErrorTracker(); } diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/core/Metrics.java index 7a60ede1..4dc2f25c 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/core/Metrics.java @@ -1,10 +1,10 @@ package dev.faststats.core; import dev.faststats.core.data.Metric; +import dev.faststats.core.flags.FeatureFlagService; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.net.URI; import java.util.Optional; import java.util.UUID; @@ -15,14 +15,13 @@ */ public interface Metrics { /** - * Get the token used to authenticate with the metrics server and identify the project. + * Get the SDK-wide settings for this metrics instance. * - * @return the metrics token - * @since 0.1.0 + * @return the settings + * @since 0.23.0 */ - @Token @Contract(pure = true) - String getToken(); + Settings getSettings(); /** * Get the error tracker for this metrics instance. @@ -33,6 +32,15 @@ public interface Metrics { @Contract(pure = true) Optional getErrorTracker(); + /** + * Get the feature flag service for this metrics instance. + * + * @return the feature flag service + * @since 0.23.0 + */ + @Contract(pure = true) + Optional getFeatureFlagService(); + /** * Get the metrics configuration. * @@ -49,6 +57,7 @@ public interface Metrics { *

* No-op in most implementations. * + * @apiNote Refer to your {@code Metrics} provider's documentation. * @since 0.14.0 */ default void ready() { @@ -108,44 +117,24 @@ interface Factory> { F errorTracker(ErrorTracker tracker); /** - * Enables or disabled debug mode for this metrics instance. - *

- * If {@link Config#debug()} is enabled, debug logging will be enabled for all metrics instances, - * including this one, regardless of this setting. - *

- * This is only meant for development and testing and should not be enabled in production. + * Sets the feature flag service for this metrics instance. * - * @param enabled whether debug mode is enabled + * @param service the feature flag service * @return the metrics factory - * @since 0.1.0 + * @since 0.23.0 */ @Contract(mutates = "this") - F debug(boolean enabled); + F featureFlagService(FeatureFlagService service); /** - * Sets the token used to authenticate with the metrics server and identify the project. - *

- * This token can be found in the settings of your project under "Your API Token". + * Sets the SDK-wide settings for this metrics instance. * - * @param token the metrics token + * @param settings the settings * @return the metrics factory - * @throws IllegalArgumentException if the token does not match the {@link Token#PATTERN} - * @since 0.1.0 - */ - @Contract(mutates = "this") - F token(@Token String token) throws IllegalArgumentException; - - /** - * Sets the metrics server URL. - *

- * This is only required for self-hosted metrics servers. - * - * @param url the metrics server URL - * @return the metrics factory - * @since 0.1.0 + * @since 0.23.0 */ @Contract(mutates = "this") - F url(URI url); + F settings(Settings settings); /** * Creates a new metrics instance. @@ -154,8 +143,8 @@ interface Factory> { * * @param object a required object as defined by the implementation * @return the metrics instance - * @throws IllegalStateException if the token is not specified - * @see #token(String) + * @throws IllegalStateException if the settings are not specified + * @see #settings(Settings) * @since 0.1.0 */ @Async.Schedule diff --git a/core/src/main/java/dev/faststats/core/Settings.java b/core/src/main/java/dev/faststats/core/Settings.java new file mode 100644 index 00000000..45ff172d --- /dev/null +++ b/core/src/main/java/dev/faststats/core/Settings.java @@ -0,0 +1,120 @@ +package dev.faststats.core; + +import org.jetbrains.annotations.Contract; + +import java.net.URI; + +/** + * SDK-wide settings shared across all FastStats services. + * + * @since 0.23.0 + */ +public sealed interface Settings permits SimpleSettings { + /** + * Creates a new {@link Settings} instance with the given token. + *

+ * This token can be found in the settings of your project under "Your API Token". + * It is used to authenticate with the server and identify the project. + * + * @param token the token + * @return the new settings + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + static Settings withToken(@Token final String token) { + return factory().token(token).create(); + } + + /** + * Create a new factory for building {@link Settings}. + * + * @return a new factory + * @since 0.23.0 + */ + @Contract(value = " -> new", pure = true) + static Factory factory() { + return new SimpleSettings.Factory(); + } + + /** + * The token used to authenticate with the server and identify the project. + * + * @return the token + * @since 0.23.0 + */ + @Token + @Contract(pure = true) + String token(); + + /** + * The server URL. + * + * @return the server URL + * @since 0.23.0 + */ + @Contract(pure = true) + URI url(); + + /** + * Whether debug logging is enabled. + * + * @return {@code true} if debug logging is enabled, {@code false} otherwise + * @since 0.23.0 + */ + @Contract(pure = true) + boolean debug(); + + /** + * A factory for creating {@link Settings} instances. + * + * @since 0.23.0 + */ + sealed interface Factory permits SimpleSettings.Factory { + /** + * Sets the token used to authenticate with the server and identify the project. + *

+ * This token can be found in the settings of your project under "Your API Token". + * + * @param token the token + * @return the factory + * @throws IllegalArgumentException if the token does not match the {@link Token#PATTERN} + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory token(@Token String token) throws IllegalArgumentException; + + /** + * Sets the server URL. + *

+ * This is only required for self-hosted servers. + * + * @param url the server URL + * @return the factory + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory url(URI url); + + /** + * Enables or disables debug logging. + *

+ * This is only meant for development and testing and should not be enabled in production. + * + * @param enabled whether debug logging is enabled + * @return the factory + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory debug(boolean enabled); + + /** + * Creates a new {@link Settings} instance. + * + * @return the settings + * @throws IllegalStateException if the token is not specified + * @since 0.23.0 + */ + @Contract(value = " -> new", pure = true) + Settings create() throws IllegalStateException; + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index bd624bd4..4992d028 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.data.Metric; +import dev.faststats.core.flags.FeatureFlagService; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.MustBeInvokedByOverriders; @@ -12,7 +13,6 @@ import java.io.IOException; import java.io.OutputStreamWriter; import java.net.ConnectException; -import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; @@ -43,10 +43,10 @@ public abstract class SimpleMetrics implements Metrics { private final Set> metrics; private final Config config; - private final @Token String token; + private final Settings settings; private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; - private final URI url; + private final @Nullable FeatureFlagService flagService; private final boolean debug; private final String SDK_NAME; @@ -65,17 +65,16 @@ public abstract class SimpleMetrics implements Metrics { } @Contract(mutates = "io") - @SuppressWarnings("PatternValidation") protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { - if (factory.token == null) throw new IllegalStateException("Token must be specified"); + if (factory.settings == null) throw new IllegalStateException("Settings must be specified"); this.config = config; + this.settings = factory.settings; this.metrics = config.additionalMetrics ? Set.copyOf(factory.metrics) : Set.of(); - this.debug = factory.debug || Boolean.getBoolean("faststats.debug") || config.debug(); - this.token = factory.token; + this.debug = settings.debug() || Boolean.getBoolean("faststats.debug") || config.debug(); this.tracker = config.errorTracking ? factory.tracker : null; this.flush = factory.flush; - this.url = factory.url; + this.flagService = factory.flagService; } @Contract(mutates = "io") @@ -87,23 +86,17 @@ protected SimpleMetrics(final Factory factory, final Path config) throws I protected SimpleMetrics( final Config config, final Set> metrics, - @Token final String token, + final Settings settings, @Nullable final ErrorTracker tracker, - @Nullable final Runnable flush, - final URI url, - final boolean debug + @Nullable final Runnable flush ) { - if (!token.matches(Token.PATTERN)) { - throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } - this.metrics = config.additionalMetrics ? Set.copyOf(metrics) : Set.of(); this.config = config; - this.debug = debug; - this.token = token; + this.debug = settings.debug(); + this.settings = settings; this.tracker = tracker; this.flush = flush; - this.url = url; + this.flagService = null; } protected String getOnboardingMessage() { @@ -205,13 +198,13 @@ private boolean submitNow() throws IOException { .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + settings.token()) .header("User-Agent", "FastStats Metrics " + SDK_NAME + "/" + SDK_VERSION) .timeout(Duration.ofSeconds(3)) - .uri(url) + .uri(settings.url()) .build(); - info("Sending metrics to: " + url); + info("Sending metrics to: " + settings.url()); try { final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); final var statusCode = response.statusCode(); @@ -232,9 +225,9 @@ private boolean submitNow() throws IOException { warn("Received unexpected response from metrics server: " + statusCode + " (" + body + ")"); } } catch (final HttpConnectTimeoutException t) { - error("Metrics submission timed out after 3 seconds: " + url, null); + error("Metrics submission timed out after 3 seconds: " + settings.url(), null); } catch (final ConnectException t) { - error("Failed to connect to metrics server: " + url, null); + error("Failed to connect to metrics server: " + settings.url(), null); } catch (final Throwable t) { error("Failed to submit metrics", t); } @@ -287,8 +280,8 @@ protected JsonObject createData() { } @Override - public @Token String getToken() { - return token; + public Settings getSettings() { + return settings; } @Override @@ -296,6 +289,11 @@ public Optional getErrorTracker() { return Optional.ofNullable(tracker); } + @Override + public Optional getFeatureFlagService() { + return Optional.ofNullable(flagService); + } + @Override public Metrics.Config getConfig() { return config; @@ -324,6 +322,7 @@ protected void info(final String message) { @Override public void shutdown() { + if (flagService != null) flagService.shutdown(); getErrorTracker().ifPresent(ErrorTracker::detachErrorContext); if (executor != null) try { info("Shutting down metrics submission"); @@ -338,11 +337,10 @@ public void shutdown() { public abstract static class Factory> implements Metrics.Factory { private final Set> metrics = new HashSet<>(0); - private URI url = URI.create("https://metrics.faststats.dev/v1/collect"); private @Nullable ErrorTracker tracker; + private @Nullable FeatureFlagService flagService; private @Nullable Runnable flush; - private @Nullable String token; - private boolean debug = false; + private @Nullable Settings settings; @Override @SuppressWarnings("unchecked") @@ -367,25 +365,15 @@ public F errorTracker(final ErrorTracker tracker) { @Override @SuppressWarnings("unchecked") - public F debug(final boolean enabled) { - this.debug = enabled; - return (F) this; - } - - @Override - @SuppressWarnings("unchecked") - public F token(@Token final String token) throws IllegalArgumentException { - if (!token.matches(Token.PATTERN)) { - throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } - this.token = token; + public F featureFlagService(final FeatureFlagService service) { + this.flagService = service; return (F) this; } @Override @SuppressWarnings("unchecked") - public F url(final URI url) { - this.url = url; + public F settings(final Settings settings) { + this.settings = settings; return (F) this; } } diff --git a/core/src/main/java/dev/faststats/core/SimpleSettings.java b/core/src/main/java/dev/faststats/core/SimpleSettings.java new file mode 100644 index 00000000..534a670d --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleSettings.java @@ -0,0 +1,44 @@ +package dev.faststats.core; + +import org.jspecify.annotations.Nullable; + +import java.net.URI; + +record SimpleSettings(@Token String token, URI url, boolean debug) implements Settings { + + static final class Factory implements Settings.Factory { + private static final URI DEFAULT_URL = URI.create("https://metrics.faststats.dev/v1/collect"); + + private URI url = DEFAULT_URL; + private @Nullable String token; + private boolean debug = false; + + @Override + public Settings.Factory token(@Token final String token) throws IllegalArgumentException { + if (!token.matches(Token.PATTERN)) { + throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); + } + this.token = token; + return this; + } + + @Override + public Settings.Factory url(final URI url) { + this.url = url; + return this; + } + + @Override + public Settings.Factory debug(final boolean enabled) { + this.debug = enabled; + return this; + } + + @Override + @SuppressWarnings("PatternValidation") + public Settings create() throws IllegalStateException { + if (token == null) throw new IllegalStateException("Token must be specified"); + return new SimpleSettings(token, url, debug); + } + } +} diff --git a/core/src/main/java/dev/faststats/core/flags/Attributes.java b/core/src/main/java/dev/faststats/core/flags/Attributes.java new file mode 100644 index 00000000..ba0c776e --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/Attributes.java @@ -0,0 +1,113 @@ +package dev.faststats.core.flags; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.Nullable; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Mutable key-value attributes for feature flag targeting. + *

+ * Attributes are sent to the server on each flag fetch + * so that targeting rules can be evaluated server-side. + * + * @since 0.23.0 + */ +public sealed interface Attributes permits SimpleAttributes { + /** + * Create new empty attributes. + * + * @return new attributes + * @since 0.23.0 + */ + @Contract(value = " -> new", pure = true) + static Attributes create() { + return new SimpleAttributes(new ConcurrentHashMap<>()); + } + + /** + * Create new attributes by copying entries from the given source. + * + * @param attributes the source attributes to copy + * @return new attributes containing the copied entries + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + static Attributes copyOf(final Attributes attributes) { + final var entries = ((SimpleAttributes) attributes).attributes(); + return new SimpleAttributes(new ConcurrentHashMap<>(entries)); + } + + /** + * Set a string value. + * + * @param key the key + * @param value the value + * @return these attributes + * @since 0.23.0 + */ + @Contract(value = "_, _ -> this", mutates = "this") + Attributes put(String key, String value); + + /** + * Set a number value. + * + * @param key the key + * @param value the value + * @return these attributes + * @since 0.23.0 + */ + @Contract(value = "_, _ -> this", mutates = "this") + Attributes put(String key, Number value); + + /** + * Set a boolean value. + * + * @param key the key + * @param value the value + * @return these attributes + * @since 0.23.0 + */ + @Contract(value = "_, _ -> this", mutates = "this") + Attributes put(String key, boolean value); + + /** + * Remove a value. + * + * @param key the key + * @return these attributes + * @since 0.23.0 + */ + @Contract(value = "_ -> this", mutates = "this") + Attributes remove(String key); + + /** + * Return an unmodifiable view of all attribute entries. + * + * @return unmodifiable map of attribute entries + * @since 0.23.0 + */ + @Unmodifiable + @Contract(pure = true) + Map entries(); + + /** + * Create new attributes by merging two attribute sets. + *

+ * If both contain the same key, the value from {@code second} takes precedence. + * + * @param first the first attributes + * @param second the second attributes, takes precedence on conflicts + * @return new merged attributes + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + static Attributes join(@Nullable final Attributes first, @Nullable final Attributes second) { + final var attributes = new ConcurrentHashMap(); + if (first instanceof final SimpleAttributes simple) attributes.putAll(simple.attributes()); + if (second instanceof final SimpleAttributes simple) attributes.putAll(simple.attributes()); + return new SimpleAttributes(attributes); + } +} diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java new file mode 100644 index 00000000..e2f275dd --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java @@ -0,0 +1,141 @@ +package dev.faststats.core.flags; + +import org.jetbrains.annotations.Contract; + +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * A feature flag. + *

+ * Feature flags are defined via {@link FeatureFlagService#define} and are bound to + * the service's cache and lifecycle. + * + * @param the flag value type + * @since 0.23.0 + */ +public sealed interface FeatureFlag permits SimpleFeatureFlag { + /** + * Get the flag identifier. + * + * @return the flag id + * @since 0.23.0 + */ + @Contract(pure = true) + String getId(); + + /** + * Returns the class representing the value type of this flag. + * + * @return the value type class + * @since 0.23.0 + */ + @Contract(pure = true) + Class getType(); + + /** + * Get the current cached flag value. + *

+ * This method is non-blocking and never performs a network request. It + * returns {@link Optional#empty()} until a value has been fetched and + * stored locally. + * + * @return the cached value, if present + * @since 0.23.0 + */ + @Contract(pure = true) + Optional getCached(); + + /** + * Get the expiration time for the current cached value. + *

+ * If no value has been cached yet, this returns {@link Optional#empty()}. + * The returned timestamp indicates when the cached value should be treated + * as stale according to the configured TTL. + * + * @return the expiration time of the cached value, if present + * @see #isValid() + * @since 0.23.0 + */ + @Contract(pure = true) + Optional getExpiration(); + + /** + * Returns whether the current cached value is still valid. + *

+ * A value is valid when it is cached and its configured TTL has not yet + * expired. This method is non-blocking and never performs a network + * request. + * + * @return {@code true} if a non-expired cached value is available + * @see #getExpiration() + * @since 0.23.0 + */ + @Contract(pure = true) + boolean isValid(); + + /** + * Return a future that completes with the flag value once it is ready. + *

+ * If the value is valid according to {@link #isValid()}, + * the returned future completes immediately. Otherwise, a new fetch is + * performed and the future completes when the response arrives. + * + * @return a future completing with the flag value + * @see #fetch() + * @since 0.23.0 + */ + CompletableFuture whenReady(); + + /** + * Force a fresh fetch of the flag value from the server. + *

+ * Unlike {@link #whenReady()}, this always performs a server request. + * + * @return a future completing with the latest server value + * @since 0.23.0 + */ + @Contract(mutates = "this") + CompletableFuture fetch(); + + /** + * Request that the server opt in to this flag, then invalidate the local + * value and fetch the current server value again. + *

+ * This sends a {@code POST /v1/flag/opt-in} request. The server determines + * the resulting flag value based on its own conditions. + *

+ * The returned future completes with the updated value after the local + * cache has been reset and the follow-up fetch finishes. + * + * @return a future completing with the updated flag value + * @since 0.23.0 + */ + @Contract(mutates = "this") + CompletableFuture optIn(); + + /** + * Request that the server opt out of this flag, then invalidate the local + * value and fetch the current server value again. + *

+ * This sends a {@code POST /v1/flag/opt-out} request. + *

+ * The returned future completes with the updated value after the local + * cache has been reset and the follow-up fetch finishes. + * + * @return a future completing with the updated flag value + * @since 0.23.0 + */ + @Contract(mutates = "this") + CompletableFuture optOut(); + + /** + * Get the default value for this flag. + * + * @return the default value + * @since 0.23.0 + */ + @Contract(pure = true) + T getDefaultValue(); +} diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java new file mode 100644 index 00000000..cebbed40 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java @@ -0,0 +1,169 @@ +package dev.faststats.core.flags; + +import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; +import org.jetbrains.annotations.Contract; + +import java.time.Duration; + +/** + * A service for managing feature flags. + *

+ * Create an instance using the {@link Factory} and pass it to the metrics factory + * via {@link Metrics.Factory#featureFlagService(FeatureFlagService)}. + * + * @since 0.23.0 + */ +public sealed interface FeatureFlagService permits SimpleFeatureFlagService { + /** + * Create a new {@link FeatureFlagService} with the given settings and default options. + * + * @param settings the SDK-wide settings + * @return a new feature flag service + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + static FeatureFlagService create(final Settings settings) { + return factory().settings(settings).create(); + } + + /** + * Create a new factory for building a {@link FeatureFlagService}. + * + * @return a new factory + * @since 0.23.0 + */ + @Contract(value = " -> new", pure = true) + static Factory factory() { + return new SimpleFeatureFlagService.Factory(); + } + + /** + * Define a boolean feature flag. + * + * @param id the flag identifier + * @param defaultValue the default value + * @return the feature flag + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + FeatureFlag define(String id, boolean defaultValue); + + /** + * Define a boolean feature flag with per-flag targeting attributes. + * + * @param id the flag identifier + * @param defaultValue the default value + * @param attributes the per-flag targeting attributes, merged with the service attributes + * @return the feature flag + * @since 0.23.0 + */ + @Contract(value = "_, _, _ -> new", pure = true) + FeatureFlag define(String id, boolean defaultValue, Attributes attributes); + + /** + * Define a string feature flag. + * + * @param id the flag identifier + * @param defaultValue the default value + * @return the feature flag + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + FeatureFlag define(String id, String defaultValue); + + /** + * Define a string feature flag with per-flag targeting attributes. + * + * @param id the flag identifier + * @param defaultValue the default value + * @param attributes the per-flag targeting attributes, merged with the service attributes + * @return the feature flag + * @since 0.23.0 + */ + @Contract(value = "_, _, _ -> new", pure = true) + FeatureFlag define(String id, String defaultValue, Attributes attributes); + + /** + * Define a number feature flag. + * + * @param id the flag identifier + * @param defaultValue the default value + * @return the feature flag + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + FeatureFlag define(String id, Number defaultValue); + + /** + * Define a number feature flag with per-flag targeting attributes. + * + * @param id the flag identifier + * @param defaultValue the default value + * @param attributes the per-flag targeting attributes, merged with the service attributes + * @return the feature flag + * @since 0.23.0 + */ + @Contract(value = "_, _, _ -> new", pure = true) + FeatureFlag define(String id, Number defaultValue, Attributes attributes); + + /** + * Shuts down the feature flag service. + * + * @since 0.23.0 + */ + @Contract(mutates = "this") + void shutdown(); + + /** + * A factory for creating {@link FeatureFlagService} instances. + * + * @since 0.23.0 + */ + interface Factory { + /** + * Sets the cache time-to-live for flag values. + *

+ * This TTL determines the staleness window reported by + * {@link FeatureFlag#getExpiration()}. Expired cached values remain + * readable until they are explicitly refreshed or invalidated. + * + * @param ttl the cache time-to-live + * @return the factory + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory ttl(Duration ttl); + + /** + * Sets the global targeting attributes for all flags created by this service. + * + * @param attributes the targeting attributes + * @return the factory + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory attributes(Attributes attributes); + + /** + * Sets the SDK-wide settings for this feature flag service. + * + * @param settings the settings + * @return the factory + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory settings(Settings settings); + + /** + * Creates a new {@link FeatureFlagService} instance. + * + * @return the feature flag service + * @throws IllegalStateException if the settings are not specified + * @see #settings(Settings) + * @since 0.23.0 + */ + @Contract(value = " -> new", pure = true) + FeatureFlagService create() throws IllegalStateException; + } +} diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java new file mode 100644 index 00000000..84d1ca49 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java @@ -0,0 +1,34 @@ +package dev.faststats.core.flags; + +import java.util.Map; + +record SimpleAttributes(Map attributes) implements Attributes { + @Override + public Attributes put(final String key, final String value) { + attributes.put(key, value); + return this; + } + + @Override + public Attributes put(final String key, final Number value) { + attributes.put(key, value); + return this; + } + + @Override + public Attributes put(final String key, final boolean value) { + attributes.put(key, value); + return this; + } + + @Override + public Attributes remove(final String key) { + attributes.remove(key); + return this; + } + + @Override + public Map entries() { + return Map.copyOf(attributes); + } +} diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java new file mode 100644 index 00000000..5743f63c --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java @@ -0,0 +1,103 @@ +package dev.faststats.core.flags; + +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +final class SimpleFeatureFlag implements FeatureFlag { + private final SimpleFeatureFlagService service; + + private final String id; + private final T defaultValue; + private final @Nullable Attributes attributes; + + SimpleFeatureFlag( + final String id, + final T defaultValue, + final @Nullable Attributes attributes, + final SimpleFeatureFlagService service + ) { + this.id = id; + this.defaultValue = defaultValue; + this.attributes = attributes; + this.service = service; + service.fetch(this); + } + + @Override + public String getId() { + return id; + } + + @Override + @SuppressWarnings("unchecked") + public Class getType() { + return (Class) defaultValue.getClass(); + } + + @Override + public Optional getCached() { + return service.get(this); + } + + @Override + public Optional getExpiration() { + return service.getExpiration(this); + } + + @Override + public boolean isValid() { + return service.isValid(this); + } + + @Override + public CompletableFuture whenReady() { + return service.whenReady(this); + } + + @Override + public CompletableFuture fetch() { + return service.fetch(this); + } + + @Override + public CompletableFuture optIn() { + return service.optIn(this); + } + + @Override + public CompletableFuture optOut() { + return service.optOut(this); + } + + @Override + public T getDefaultValue() { + return defaultValue; + } + + @Nullable Attributes attributes() { + return attributes; + } + + @Override + public boolean equals(@Nullable final Object o) { + if (o == null || getClass() != o.getClass()) return false; + final SimpleFeatureFlag that = (SimpleFeatureFlag) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "SimpleFeatureFlag{" + + "id='" + id + '\'' + + '}'; + } +} diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java new file mode 100644 index 00000000..d091dd2d --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -0,0 +1,227 @@ +package dev.faststats.core.flags; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.faststats.core.Settings; +import org.jspecify.annotations.Nullable; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +final class SimpleFeatureFlagService implements FeatureFlagService { + private static final Gson GSON = new Gson(); + + private final Settings settings; + private final @Nullable Attributes attributes; + private final Duration ttl; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + private final Map cache = new ConcurrentHashMap<>(); + private final Map fetchTimes = new ConcurrentHashMap<>(); + private final Map> fetchesInProgress = new ConcurrentHashMap<>(); + + SimpleFeatureFlagService( + final Settings settings, + final @Nullable Attributes attributes, + final Duration ttl + ) { + this.settings = settings; + this.attributes = attributes; + this.ttl = ttl; + } + + @SuppressWarnings("unchecked") + Optional get(final SimpleFeatureFlag flag) { + final var cached = cache.get(flag.getId()); + return Optional.ofNullable((T) cached); + } + + @SuppressWarnings("unchecked") + CompletableFuture whenReady(final SimpleFeatureFlag flag) { + final var cached = cache.get(flag.getId()); + if (cached != null && !isExpired(flag)) { + return CompletableFuture.completedFuture((T) cached); + } + return fetch(flag); + } + + @SuppressWarnings("unchecked") + CompletableFuture fetch(final SimpleFeatureFlag flag) { + return (CompletableFuture) fetchesInProgress.computeIfAbsent(flag.getId(), ignored -> createFetch(flag)); + } + + CompletableFuture optIn(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/v1/flag/opt-in"); + } + + CompletableFuture optOut(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/v1/flag/opt-out"); + } + + private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { + invalidate(flag); + + final var requestBody = new JsonObject(); + requestBody.addProperty("projectToken", settings.token()); + requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config + requestBody.addProperty("flag", flag.getId()); + + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + settings.token()) + .timeout(Duration.ofSeconds(3)) + .uri(settings.url().resolve(path)) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Feature flag opt request failed with status " + response.statusCode() + )); + } + return fetch(flag); + }); + } + + void invalidate(final SimpleFeatureFlag flag) { + final var id = flag.getId(); + cache.remove(id); + fetchTimes.remove(id); + } + + Optional getExpiration(final SimpleFeatureFlag flag) { + final var lastFetch = fetchTimes.get(flag.getId()); + if (lastFetch == null) return Optional.empty(); + return Optional.of(Instant.ofEpochMilli(lastFetch).plus(ttl)); + } + + boolean isValid(final SimpleFeatureFlag flag) { + return cache.containsKey(flag.getId()) && !isExpired(flag); + } + + boolean isExpired(final SimpleFeatureFlag flag) { + final var lastFetch = fetchTimes.get(flag.getId()); + if (lastFetch == null) return true; + return System.currentTimeMillis() - lastFetch > ttl.toMillis(); + } + + private CompletableFuture createFetch(final SimpleFeatureFlag flag) { + final var requestBody = new JsonObject(); + requestBody.addProperty("flag", flag.getId()); + + final var mergedAttributes = Attributes.join(attributes, flag.attributes()); + // todo: drop gson + requestBody.add("attributes", GSON.toJsonTree(mergedAttributes)); + + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + settings.token()) + .timeout(Duration.ofSeconds(3)) + .uri(settings.url().resolve("/flags")) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { + if (response.statusCode() >= 200 && response.statusCode() < 300) { + // todo: replace gson with safer read + final var body = GSON.fromJson(response.body(), JsonObject.class); + final var value = body.get("value"); + if (value != null && !value.isJsonNull()) { + cache.put(flag.getId(), toValue(value)); + fetchTimes.put(flag.getId(), System.currentTimeMillis()); + return flag.getType().cast(cache.get(flag.getId())); + } + } + return flag.getDefaultValue(); + }).whenComplete((ignored, throwable) -> fetchesInProgress.remove(flag.getId())); + } + + private Object toValue(final JsonElement element) { + if (element.isJsonPrimitive()) { + final var primitive = element.getAsJsonPrimitive(); + if (primitive.isBoolean()) return primitive.getAsBoolean(); + if (primitive.isNumber()) return primitive.getAsNumber(); + return primitive.getAsString(); + } // todo: guarantee for primitives? + return element.toString(); + } + + @Override + public FeatureFlag define(final String id, final boolean defaultValue) { + return new SimpleFeatureFlag<>(id, defaultValue, null, this); + } + + @Override + public FeatureFlag define(final String id, final boolean defaultValue, final Attributes attributes) { + return new SimpleFeatureFlag<>(id, defaultValue, attributes, this); + } + + @Override + public FeatureFlag define(final String id, final String defaultValue) { + return new SimpleFeatureFlag<>(id, defaultValue, null, this); + } + + @Override + public FeatureFlag define(final String id, final String defaultValue, final Attributes attributes) { + return new SimpleFeatureFlag<>(id, defaultValue, attributes, this); + } + + @Override + public FeatureFlag define(final String id, final Number defaultValue) { + return new SimpleFeatureFlag<>(id, defaultValue, null, this); + } + + @Override + public FeatureFlag define(final String id, final Number defaultValue, final Attributes attributes) { + return new SimpleFeatureFlag<>(id, defaultValue, attributes, this); + } + + @Override + public void shutdown() { + cache.clear(); + fetchTimes.clear(); + fetchesInProgress.clear(); + } + + static final class Factory implements FeatureFlagService.Factory { + private Duration ttl = Duration.ofMinutes(5); + private @Nullable Settings settings; + private @Nullable Attributes attributes; + + @Override + public FeatureFlagService.Factory ttl(final Duration ttl) { + this.ttl = ttl; + return this; + } + + @Override + public FeatureFlagService.Factory attributes(final Attributes attributes) { + this.attributes = attributes; + return this; + } + + @Override + public FeatureFlagService.Factory settings(final Settings settings) { + this.settings = settings; + return this; + } + + @Override + public FeatureFlagService create() throws IllegalStateException { + if (settings == null) throw new IllegalStateException("Settings must be specified"); + return new SimpleFeatureFlagService(settings, attributes, ttl); + } + } +} diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 612834e2..e635f815 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.data; + exports dev.faststats.core.flags; exports dev.faststats.core; requires com.google.gson; diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 3d9df9e5..01cc1bec 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.ErrorTracker; +import dev.faststats.core.Settings; import dev.faststats.core.SimpleMetrics; import dev.faststats.core.Token; import org.jspecify.annotations.NullMarked; @@ -14,7 +15,11 @@ @NullMarked public final class MockMetrics extends SimpleMetrics { public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new Config(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); + super(new Config(serverId, true, debug, true, true, false, false), Set.of(), Settings.factory() + .url(URI.create("http://localhost:5000/v1/collect")) + .token(token) + .debug(debug) + .create(), tracker, null); } @Override diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 47d8eae7..78b44316 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -2,49 +2,22 @@ import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.fabric.FabricMetrics; import net.fabricmc.api.ModInitializer; -import java.net.URI; - public class ExampleMod implements ModInitializer { - // 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 = FabricMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project .create("example-mod"); // your mod id as defined in fabric.mod.json - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } - } - @Override public void onInitialize() { } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java index f6ce2df7..2df30b75 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java @@ -1,6 +1,7 @@ package dev.faststats.fabric; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -29,9 +30,9 @@ interface Factory extends Metrics.Factory { * * @param modId the mod id * @return the metrics instance - * @throws IllegalStateException if the token is not specified + * @throws IllegalStateException if the settings are not specified * @throws IllegalArgumentException if the mod is not found - * @see #token(String) + * @see #settings(Settings) * @since 0.12.0 */ @Override diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index a2154878..88921ae1 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -4,37 +4,19 @@ import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.hytale.HytaleMetrics; -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 = HytaleMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project .create(this); public ExamplePlugin(final JavaPluginInit init) { @@ -45,13 +27,4 @@ public ExamplePlugin(final JavaPluginInit init) { protected void shutdown() { metrics.shutdown(); // safely shut down metrics submission } - - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } - } } diff --git a/settings.gradle.kts b/settings.gradle.kts index e67c2efd..d9449921 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,7 @@ include("bukkit:example-plugin") include("bungeecord") include("bungeecord:example-plugin") include("core") +include("core:example") include("fabric") include("fabric:example-mod") include("hytale") diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 3c43e7d2..920bb3e0 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -3,6 +3,7 @@ import com.google.inject.Inject; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.sponge.SpongeMetrics; import org.jspecify.annotations.Nullable; @@ -13,17 +14,8 @@ import org.spongepowered.plugin.PluginContainer; import org.spongepowered.plugin.builtin.jvm.Plugin; -import java.net.URI; - - @Plugin("example") public class ExamplePlugin { - // 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 @Inject PluginContainer pluginContainer; private @Inject SpongeMetrics.Factory factory; @@ -32,24 +24,13 @@ public class ExamplePlugin { @Listener public void onServerStart(final StartedEngineEvent event) { this.metrics = factory - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.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 + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project .create(pluginContainer); } @@ -57,13 +38,4 @@ public void onServerStart(final StartedEngineEvent event) { public void onServerStop(final StoppingEngineEvent event) { if (metrics != null) metrics.shutdown(); // safely shut down metrics submission } - - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } - } } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index b852d120..69afdc47 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -7,22 +7,14 @@ import com.velocitypowered.api.plugin.Plugin; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.velocity.VelocityMetrics; import org.jspecify.annotations.Nullable; -import java.net.URI; - - @Plugin(id = "example", name = "Example Plugin", version = "1.0.0", url = "https://example.com", authors = {"Your Name"}) public class ExamplePlugin { - // 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 VelocityMetrics.Factory metricsFactory; private @Nullable Metrics metrics = null; @@ -34,24 +26,13 @@ public ExamplePlugin(final VelocityMetrics.Factory factory) { @Subscribe public void onProxyInitialize(final ProxyInitializeEvent event) { this.metrics = metricsFactory - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.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 + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project + .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project .create(this); } @@ -59,13 +40,4 @@ public void onProxyInitialize(final ProxyInitializeEvent event) { public void onProxyStop(final ProxyShutdownEvent event) { if (metrics != null) metrics.shutdown(); // safely shut down metrics submission } - - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } - } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 0c64cf8a..70cc8d31 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -5,6 +5,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import dev.faststats.core.Metrics; +import dev.faststats.core.Settings; import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -79,9 +80,9 @@ public Factory(final ProxyServer server, final Logger logger, @DataDirectory fin * * @param plugin the plugin instance * @return the metrics instance - * @throws IllegalStateException if the token is not specified + * @throws IllegalStateException if the settings are not specified * @throws IllegalArgumentException if the given object is not a valid plugin - * @see #token(String) + * @see #settings(Settings) * @since 0.1.0 */ @Override From 7f2d1edf22216a36d0ebad7e104a178e813eefbc Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 13:18:45 +0200 Subject: [PATCH 002/140] Split metrics and flags url into separate values --- .../java/dev/faststats/core/Settings.java | 21 +++++++++++++++---- .../dev/faststats/core/SimpleMetrics.java | 9 ++++---- .../dev/faststats/core/SimpleSettings.java | 19 ++++++++++------- .../core/flags/SimpleFeatureFlagService.java | 16 +++++++------- .../test/java/dev/faststats/MockMetrics.java | 3 ++- 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/Settings.java b/core/src/main/java/dev/faststats/core/Settings.java index 45ff172d..7e0b02c9 100644 --- a/core/src/main/java/dev/faststats/core/Settings.java +++ b/core/src/main/java/dev/faststats/core/Settings.java @@ -47,13 +47,22 @@ static Factory factory() { String token(); /** - * The server URL. + * The metrics server URL. * - * @return the server URL + * @return the metrics server URL * @since 0.23.0 */ @Contract(pure = true) - URI url(); + URI metricsUrl(); + + /** + * The flags server URL. + * + * @return the flags server URL + * @since 0.23.0 + */ + @Contract(pure = true) + URI flagsUrl(); /** * Whether debug logging is enabled. @@ -93,7 +102,11 @@ sealed interface Factory permits SimpleSettings.Factory { * @since 0.23.0 */ @Contract(mutates = "this") - Factory url(URI url); + Factory metricsServer(URI url); // todo: rethink naming + + // todo: add docs + @Contract(mutates = "this") + Factory flagsServer(URI url); // todo: rethink naming /** * Enables or disables debug logging. diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 4992d028..00a74b13 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -194,6 +194,7 @@ private boolean submitNow() throws IOException { final var compressed = byteOutput.toByteArray(); info("Compressed size: " + compressed.length + " bytes"); + final var url = settings.metricsUrl().resolve("/collect"); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") @@ -201,10 +202,10 @@ private boolean submitNow() throws IOException { .header("Authorization", "Bearer " + settings.token()) .header("User-Agent", "FastStats Metrics " + SDK_NAME + "/" + SDK_VERSION) .timeout(Duration.ofSeconds(3)) - .uri(settings.url()) + .uri(url) .build(); - info("Sending metrics to: " + settings.url()); + info("Sending metrics to: " + url); try { final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); final var statusCode = response.statusCode(); @@ -225,9 +226,9 @@ private boolean submitNow() throws IOException { warn("Received unexpected response from metrics server: " + statusCode + " (" + body + ")"); } } catch (final HttpConnectTimeoutException t) { - error("Metrics submission timed out after 3 seconds: " + settings.url(), null); + error("Metrics submission timed out after 3 seconds: " + url, null); } catch (final ConnectException t) { - error("Failed to connect to metrics server: " + settings.url(), null); + error("Failed to connect to metrics server: " + url, null); } catch (final Throwable t) { error("Failed to submit metrics", t); } diff --git a/core/src/main/java/dev/faststats/core/SimpleSettings.java b/core/src/main/java/dev/faststats/core/SimpleSettings.java index 534a670d..5f01d2e8 100644 --- a/core/src/main/java/dev/faststats/core/SimpleSettings.java +++ b/core/src/main/java/dev/faststats/core/SimpleSettings.java @@ -4,12 +4,11 @@ import java.net.URI; -record SimpleSettings(@Token String token, URI url, boolean debug) implements Settings { +record SimpleSettings(@Token String token, URI metricsUrl, URI flagsUrl, boolean debug) implements Settings { static final class Factory implements Settings.Factory { - private static final URI DEFAULT_URL = URI.create("https://metrics.faststats.dev/v1/collect"); - - private URI url = DEFAULT_URL; + private URI metricsUrl = URI.create("https://metrics.faststats.dev/v1"); + private URI flagsUrl = URI.create("https://flags.faststats.dev/v1"); private @Nullable String token; private boolean debug = false; @@ -23,8 +22,14 @@ public Settings.Factory token(@Token final String token) throws IllegalArgumentE } @Override - public Settings.Factory url(final URI url) { - this.url = url; + public Settings.Factory metricsServer(final URI url) { + this.metricsUrl = url; + return this; + } + + @Override + public Settings.Factory flagsServer(final URI url) { + this.flagsUrl = url; return this; } @@ -38,7 +43,7 @@ public Settings.Factory debug(final boolean enabled) { @SuppressWarnings("PatternValidation") public Settings create() throws IllegalStateException { if (token == null) throw new IllegalStateException("Token must be specified"); - return new SimpleSettings(token, url, debug); + return new SimpleSettings(token, metricsUrl, flagsUrl, debug); } } } diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index d091dd2d..0e296092 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -20,13 +20,15 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private static final Gson GSON = new Gson(); + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + private final Settings settings; private final @Nullable Attributes attributes; private final Duration ttl; - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(3)) - .build(); private final Map cache = new ConcurrentHashMap<>(); private final Map fetchTimes = new ConcurrentHashMap<>(); private final Map> fetchesInProgress = new ConcurrentHashMap<>(); @@ -62,11 +64,11 @@ CompletableFuture fetch(final SimpleFeatureFlag flag) { } CompletableFuture optIn(final SimpleFeatureFlag flag) { - return sendOptRequest(flag, "/v1/flag/opt-in"); + return sendOptRequest(flag, "/opt-in"); } CompletableFuture optOut(final SimpleFeatureFlag flag) { - return sendOptRequest(flag, "/v1/flag/opt-out"); + return sendOptRequest(flag, "/opt-out"); } private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { @@ -82,7 +84,7 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, .header("Content-Type", "application/json") .header("Authorization", "Bearer " + settings.token()) .timeout(Duration.ofSeconds(3)) - .uri(settings.url().resolve(path)) + .uri(settings.flagsUrl().resolve(path)) .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { @@ -130,7 +132,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { .header("Content-Type", "application/json") .header("Authorization", "Bearer " + settings.token()) .timeout(Duration.ofSeconds(3)) - .uri(settings.url().resolve("/flags")) + .uri(settings.flagsUrl().resolve("/check")) .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 01cc1bec..4ff0999f 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -16,7 +16,8 @@ public final class MockMetrics extends SimpleMetrics { public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { super(new Config(serverId, true, debug, true, true, false, false), Set.of(), Settings.factory() - .url(URI.create("http://localhost:5000/v1/collect")) + .metricsServer(URI.create("http://localhost:5000/v1")) + .flagsServer(URI.create("http://localhost:5001/v1")) .token(token) .debug(debug) .create(), tracker, null); From 73614076307b6385cac3f6cae0d330b5b2d097dc Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 13:19:23 +0200 Subject: [PATCH 003/140] Throw on non-finite numbers --- core/src/main/java/dev/faststats/core/flags/Attributes.java | 3 ++- .../main/java/dev/faststats/core/flags/SimpleAttributes.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/Attributes.java b/core/src/main/java/dev/faststats/core/flags/Attributes.java index ba0c776e..0a161a1c 100644 --- a/core/src/main/java/dev/faststats/core/flags/Attributes.java +++ b/core/src/main/java/dev/faststats/core/flags/Attributes.java @@ -57,10 +57,11 @@ static Attributes copyOf(final Attributes attributes) { * @param key the key * @param value the value * @return these attributes + * @throws IllegalArgumentException if the given value is not {@link Double#isFinite(double) finite} * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") - Attributes put(String key, Number value); + Attributes put(String key, Number value) throws IllegalArgumentException; /** * Set a boolean value. diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java index 84d1ca49..c9cffd8c 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java @@ -11,7 +11,8 @@ public Attributes put(final String key, final String value) { @Override public Attributes put(final String key, final Number value) { - attributes.put(key, value); + if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Value must be finite"); + attributes.put(key, new JsonPrimitive(value)); return this; } From 971163c165075f26293ba95ee64c957e5ab2b3a3 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 13:21:12 +0200 Subject: [PATCH 004/140] Replace Object with JsonPrimitive in Attributes --- .../dev/faststats/core/flags/Attributes.java | 17 +++++------------ .../faststats/core/flags/SimpleAttributes.java | 13 ++++++++----- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/Attributes.java b/core/src/main/java/dev/faststats/core/flags/Attributes.java index 0a161a1c..ccd79b16 100644 --- a/core/src/main/java/dev/faststats/core/flags/Attributes.java +++ b/core/src/main/java/dev/faststats/core/flags/Attributes.java @@ -1,11 +1,11 @@ package dev.faststats.core.flags; +import com.google.gson.JsonPrimitive; import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.Unmodifiable; import org.jspecify.annotations.Nullable; -import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; /** * Mutable key-value attributes for feature flag targeting. @@ -84,15 +84,8 @@ static Attributes copyOf(final Attributes attributes) { @Contract(value = "_ -> this", mutates = "this") Attributes remove(String key); - /** - * Return an unmodifiable view of all attribute entries. - * - * @return unmodifiable map of attribute entries - * @since 0.23.0 - */ - @Unmodifiable - @Contract(pure = true) - Map entries(); + // todo: add docs + void forEachPrimitive(BiConsumer action); /** * Create new attributes by merging two attribute sets. @@ -106,7 +99,7 @@ static Attributes copyOf(final Attributes attributes) { */ @Contract(value = "_, _ -> new", pure = true) static Attributes join(@Nullable final Attributes first, @Nullable final Attributes second) { - final var attributes = new ConcurrentHashMap(); + final var attributes = new ConcurrentHashMap(); if (first instanceof final SimpleAttributes simple) attributes.putAll(simple.attributes()); if (second instanceof final SimpleAttributes simple) attributes.putAll(simple.attributes()); return new SimpleAttributes(attributes); diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java index c9cffd8c..a1378011 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java @@ -1,11 +1,14 @@ package dev.faststats.core.flags; +import com.google.gson.JsonPrimitive; + import java.util.Map; +import java.util.function.BiConsumer; -record SimpleAttributes(Map attributes) implements Attributes { +record SimpleAttributes(Map attributes) implements Attributes { @Override public Attributes put(final String key, final String value) { - attributes.put(key, value); + attributes.put(key, new JsonPrimitive(value)); return this; } @@ -18,7 +21,7 @@ public Attributes put(final String key, final Number value) { @Override public Attributes put(final String key, final boolean value) { - attributes.put(key, value); + attributes.put(key, new JsonPrimitive(value)); return this; } @@ -29,7 +32,7 @@ public Attributes remove(final String key) { } @Override - public Map entries() { - return Map.copyOf(attributes); + public void forEachPrimitive(final BiConsumer action) { + attributes.forEach(action); } } From fd2d4c620d281c1703f4ebc3a2f6a2f1bb3d6db1 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 13:21:28 +0200 Subject: [PATCH 005/140] Add Type enum to FeatureFlags --- .../dev/faststats/core/flags/FeatureFlag.java | 16 +++++++++++++- .../core/flags/SimpleFeatureFlag.java | 21 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java index e2f275dd..23cf853d 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java @@ -25,6 +25,15 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { @Contract(pure = true) String getId(); + /** + * Returns the type representing the value type of this flag. + * + * @return the value type class + * @since 0.23.0 + */ + @Contract(pure = true) + Type getType(); + /** * Returns the class representing the value type of this flag. * @@ -32,7 +41,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * @since 0.23.0 */ @Contract(pure = true) - Class getType(); + Class getTypeClass(); /** * Get the current cached flag value. @@ -138,4 +147,9 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { */ @Contract(pure = true) T getDefaultValue(); + + // todo: add docs + enum Type { + STRING, BOOLEAN, NUMBER + } } diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java index 5743f63c..b7f4670f 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java @@ -13,6 +13,7 @@ final class SimpleFeatureFlag implements FeatureFlag { private final String id; private final T defaultValue; private final @Nullable Attributes attributes; + private final Type type; SimpleFeatureFlag( final String id, @@ -24,6 +25,13 @@ final class SimpleFeatureFlag implements FeatureFlag { this.defaultValue = defaultValue; this.attributes = attributes; this.service = service; + if (defaultValue instanceof final String string) { + this.type = Type.STRING; + } else if (defaultValue instanceof final Number number) { + this.type = Type.NUMBER; + } else if (defaultValue instanceof final Boolean bool) { + this.type = Type.BOOLEAN; + } else throw new IllegalArgumentException("Unsupported type: " + defaultValue.getClass().getName()); service.fetch(this); } @@ -32,10 +40,19 @@ public String getId() { return id; } + @Override + public Type getType() { + return type; + } + @Override @SuppressWarnings("unchecked") - public Class getType() { - return (Class) defaultValue.getClass(); + public Class getTypeClass() { + return (Class) switch (type) { + case STRING -> String.class; + case NUMBER -> Number.class; + case BOOLEAN -> Boolean.class; + }; } @Override From 6cdb5c9ad9b73a18b13a830e2c9681f0ee893bfc Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 13:22:24 +0200 Subject: [PATCH 006/140] Refactor SimpleFeatureFlagService --- .../core/flags/SimpleFeatureFlagService.java | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index 0e296092..c9449967 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -3,6 +3,9 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; import dev.faststats.core.Settings; import org.jspecify.annotations.Nullable; @@ -45,17 +48,14 @@ final class SimpleFeatureFlagService implements FeatureFlagService { @SuppressWarnings("unchecked") Optional get(final SimpleFeatureFlag flag) { - final var cached = cache.get(flag.getId()); - return Optional.ofNullable((T) cached); + return Optional.ofNullable((T) cache.get(flag.getId())); } @SuppressWarnings("unchecked") CompletableFuture whenReady(final SimpleFeatureFlag flag) { final var cached = cache.get(flag.getId()); - if (cached != null && !isExpired(flag)) { - return CompletableFuture.completedFuture((T) cached); - } - return fetch(flag); + if (cached == null || isExpired(flag)) return fetch(flag); + return CompletableFuture.completedFuture((T) cached); } @SuppressWarnings("unchecked") @@ -72,8 +72,6 @@ CompletableFuture optOut(final SimpleFeatureFlag flag) { } private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { - invalidate(flag); - final var requestBody = new JsonObject(); requestBody.addProperty("projectToken", settings.token()); requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config @@ -97,12 +95,6 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, }); } - void invalidate(final SimpleFeatureFlag flag) { - final var id = flag.getId(); - cache.remove(id); - fetchTimes.remove(id); - } - Optional getExpiration(final SimpleFeatureFlag flag) { final var lastFetch = fetchTimes.get(flag.getId()); if (lastFetch == null) return Optional.empty(); @@ -121,11 +113,14 @@ boolean isExpired(final SimpleFeatureFlag flag) { private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); - requestBody.addProperty("flag", flag.getId()); + requestBody.addProperty("projectToken", settings.token()); + requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config + requestBody.addProperty("key", flag.getId()); - final var mergedAttributes = Attributes.join(attributes, flag.attributes()); - // todo: drop gson - requestBody.add("attributes", GSON.toJsonTree(mergedAttributes)); + final var attributes = new JsonObject(); + if (this.attributes != null) this.attributes.forEachPrimitive(attributes::add); + if (flag.attributes() != null) flag.attributes().forEachPrimitive(attributes::add); + if (!attributes.isEmpty()) requestBody.add("attributes", attributes); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) @@ -136,28 +131,34 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { - if (response.statusCode() >= 200 && response.statusCode() < 300) { - // todo: replace gson with safer read - final var body = GSON.fromJson(response.body(), JsonObject.class); - final var value = body.get("value"); - if (value != null && !value.isJsonNull()) { - cache.put(flag.getId(), toValue(value)); - fetchTimes.put(flag.getId(), System.currentTimeMillis()); - return flag.getType().cast(cache.get(flag.getId())); - } + try { + final var body = JsonParser.parseString(response.body()); + + if (response.statusCode() < 200 && response.statusCode() >= 300) + throw new IllegalStateException("Unexpected response status: %s (%s)".formatted(response.statusCode(), body)); + + final var value = getValue(flag, body); + cache.put(flag.getId(), value); + fetchTimes.put(flag.getId(), System.currentTimeMillis()); + return value; + } catch (final JsonParseException e) { + throw new IllegalStateException("Unexpected response body: %s (%s)".formatted(response.body(), response.statusCode()), e); } - return flag.getDefaultValue(); }).whenComplete((ignored, throwable) -> fetchesInProgress.remove(flag.getId())); } - private Object toValue(final JsonElement element) { - if (element.isJsonPrimitive()) { - final var primitive = element.getAsJsonPrimitive(); - if (primitive.isBoolean()) return primitive.getAsBoolean(); - if (primitive.isNumber()) return primitive.getAsNumber(); - return primitive.getAsString(); - } // todo: guarantee for primitives? - return element.toString(); + @SuppressWarnings("unchecked") + private static T getValue(final SimpleFeatureFlag flag, final JsonElement body) { + if (!(body instanceof final JsonObject object)) + throw new IllegalStateException("Unexpected JSON response: " + body); + if (!(object.get("value") instanceof final JsonPrimitive primitive)) + throw new IllegalStateException("Missing or invalid 'value' in JSON response: " + body); + + return (T) switch (flag.getType()) { + case STRING -> primitive.getAsString(); + case NUMBER -> primitive.getAsNumber(); + case BOOLEAN -> primitive.getAsBoolean(); + }; } @Override From b22d3f0ae22a2f6c0f45fae24b498764a5a1afe4 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 13:22:47 +0200 Subject: [PATCH 007/140] Generalize terms in onboarding message and default config --- core/src/main/java/dev/faststats/core/SimpleMetrics.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 00a74b13..92dabf49 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -101,7 +101,7 @@ protected SimpleMetrics( protected String getOnboardingMessage() { return """ - This plugin uses FastStats to collect anonymous usage statistics. + This piece of software uses FastStats to collect anonymous usage statistics. No personal or identifying information is ever collected. To opt out, set 'enabled=false' in the metrics configuration file. Learn more at: https://faststats.dev/info @@ -390,7 +390,7 @@ public record Config( ) implements Metrics.Config { public static final String DEFAULT_COMMENT = """ - FastStats (https://faststats.dev) collects anonymous usage statistics for plugin developers. + FastStats (https://faststats.dev) collects anonymous usage statistics for developers. # This helps developers understand how their projects are used in the real world. # # No IP addresses, player data, or personal information is collected. @@ -398,9 +398,9 @@ public record Config( # # Enabling metrics has no noticeable performance impact. # Keeping metrics enabled is recommended, but you can opt out by setting - # 'enabled=false' in plugins/faststats/config.properties. + # 'enabled=false' in faststats/config.properties. # - # If you suspect a plugin is collecting personal data or bypassing the "enabled" option, + # If you suspect a developer is collecting personal data or bypassing the "enabled" option, # please report it at: https://faststats.dev/abuse # # For more information, visit: https://faststats.dev/info From 3d1bb9a3918cf0e7657091dca3e3aa0309d0bef6 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 15:28:13 +0200 Subject: [PATCH 008/140] Refactored logging --- .../faststats/bukkit/BukkitMetricsImpl.java | 17 ------ .../faststats/bungee/BungeeMetricsImpl.java | 20 ------- .../dev/faststats/core/SimpleMetrics.java | 58 +++++++++++-------- core/src/main/java/module-info.java | 1 + .../test/java/dev/faststats/MockMetrics.java | 16 ----- .../faststats/fabric/FabricMetricsImpl.java | 18 ------ .../faststats/hytale/HytaleMetricsImpl.java | 14 ++--- hytale/src/main/java/module-info.java | 1 + .../minestom/MinestomMetricsImpl.java | 20 ------- .../faststats/nukkit/NukkitMetricsImpl.java | 17 ------ .../faststats/sponge/SpongeMetricsImpl.java | 20 ------- .../velocity/VelocityMetricsImpl.java | 19 ------ 12 files changed, 41 insertions(+), 180 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 58e30e5f..4afb46f9 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -5,12 +5,10 @@ import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.nio.file.Path; import java.util.Optional; import java.util.function.Supplier; -import java.util.logging.Level; final class BukkitMetricsImpl extends SimpleMetrics implements BukkitMetrics { private final Plugin plugin; @@ -81,21 +79,6 @@ private int getPlayerCount() { } } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - plugin.getLogger().log(Level.SEVERE, message, throwable); - } - - @Override - protected void printInfo(final String message) { - plugin.getLogger().info(message); - } - - @Override - protected void printWarning(final String message) { - plugin.getLogger().warning(message); - } - @Override public void ready() { if (getErrorTracker().isPresent()) try { diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index 34fd29f0..ba70bc0d 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -7,14 +7,10 @@ import net.md_5.bungee.api.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.nio.file.Path; -import java.util.logging.Level; -import java.util.logging.Logger; final class BungeeMetricsImpl extends SimpleMetrics implements BungeeMetrics { - private final Logger logger; private final ProxyServer server; private final Plugin plugin; @@ -23,7 +19,6 @@ final class BungeeMetricsImpl extends SimpleMetrics implements BungeeMetrics { private BungeeMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { super(factory, config); - this.logger = plugin.getLogger(); this.server = plugin.getProxy(); this.plugin = plugin; @@ -39,21 +34,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getName()); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.log(Level.SEVERE, message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warning(message); - } - static final class Factory extends SimpleMetrics.Factory implements BungeeMetrics.Factory { @Override public Metrics create(final Plugin plugin) throws IllegalStateException { diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 92dabf49..0661d05e 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -3,6 +3,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.data.Metric; import dev.faststats.core.flags.FeatureFlagService; +import org.intellij.lang.annotations.PrintFormat; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.MustBeInvokedByOverriders; @@ -30,11 +31,16 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiPredicate; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import java.util.zip.GZIPOutputStream; import static java.nio.charset.StandardCharsets.UTF_8; public abstract class SimpleMetrics implements Metrics { + protected final Logger logger = Logger.getLogger(getClass().getName()); + private static final URI defaultUrl = URI.create("https://metrics.faststats.dev/v1/collect"); private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) @@ -124,6 +130,7 @@ protected void startSubmitting() { startSubmitting(getInitialDelay(), getPeriod(), TimeUnit.MILLISECONDS); } + @SuppressWarnings("PatternValidation") private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { if (Boolean.getBoolean("faststats.first-run")) { info("Skipping metrics submission due to first-run flag"); @@ -136,9 +143,9 @@ private void startSubmitting(final long initialDelay, final long period, final T final var split = getOnboardingMessage().split("\n"); for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); - printInfo("-".repeat(separatorLength)); - for (final var s : split) printInfo(s); - printInfo("-".repeat(separatorLength)); + info("-".repeat(separatorLength)); + for (final var s : split) info(s); + info("-".repeat(separatorLength)); System.setProperty("faststats.first-run", "true"); if (!config.externallyManaged()) return; @@ -183,7 +190,7 @@ private boolean submitNow() throws IOException { final var data = createData().toString(); final var bytes = data.getBytes(UTF_8); - info("Uncompressed data: " + data); + info("Uncompressed data: %s", data); try (final var byteOutput = new ByteArrayOutputStream(); final var output = new GZIPOutputStream(byteOutput)) { @@ -192,7 +199,7 @@ private boolean submitNow() throws IOException { output.finish(); final var compressed = byteOutput.toByteArray(); - info("Compressed size: " + compressed.length + " bytes"); + info("Compressed size: %s bytes", compressed.length); final var url = settings.metricsUrl().resolve("/collect"); final var request = HttpRequest.newBuilder() @@ -212,23 +219,23 @@ private boolean submitNow() throws IOException { final var body = response.body(); if (statusCode >= 200 && statusCode < 300) { - info("Metrics submitted with status code: " + statusCode + " (" + body + ")"); + info("Metrics submitted with status code: %s (%s)", statusCode, body); getErrorTracker().map(SimpleErrorTracker.class::cast).ifPresent(SimpleErrorTracker::clear); if (flush != null) flush.run(); return true; } else if (statusCode >= 300 && statusCode < 400) { - warn("Received redirect response from metrics server: " + statusCode + " (" + body + ")"); + warn("Received redirect response from metrics server: %s (%s)", statusCode, body); } else if (statusCode >= 400 && statusCode < 500) { - error("Submitted invalid request to metrics server: " + statusCode + " (" + body + ")", null); + error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); } else if (statusCode >= 500 && statusCode < 600) { - error("Received server error response from metrics server: " + statusCode + " (" + body + ")", null); + error("Received server error response from metrics server: %s (%s)", null, statusCode, body); } else { - warn("Received unexpected response from metrics server: " + statusCode + " (" + body + ")"); + warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); } } catch (final HttpConnectTimeoutException t) { - error("Metrics submission timed out after 3 seconds: " + url, null); + error("Metrics submission timed out after 3 seconds: %s", null, defaultUrl); } catch (final ConnectException t) { - error("Failed to connect to metrics server: " + url, null); + error("Failed to connect to metrics server: %s", null, defaultUrl); } catch (final Throwable t) { error("Failed to submit metrics", t); } @@ -265,7 +272,7 @@ protected JsonObject createData() { try { metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { - error("Failed to build metric data: " + metric.getId(), t); + error("Failed to build metric data: %s", t, metric.getId()); getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); } }); @@ -303,23 +310,26 @@ public Metrics.Config getConfig() { @Contract(mutates = "param1") protected abstract void appendDefaultData(JsonObject metrics); - protected void error(final String message, @Nullable final Throwable throwable) { - if (debug) printError("[" + getClass().getName() + "]: " + message, throwable); + protected void error(@PrintFormat final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { + if (throwable != null) { + if (!logger.isLoggable(Level.SEVERE)) return; + final var logRecord = new LogRecord(Level.SEVERE, message.formatted(args)); + logRecord.setThrown(throwable); + logger.log(logRecord); + } else log(Level.SEVERE, message, args); } - protected void warn(final String message) { - if (debug) printWarning("[" + getClass().getName() + "]: " + message); + protected void log(final Level level, @PrintFormat final String message, @Nullable final Object... args) { + logger.log(level, () -> message.formatted(args)); } - protected void info(final String message) { - if (debug) printInfo("[" + getClass().getName() + "]: " + message); + protected void info(@PrintFormat final String message, @Nullable final Object... args) { + log(Level.INFO, message, args); } - protected abstract void printError(String message, @Nullable Throwable throwable); - - protected abstract void printInfo(String message); - - protected abstract void printWarning(String message); + protected void warn(@PrintFormat final String message, @Nullable final Object... args) { + log(Level.WARNING, message, args); + } @Override public void shutdown() { diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index e635f815..fce66ce9 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -7,6 +7,7 @@ exports dev.faststats.core; requires com.google.gson; + requires java.logging; requires java.net.http; requires static org.jetbrains.annotations; diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 4ff0999f..44d5977d 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -23,22 +23,6 @@ public MockMetrics(final UUID serverId, @Token final String token, @Nullable fin .create(), tracker, null); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - System.err.println(message); - if (throwable != null) throwable.printStackTrace(System.err); - } - - @Override - protected void printInfo(final String message) { - System.out.println(message); - } - - @Override - protected void printWarning(final String message) { - System.out.println(message); - } - @Override public JsonObject createData() { return super.createData(); diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index ba48e450..5195fa7f 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -10,15 +10,12 @@ import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.nio.file.Path; import java.util.Optional; import java.util.function.Supplier; final class FabricMetricsImpl extends SimpleMetrics implements FabricMetrics { - private final Logger logger = LoggerFactory.getLogger("FastStats"); private final ModContainer mod; private @Nullable MinecraftServer server; @@ -47,21 +44,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Fabric"); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warn(message); - } - private Optional tryOrEmpty(final Supplier supplier) { try { return Optional.of(supplier.get()); diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index d8372021..6049c9b2 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -12,6 +12,7 @@ import org.jspecify.annotations.Nullable; import java.nio.file.Path; +import java.util.logging.Level; final class HytaleMetricsImpl extends SimpleMetrics implements HytaleMetrics { private final HytaleLogger logger; @@ -33,18 +34,13 @@ protected void appendDefaultData(final JsonObject metrics) { } @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.atSevere().log(message, throwable); + protected void error(final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { + if (super.logger.isLoggable(Level.SEVERE)) logger.atSevere().withCause(throwable).logVarargs(message, args); } @Override - protected void printInfo(final String message) { - logger.atInfo().log(message); - } - - @Override - protected void printWarning(final String message) { - logger.atWarning().log(message); + protected void log(final Level level, final String message, @Nullable final Object... args) { + if (super.logger.isLoggable(level)) logger.at(level).logVarargs(message, args); } static final class Factory extends SimpleMetrics.Factory implements HytaleMetrics.Factory { diff --git a/hytale/src/main/java/module-info.java b/hytale/src/main/java/module-info.java index a091bad2..1b3293d7 100644 --- a/hytale/src/main/java/module-info.java +++ b/hytale/src/main/java/module-info.java @@ -6,6 +6,7 @@ requires com.google.gson; requires dev.faststats.core; + requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 217f1942..91bc04ee 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -8,15 +8,10 @@ import net.minestom.server.MinecraftServer; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.nio.file.Path; final class MinestomMetricsImpl extends SimpleMetrics implements MinestomMetrics { - private final Logger logger = LoggerFactory.getLogger(MinestomMetricsImpl.class); - @Async.Schedule @Contract(mutates = "io") private MinestomMetricsImpl(final Factory factory, final Path config) throws IllegalStateException { @@ -33,21 +28,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Minestom"); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warn(message); - } - @Override public void ready() { getErrorTracker().ifPresent(this::registerExceptionHandler); diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index 1316d890..c9c8eac1 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -15,7 +15,6 @@ import java.util.function.Supplier; final class NukkitMetricsImpl extends SimpleMetrics implements NukkitMetrics { - private final Logger logger; private final Server server; private final PluginBase plugin; @@ -24,7 +23,6 @@ final class NukkitMetricsImpl extends SimpleMetrics implements NukkitMetrics { private NukkitMetricsImpl(final Factory factory, final PluginBase plugin, final Path config) throws IllegalStateException { super(factory, config); - this.logger = plugin.getLogger(); this.server = plugin.getServer(); this.plugin = plugin; @@ -40,21 +38,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getName()); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warning(message); - } - private Optional tryOrEmpty(final Supplier supplier) { try { return Optional.of(supplier.get()); diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index b029e849..006b4914 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -6,7 +6,6 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import org.spongepowered.api.Platform; import org.spongepowered.api.Sponge; import org.spongepowered.plugin.PluginContainer; @@ -31,7 +30,6 @@ final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { # For more information, visit: https://faststats.dev/info """; - private final Logger logger; private final PluginContainer plugin; @Async.Schedule @@ -44,10 +42,7 @@ private SpongeMetricsImpl( ) throws IllegalStateException { super(factory, SimpleMetrics.Config.read(config, COMMENT, true, Sponge.metricsConfigManager() .effectiveCollectionState(plugin).asBoolean())); - - this.logger = logger; this.plugin = plugin; - startSubmitting(); } @@ -70,21 +65,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", Sponge.platform().container(Platform.Component.IMPLEMENTATION).metadata().id()); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warn(message); - } - static class Factory extends SimpleMetrics.Factory { protected final Logger logger; protected final Path dataDirectory; diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 70cc8d31..d082fe83 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -5,17 +5,14 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import java.nio.file.Path; final class VelocityMetricsImpl extends SimpleMetrics implements VelocityMetrics { - private final Logger logger; private final ProxyServer server; private final PluginContainer plugin; @@ -30,7 +27,6 @@ private VelocityMetricsImpl( ) throws IllegalStateException { super(factory, config); - this.logger = logger; this.server = server; this.plugin = plugin; @@ -47,21 +43,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getVersion().getName()); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warn(message); - } - static class Factory extends SimpleMetrics.Factory { protected final Logger logger; protected final Path dataDirectory; From 59a4cdfb91d82f1703305b55e9bdc495f35a336f Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 15:43:19 +0200 Subject: [PATCH 009/140] Removed settings and ability to define metrics URL and debug --- .../main/java/com/example/ExamplePlugin.java | 3 +- .../main/java/com/example/ExamplePlugin.java | 3 +- .../faststats/example/FeatureFlagExample.java | 15 +- .../faststats/example/SettingsExample.java | 17 --- .../main/java/dev/faststats/core/Metrics.java | 24 ++-- .../java/dev/faststats/core/Settings.java | 133 ------------------ .../dev/faststats/core/SimpleMetrics.java | 59 +++++--- .../dev/faststats/core/SimpleSettings.java | 49 ------- .../core/flags/FeatureFlagService.java | 98 +++++-------- .../core/flags/SimpleFeatureFlagService.java | 63 +++------ .../test/java/dev/faststats/MockMetrics.java | 8 +- .../src/main/java/com/example/ExampleMod.java | 3 +- .../dev/faststats/fabric/FabricMetrics.java | 5 +- .../main/java/com/example/ExamplePlugin.java | 3 +- .../main/java/com/example/ExamplePlugin.java | 3 +- .../main/java/com/example/ExamplePlugin.java | 3 +- .../velocity/VelocityMetricsImpl.java | 4 +- 17 files changed, 129 insertions(+), 364 deletions(-) delete mode 100644 core/example/src/main/java/dev/faststats/example/SettingsExample.java delete mode 100644 core/src/main/java/dev/faststats/core/Settings.java delete mode 100644 core/src/main/java/dev/faststats/core/SimpleSettings.java 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 296adec6..90884f32 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,7 +2,6 @@ import dev.faststats.bukkit.BukkitMetrics; import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import org.bukkit.plugin.java.JavaPlugin; @@ -23,7 +22,7 @@ public final class ExamplePlugin extends JavaPlugin { // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush - .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); @Override diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 0c9a1f8b..223a64dd 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -3,7 +3,6 @@ import dev.faststats.bungee.BungeeMetrics; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import net.md_5.bungee.api.plugin.Plugin; @@ -24,7 +23,7 @@ public class ExamplePlugin extends Plugin { // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush - .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); @Override diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index fb0c49de..bae91d86 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -1,6 +1,5 @@ package dev.faststats.example; -import dev.faststats.core.Settings; import dev.faststats.core.flags.Attributes; import dev.faststats.core.flags.FeatureFlag; import dev.faststats.core.flags.FeatureFlagService; @@ -9,16 +8,14 @@ import java.time.Instant; public final class FeatureFlagExample { - private static final Settings SETTINGS = Settings.withToken("YOUR_TOKEN_HERE"); - - public static final FeatureFlagService SERVICE = FeatureFlagService.factory() - .settings(SETTINGS) - .ttl(Duration.ofMinutes(10)) - .attributes(Attributes.create() + public static final FeatureFlagService SERVICE = FeatureFlagService.create( + "YOUR_TOKEN_HERE", // token can be found in the settings of your project + Attributes.create() .put("version", "1.2.3") .put("java_version", System.getProperty("java.version")) - .put("java_vendor", System.getProperty("java.vendor"))) - .create(); + .put("java_vendor", System.getProperty("java.vendor")), + Duration.ofMinutes(10) + ); // Define flags with default values public static final FeatureFlag NEW_COMMANDS = SERVICE.define("new_commands", false); diff --git a/core/example/src/main/java/dev/faststats/example/SettingsExample.java b/core/example/src/main/java/dev/faststats/example/SettingsExample.java deleted file mode 100644 index 6dd9bd21..00000000 --- a/core/example/src/main/java/dev/faststats/example/SettingsExample.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.faststats.example; - -import dev.faststats.core.Settings; - -import java.net.URI; - -public final class SettingsExample { - // Recommended: create settings with just a token - public static final Settings SETTINGS = Settings.withToken("YOUR_TOKEN_HERE"); - - // Or use the factory for full control - public static final Settings ALL_SETTINGS = Settings.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // only for different metrics servers (mainly for testing) - .debug(true) // Enable debug mode for development and testing - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(); -} diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/core/Metrics.java index 4dc2f25c..04beb858 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/core/Metrics.java @@ -15,13 +15,14 @@ */ public interface Metrics { /** - * Get the SDK-wide settings for this metrics instance. + * Get the token used to authenticate with the metrics server and identify the project. * - * @return the settings - * @since 0.23.0 + * @return the metrics token + * @since 0.1.0 */ + @Token @Contract(pure = true) - Settings getSettings(); + String getToken(); /** * Get the error tracker for this metrics instance. @@ -127,14 +128,17 @@ interface Factory> { F featureFlagService(FeatureFlagService service); /** - * Sets the SDK-wide settings for this metrics instance. + * Sets the token used to authenticate with the metrics server and identify the project. + *

+ * This token can be found in the settings of your project under "Your API Token". * - * @param settings the settings + * @param token the metrics token * @return the metrics factory - * @since 0.23.0 + * @throws IllegalArgumentException if the token does not match the {@link Token#PATTERN} + * @since 0.1.0 */ @Contract(mutates = "this") - F settings(Settings settings); + F token(@Token String token) throws IllegalArgumentException; /** * Creates a new metrics instance. @@ -143,8 +147,8 @@ interface Factory> { * * @param object a required object as defined by the implementation * @return the metrics instance - * @throws IllegalStateException if the settings are not specified - * @see #settings(Settings) + * @throws IllegalStateException if the token is not specified + * @see #token(String) * @since 0.1.0 */ @Async.Schedule diff --git a/core/src/main/java/dev/faststats/core/Settings.java b/core/src/main/java/dev/faststats/core/Settings.java deleted file mode 100644 index 7e0b02c9..00000000 --- a/core/src/main/java/dev/faststats/core/Settings.java +++ /dev/null @@ -1,133 +0,0 @@ -package dev.faststats.core; - -import org.jetbrains.annotations.Contract; - -import java.net.URI; - -/** - * SDK-wide settings shared across all FastStats services. - * - * @since 0.23.0 - */ -public sealed interface Settings permits SimpleSettings { - /** - * Creates a new {@link Settings} instance with the given token. - *

- * This token can be found in the settings of your project under "Your API Token". - * It is used to authenticate with the server and identify the project. - * - * @param token the token - * @return the new settings - * @since 0.23.0 - */ - @Contract(value = "_ -> new", pure = true) - static Settings withToken(@Token final String token) { - return factory().token(token).create(); - } - - /** - * Create a new factory for building {@link Settings}. - * - * @return a new factory - * @since 0.23.0 - */ - @Contract(value = " -> new", pure = true) - static Factory factory() { - return new SimpleSettings.Factory(); - } - - /** - * The token used to authenticate with the server and identify the project. - * - * @return the token - * @since 0.23.0 - */ - @Token - @Contract(pure = true) - String token(); - - /** - * The metrics server URL. - * - * @return the metrics server URL - * @since 0.23.0 - */ - @Contract(pure = true) - URI metricsUrl(); - - /** - * The flags server URL. - * - * @return the flags server URL - * @since 0.23.0 - */ - @Contract(pure = true) - URI flagsUrl(); - - /** - * Whether debug logging is enabled. - * - * @return {@code true} if debug logging is enabled, {@code false} otherwise - * @since 0.23.0 - */ - @Contract(pure = true) - boolean debug(); - - /** - * A factory for creating {@link Settings} instances. - * - * @since 0.23.0 - */ - sealed interface Factory permits SimpleSettings.Factory { - /** - * Sets the token used to authenticate with the server and identify the project. - *

- * This token can be found in the settings of your project under "Your API Token". - * - * @param token the token - * @return the factory - * @throws IllegalArgumentException if the token does not match the {@link Token#PATTERN} - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory token(@Token String token) throws IllegalArgumentException; - - /** - * Sets the server URL. - *

- * This is only required for self-hosted servers. - * - * @param url the server URL - * @return the factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory metricsServer(URI url); // todo: rethink naming - - // todo: add docs - @Contract(mutates = "this") - Factory flagsServer(URI url); // todo: rethink naming - - /** - * Enables or disables debug logging. - *

- * This is only meant for development and testing and should not be enabled in production. - * - * @param enabled whether debug logging is enabled - * @return the factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory debug(boolean enabled); - - /** - * Creates a new {@link Settings} instance. - * - * @return the settings - * @throws IllegalStateException if the token is not specified - * @since 0.23.0 - */ - @Contract(value = " -> new", pure = true) - Settings create() throws IllegalStateException; - } -} diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 0661d05e..849db551 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -14,6 +14,8 @@ import java.io.IOException; import java.io.OutputStreamWriter; import java.net.ConnectException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; @@ -41,19 +43,24 @@ public abstract class SimpleMetrics implements Metrics { protected final Logger logger = Logger.getLogger(getClass().getName()); private static final URI defaultUrl = URI.create("https://metrics.faststats.dev/v1/collect"); + + private static final String SDK_NAME; + private static final String SDK_VERSION; + private static final String BUILD_ID; + private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); private @Nullable ScheduledExecutorService executor = null; + private final URI url; private final Set> metrics; private final Config config; - private final Settings settings; + private final @Token String token; private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; private final @Nullable FeatureFlagService flagService; - private final boolean debug; private final String SDK_NAME; private final String SDK_VERSION; @@ -71,16 +78,29 @@ public abstract class SimpleMetrics implements Metrics { } @Contract(mutates = "io") + @SuppressWarnings("PatternValidation") protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { - if (factory.settings == null) throw new IllegalStateException("Settings must be specified"); + if (factory.token == null) throw new IllegalStateException("Token must be specified"); this.config = config; - this.settings = factory.settings; + this.token = factory.token; this.metrics = config.additionalMetrics ? Set.copyOf(factory.metrics) : Set.of(); - this.debug = settings.debug() || Boolean.getBoolean("faststats.debug") || config.debug(); + final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); + this.logger.setFilter(record -> debug || record.getLevel().equals(Level.CONFIG)); this.tracker = config.errorTracking ? factory.tracker : null; this.flush = factory.flush; this.flagService = factory.flagService; + this.url = getMetricsServerUrl(); + } + + private URI getMetricsServerUrl() { + final var property = System.getProperty("faststats.metrics-server"); + try { + return property != null ? new URI(property) : defaultUrl; + } catch (final URISyntaxException e) { + error("Failed to parse metrics server url: %s", e, property); + return defaultUrl; + } } @Contract(mutates = "io") @@ -92,16 +112,19 @@ protected SimpleMetrics(final Factory factory, final Path config) throws I protected SimpleMetrics( final Config config, final Set> metrics, - final Settings settings, + @Token final String token, @Nullable final ErrorTracker tracker, - @Nullable final Runnable flush + @Nullable final Runnable flush, + final URI url, + final boolean debug ) { this.metrics = config.additionalMetrics ? Set.copyOf(metrics) : Set.of(); this.config = config; - this.debug = settings.debug(); - this.settings = settings; + this.logger.setLevel(debug ? Level.ALL : Level.OFF); + this.token = token; this.tracker = tracker; this.flush = flush; + this.url = url; this.flagService = null; } @@ -201,18 +224,17 @@ private boolean submitNow() throws IOException { final var compressed = byteOutput.toByteArray(); info("Compressed size: %s bytes", compressed.length); - final var url = settings.metricsUrl().resolve("/collect"); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") - .header("Authorization", "Bearer " + settings.token()) + .header("Authorization", "Bearer " + token) .header("User-Agent", "FastStats Metrics " + SDK_NAME + "/" + SDK_VERSION) .timeout(Duration.ofSeconds(3)) .uri(url) .build(); - info("Sending metrics to: " + url); + info("Sending metrics to: %s", url); try { final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); final var statusCode = response.statusCode(); @@ -288,8 +310,8 @@ protected JsonObject createData() { } @Override - public Settings getSettings() { - return settings; + public @Token String getToken() { + return token; } @Override @@ -351,7 +373,7 @@ public abstract static class Factory> impleme private @Nullable ErrorTracker tracker; private @Nullable FeatureFlagService flagService; private @Nullable Runnable flush; - private @Nullable Settings settings; + private @Nullable String token; @Override @SuppressWarnings("unchecked") @@ -383,8 +405,11 @@ public F featureFlagService(final FeatureFlagService service) { @Override @SuppressWarnings("unchecked") - public F settings(final Settings settings) { - this.settings = settings; + public F token(@Token final String token) throws IllegalArgumentException { + if (!token.matches(Token.PATTERN)) { + throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); + } + this.token = token; return (F) this; } } diff --git a/core/src/main/java/dev/faststats/core/SimpleSettings.java b/core/src/main/java/dev/faststats/core/SimpleSettings.java deleted file mode 100644 index 5f01d2e8..00000000 --- a/core/src/main/java/dev/faststats/core/SimpleSettings.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.faststats.core; - -import org.jspecify.annotations.Nullable; - -import java.net.URI; - -record SimpleSettings(@Token String token, URI metricsUrl, URI flagsUrl, boolean debug) implements Settings { - - static final class Factory implements Settings.Factory { - private URI metricsUrl = URI.create("https://metrics.faststats.dev/v1"); - private URI flagsUrl = URI.create("https://flags.faststats.dev/v1"); - private @Nullable String token; - private boolean debug = false; - - @Override - public Settings.Factory token(@Token final String token) throws IllegalArgumentException { - if (!token.matches(Token.PATTERN)) { - throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } - this.token = token; - return this; - } - - @Override - public Settings.Factory metricsServer(final URI url) { - this.metricsUrl = url; - return this; - } - - @Override - public Settings.Factory flagsServer(final URI url) { - this.flagsUrl = url; - return this; - } - - @Override - public Settings.Factory debug(final boolean enabled) { - this.debug = enabled; - return this; - } - - @Override - @SuppressWarnings("PatternValidation") - public Settings create() throws IllegalStateException { - if (token == null) throw new IllegalStateException("Token must be specified"); - return new SimpleSettings(token, metricsUrl, flagsUrl, debug); - } - } -} diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java index cebbed40..63e54176 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java @@ -1,41 +1,61 @@ package dev.faststats.core.flags; -import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; +import dev.faststats.core.Token; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import java.time.Duration; /** * A service for managing feature flags. *

- * Create an instance using the {@link Factory} and pass it to the metrics factory - * via {@link Metrics.Factory#featureFlagService(FeatureFlagService)}. + * Use one of the static {@code create} methods to construct a service instance. * * @since 0.23.0 */ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { /** - * Create a new {@link FeatureFlagService} with the given settings and default options. + * Creates a feature flag service for the given environment token + * and a default cache TTL of five minutes. * - * @param settings the SDK-wide settings + * @param token the environment token * @return a new feature flag service + * @see #create(String, Attributes) * @since 0.23.0 */ @Contract(value = "_ -> new", pure = true) - static FeatureFlagService create(final Settings settings) { - return factory().settings(settings).create(); + static FeatureFlagService create(@Token final String token) { + return create(token, null); } /** - * Create a new factory for building a {@link FeatureFlagService}. + * Creates a feature flag service for the given environment token + * and global targeting attributes with a default cache TTL of five minutes. * - * @return a new factory + * @param token the environment token + * @param attributes the global targeting attributes + * @return a new feature flag service + * @see #create(String, Attributes, Duration) + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + static FeatureFlagService create(@Token final String token, @Nullable final Attributes attributes) { + return create(token, attributes, Duration.ofMinutes(5)); + } + + /** + * Creates a feature flag service for the given environment token, + * global targeting attributes, and cache TTL. + * + * @param token the environment token + * @param attributes the global targeting attributes + * @param ttl the cache time-to-live for resolved flag values + * @return a new feature flag service * @since 0.23.0 */ - @Contract(value = " -> new", pure = true) - static Factory factory() { - return new SimpleFeatureFlagService.Factory(); + @Contract(value = "_, _, _ -> new", pure = true) + static FeatureFlagService create(@Token final String token, @Nullable final Attributes attributes, final Duration ttl) { + return new SimpleFeatureFlagService(token, attributes, ttl); } /** @@ -114,56 +134,4 @@ static Factory factory() { */ @Contract(mutates = "this") void shutdown(); - - /** - * A factory for creating {@link FeatureFlagService} instances. - * - * @since 0.23.0 - */ - interface Factory { - /** - * Sets the cache time-to-live for flag values. - *

- * This TTL determines the staleness window reported by - * {@link FeatureFlag#getExpiration()}. Expired cached values remain - * readable until they are explicitly refreshed or invalidated. - * - * @param ttl the cache time-to-live - * @return the factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory ttl(Duration ttl); - - /** - * Sets the global targeting attributes for all flags created by this service. - * - * @param attributes the targeting attributes - * @return the factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory attributes(Attributes attributes); - - /** - * Sets the SDK-wide settings for this feature flag service. - * - * @param settings the settings - * @return the factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory settings(Settings settings); - - /** - * Creates a new {@link FeatureFlagService} instance. - * - * @return the feature flag service - * @throws IllegalStateException if the settings are not specified - * @see #settings(Settings) - * @since 0.23.0 - */ - @Contract(value = " -> new", pure = true) - FeatureFlagService create() throws IllegalStateException; - } } diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index c9449967..68c3a617 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -6,9 +6,11 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; -import dev.faststats.core.Settings; +import dev.faststats.core.Token; import org.jspecify.annotations.Nullable; +import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -22,28 +24,41 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private static final Gson GSON = new Gson(); + private static final URI defaultUrl = URI.create("https://flags.faststats.dev/v1"); private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); - private final Settings settings; + private final @Token String token; private final @Nullable Attributes attributes; private final Duration ttl; + private final URI url; private final Map cache = new ConcurrentHashMap<>(); private final Map fetchTimes = new ConcurrentHashMap<>(); private final Map> fetchesInProgress = new ConcurrentHashMap<>(); SimpleFeatureFlagService( - final Settings settings, + final @Token String token, final @Nullable Attributes attributes, final Duration ttl ) { - this.settings = settings; + this.token = token; this.attributes = attributes; this.ttl = ttl; + this.url = getFlagsServerUrl(); + } + + private URI getFlagsServerUrl() { + final var property = System.getProperty("faststats.flags-server"); + try { + return property != null ? new URI(property) : defaultUrl; + } catch (final URISyntaxException e) { + //error("Failed to parse flags server url: %s", e, property); // todo: recover + return defaultUrl; + } } @SuppressWarnings("unchecked") @@ -73,16 +88,15 @@ CompletableFuture optOut(final SimpleFeatureFlag flag) { private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { final var requestBody = new JsonObject(); - requestBody.addProperty("projectToken", settings.token()); requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config requestBody.addProperty("flag", flag.getId()); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + settings.token()) + .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(3)) - .uri(settings.flagsUrl().resolve(path)) + .uri(url.resolve(path)) .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { @@ -113,7 +127,6 @@ boolean isExpired(final SimpleFeatureFlag flag) { private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); - requestBody.addProperty("projectToken", settings.token()); requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config requestBody.addProperty("key", flag.getId()); @@ -125,9 +138,9 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + settings.token()) + .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(3)) - .uri(settings.flagsUrl().resolve("/check")) + .uri(url.resolve("/check")) .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { @@ -197,34 +210,4 @@ public void shutdown() { fetchTimes.clear(); fetchesInProgress.clear(); } - - static final class Factory implements FeatureFlagService.Factory { - private Duration ttl = Duration.ofMinutes(5); - private @Nullable Settings settings; - private @Nullable Attributes attributes; - - @Override - public FeatureFlagService.Factory ttl(final Duration ttl) { - this.ttl = ttl; - return this; - } - - @Override - public FeatureFlagService.Factory attributes(final Attributes attributes) { - this.attributes = attributes; - return this; - } - - @Override - public FeatureFlagService.Factory settings(final Settings settings) { - this.settings = settings; - return this; - } - - @Override - public FeatureFlagService create() throws IllegalStateException { - if (settings == null) throw new IllegalStateException("Settings must be specified"); - return new SimpleFeatureFlagService(settings, attributes, ttl); - } - } } diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 44d5977d..fec30627 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -2,7 +2,6 @@ import com.google.gson.JsonObject; import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Settings; import dev.faststats.core.SimpleMetrics; import dev.faststats.core.Token; import org.jspecify.annotations.NullMarked; @@ -15,12 +14,7 @@ @NullMarked public final class MockMetrics extends SimpleMetrics { public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new Config(serverId, true, debug, true, true, false, false), Set.of(), Settings.factory() - .metricsServer(URI.create("http://localhost:5000/v1")) - .flagsServer(URI.create("http://localhost:5001/v1")) - .token(token) - .debug(debug) - .create(), tracker, null); + super(new Config(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); } @Override diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 78b44316..779163af 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -2,7 +2,6 @@ import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.fabric.FabricMetrics; import net.fabricmc.api.ModInitializer; @@ -15,7 +14,7 @@ public class ExampleMod implements ModInitializer { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create("example-mod"); // your mod id as defined in fabric.mod.json @Override diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java index 2df30b75..f6ce2df7 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java @@ -1,7 +1,6 @@ package dev.faststats.fabric; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -30,9 +29,9 @@ interface Factory extends Metrics.Factory { * * @param modId the mod id * @return the metrics instance - * @throws IllegalStateException if the settings are not specified + * @throws IllegalStateException if the token is not specified * @throws IllegalArgumentException if the mod is not found - * @see #settings(Settings) + * @see #token(String) * @since 0.12.0 */ @Override diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 88921ae1..98a7958f 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -4,7 +4,6 @@ import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.hytale.HytaleMetrics; @@ -16,7 +15,7 @@ public class ExamplePlugin extends JavaPlugin { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); public ExamplePlugin(final JavaPluginInit init) { diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 920bb3e0..bb6caabc 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -3,7 +3,6 @@ import com.google.inject.Inject; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.sponge.SpongeMetrics; import org.jspecify.annotations.Nullable; @@ -30,7 +29,7 @@ public void onServerStart(final StartedEngineEvent event) { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(pluginContainer); } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 69afdc47..29d136d3 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -7,7 +7,6 @@ import com.velocitypowered.api.plugin.Plugin; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; -import dev.faststats.core.Settings; import dev.faststats.core.data.Metric; import dev.faststats.velocity.VelocityMetrics; import org.jspecify.annotations.Nullable; @@ -32,7 +31,7 @@ public void onProxyInitialize(final ProxyInitializeEvent event) { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .settings(Settings.withToken("YOUR_TOKEN_HERE")) // token can be found in the settings of your project + .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index d082fe83..dbfb60b7 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -61,9 +61,9 @@ public Factory(final ProxyServer server, final Logger logger, @DataDirectory fin * * @param plugin the plugin instance * @return the metrics instance - * @throws IllegalStateException if the settings are not specified + * @throws IllegalStateException if the token is not specified * @throws IllegalArgumentException if the given object is not a valid plugin - * @see #settings(Settings) + * @see #token(String) * @since 0.1.0 */ @Override From d679ef84da1c4c63966b7e4e19171af2a0e37ec0 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 15:43:40 +0200 Subject: [PATCH 010/140] Document FeatureFlags --- .../dev/faststats/core/flags/FeatureFlag.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java index 23cf853d..77532a14 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java @@ -36,6 +36,9 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { /** * Returns the class representing the value type of this flag. + *

+ * This always returns exactly one of {@link String}.class, + * {@link Number}.class, or {@link Boolean}.class, matching {@link #getType()}. * * @return the value type class * @since 0.23.0 @@ -148,8 +151,31 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { @Contract(pure = true) T getDefaultValue(); - // todo: add docs + /** + * Supported value types for feature flags. + * + * @since 0.23.0 + */ enum Type { - STRING, BOOLEAN, NUMBER + /** + * A string-valued flag. + * + * @since 0.23.0 + */ + STRING, + + /** + * A boolean-valued flag. + * + * @since 0.23.0 + */ + BOOLEAN, + + /** + * A numeric flag. + * + * @since 0.23.0 + */ + NUMBER } } From 17137457ac4abf12f5225bfb06c0d1ccb96fd071 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 15:43:48 +0200 Subject: [PATCH 011/140] Document Attributes#forEachPrimitive --- .../src/main/java/dev/faststats/core/flags/Attributes.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/core/flags/Attributes.java b/core/src/main/java/dev/faststats/core/flags/Attributes.java index ccd79b16..ad08cb8e 100644 --- a/core/src/main/java/dev/faststats/core/flags/Attributes.java +++ b/core/src/main/java/dev/faststats/core/flags/Attributes.java @@ -84,7 +84,12 @@ static Attributes copyOf(final Attributes attributes) { @Contract(value = "_ -> this", mutates = "this") Attributes remove(String key); - // todo: add docs + /** + * Visit each stored attribute as its underlying JSON primitive value. + * + * @param action the action to invoke for each key-value pair + * @since 0.23.0 + */ void forEachPrimitive(BiConsumer action); /** From 465223211945b70c05b4c22536843a918c3498f3 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 15:44:12 +0200 Subject: [PATCH 012/140] Use correct url --- core/src/main/java/dev/faststats/core/SimpleMetrics.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 849db551..ae49a87b 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -255,9 +255,9 @@ private boolean submitNow() throws IOException { warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); } } catch (final HttpConnectTimeoutException t) { - error("Metrics submission timed out after 3 seconds: %s", null, defaultUrl); + error("Metrics submission timed out after 3 seconds: %s", null, url); } catch (final ConnectException t) { - error("Failed to connect to metrics server: %s", null, defaultUrl); + error("Failed to connect to metrics server: %s", null, url); } catch (final Throwable t) { error("Failed to submit metrics", t); } From 02d3fd36247c407b107eaccb1a20c499c964eb4c Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 15:44:32 +0200 Subject: [PATCH 013/140] Make SDK properties static --- .../java/dev/faststats/core/SimpleMetrics.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index ae49a87b..c508382f 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -62,19 +62,15 @@ public abstract class SimpleMetrics implements Metrics { private final @Nullable Runnable flush; private final @Nullable FeatureFlagService flagService; - private final String SDK_NAME; - private final String SDK_VERSION; - private final String BUILD_ID; - - { + static { final var properties = new Properties(); - try (final var stream = getClass().getResourceAsStream("/META-INF/faststats.properties")) { + try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { if (stream != null) properties.load(stream); } catch (final IOException ignored) { } - this.SDK_NAME = properties.getProperty("name", "unknown"); - this.SDK_VERSION = properties.getProperty("version", "unknown"); - this.BUILD_ID = properties.getProperty("build-id", "unknown"); + SDK_NAME = properties.getProperty("name", "unknown"); + SDK_VERSION = properties.getProperty("version", "unknown"); + BUILD_ID = properties.getProperty("build-id", "unknown"); } @Contract(mutates = "io") From b6032232bd1fefee66f9b9c58b2f2ffa5c70d174 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 16:02:33 +0200 Subject: [PATCH 014/140] Add minimal logger api --- .../faststats/bukkit/BukkitMetricsImpl.java | 2 +- .../dev/faststats/core/SimpleMetrics.java | 110 ++++++------------ .../dev/faststats/core/internal/Logger.java | 27 +++++ .../core/internal/LoggerFactory.java | 16 +++ .../faststats/core/internal/SimpleLogger.java | 45 +++++++ .../core/internal/SimpleLoggerFactory.java | 8 ++ .../faststats/core/internal/package-info.java | 4 + core/src/main/java/module-info.java | 5 +- 8 files changed, 142 insertions(+), 75 deletions(-) create mode 100644 core/src/main/java/dev/faststats/core/internal/Logger.java create mode 100644 core/src/main/java/dev/faststats/core/internal/LoggerFactory.java create mode 100644 core/src/main/java/dev/faststats/core/internal/SimpleLogger.java create mode 100644 core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java create mode 100644 core/src/main/java/dev/faststats/core/internal/package-info.java diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 4afb46f9..4222d8f0 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -74,7 +74,7 @@ private int getPlayerCount() { try { return plugin.getServer().getOnlinePlayers().size(); } catch (final Throwable t) { - error("Failed to get player count", t); + logger.error("Failed to get player count", t); return 0; } } diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index c508382f..87b46c5b 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -3,7 +3,9 @@ import com.google.gson.JsonObject; import dev.faststats.core.data.Metric; import dev.faststats.core.flags.FeatureFlagService; -import org.intellij.lang.annotations.PrintFormat; +import dev.faststats.core.internal.Constants; +import dev.faststats.core.internal.Logger; +import dev.faststats.core.internal.LoggerFactory; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.MustBeInvokedByOverriders; @@ -34,20 +36,14 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiPredicate; import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; import java.util.zip.GZIPOutputStream; import static java.nio.charset.StandardCharsets.UTF_8; public abstract class SimpleMetrics implements Metrics { - protected final Logger logger = Logger.getLogger(getClass().getName()); + protected final Logger logger = LoggerFactory.factory().getLogger(getClass().getName()); private static final URI defaultUrl = URI.create("https://metrics.faststats.dev/v1/collect"); - private static final String SDK_NAME; - private static final String SDK_VERSION; - private static final String BUILD_ID; - private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) @@ -62,17 +58,6 @@ public abstract class SimpleMetrics implements Metrics { private final @Nullable Runnable flush; private final @Nullable FeatureFlagService flagService; - static { - final var properties = new Properties(); - try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { - if (stream != null) properties.load(stream); - } catch (final IOException ignored) { - } - SDK_NAME = properties.getProperty("name", "unknown"); - SDK_VERSION = properties.getProperty("version", "unknown"); - BUILD_ID = properties.getProperty("build-id", "unknown"); - } - @Contract(mutates = "io") @SuppressWarnings("PatternValidation") protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { @@ -94,7 +79,7 @@ private URI getMetricsServerUrl() { try { return property != null ? new URI(property) : defaultUrl; } catch (final URISyntaxException e) { - error("Failed to parse metrics server url: %s", e, property); + logger.error("Failed to parse metrics server url: %s", e, property); return defaultUrl; } } @@ -152,7 +137,7 @@ protected void startSubmitting() { @SuppressWarnings("PatternValidation") private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { if (Boolean.getBoolean("faststats.first-run")) { - info("Skipping metrics submission due to first-run flag"); + logger.info("Skipping metrics submission due to first-run flag"); return; } @@ -162,9 +147,9 @@ private void startSubmitting(final long initialDelay, final long period, final T final var split = getOnboardingMessage().split("\n"); for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); - info("-".repeat(separatorLength)); - for (final var s : split) info(s); - info("-".repeat(separatorLength)); + logger.info("-".repeat(separatorLength)); + for (final var s : split) logger.info(s); + logger.info("-".repeat(separatorLength)); System.setProperty("faststats.first-run", "true"); if (!config.externallyManaged()) return; @@ -173,22 +158,22 @@ private void startSubmitting(final long initialDelay, final long period, final T final var enabled = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); if (!config.enabled() || !enabled) { - warn("Metrics disabled, not starting submission"); + logger.warn("Metrics disabled, not starting submission"); return; } if (isSubmitting()) { - warn("Metrics already submitting, not starting again"); + logger.warn("Metrics already submitting, not starting again"); return; } - this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> { // todo: SINGLE THREAD??? what was i smoking? final var thread = new Thread(runnable, "metrics-submitter"); thread.setDaemon(true); return thread; }); - info("Starting metrics submission"); + logger.info("Starting metrics submission"); executor.scheduleAtFixedRate(this::submit, Math.max(0, initialDelay), Math.max(1000, period), unit); } @@ -200,7 +185,7 @@ public boolean submit() { try { return submitNow(); } catch (final Throwable t) { - error("Failed to submit metrics", t); + logger.error("Failed to submit metrics", t); return false; } } @@ -209,7 +194,7 @@ private boolean submitNow() throws IOException { final var data = createData().toString(); final var bytes = data.getBytes(UTF_8); - info("Uncompressed data: %s", data); + logger.info("Uncompressed data: %s", data); try (final var byteOutput = new ByteArrayOutputStream(); final var output = new GZIPOutputStream(byteOutput)) { @@ -218,55 +203,55 @@ private boolean submitNow() throws IOException { output.finish(); final var compressed = byteOutput.toByteArray(); - info("Compressed size: %s bytes", compressed.length); + logger.info("Compressed size: %s bytes", compressed.length); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") .header("Authorization", "Bearer " + token) - .header("User-Agent", "FastStats Metrics " + SDK_NAME + "/" + SDK_VERSION) + .header("User-Agent", "FastStats Metrics " + Constants.SDK_NAME + "/" + Constants.SDK_VERSION) .timeout(Duration.ofSeconds(3)) .uri(url) .build(); - info("Sending metrics to: %s", url); + logger.info("Sending metrics to: %s", url); try { final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); final var statusCode = response.statusCode(); final var body = response.body(); if (statusCode >= 200 && statusCode < 300) { - info("Metrics submitted with status code: %s (%s)", statusCode, body); + logger.info("Metrics submitted with status code: %s (%s)", statusCode, body); getErrorTracker().map(SimpleErrorTracker.class::cast).ifPresent(SimpleErrorTracker::clear); if (flush != null) flush.run(); return true; } else if (statusCode >= 300 && statusCode < 400) { - warn("Received redirect response from metrics server: %s (%s)", statusCode, body); + logger.warn("Received redirect response from metrics server: %s (%s)", statusCode, body); } else if (statusCode >= 400 && statusCode < 500) { - error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); + logger.error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); } else if (statusCode >= 500 && statusCode < 600) { - error("Received server error response from metrics server: %s (%s)", null, statusCode, body); + logger.error("Received server error response from metrics server: %s (%s)", null, statusCode, body); } else { - warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); + logger.warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); } } catch (final HttpConnectTimeoutException t) { - error("Metrics submission timed out after 3 seconds: %s", null, url); + logger.error("Metrics submission timed out after 3 seconds: %s", null, url); } catch (final ConnectException t) { - error("Failed to connect to metrics server: %s", null, url); + logger.error("Failed to connect to metrics server: %s", null, url); } catch (final Throwable t) { - error("Failed to submit metrics", t); + logger.error("Failed to submit metrics", t); } return false; } } - private final String javaVendor = System.getProperty("java.vendor"); - private final String javaVersion = System.getProperty("java.version"); - private final String osArch = System.getProperty("os.arch"); - private final String osName = System.getProperty("os.name"); - private final String osVersion = System.getProperty("os.version"); - private final int coreCount = Runtime.getRuntime().availableProcessors(); + private static final String javaVendor = System.getProperty("java.vendor"); + private static final String javaVersion = System.getProperty("java.version"); + private static final String osArch = System.getProperty("os.arch"); + private static final String osName = System.getProperty("os.name"); + private static final String osVersion = System.getProperty("os.version"); + private static final int coreCount = Runtime.getRuntime().availableProcessors(); protected JsonObject createData() { final var data = new JsonObject(); @@ -282,7 +267,7 @@ protected JsonObject createData() { try { appendDefaultData(metrics); } catch (final Throwable t) { - error("Failed to append default data", t); + logger.error("Failed to append default data", t); getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); } @@ -290,7 +275,7 @@ protected JsonObject createData() { try { metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { - error("Failed to build metric data: %s", t, metric.getId()); + logger.error("Failed to build metric data: %s", t, metric.getId()); getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); } }); @@ -299,7 +284,7 @@ protected JsonObject createData() { data.add("data", metrics); getErrorTracker().map(SimpleErrorTracker.class::cast) - .map(tracker -> tracker.getData(BUILD_ID)) + .map(tracker -> tracker.getData(Constants.BUILD_ID)) .filter(errors -> !errors.isEmpty()) .ifPresent(errors -> data.add("errors", errors)); return data; @@ -328,37 +313,16 @@ public Metrics.Config getConfig() { @Contract(mutates = "param1") protected abstract void appendDefaultData(JsonObject metrics); - protected void error(@PrintFormat final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { - if (throwable != null) { - if (!logger.isLoggable(Level.SEVERE)) return; - final var logRecord = new LogRecord(Level.SEVERE, message.formatted(args)); - logRecord.setThrown(throwable); - logger.log(logRecord); - } else log(Level.SEVERE, message, args); - } - - protected void log(final Level level, @PrintFormat final String message, @Nullable final Object... args) { - logger.log(level, () -> message.formatted(args)); - } - - protected void info(@PrintFormat final String message, @Nullable final Object... args) { - log(Level.INFO, message, args); - } - - protected void warn(@PrintFormat final String message, @Nullable final Object... args) { - log(Level.WARNING, message, args); - } - @Override public void shutdown() { if (flagService != null) flagService.shutdown(); getErrorTracker().ifPresent(ErrorTracker::detachErrorContext); if (executor != null) try { - info("Shutting down metrics submission"); + logger.info("Shutting down metrics submission"); executor.shutdown(); submit(); } catch (final Throwable t) { - error("Failed to submit metrics on shutdown", t); + logger.error("Failed to submit metrics on shutdown", t); } finally { executor = null; } diff --git a/core/src/main/java/dev/faststats/core/internal/Logger.java b/core/src/main/java/dev/faststats/core/internal/Logger.java new file mode 100644 index 00000000..2a44988f --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/Logger.java @@ -0,0 +1,27 @@ +package dev.faststats.core.internal; + +import org.intellij.lang.annotations.PrintFormat; +import org.jspecify.annotations.Nullable; + +import java.util.logging.Filter; +import java.util.logging.Level; + +public interface Logger { + void setLevel(Level level); + + boolean isLoggable(Level level); + + void setFilter(@Nullable Filter filter); + + void error(@PrintFormat final String message, @Nullable final Throwable throwable, @Nullable final Object... args); + + void log(final Level level, @PrintFormat final String message, @Nullable final Object... args); + + default void info(@PrintFormat final String message, @Nullable final Object... args) { + log(Level.INFO, message, args); + } + + default void warn(@PrintFormat final String message, @Nullable final Object... args) { + log(Level.WARNING, message, args); + } +} diff --git a/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java b/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java new file mode 100644 index 00000000..64b63c2a --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java @@ -0,0 +1,16 @@ +package dev.faststats.core.internal; + +import java.util.ServiceLoader; + +public interface LoggerFactory { + static LoggerFactory factory() { + final class Holder { + private static final LoggerFactory INSTANCE = ServiceLoader.load(LoggerFactory.class) + .findFirst() + .orElseGet(SimpleLoggerFactory::new); + } + return Holder.INSTANCE; + } + + Logger getLogger(String name); +} diff --git a/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java b/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java new file mode 100644 index 00000000..f6e6b785 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java @@ -0,0 +1,45 @@ +package dev.faststats.core.internal; + +import org.jspecify.annotations.Nullable; + +import java.util.logging.Filter; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +class SimpleLogger implements Logger { + private final java.util.logging.Logger logger; + + public SimpleLogger(final String name) { + this.logger = java.util.logging.Logger.getLogger(name); + } + + @Override + public void setLevel(final Level level) { + logger.setLevel(level); + } + + @Override + public boolean isLoggable(final Level level) { + return logger.isLoggable(level); + } + + @Override + public void setFilter(@Nullable final Filter filter) { + logger.setFilter(filter); + } + + @Override + public void error(final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { + if (throwable != null) { + if (!logger.isLoggable(Level.SEVERE)) return; + final var logRecord = new LogRecord(Level.SEVERE, message.formatted(args)); + logRecord.setThrown(throwable); + logger.log(logRecord); + } else log(Level.SEVERE, message, args); + } + + @Override + public void log(final Level level, final String message, @Nullable final Object... args) { + logger.log(level, () -> message.formatted(args)); + } +} diff --git a/core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java b/core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java new file mode 100644 index 00000000..aab3ef87 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java @@ -0,0 +1,8 @@ +package dev.faststats.core.internal; + +final class SimpleLoggerFactory implements LoggerFactory { + @Override + public Logger getLogger(final String name) { + return new SimpleLogger(name); + } +} diff --git a/core/src/main/java/dev/faststats/core/internal/package-info.java b/core/src/main/java/dev/faststats/core/internal/package-info.java new file mode 100644 index 00000000..93362edd --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/package-info.java @@ -0,0 +1,4 @@ +@ApiStatus.Internal +package dev.faststats.core.internal; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index fce66ce9..e665b643 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -4,6 +4,7 @@ module dev.faststats.core { exports dev.faststats.core.data; exports dev.faststats.core.flags; + exports dev.faststats.core.internal; exports dev.faststats.core; requires com.google.gson; @@ -12,4 +13,6 @@ requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file + + uses dev.faststats.core.internal.LoggerFactory; +} From 598cc2d9c9048a2713e2ee6555f4e33eaf9c7958 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 16:02:46 +0200 Subject: [PATCH 015/140] Extracts constants to its own class --- .../faststats/core/internal/Constants.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 core/src/main/java/dev/faststats/core/internal/Constants.java diff --git a/core/src/main/java/dev/faststats/core/internal/Constants.java b/core/src/main/java/dev/faststats/core/internal/Constants.java new file mode 100644 index 00000000..b379046d --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/Constants.java @@ -0,0 +1,23 @@ +package dev.faststats.core.internal; + +import dev.faststats.core.SimpleMetrics; + +import java.io.IOException; +import java.util.Properties; + +public final class Constants { + public static final String SDK_NAME; + public static final String SDK_VERSION; + public static final String BUILD_ID; + + static { + final var properties = new Properties(); + try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { + if (stream != null) properties.load(stream); + } catch (final IOException ignored) { + } + SDK_NAME = properties.getProperty("name", "unknown"); + SDK_VERSION = properties.getProperty("version", "unknown"); + BUILD_ID = properties.getProperty("build-id", "unknown"); + } +} From 3e990b20da3f88934f8bed36fa1386f5194eb2d7 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 16:16:56 +0200 Subject: [PATCH 016/140] Add dedicated Hytale logger --- .../faststats/hytale/HytaleMetricsImpl.java | 20 +------ .../faststats/hytale/logger/HytaleLogger.java | 53 +++++++++++++++++++ .../hytale/logger/HytaleLoggerFactory.java | 8 +++ hytale/src/main/java/module-info.java | 4 +- .../dev.faststats.core.internal.LoggerFactory | 1 + 5 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java create mode 100644 hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java create mode 100644 hytale/src/main/resources/META-INF/services/dev.faststats.core.internal.LoggerFactory diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index 6049c9b2..8a6cba21 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -1,7 +1,6 @@ package dev.faststats.hytale; import com.google.gson.JsonObject; -import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.universe.Universe; @@ -9,19 +8,14 @@ import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.nio.file.Path; -import java.util.logging.Level; final class HytaleMetricsImpl extends SimpleMetrics implements HytaleMetrics { - private final HytaleLogger logger; - @Async.Schedule @Contract(mutates = "io") - private HytaleMetricsImpl(final Factory factory, final HytaleLogger logger, final Path config) throws IllegalStateException { + private HytaleMetricsImpl(final Factory factory, final Path config) throws IllegalStateException { super(factory, config); - this.logger = logger; startSubmitting(); } @@ -33,22 +27,12 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Hytale"); } - @Override - protected void error(final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { - if (super.logger.isLoggable(Level.SEVERE)) logger.atSevere().withCause(throwable).logVarargs(message, args); - } - - @Override - protected void log(final Level level, final String message, @Nullable final Object... args) { - if (super.logger.isLoggable(level)) logger.at(level).logVarargs(message, args); - } - static final class Factory extends SimpleMetrics.Factory implements HytaleMetrics.Factory { @Override public Metrics create(final JavaPlugin plugin) throws IllegalStateException { final var mods = plugin.getDataDirectory().toAbsolutePath().getParent(); final var config = mods.resolve("faststats").resolve("config.properties"); - return new HytaleMetricsImpl(this, plugin.getLogger(), config); + return new HytaleMetricsImpl(this, config); } } } diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java new file mode 100644 index 00000000..3b9ae5ce --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java @@ -0,0 +1,53 @@ +package dev.faststats.hytale.logger; + +import org.intellij.lang.annotations.PrintFormat; +import org.jspecify.annotations.Nullable; + +import java.util.function.Predicate; +import java.util.logging.Level; + +final class HytaleLogger implements dev.faststats.core.internal.Logger { + private final com.hypixel.hytale.logger.HytaleLogger logger; + private volatile @Nullable Predicate filter; + + HytaleLogger(final String name) { + this.logger = com.hypixel.hytale.logger.HytaleLogger.get(name); + } + + @Override + public void setLevel(final Level level) { + logger.setLevel(level); + } + + @Override + public boolean isLoggable(final Level level) { + final var loggerLevel = logger.getLevel(); + if (level.intValue() < loggerLevel.intValue()) return false; + + final var currentFilter = filter; + return currentFilter != null && currentFilter.test(level); + } + + @Override + public void setFilter(@Nullable final Predicate filter) { + this.filter = filter; + } + + @Override + public void error(@PrintFormat final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { + if (!isLoggable(Level.SEVERE)) return; + + final var api = logger.atSevere(); + if (throwable != null) { + api.withCause(throwable).logVarargs(message, args); + return; + } + api.logVarargs(message, args); + } + + @Override + public void log(final Level level, @PrintFormat final String message, @Nullable final Object... args) { + if (!isLoggable(level)) return; + logger.at(level).logVarargs(message, args); + } +} diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java new file mode 100644 index 00000000..81d2cce9 --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java @@ -0,0 +1,8 @@ +package dev.faststats.hytale.logger; + +public final class HytaleLoggerFactory implements dev.faststats.core.internal.LoggerFactory { + @Override + public dev.faststats.core.internal.Logger getLogger(final String name) { + return new HytaleLogger(name); + } +} diff --git a/hytale/src/main/java/module-info.java b/hytale/src/main/java/module-info.java index 1b3293d7..ce315628 100644 --- a/hytale/src/main/java/module-info.java +++ b/hytale/src/main/java/module-info.java @@ -10,4 +10,6 @@ requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file + + provides dev.faststats.core.internal.LoggerFactory with dev.faststats.hytale.logger.HytaleLoggerFactory; +} diff --git a/hytale/src/main/resources/META-INF/services/dev.faststats.core.internal.LoggerFactory b/hytale/src/main/resources/META-INF/services/dev.faststats.core.internal.LoggerFactory new file mode 100644 index 00000000..9affb6ba --- /dev/null +++ b/hytale/src/main/resources/META-INF/services/dev.faststats.core.internal.LoggerFactory @@ -0,0 +1 @@ +dev.faststats.hytale.logger.HytaleLoggerFactory From 808d69c1299dd406b93b9283b58e1094b135d68a Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 16:17:12 +0200 Subject: [PATCH 017/140] Use custom filter predicate --- core/src/main/java/dev/faststats/core/SimpleMetrics.java | 2 +- core/src/main/java/dev/faststats/core/internal/Logger.java | 4 ++-- .../main/java/dev/faststats/core/internal/SimpleLogger.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 87b46c5b..4a9a5561 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -67,7 +67,7 @@ protected SimpleMetrics(final Factory factory, final Config config) throws this.token = factory.token; this.metrics = config.additionalMetrics ? Set.copyOf(factory.metrics) : Set.of(); final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); - this.logger.setFilter(record -> debug || record.getLevel().equals(Level.CONFIG)); + this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); this.tracker = config.errorTracking ? factory.tracker : null; this.flush = factory.flush; this.flagService = factory.flagService; diff --git a/core/src/main/java/dev/faststats/core/internal/Logger.java b/core/src/main/java/dev/faststats/core/internal/Logger.java index 2a44988f..d5fd1d9c 100644 --- a/core/src/main/java/dev/faststats/core/internal/Logger.java +++ b/core/src/main/java/dev/faststats/core/internal/Logger.java @@ -3,7 +3,7 @@ import org.intellij.lang.annotations.PrintFormat; import org.jspecify.annotations.Nullable; -import java.util.logging.Filter; +import java.util.function.Predicate; import java.util.logging.Level; public interface Logger { @@ -11,7 +11,7 @@ public interface Logger { boolean isLoggable(Level level); - void setFilter(@Nullable Filter filter); + void setFilter(@Nullable Predicate filter); void error(@PrintFormat final String message, @Nullable final Throwable throwable, @Nullable final Object... args); diff --git a/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java b/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java index f6e6b785..37d96edf 100644 --- a/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java +++ b/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java @@ -2,7 +2,7 @@ import org.jspecify.annotations.Nullable; -import java.util.logging.Filter; +import java.util.function.Predicate; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -24,8 +24,8 @@ public boolean isLoggable(final Level level) { } @Override - public void setFilter(@Nullable final Filter filter) { - logger.setFilter(filter); + public void setFilter(@Nullable final Predicate filter) { + logger.setFilter(filter != null ? record -> filter.test(record.getLevel()) : null); } @Override From d268a7fd6f49633f4bfdc874a8dbc8a1f70fb331 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 16:17:50 +0200 Subject: [PATCH 018/140] Move logger below metrics server url --- core/src/main/java/dev/faststats/core/SimpleMetrics.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 4a9a5561..703dbe0e 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -41,9 +41,10 @@ import static java.nio.charset.StandardCharsets.UTF_8; public abstract class SimpleMetrics implements Metrics { - protected final Logger logger = LoggerFactory.factory().getLogger(getClass().getName()); private static final URI defaultUrl = URI.create("https://metrics.faststats.dev/v1/collect"); + protected final Logger logger = LoggerFactory.factory().getLogger(getClass().getName()); + private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) From 3201b48163e24e3bb3798ec272937a68763fa6a9 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 16:18:24 +0200 Subject: [PATCH 019/140] Removed unused imports --- .../src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index c9c8eac1..80bec0db 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -2,13 +2,11 @@ import cn.nukkit.Server; import cn.nukkit.plugin.PluginBase; -import cn.nukkit.utils.Logger; import com.google.gson.JsonObject; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.nio.file.Path; import java.util.Optional; From a0b8bb654d9d78a24ffa19021c34a66fe423106d Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 17:09:51 +0200 Subject: [PATCH 020/140] Replace Gson#toJson with toString --- .../dev/faststats/core/flags/SimpleFeatureFlagService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index 68c3a617..91dc53d0 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -92,7 +92,7 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, requestBody.addProperty("flag", flag.getId()); final var request = HttpRequest.newBuilder() - .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(3)) @@ -136,7 +136,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { if (!attributes.isEmpty()) requestBody.add("attributes", attributes); final var request = HttpRequest.newBuilder() - .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(requestBody))) + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(3)) From 30b554f42b2cba9069bf128be6cbddf6d69f7cf1 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 17:10:09 +0200 Subject: [PATCH 021/140] Add logger to feature flag service --- .../dev/faststats/core/flags/SimpleFeatureFlagService.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index 91dc53d0..ae0a2761 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -1,12 +1,13 @@ package dev.faststats.core.flags; -import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import dev.faststats.core.Token; +import dev.faststats.core.internal.Logger; +import dev.faststats.core.internal.LoggerFactory; import org.jspecify.annotations.Nullable; import java.net.URI; @@ -23,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; final class SimpleFeatureFlagService implements FeatureFlagService { - private static final Gson GSON = new Gson(); + private static final Logger logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class.getName()); private static final URI defaultUrl = URI.create("https://flags.faststats.dev/v1"); private final HttpClient httpClient = HttpClient.newBuilder() @@ -56,7 +57,7 @@ private URI getFlagsServerUrl() { try { return property != null ? new URI(property) : defaultUrl; } catch (final URISyntaxException e) { - //error("Failed to parse flags server url: %s", e, property); // todo: recover + logger.error("Failed to parse flags server url: %s", e, property); return defaultUrl; } } From 4e1b50aa7aa6d02856d53f05015fc801cec4d0ad Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 17:12:17 +0200 Subject: [PATCH 022/140] Decouple config from Metrics interface --- .../main/java/dev/faststats/core/Config.java | 60 ++++++++ .../main/java/dev/faststats/core/Metrics.java | 55 -------- .../java/dev/faststats/core/SimpleConfig.java | 119 ++++++++++++++++ .../dev/faststats/core/SimpleMetrics.java | 130 ++---------------- .../test/java/dev/faststats/MockMetrics.java | 3 +- .../faststats/sponge/SpongeMetricsImpl.java | 3 +- 6 files changed, 194 insertions(+), 176 deletions(-) create mode 100644 core/src/main/java/dev/faststats/core/Config.java create mode 100644 core/src/main/java/dev/faststats/core/SimpleConfig.java diff --git a/core/src/main/java/dev/faststats/core/Config.java b/core/src/main/java/dev/faststats/core/Config.java new file mode 100644 index 00000000..1fedf528 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/Config.java @@ -0,0 +1,60 @@ +package dev.faststats.core; + +import org.jetbrains.annotations.Contract; + +import java.util.UUID; + +/** + * A representation of the metrics configuration. + * + * @since 0.23.0 + */ +public sealed interface Config permits SimpleConfig { + /** + * The server id. + * + * @return the server id + * @since 0.23.0 + */ + @Contract(pure = true) + UUID serverId(); + + /** + * Whether metrics submission is enabled. + *

+ * Bypassing this setting may get your project banned from FastStats.
+ * Users have to be able to opt out from metrics submission. + * + * @return {@code true} if metrics submission is enabled, {@code false} otherwise + * @since 0.23.0 + */ + @Contract(pure = true) + boolean enabled(); + + /** + * Whether error tracking is enabled across all metrics instances. + * + * @return {@code true} if error tracking is enabled, {@code false} otherwise + * @since 0.23.0 + */ + @Contract(pure = true) + boolean errorTracking(); + + /** + * Whether additional metrics are enabled across all metrics instances. + * + * @return {@code true} if additional metrics are enabled, {@code false} otherwise + * @since 0.23.0 + */ + @Contract(pure = true) + boolean additionalMetrics(); + + /** + * Whether debug logging is enabled across all metrics instances. + * + * @return {@code true} if debug logging is enabled, {@code false} otherwise + * @since 0.23.0 + */ + @Contract(pure = true) + boolean debug(); +} diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/core/Metrics.java index 04beb858..d5b65d2f 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/core/Metrics.java @@ -6,7 +6,6 @@ import org.jetbrains.annotations.Contract; import java.util.Optional; -import java.util.UUID; /** * Metrics interface. @@ -156,58 +155,4 @@ interface Factory> { Metrics create(T object) throws IllegalStateException; } - /** - * A representation of the metrics configuration. - * - * @since 0.1.0 - */ - sealed interface Config permits SimpleMetrics.Config { - /** - * The server id. - * - * @return the server id - * @since 0.1.0 - */ - @Contract(pure = true) - UUID serverId(); - - /** - * Whether metrics submission is enabled. - *

- * Bypassing this setting may get your project banned from FastStats.
- * Users have to be able to opt out from metrics submission. - * - * @return {@code true} if metrics submission is enabled, {@code false} otherwise - * @since 0.1.0 - */ - @Contract(pure = true) - boolean enabled(); - - /** - * Whether error tracking is enabled across all metrics instances. - * - * @return {@code true} if error tracking is enabled, {@code false} otherwise - * @since 0.11.0 - */ - @Contract(pure = true) - boolean errorTracking(); - - /** - * Whether additional metrics are enabled across all metrics instances. - * - * @return {@code true} if additional metrics are enabled, {@code false} otherwise - * @since 0.11.0 - */ - @Contract(pure = true) - boolean additionalMetrics(); - - /** - * Whether debug logging is enabled across all metrics instances. - * - * @return {@code true} if debug logging is enabled, {@code false} otherwise - * @since 0.1.0 - */ - @Contract(pure = true) - boolean debug(); - } } diff --git a/core/src/main/java/dev/faststats/core/SimpleConfig.java b/core/src/main/java/dev/faststats/core/SimpleConfig.java new file mode 100644 index 00000000..fdb7c115 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleConfig.java @@ -0,0 +1,119 @@ +package dev.faststats.core; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiPredicate; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApiStatus.Internal +public record SimpleConfig( + UUID serverId, + boolean additionalMetrics, + boolean debug, + boolean enabled, + boolean errorTracking, + boolean firstRun, + boolean externallyManaged +) implements Config { + + public static final String DEFAULT_COMMENT = """ + FastStats (https://faststats.dev) collects anonymous usage statistics for developers. + # This helps developers understand how their projects are used in the real world. + # + # No IP addresses, player data, or personal information is collected. + # The server ID below is randomly generated and can be regenerated at any time. + # + # Enabling metrics has no noticeable performance impact. + # Keeping metrics enabled is recommended, but you can opt out by setting + # 'enabled=false' in faststats/config.properties. + # + # If you suspect a developer is collecting personal data or bypassing the "enabled" option, + # please report it at: https://faststats.dev/abuse + # + # For more information, visit: https://faststats.dev/info + """; + + @Contract(mutates = "io") + public static SimpleConfig read(final Path file) throws RuntimeException { + return read(file, DEFAULT_COMMENT, false, false); + } + + @Contract(mutates = "io") + public static SimpleConfig read(final Path file, final String comment, final boolean externallyManaged, final boolean externallyEnabled) throws RuntimeException { + final var properties = readOrEmpty(file); + final var firstRun = properties.isEmpty(); + final var saveConfig = new AtomicBoolean(firstRun); + + final var serverId = properties.map(object -> object.getProperty("serverId")).map(string -> { + try { + final var trimmed = string.trim(); + final var corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed; + if (!corrected.equals(string)) saveConfig.set(true); + return UUID.fromString(corrected); + } catch (final IllegalArgumentException e) { + saveConfig.set(true); + return UUID.randomUUID(); + } + }).orElseGet(() -> { + saveConfig.set(true); + return UUID.randomUUID(); + }); + + final BiPredicate predicate = (key, defaultValue) -> { + return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> { + saveConfig.set(true); + return defaultValue; + }); + }; + + final var enabled = externallyManaged ? externallyEnabled : predicate.test("enabled", true); + final var errorTracking = predicate.test("submitErrors", true); + final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); + final var debug = predicate.test("debug", false); + + if (saveConfig.get()) try { + save(file, externallyManaged, comment, serverId, enabled, errorTracking, additionalMetrics, debug); + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + + return new SimpleConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun, externallyManaged); + } + + private static Optional readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return Optional.empty(); + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return Optional.of(properties); + } catch (final IOException e) { + throw new RuntimeException("Failed to read metrics config", e); + } + } + + private static void save(final Path file, final boolean externallyManaged, final String comment, final UUID serverId, final boolean enabled, final boolean errorTracking, final boolean additionalMetrics, final boolean debug) throws IOException { + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var properties = new Properties(); + + properties.setProperty("serverId", serverId.toString()); + if (!externallyManaged) properties.setProperty("enabled", Boolean.toString(enabled)); + properties.setProperty("submitErrors", Boolean.toString(errorTracking)); + properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + properties.setProperty("debug", Boolean.toString(debug)); + + properties.store(writer, comment); + } + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 703dbe0e..7cb5128c 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -3,6 +3,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.data.Metric; import dev.faststats.core.flags.FeatureFlagService; +import dev.faststats.core.internal.ConfigProvider; import dev.faststats.core.internal.Constants; import dev.faststats.core.internal.Logger; import dev.faststats.core.internal.LoggerFactory; @@ -14,7 +15,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.OutputStreamWriter; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; @@ -22,19 +22,13 @@ import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Duration; import java.util.HashSet; import java.util.Optional; -import java.util.Properties; import java.util.Set; -import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.BiPredicate; import java.util.logging.Level; import java.util.zip.GZIPOutputStream; @@ -53,7 +47,7 @@ public abstract class SimpleMetrics implements Metrics { private final URI url; private final Set> metrics; - private final Config config; + private final SimpleConfig config; private final @Token String token; private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; @@ -64,12 +58,12 @@ public abstract class SimpleMetrics implements Metrics { protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { if (factory.token == null) throw new IllegalStateException("Token must be specified"); - this.config = config; + this.config = (SimpleConfig) config; this.token = factory.token; - this.metrics = config.additionalMetrics ? Set.copyOf(factory.metrics) : Set.of(); + this.metrics = config.additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); - this.tracker = config.errorTracking ? factory.tracker : null; + this.tracker = config.errorTracking() ? factory.tracker : null; this.flush = factory.flush; this.flagService = factory.flagService; this.url = getMetricsServerUrl(); @@ -86,8 +80,8 @@ private URI getMetricsServerUrl() { } @Contract(mutates = "io") - protected SimpleMetrics(final Factory factory, final Path config) throws IllegalStateException { - this(factory, Config.read(config)); + protected SimpleMetrics(final Factory factory) throws IllegalStateException { + this(factory, SimpleConfig.read(ConfigProvider.provider().getConfigPath().resolve("config.properties"))); } @VisibleForTesting @@ -100,8 +94,8 @@ protected SimpleMetrics( final URI url, final boolean debug ) { - this.metrics = config.additionalMetrics ? Set.copyOf(metrics) : Set.of(); - this.config = config; + this.metrics = config.additionalMetrics() ? Set.copyOf(metrics) : Set.of(); + this.config = (SimpleConfig) config; this.logger.setLevel(debug ? Level.ALL : Level.OFF); this.token = token; this.tracker = tracker; @@ -142,8 +136,7 @@ private void startSubmitting(final long initialDelay, final long period, final T return; } - if (config.firstRun) { - + if (config.firstRun()) { var separatorLength = 0; final var split = getOnboardingMessage().split("\n"); for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); @@ -307,7 +300,7 @@ public Optional getFeatureFlagService() { } @Override - public Metrics.Config getConfig() { + public dev.faststats.core.Config getConfig() { return config; } @@ -375,105 +368,4 @@ public F token(@Token final String token) throws IllegalArgumentException { } } - public record Config( - UUID serverId, - boolean additionalMetrics, - boolean debug, - boolean enabled, - boolean errorTracking, - boolean firstRun, - boolean externallyManaged - ) implements Metrics.Config { - - public static final String DEFAULT_COMMENT = """ - FastStats (https://faststats.dev) collects anonymous usage statistics for developers. - # This helps developers understand how their projects are used in the real world. - # - # No IP addresses, player data, or personal information is collected. - # The server ID below is randomly generated and can be regenerated at any time. - # - # Enabling metrics has no noticeable performance impact. - # Keeping metrics enabled is recommended, but you can opt out by setting - # 'enabled=false' in faststats/config.properties. - # - # If you suspect a developer is collecting personal data or bypassing the "enabled" option, - # please report it at: https://faststats.dev/abuse - # - # For more information, visit: https://faststats.dev/info - """; - - @Contract(mutates = "io") - public static Config read(final Path file) throws RuntimeException { - return read(file, DEFAULT_COMMENT, false, false); - } - - @Contract(mutates = "io") - public static Config read(final Path file, final String comment, final boolean externallyManaged, final boolean externallyEnabled) throws RuntimeException { - final var properties = readOrEmpty(file); - final var firstRun = properties.isEmpty(); - final var saveConfig = new AtomicBoolean(firstRun); - - final var serverId = properties.map(object -> object.getProperty("serverId")).map(string -> { - try { - final var trimmed = string.trim(); - final var corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed; - if (!corrected.equals(string)) saveConfig.set(true); - return UUID.fromString(corrected); - } catch (final IllegalArgumentException e) { - saveConfig.set(true); - return UUID.randomUUID(); - } - }).orElseGet(() -> { - saveConfig.set(true); - return UUID.randomUUID(); - }); - - final BiPredicate predicate = (key, defaultValue) -> { - return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> { - saveConfig.set(true); - return defaultValue; - }); - }; - - final var enabled = externallyManaged ? externallyEnabled : predicate.test("enabled", true); - final var errorTracking = predicate.test("submitErrors", true); - final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); - final var debug = predicate.test("debug", false); - - if (saveConfig.get()) try { - save(file, externallyManaged, comment, serverId, enabled, errorTracking, additionalMetrics, debug); - } catch (final IOException e) { - throw new RuntimeException("Failed to save metrics config", e); - } - - return new Config(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun, externallyManaged); - } - - private static Optional readOrEmpty(final Path file) throws RuntimeException { - if (!Files.isRegularFile(file)) return Optional.empty(); - try (final var reader = Files.newBufferedReader(file, UTF_8)) { - final var properties = new Properties(); - properties.load(reader); - return Optional.of(properties); - } catch (final IOException e) { - throw new RuntimeException("Failed to read metrics config", e); - } - } - - private static void save(final Path file, final boolean externallyManaged, final String comment, final UUID serverId, final boolean enabled, final boolean errorTracking, final boolean additionalMetrics, final boolean debug) throws IOException { - Files.createDirectories(file.getParent()); - try (final var out = Files.newOutputStream(file); - final var writer = new OutputStreamWriter(out, UTF_8)) { - final var properties = new Properties(); - - properties.setProperty("serverId", serverId.toString()); - if (!externallyManaged) properties.setProperty("enabled", Boolean.toString(enabled)); - properties.setProperty("submitErrors", Boolean.toString(errorTracking)); - properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); - properties.setProperty("debug", Boolean.toString(debug)); - - properties.store(writer, comment); - } - } - } } diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index fec30627..87494bd9 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.ErrorTracker; +import dev.faststats.core.SimpleConfig; import dev.faststats.core.SimpleMetrics; import dev.faststats.core.Token; import org.jspecify.annotations.NullMarked; @@ -14,7 +15,7 @@ @NullMarked public final class MockMetrics extends SimpleMetrics { public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new Config(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); + super(new SimpleConfig(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); } @Override diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index 006b4914..5aea55f3 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import dev.faststats.core.Metrics; +import dev.faststats.core.SimpleConfig; import dev.faststats.core.SimpleMetrics; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Async; @@ -40,7 +41,7 @@ private SpongeMetricsImpl( final PluginContainer plugin, final Path config ) throws IllegalStateException { - super(factory, SimpleMetrics.Config.read(config, COMMENT, true, Sponge.metricsConfigManager() + super(factory, SimpleConfig.read(config, COMMENT, true, Sponge.metricsConfigManager() .effectiveCollectionState(plugin).asBoolean())); this.plugin = plugin; startSubmitting(); From dd9266ce80d0066693b94ca144a074d8607d219c Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 17:18:29 +0200 Subject: [PATCH 023/140] Undo happy little accident :) --- core/src/main/java/dev/faststats/core/SimpleMetrics.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 7cb5128c..924d638e 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -3,7 +3,6 @@ import com.google.gson.JsonObject; import dev.faststats.core.data.Metric; import dev.faststats.core.flags.FeatureFlagService; -import dev.faststats.core.internal.ConfigProvider; import dev.faststats.core.internal.Constants; import dev.faststats.core.internal.Logger; import dev.faststats.core.internal.LoggerFactory; @@ -22,6 +21,7 @@ import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Path; import java.time.Duration; import java.util.HashSet; import java.util.Optional; @@ -80,8 +80,8 @@ private URI getMetricsServerUrl() { } @Contract(mutates = "io") - protected SimpleMetrics(final Factory factory) throws IllegalStateException { - this(factory, SimpleConfig.read(ConfigProvider.provider().getConfigPath().resolve("config.properties"))); + protected SimpleMetrics(final Factory factory, final Path config) throws IllegalStateException { + this(factory, SimpleConfig.read(config)); } @VisibleForTesting From 0f59272d772fcd5a9e1a52d0a99f31055b8a3333 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:25:45 +0200 Subject: [PATCH 024/140] Add info comments to example --- .../main/java/dev/faststats/example/FeatureFlagExample.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index bae91d86..1461d997 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -10,11 +10,11 @@ public final class FeatureFlagExample { public static final FeatureFlagService SERVICE = FeatureFlagService.create( "YOUR_TOKEN_HERE", // token can be found in the settings of your project - Attributes.create() + Attributes.create() // Define global attributes .put("version", "1.2.3") .put("java_version", System.getProperty("java.version")) .put("java_vendor", System.getProperty("java.vendor")), - Duration.ofMinutes(10) + Duration.ofMinutes(10) // Custom cache TTL for resolved flag values ); // Define flags with default values From 4c5d9bd7a43db6a6ab0b82be2890ae131d90b4fa Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:26:11 +0200 Subject: [PATCH 025/140] Throw on negative ttl --- .../main/java/dev/faststats/core/flags/FeatureFlagService.java | 3 ++- .../dev/faststats/core/flags/SimpleFeatureFlagService.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java index 63e54176..f4d5fd53 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java @@ -51,10 +51,11 @@ static FeatureFlagService create(@Token final String token, @Nullable final Attr * @param attributes the global targeting attributes * @param ttl the cache time-to-live for resolved flag values * @return a new feature flag service + * @throws IllegalArgumentException if the TTL is negative * @since 0.23.0 */ @Contract(value = "_, _, _ -> new", pure = true) - static FeatureFlagService create(@Token final String token, @Nullable final Attributes attributes, final Duration ttl) { + static FeatureFlagService create(@Token final String token, @Nullable final Attributes attributes, final Duration ttl) throws IllegalArgumentException { return new SimpleFeatureFlagService(token, attributes, ttl); } diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index ae0a2761..024946e2 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -45,7 +45,8 @@ final class SimpleFeatureFlagService implements FeatureFlagService { final @Token String token, final @Nullable Attributes attributes, final Duration ttl - ) { + ) throws IllegalArgumentException { + if (ttl.isNegative()) throw new IllegalArgumentException("TTL cannot be negative"); this.token = token; this.attributes = attributes; this.ttl = ttl; From 631067c4de059139fb236c346c481765097fb20a Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:28:26 +0200 Subject: [PATCH 026/140] Add attributes and TTL getters --- .../core/flags/FeatureFlagService.java | 21 +++++++++++++++++++ .../core/flags/SimpleFeatureFlagService.java | 10 +++++++++ 2 files changed, 31 insertions(+) diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java index f4d5fd53..d576a1bb 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java @@ -5,6 +5,7 @@ import org.jspecify.annotations.Nullable; import java.time.Duration; +import java.util.Optional; /** * A service for managing feature flags. @@ -128,6 +129,26 @@ static FeatureFlagService create(@Token final String token, @Nullable final Attr @Contract(value = "_, _, _ -> new", pure = true) FeatureFlag define(String id, Number defaultValue, Attributes attributes); + /** + * Returns the global targeting attributes configured for this service. + *

+ * These attributes apply to every flag defined by the service and are + * merged with any per-flag attributes supplied during definition. + * + * @return the global targeting attributes, if configured + * @since 0.23.0 + */ + @Contract(pure = true) + Optional getAttributes(); + + /** + * Returns the cache time-to-live used for resolved flag values. + * + * @return the configured cache time-to-live + * @since 0.23.0 + */ + Duration getTTL(); + /** * Shuts down the feature flag service. * diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index 024946e2..c153e430 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -206,6 +206,16 @@ public FeatureFlag define(final String id, final Number defaultValue, fi return new SimpleFeatureFlag<>(id, defaultValue, attributes, this); } + @Override + public Optional getAttributes() { + return Optional.ofNullable(attributes); + } + + @Override + public Duration getTTL() { + return ttl; + } + @Override public void shutdown() { cache.clear(); From 08c756b8867157defed6721b7ad4e59228029b5c Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:35:51 +0200 Subject: [PATCH 027/140] Refactor URL retrieval --- .../java/dev/faststats/core/SimpleMetrics.java | 11 ++++------- .../core/flags/SimpleFeatureFlagService.java | 14 ++++++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 924d638e..4542cafb 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -35,9 +35,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; public abstract class SimpleMetrics implements Metrics { - private static final URI defaultUrl = URI.create("https://metrics.faststats.dev/v1/collect"); - - protected final Logger logger = LoggerFactory.factory().getLogger(getClass().getName()); + protected final Logger logger = LoggerFactory.factory().getLogger(getClass()); private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) @@ -71,12 +69,12 @@ protected SimpleMetrics(final Factory factory, final Config config) throws private URI getMetricsServerUrl() { final var property = System.getProperty("faststats.metrics-server"); - try { - return property != null ? new URI(property) : defaultUrl; + if (property != null) try { + return new URI(property); } catch (final URISyntaxException e) { logger.error("Failed to parse metrics server url: %s", e, property); - return defaultUrl; } + return URI.create("https://metrics.faststats.dev/v1/collect"); } @Contract(mutates = "io") @@ -101,7 +99,6 @@ protected SimpleMetrics( this.tracker = tracker; this.flush = flush; this.url = url; - this.flagService = null; } protected String getOnboardingMessage() { diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index c153e430..5d26985b 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -24,8 +24,8 @@ import java.util.concurrent.ConcurrentHashMap; final class SimpleFeatureFlagService implements FeatureFlagService { - private static final Logger logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class.getName()); - private static final URI defaultUrl = URI.create("https://flags.faststats.dev/v1"); + private static final Logger logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class); + private static final URI url = getFlagsServerUrl(); private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) @@ -35,7 +35,6 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private final @Token String token; private final @Nullable Attributes attributes; private final Duration ttl; - private final URI url; private final Map cache = new ConcurrentHashMap<>(); private final Map fetchTimes = new ConcurrentHashMap<>(); @@ -50,17 +49,16 @@ final class SimpleFeatureFlagService implements FeatureFlagService { this.token = token; this.attributes = attributes; this.ttl = ttl; - this.url = getFlagsServerUrl(); } - private URI getFlagsServerUrl() { + private static URI getFlagsServerUrl() { final var property = System.getProperty("faststats.flags-server"); - try { - return property != null ? new URI(property) : defaultUrl; + if (property != null) try { + return new URI(property); } catch (final URISyntaxException e) { logger.error("Failed to parse flags server url: %s", e, property); - return defaultUrl; } + return URI.create("https://flags.faststats.dev/v1"); } @SuppressWarnings("unchecked") From cfe4a1fe36ffc453a5ee52618fda2c04c0bae59c Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:36:08 +0200 Subject: [PATCH 028/140] Add `getLogger(Class)` overload --- .../main/java/dev/faststats/core/internal/LoggerFactory.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java b/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java index 64b63c2a..5a4a1af5 100644 --- a/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java +++ b/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java @@ -12,5 +12,9 @@ final class Holder { return Holder.INSTANCE; } + default Logger getLogger(final Class clazz) { + return getLogger(clazz.getName()); + } + Logger getLogger(String name); } From a32d6c0fbdff14e5299afe271b72defb35505a85 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:36:35 +0200 Subject: [PATCH 029/140] Decouple metrics and feature flags --- .../main/java/dev/faststats/core/Metrics.java | 20 ------------------- .../dev/faststats/core/SimpleMetrics.java | 17 ---------------- 2 files changed, 37 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/core/Metrics.java index d5b65d2f..d931cc20 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/core/Metrics.java @@ -1,7 +1,6 @@ package dev.faststats.core; import dev.faststats.core.data.Metric; -import dev.faststats.core.flags.FeatureFlagService; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -32,15 +31,6 @@ public interface Metrics { @Contract(pure = true) Optional getErrorTracker(); - /** - * Get the feature flag service for this metrics instance. - * - * @return the feature flag service - * @since 0.23.0 - */ - @Contract(pure = true) - Optional getFeatureFlagService(); - /** * Get the metrics configuration. * @@ -116,16 +106,6 @@ interface Factory> { @Contract(mutates = "this") F errorTracker(ErrorTracker tracker); - /** - * Sets the feature flag service for this metrics instance. - * - * @param service the feature flag service - * @return the metrics factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - F featureFlagService(FeatureFlagService service); - /** * Sets the token used to authenticate with the metrics server and identify the project. *

diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 4542cafb..22ce3f11 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -2,7 +2,6 @@ import com.google.gson.JsonObject; import dev.faststats.core.data.Metric; -import dev.faststats.core.flags.FeatureFlagService; import dev.faststats.core.internal.Constants; import dev.faststats.core.internal.Logger; import dev.faststats.core.internal.LoggerFactory; @@ -49,7 +48,6 @@ public abstract class SimpleMetrics implements Metrics { private final @Token String token; private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; - private final @Nullable FeatureFlagService flagService; @Contract(mutates = "io") @SuppressWarnings("PatternValidation") @@ -63,7 +61,6 @@ protected SimpleMetrics(final Factory factory, final Config config) throws this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); this.tracker = config.errorTracking() ? factory.tracker : null; this.flush = factory.flush; - this.flagService = factory.flagService; this.url = getMetricsServerUrl(); } @@ -291,11 +288,6 @@ public Optional getErrorTracker() { return Optional.ofNullable(tracker); } - @Override - public Optional getFeatureFlagService() { - return Optional.ofNullable(flagService); - } - @Override public dev.faststats.core.Config getConfig() { return config; @@ -306,7 +298,6 @@ public dev.faststats.core.Config getConfig() { @Override public void shutdown() { - if (flagService != null) flagService.shutdown(); getErrorTracker().ifPresent(ErrorTracker::detachErrorContext); if (executor != null) try { logger.info("Shutting down metrics submission"); @@ -322,7 +313,6 @@ public void shutdown() { public abstract static class Factory> implements Metrics.Factory { private final Set> metrics = new HashSet<>(0); private @Nullable ErrorTracker tracker; - private @Nullable FeatureFlagService flagService; private @Nullable Runnable flush; private @Nullable String token; @@ -347,13 +337,6 @@ public F errorTracker(final ErrorTracker tracker) { return (F) this; } - @Override - @SuppressWarnings("unchecked") - public F featureFlagService(final FeatureFlagService service) { - this.flagService = service; - return (F) this; - } - @Override @SuppressWarnings("unchecked") public F token(@Token final String token) throws IllegalArgumentException { From 13b8a4281c6925f3ec5696d5630dddca84d53d5d Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 20:59:46 +0200 Subject: [PATCH 030/140] Cancel all running fetches on shutdown --- .../dev/faststats/core/flags/SimpleFeatureFlagService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index 5d26985b..8597a569 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -216,8 +216,9 @@ public Duration getTTL() { @Override public void shutdown() { - cache.clear(); - fetchTimes.clear(); + fetchesInProgress.values().forEach(fetch -> fetch.cancel(true)); fetchesInProgress.clear(); + fetchTimes.clear(); + cache.clear(); } } From 50ca978015eb415bb3ebf28714675861235590d7 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 21:46:24 +0200 Subject: [PATCH 031/140] Retrieve server id from config --- .../faststats/core/flags/SimpleFeatureFlagService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index 8597a569..f3612799 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -5,6 +5,8 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; +import dev.faststats.core.Config; +import dev.faststats.core.SimpleConfig; import dev.faststats.core.Token; import dev.faststats.core.internal.Logger; import dev.faststats.core.internal.LoggerFactory; @@ -15,11 +17,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -31,6 +33,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); + private final Config config = SimpleConfig.read(Path.of("plugins/faststats")); // todo: DI config or just server id? private final @Token String token; private final @Nullable Attributes attributes; @@ -88,7 +91,7 @@ CompletableFuture optOut(final SimpleFeatureFlag flag) { private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { final var requestBody = new JsonObject(); - requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config + requestBody.addProperty("serverId", config.serverId().toString()); requestBody.addProperty("flag", flag.getId()); final var request = HttpRequest.newBuilder() @@ -127,7 +130,7 @@ boolean isExpired(final SimpleFeatureFlag flag) { private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); - requestBody.addProperty("serverId", UUID.randomUUID().toString()); // todo: read from config + requestBody.addProperty("serverId", config.serverId().toString()); requestBody.addProperty("key", flag.getId()); final var attributes = new JsonObject(); From 45d761b3cba82c1bd6fd4c037af8b3641f98c31f Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 21:46:31 +0200 Subject: [PATCH 032/140] Unseal config --- core/src/main/java/dev/faststats/core/Config.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/core/Config.java b/core/src/main/java/dev/faststats/core/Config.java index 1fedf528..f1e39f5f 100644 --- a/core/src/main/java/dev/faststats/core/Config.java +++ b/core/src/main/java/dev/faststats/core/Config.java @@ -9,7 +9,7 @@ * * @since 0.23.0 */ -public sealed interface Config permits SimpleConfig { +public interface Config { /** * The server id. * From 375efbcc076e10a4f9e651868ad96de284c3a7c4 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 21:46:45 +0200 Subject: [PATCH 033/140] Update config comment --- core/src/main/java/dev/faststats/core/SimpleConfig.java | 5 ++--- .../main/java/dev/faststats/sponge/SpongeMetricsImpl.java | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleConfig.java b/core/src/main/java/dev/faststats/core/SimpleConfig.java index fdb7c115..b515c038 100644 --- a/core/src/main/java/dev/faststats/core/SimpleConfig.java +++ b/core/src/main/java/dev/faststats/core/SimpleConfig.java @@ -27,15 +27,14 @@ public record SimpleConfig( ) implements Config { public static final String DEFAULT_COMMENT = """ - FastStats (https://faststats.dev) collects anonymous usage statistics for developers. + FastStats (https://faststats.dev) collects anonymous usage statistics. # This helps developers understand how their projects are used in the real world. # # No IP addresses, player data, or personal information is collected. # The server ID below is randomly generated and can be regenerated at any time. # # Enabling metrics has no noticeable performance impact. - # Keeping metrics enabled is recommended, but you can opt out by setting - # 'enabled=false' in faststats/config.properties. + # Keeping metrics enabled is recommended, but you can opt out by setting 'enabled=false'. # # If you suspect a developer is collecting personal data or bypassing the "enabled" option, # please report it at: https://faststats.dev/abuse diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index 5aea55f3..c47e4bfc 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -15,7 +15,7 @@ final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { public static final String COMMENT = """ - FastStats (https://faststats.dev) collects anonymous usage statistics for plugin developers. + FastStats (https://faststats.dev) collects anonymous usage statistics. # This helps developers understand how their projects are used in the real world. # # No IP addresses, player data, or personal information is collected. @@ -25,7 +25,7 @@ final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { # Enabling metrics is recommended, you can do so in the Sponge metrics.config, # by setting the "global-state" property to "TRUE". # - # If you suspect a plugin is collecting personal data or bypassing the Sponge config, + # If you suspect a developer is collecting personal data or bypassing the Sponge config, # please report it at: https://faststats.dev/abuse # # For more information, visit: https://faststats.dev/info From 84b474024dcd6275b5025682f2d402be0b11ef2e Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 21:48:24 +0200 Subject: [PATCH 034/140] Very elegant but sounds stupid --- core/src/main/java/dev/faststats/core/SimpleMetrics.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 22ce3f11..e4ee2edc 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -100,7 +100,7 @@ protected SimpleMetrics( protected String getOnboardingMessage() { return """ - This piece of software uses FastStats to collect anonymous usage statistics. + This plugin uses FastStats to collect anonymous usage statistics. No personal or identifying information is ever collected. To opt out, set 'enabled=false' in the metrics configuration file. Learn more at: https://faststats.dev/info From bcf6c9132440826cf87da9b6b7d3428000e46bf8 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 21:49:01 +0200 Subject: [PATCH 035/140] Prepare for config impl extraction --- .../dev/faststats/core/SimpleMetrics.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index e4ee2edc..af41984e 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -44,7 +44,7 @@ public abstract class SimpleMetrics implements Metrics { private final URI url; private final Set> metrics; - private final SimpleConfig config; + private final Config config; private final @Token String token; private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; @@ -54,7 +54,7 @@ public abstract class SimpleMetrics implements Metrics { protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { if (factory.token == null) throw new IllegalStateException("Token must be specified"); - this.config = (SimpleConfig) config; + this.config = config; this.token = factory.token; this.metrics = config.additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); @@ -90,7 +90,7 @@ protected SimpleMetrics( final boolean debug ) { this.metrics = config.additionalMetrics() ? Set.copyOf(metrics) : Set.of(); - this.config = (SimpleConfig) config; + this.config = config; this.logger.setLevel(debug ? Level.ALL : Level.OFF); this.token = token; this.tracker = tracker; @@ -124,10 +124,11 @@ protected void startSubmitting() { } @SuppressWarnings("PatternValidation") - private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { + protected boolean preSubmissionStart() { + /* if (Boolean.getBoolean("faststats.first-run")) { logger.info("Skipping metrics submission due to first-run flag"); - return; + return false; } if (config.firstRun()) { @@ -140,8 +141,14 @@ private void startSubmitting(final long initialDelay, final long period, final T logger.info("-".repeat(separatorLength)); System.setProperty("faststats.first-run", "true"); - if (!config.externallyManaged()) return; + if (!config.externallyManaged()) return false; } + */ + return true; // todo: move to config module? + } + + private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { + if (!preSubmissionStart()) return; final var enabled = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); From 700ce5bca8c520336aba32b5ca28c7defde69c69 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 21:49:09 +0200 Subject: [PATCH 036/140] todo --- core/src/main/java/dev/faststats/core/SimpleErrorTracker.java | 2 +- .../src/main/java/dev/faststats/fabric/FabricMetricsImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java index b0969e85..45f77250 100644 --- a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java @@ -60,7 +60,7 @@ public void trackError(final Throwable error, final boolean handled) { try { if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - final var hashed = MurmurHash3.hash(compiled); + final var hashed = MurmurHash3.hash(compiled); // todo: replace with minimization and normalization algorithm if (collected.compute(hashed, (k, v) -> { return v == null ? 1 : v + 1; }) > 1) return; diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index 5195fa7f..35b443be 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -27,7 +27,7 @@ private FabricMetricsImpl(final Factory factory, final ModContainer mod, final P this.mod = mod; - ServerLifecycleEvents.SERVER_STARTED.register(server -> { + ServerLifecycleEvents.SERVER_STARTED.register(server -> { // todo: client support this.server = server; startSubmitting(); }); From 1b9dfef21ae9b272e55471c984395b18b253b9ec Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 22:15:53 +0200 Subject: [PATCH 037/140] Extract config impl to separate module --- bukkit/build.gradle.kts | 1 + .../java/dev/faststats/bukkit/BukkitMetricsImpl.java | 3 ++- bukkit/src/main/java/module-info.java | 3 ++- bungeecord/build.gradle.kts | 1 + .../java/dev/faststats/bungee/BungeeMetricsImpl.java | 3 ++- bungeecord/src/main/java/module-info.java | 3 ++- config/build.gradle.kts | 3 +++ .../main/java/dev/faststats/config}/SimpleConfig.java | 3 ++- config/src/main/java/module-info.java | 11 +++++++++++ core/build.gradle.kts | 1 + .../main/java/dev/faststats/core/SimpleMetrics.java | 6 ------ .../core/flags/SimpleFeatureFlagService.java | 11 +++++------ core/src/test/java/dev/faststats/MockMetrics.java | 2 +- fabric/build.gradle.kts | 1 + .../java/dev/faststats/fabric/FabricMetricsImpl.java | 3 ++- fabric/src/main/java/module-info.java | 3 ++- hytale/build.gradle.kts | 1 + .../java/dev/faststats/hytale/HytaleMetricsImpl.java | 3 ++- hytale/src/main/java/module-info.java | 1 + minestom/build.gradle.kts | 3 ++- .../dev/faststats/minestom/MinestomMetricsImpl.java | 3 ++- minestom/src/main/java/module-info.java | 3 ++- nukkit/build.gradle.kts | 1 + .../java/dev/faststats/nukkit/NukkitMetricsImpl.java | 3 ++- nukkit/src/main/java/module-info.java | 3 ++- settings.gradle.kts | 1 + sponge/build.gradle.kts | 1 + .../java/dev/faststats/sponge/SpongeMetricsImpl.java | 2 +- sponge/src/main/java/module-info.java | 3 ++- velocity/build.gradle.kts | 1 + .../dev/faststats/velocity/VelocityMetricsImpl.java | 3 ++- velocity/src/main/java/module-info.java | 3 ++- 32 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 config/build.gradle.kts rename {core/src/main/java/dev/faststats/core => config/src/main/java/dev/faststats/config}/SimpleConfig.java (98%) create mode 100644 config/src/main/java/module-info.java diff --git a/bukkit/build.gradle.kts b/bukkit/build.gradle.kts index 10ff4af6..dce2e74c 100644 --- a/bukkit/build.gradle.kts +++ b/bukkit/build.gradle.kts @@ -14,5 +14,6 @@ configurations.compileClasspath { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 4222d8f0..584fd79e 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -1,6 +1,7 @@ package dev.faststats.bukkit; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.SimpleMetrics; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Async; @@ -21,7 +22,7 @@ final class BukkitMetricsImpl extends SimpleMetrics implements BukkitMetrics { @Contract(mutates = "io") @SuppressWarnings({"deprecation", "Convert2MethodRef"}) private BukkitMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); this.plugin = plugin; final var server = plugin.getServer(); diff --git a/bukkit/src/main/java/module-info.java b/bukkit/src/main/java/module-info.java index afc285b2..d8eced3e 100644 --- a/bukkit/src/main/java/module-info.java +++ b/bukkit/src/main/java/module-info.java @@ -5,10 +5,11 @@ exports dev.faststats.bukkit; requires com.google.gson; + requires dev.faststats.config; requires dev.faststats.core; requires java.logging; requires org.bukkit; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/bungeecord/build.gradle.kts b/bungeecord/build.gradle.kts index ed20e028..7bf56fae 100644 --- a/bungeecord/build.gradle.kts +++ b/bungeecord/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("net.md-5:bungeecord-api:26.1-R0.1-SNAPSHOT") } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index ba70bc0d..ec21011d 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -1,6 +1,7 @@ package dev.faststats.bungee; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import net.md_5.bungee.api.ProxyServer; @@ -17,7 +18,7 @@ final class BungeeMetricsImpl extends SimpleMetrics implements BungeeMetrics { @Async.Schedule @Contract(mutates = "io") private BungeeMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); this.server = plugin.getProxy(); this.plugin = plugin; diff --git a/bungeecord/src/main/java/module-info.java b/bungeecord/src/main/java/module-info.java index 5764d134..8380b462 100644 --- a/bungeecord/src/main/java/module-info.java +++ b/bungeecord/src/main/java/module-info.java @@ -5,9 +5,10 @@ exports dev.faststats.bungee; requires com.google.gson; + requires dev.faststats.config; requires dev.faststats.core; requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/config/build.gradle.kts b/config/build.gradle.kts new file mode 100644 index 00000000..e762f00d --- /dev/null +++ b/config/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + compileOnly(project(":core")) +} diff --git a/core/src/main/java/dev/faststats/core/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java similarity index 98% rename from core/src/main/java/dev/faststats/core/SimpleConfig.java rename to config/src/main/java/dev/faststats/config/SimpleConfig.java index b515c038..7657623b 100644 --- a/core/src/main/java/dev/faststats/core/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -1,5 +1,6 @@ -package dev.faststats.core; +package dev.faststats.config; +import dev.faststats.core.Config; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; diff --git a/config/src/main/java/module-info.java b/config/src/main/java/module-info.java new file mode 100644 index 00000000..a37c6499 --- /dev/null +++ b/config/src/main/java/module-info.java @@ -0,0 +1,11 @@ +import org.jspecify.annotations.NullMarked; + +@NullMarked +module dev.faststats.config { + exports dev.faststats.config; + + requires dev.faststats.core; + + requires static org.jetbrains.annotations; + requires static org.jspecify; +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 84a53911..8b573fab 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { compileOnlyApi("org.jetbrains:annotations:26.1.0") compileOnlyApi("org.jspecify:jspecify:1.0.0") + testImplementation(project(":config")) testImplementation("com.google.code.gson:gson:2.14.0") testImplementation("org.junit.jupiter:junit-jupiter") testImplementation(platform("org.junit:junit-bom:6.1.0-RC1")) diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index af41984e..9a6b112b 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -20,7 +20,6 @@ import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.file.Path; import java.time.Duration; import java.util.HashSet; import java.util.Optional; @@ -74,11 +73,6 @@ private URI getMetricsServerUrl() { return URI.create("https://metrics.faststats.dev/v1/collect"); } - @Contract(mutates = "io") - protected SimpleMetrics(final Factory factory, final Path config) throws IllegalStateException { - this(factory, SimpleConfig.read(config)); - } - @VisibleForTesting protected SimpleMetrics( final Config config, diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java index f3612799..c439117e 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java @@ -5,8 +5,6 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; -import dev.faststats.core.Config; -import dev.faststats.core.SimpleConfig; import dev.faststats.core.Token; import dev.faststats.core.internal.Logger; import dev.faststats.core.internal.LoggerFactory; @@ -17,11 +15,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -33,7 +31,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); - private final Config config = SimpleConfig.read(Path.of("plugins/faststats")); // todo: DI config or just server id? + private final UUID serverId; private final @Token String token; private final @Nullable Attributes attributes; @@ -52,6 +50,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { this.token = token; this.attributes = attributes; this.ttl = ttl; + this.serverId = UUID.randomUUID(); // todo: DI somehow } private static URI getFlagsServerUrl() { @@ -91,7 +90,7 @@ CompletableFuture optOut(final SimpleFeatureFlag flag) { private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { final var requestBody = new JsonObject(); - requestBody.addProperty("serverId", config.serverId().toString()); + requestBody.addProperty("serverId", serverId.toString()); requestBody.addProperty("flag", flag.getId()); final var request = HttpRequest.newBuilder() @@ -130,7 +129,7 @@ boolean isExpired(final SimpleFeatureFlag flag) { private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); - requestBody.addProperty("serverId", config.serverId().toString()); + requestBody.addProperty("serverId", serverId.toString()); requestBody.addProperty("key", flag.getId()); final var attributes = new JsonObject(); diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 87494bd9..2a82ac1b 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -1,8 +1,8 @@ package dev.faststats; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.ErrorTracker; -import dev.faststats.core.SimpleConfig; import dev.faststats.core.SimpleMetrics; import dev.faststats.core.Token; import org.jspecify.annotations.NullMarked; diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index d9caf071..4b1a38fe 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { api(project(":core")) + implementation(project(":config")) mappings(loom.officialMojangMappings()) minecraft("com.mojang:minecraft:1.21.11") modCompileOnly("net.fabricmc.fabric-api:fabric-api:0.139.4+1.21.11") diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index 35b443be..93d90c14 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -1,6 +1,7 @@ package dev.faststats.fabric; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; @@ -23,7 +24,7 @@ final class FabricMetricsImpl extends SimpleMetrics implements FabricMetrics { @Async.Schedule @Contract(mutates = "io") private FabricMetricsImpl(final Factory factory, final ModContainer mod, final Path config) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); this.mod = mod; diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index c6601e4e..d4d65548 100644 --- a/fabric/src/main/java/module-info.java +++ b/fabric/src/main/java/module-info.java @@ -5,10 +5,11 @@ exports dev.faststats.fabric; requires com.google.gson; + requires dev.faststats.config; requires dev.faststats.core; requires net.fabricmc.loader; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/hytale/build.gradle.kts b/hytale/build.gradle.kts index 3380a610..5bc9ebac 100644 --- a/hytale/build.gradle.kts +++ b/hytale/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("com.hypixel.hytale:Server:2026.05.07-5efa15f6d") } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index 8a6cba21..1cd5c95e 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -4,6 +4,7 @@ import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.universe.Universe; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; @@ -15,7 +16,7 @@ final class HytaleMetricsImpl extends SimpleMetrics implements HytaleMetrics { @Async.Schedule @Contract(mutates = "io") private HytaleMetricsImpl(final Factory factory, final Path config) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); startSubmitting(); } diff --git a/hytale/src/main/java/module-info.java b/hytale/src/main/java/module-info.java index ce315628..5fa387fb 100644 --- a/hytale/src/main/java/module-info.java +++ b/hytale/src/main/java/module-info.java @@ -5,6 +5,7 @@ exports dev.faststats.hytale; requires com.google.gson; + requires dev.faststats.config; requires dev.faststats.core; requires java.logging; diff --git a/minestom/build.gradle.kts b/minestom/build.gradle.kts index 755bdaca..08a06eeb 100644 --- a/minestom/build.gradle.kts +++ b/minestom/build.gradle.kts @@ -1,4 +1,5 @@ dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("net.minestom:minestom:2026.05.11-1.21.11") -} \ No newline at end of file +} diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 91bc04ee..6dc6e412 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -1,6 +1,7 @@ package dev.faststats.minestom; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.ErrorTracker; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; @@ -15,7 +16,7 @@ final class MinestomMetricsImpl extends SimpleMetrics implements MinestomMetrics @Async.Schedule @Contract(mutates = "io") private MinestomMetricsImpl(final Factory factory, final Path config) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); startSubmitting(); } diff --git a/minestom/src/main/java/module-info.java b/minestom/src/main/java/module-info.java index 629f4d5f..ff847492 100644 --- a/minestom/src/main/java/module-info.java +++ b/minestom/src/main/java/module-info.java @@ -5,10 +5,11 @@ exports dev.faststats.minestom; requires com.google.gson; + requires dev.faststats.config; requires dev.faststats.core; requires net.minestom.server; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/nukkit/build.gradle.kts b/nukkit/build.gradle.kts index af632e8a..d4a090a7 100644 --- a/nukkit/build.gradle.kts +++ b/nukkit/build.gradle.kts @@ -7,5 +7,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("cn.nukkit:nukkit:1.0-SNAPSHOT") } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index 80bec0db..9d6037b9 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -3,6 +3,7 @@ import cn.nukkit.Server; import cn.nukkit.plugin.PluginBase; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; @@ -19,7 +20,7 @@ final class NukkitMetricsImpl extends SimpleMetrics implements NukkitMetrics { @Async.Schedule @Contract(mutates = "io") private NukkitMetricsImpl(final Factory factory, final PluginBase plugin, final Path config) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); this.server = plugin.getServer(); this.plugin = plugin; diff --git a/nukkit/src/main/java/module-info.java b/nukkit/src/main/java/module-info.java index b7b0b2bd..1b104b57 100644 --- a/nukkit/src/main/java/module-info.java +++ b/nukkit/src/main/java/module-info.java @@ -5,8 +5,9 @@ exports dev.faststats.nukkit; requires com.google.gson; + requires dev.faststats.config; requires dev.faststats.core; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d9449921..402a4cd3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ include("bukkit") include("bukkit:example-plugin") include("bungeecord") include("bungeecord:example-plugin") +include("config") include("core") include("core:example") include("fabric") diff --git a/sponge/build.gradle.kts b/sponge/build.gradle.kts index 06fbed98..ee394e74 100644 --- a/sponge/build.gradle.kts +++ b/sponge/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("org.spongepowered:spongeapi:8.3.0-SNAPSHOT") } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index c47e4bfc..1db17d63 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -1,8 +1,8 @@ package dev.faststats.sponge; import com.google.gson.JsonObject; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleConfig; import dev.faststats.core.SimpleMetrics; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Async; diff --git a/sponge/src/main/java/module-info.java b/sponge/src/main/java/module-info.java index b01a1562..8eb72fc5 100644 --- a/sponge/src/main/java/module-info.java +++ b/sponge/src/main/java/module-info.java @@ -6,8 +6,9 @@ requires com.google.gson; requires com.google.guice; + requires dev.faststats.config; requires dev.faststats.core; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 74da85bf..ef8247d1 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -4,5 +4,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT") } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index dbfb60b7..fadfa558 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -4,6 +4,7 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; @@ -25,7 +26,7 @@ private VelocityMetricsImpl( final Path config, final PluginContainer plugin ) throws IllegalStateException { - super(factory, config); + super(factory, SimpleConfig.read(config)); this.server = server; this.plugin = plugin; diff --git a/velocity/src/main/java/module-info.java b/velocity/src/main/java/module-info.java index 2855dcb6..77b01dea 100644 --- a/velocity/src/main/java/module-info.java +++ b/velocity/src/main/java/module-info.java @@ -7,9 +7,10 @@ requires com.google.gson; requires com.google.guice; requires com.velocitypowered.api; + requires dev.faststats.config; requires dev.faststats.core; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} From b824d32e6c765ff8697f006dc10dc96c32f2387d Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Apr 2026 22:17:20 +0200 Subject: [PATCH 038/140] Update plugin application code --- build.gradle.kts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 668852da..8949609d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,14 +17,16 @@ val javaVersionsOverride = mapOf( val defaultJavaVersion = 17 subprojects { - apply(plugin = "java") - apply(plugin = "java-library") + apply { + plugin("java") + plugin("java-library") + } - val example = project.name.startsWith("example") - if (example) { - apply(plugin = "com.gradleup.shadow") + val noPublish = project.name.startsWith("example") || project.name != "config" + if (noPublish) { + apply { plugin("com.gradleup.shadow") } } else { - apply(plugin = "maven-publish") + apply { plugin("maven-publish") } } group = "dev.faststats.metrics" @@ -94,7 +96,7 @@ subprojects { } afterEvaluate { - if (example) return@afterEvaluate + if (noPublish) return@afterEvaluate extensions.configure { publications.create("maven") { artifactId = project.name From 9e2cfc886e78d04117e9e77e5686123f58775576 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 20 Apr 2026 19:33:06 +0200 Subject: [PATCH 039/140] Refactor config handling --- .../faststats/bukkit/BukkitMetricsImpl.java | 5 + .../faststats/bungee/BungeeMetricsImpl.java | 5 + .../dev/faststats/config/SimpleConfig.java | 64 +++++--- config/src/main/java/module-info.java | 1 + .../dev/faststats/core/SimpleMetrics.java | 35 +---- .../test/java/dev/faststats/MockMetrics.java | 7 +- .../faststats/fabric/FabricMetricsImpl.java | 5 + .../faststats/hytale/HytaleMetricsImpl.java | 5 + .../minestom/MinestomMetricsImpl.java | 5 + .../faststats/nukkit/NukkitMetricsImpl.java | 5 + sponge/build.gradle.kts | 1 - .../dev/faststats/sponge/SpongeConfig.java | 141 ++++++++++++++++++ .../faststats/sponge/SpongeMetricsImpl.java | 29 +--- sponge/src/main/java/module-info.java | 2 +- .../velocity/VelocityMetricsImpl.java | 5 + 15 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 584fd79e..01685051 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -62,6 +62,11 @@ private boolean isProxyOnlineMode() { return settings.getBoolean("bungeecord") && proxies.getBoolean("bungee-cord.online-mode"); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", minecraftVersion); diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index ec21011d..e71045e5 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -26,6 +26,11 @@ private BungeeMetricsImpl(final Factory factory, final Plugin plugin, final Path startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("online_mode", server.getConfig().isOnlineMode()); diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index 7657623b..e50548ca 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -1,6 +1,7 @@ package dev.faststats.config; import dev.faststats.core.Config; +import dev.faststats.core.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; @@ -13,6 +14,7 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiPredicate; +import java.util.logging.Level; import static java.nio.charset.StandardCharsets.UTF_8; @@ -23,11 +25,10 @@ public record SimpleConfig( boolean debug, boolean enabled, boolean errorTracking, - boolean firstRun, - boolean externallyManaged + boolean firstRun ) implements Config { - public static final String DEFAULT_COMMENT = """ + public static final String COMMENT = """ FastStats (https://faststats.dev) collects anonymous usage statistics. # This helps developers understand how their projects are used in the real world. # @@ -42,14 +43,17 @@ public record SimpleConfig( # # For more information, visit: https://faststats.dev/info """; + private static final String ONBOARDING_MESSAGE = """ + This plugin uses FastStats to collect anonymous usage statistics. + No personal or identifying information is ever collected. + To opt out, set 'enabled=false' in the metrics configuration file. + Learn more at: https://faststats.dev/info + + Since this is your first start with FastStats, metrics submission will not start + until you restart the server to allow you to opt out if you prefer."""; @Contract(mutates = "io") public static SimpleConfig read(final Path file) throws RuntimeException { - return read(file, DEFAULT_COMMENT, false, false); - } - - @Contract(mutates = "io") - public static SimpleConfig read(final Path file, final String comment, final boolean externallyManaged, final boolean externallyEnabled) throws RuntimeException { final var properties = readOrEmpty(file); final var firstRun = properties.isEmpty(); final var saveConfig = new AtomicBoolean(firstRun); @@ -76,18 +80,30 @@ public static SimpleConfig read(final Path file, final String comment, final boo }); }; - final var enabled = externallyManaged ? externallyEnabled : predicate.test("enabled", true); + final var enabled = predicate.test("enabled", true); final var errorTracking = predicate.test("submitErrors", true); final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); final var debug = predicate.test("debug", false); if (saveConfig.get()) try { - save(file, externallyManaged, comment, serverId, enabled, errorTracking, additionalMetrics, debug); + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var store = new Properties(); + + store.setProperty("serverId", serverId.toString()); + store.setProperty("enabled", Boolean.toString(enabled)); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("debug", Boolean.toString(debug)); + + store.store(writer, COMMENT); + } } catch (final IOException e) { throw new RuntimeException("Failed to save metrics config", e); } - return new SimpleConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun, externallyManaged); + return new SimpleConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun); } private static Optional readOrEmpty(final Path file) throws RuntimeException { @@ -101,19 +117,23 @@ private static Optional readOrEmpty(final Path file) throws RuntimeE } } - private static void save(final Path file, final boolean externallyManaged, final String comment, final UUID serverId, final boolean enabled, final boolean errorTracking, final boolean additionalMetrics, final boolean debug) throws IOException { - Files.createDirectories(file.getParent()); - try (final var out = Files.newOutputStream(file); - final var writer = new OutputStreamWriter(out, UTF_8)) { - final var properties = new Properties(); + @SuppressWarnings("PatternValidation") + public boolean preSubmissionStart() { + if (Boolean.getBoolean("faststats.first-run")) return false; + + if (firstRun()) { + var separatorLength = 0; + final var split = ONBOARDING_MESSAGE.split("\n"); + for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); - properties.setProperty("serverId", serverId.toString()); - if (!externallyManaged) properties.setProperty("enabled", Boolean.toString(enabled)); - properties.setProperty("submitErrors", Boolean.toString(errorTracking)); - properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); - properties.setProperty("debug", Boolean.toString(debug)); + final var logger = LoggerFactory.factory().getLogger(getClass()); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + for (final var s : split) logger.log(Level.CONFIG, s); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); - properties.store(writer, comment); + System.setProperty("faststats.first-run", "true"); + return false; } + return true; } } diff --git a/config/src/main/java/module-info.java b/config/src/main/java/module-info.java index a37c6499..2b5cca38 100644 --- a/config/src/main/java/module-info.java +++ b/config/src/main/java/module-info.java @@ -5,6 +5,7 @@ exports dev.faststats.config; requires dev.faststats.core; + requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 9a6b112b..b16c507c 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -92,17 +92,6 @@ protected SimpleMetrics( this.url = url; } - protected String getOnboardingMessage() { - return """ - This plugin uses FastStats to collect anonymous usage statistics. - No personal or identifying information is ever collected. - To opt out, set 'enabled=false' in the metrics configuration file. - Learn more at: https://faststats.dev/info - - Since this is your first start with FastStats, metrics submission will not start - until you restart the server to allow you to opt out if you prefer."""; - } - protected long getInitialDelay() { return TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)); } @@ -117,29 +106,7 @@ protected void startSubmitting() { startSubmitting(getInitialDelay(), getPeriod(), TimeUnit.MILLISECONDS); } - @SuppressWarnings("PatternValidation") - protected boolean preSubmissionStart() { - /* - if (Boolean.getBoolean("faststats.first-run")) { - logger.info("Skipping metrics submission due to first-run flag"); - return false; - } - - if (config.firstRun()) { - var separatorLength = 0; - final var split = getOnboardingMessage().split("\n"); - for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); - - logger.info("-".repeat(separatorLength)); - for (final var s : split) logger.info(s); - logger.info("-".repeat(separatorLength)); - - System.setProperty("faststats.first-run", "true"); - if (!config.externallyManaged()) return false; - } - */ - return true; // todo: move to config module? - } + protected abstract boolean preSubmissionStart(); private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { if (!preSubmissionStart()) return; diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 2a82ac1b..4fd403b3 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -15,7 +15,12 @@ @NullMarked public final class MockMetrics extends SimpleMetrics { public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new SimpleConfig(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); + super(new SimpleConfig(serverId, true, debug, true, true, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); + } + + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); } @Override diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index 93d90c14..8e670869 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -35,6 +35,11 @@ private FabricMetricsImpl(final Factory factory, final ModContainer mod, final P ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { assert server != null : "Server not initialized"; diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index 1cd5c95e..d2538eab 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -21,6 +21,11 @@ private HytaleMetricsImpl(final Factory factory, final Path config) throws Illeg startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_version", HytaleServer.get().getServerName()); diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 6dc6e412..e137c388 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -21,6 +21,11 @@ private MinestomMetricsImpl(final Factory factory, final Path config) throws Ill startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", MinecraftServer.VERSION_NAME); diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index 9d6037b9..51afb339 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -28,6 +28,11 @@ private NukkitMetricsImpl(final Factory factory, final PluginBase plugin, final startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", server.getVersion()); diff --git a/sponge/build.gradle.kts b/sponge/build.gradle.kts index ee394e74..06fbed98 100644 --- a/sponge/build.gradle.kts +++ b/sponge/build.gradle.kts @@ -6,6 +6,5 @@ repositories { dependencies { api(project(":core")) - implementation(project(":config")) compileOnly("org.spongepowered:spongeapi:8.3.0-SNAPSHOT") } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java new file mode 100644 index 00000000..9d1d5c52 --- /dev/null +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -0,0 +1,141 @@ +package dev.faststats.sponge; + +import dev.faststats.core.Config; +import dev.faststats.core.internal.LoggerFactory; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.spongepowered.api.Sponge; +import org.spongepowered.plugin.PluginContainer; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiPredicate; +import java.util.logging.Level; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApiStatus.Internal +public record SpongeConfig( + UUID serverId, + boolean additionalMetrics, + boolean debug, + boolean enabled, + boolean errorTracking, + boolean firstRun +) implements Config { + + private static final String COMMENT = """ + FastStats (https://faststats.dev) collects anonymous usage statistics. + # This helps developers understand how their projects are used in the real world. + # + # No IP addresses, player data, or personal information is collected. + # The server ID below is randomly generated and can be regenerated at any time. + # + # Enabling metrics has no noticeable performance impact. + # Enabling metrics is recommended, you can do so in the Sponge metrics.config, + # by setting the "global-state" property to "TRUE". + # + # If you suspect a developer is collecting personal data or bypassing the Sponge config, + # please report it at: https://faststats.dev/abuse + # + # For more information, visit: https://faststats.dev/info + """; + private static final String ONBOARDING_MESSAGE = """ + This plugin uses FastStats to collect anonymous usage statistics. + No personal or identifying information is ever collected. + It is recommended to enable metrics by setting 'global-state=TRUE' in the sponge metrics config. + Learn more at: https://faststats.dev/info + """; + + @Contract(mutates = "io") + public static SpongeConfig read(final PluginContainer plugin, final Path file) throws RuntimeException { + final var properties = readOrEmpty(file); + final var firstRun = properties.isEmpty(); + final var saveConfig = new AtomicBoolean(firstRun); + + final var serverId = properties.map(object -> object.getProperty("serverId")).map(string -> { + try { + final var trimmed = string.trim(); + final var corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed; + if (!corrected.equals(string)) saveConfig.set(true); + return UUID.fromString(corrected); + } catch (final IllegalArgumentException e) { + saveConfig.set(true); + return UUID.randomUUID(); + } + }).orElseGet(() -> { + saveConfig.set(true); + return UUID.randomUUID(); + }); + + final BiPredicate predicate = (key, defaultValue) -> { + return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> { + saveConfig.set(true); + return defaultValue; + }); + }; + + final var errorTracking = predicate.test("submitErrors", true); + final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); + final var debug = predicate.test("debug", false); + + if (saveConfig.get()) try { + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var store = new Properties(); + + store.setProperty("serverId", serverId.toString()); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("debug", Boolean.toString(debug)); + + store.store(writer, COMMENT); + } + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + + final var enabled = Sponge.metricsConfigManager().effectiveCollectionState(plugin).asBoolean(); + return new SpongeConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun); + } + + private static Optional readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return Optional.empty(); + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return Optional.of(properties); + } catch (final IOException e) { + throw new RuntimeException("Failed to read metrics config", e); + } + } + + @SuppressWarnings("PatternValidation") + public boolean preSubmissionStart() { + if (Boolean.getBoolean("faststats.first-run")) return false; + + if (firstRun()) { + var separatorLength = 0; + final var split = ONBOARDING_MESSAGE.split("\n"); + for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); + + final var logger = LoggerFactory.factory().getLogger(getClass()); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + for (final var s : split) logger.log(Level.CONFIG, s); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + + System.setProperty("faststats.first-run", "true"); + return false; + } + return true; + } +} + + diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index 1db17d63..c96d34cd 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -1,7 +1,6 @@ package dev.faststats.sponge; import com.google.gson.JsonObject; -import dev.faststats.config.SimpleConfig; import dev.faststats.core.Metrics; import dev.faststats.core.SimpleMetrics; import org.apache.logging.log4j.Logger; @@ -14,22 +13,6 @@ import java.nio.file.Path; final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { - public static final String COMMENT = """ - FastStats (https://faststats.dev) collects anonymous usage statistics. - # This helps developers understand how their projects are used in the real world. - # - # No IP addresses, player data, or personal information is collected. - # The server ID below is randomly generated and can be regenerated at any time. - # - # Enabling metrics has no noticeable performance impact. - # Enabling metrics is recommended, you can do so in the Sponge metrics.config, - # by setting the "global-state" property to "TRUE". - # - # If you suspect a developer is collecting personal data or bypassing the Sponge config, - # please report it at: https://faststats.dev/abuse - # - # For more information, visit: https://faststats.dev/info - """; private final PluginContainer plugin; @@ -41,20 +24,14 @@ private SpongeMetricsImpl( final PluginContainer plugin, final Path config ) throws IllegalStateException { - super(factory, SimpleConfig.read(config, COMMENT, true, Sponge.metricsConfigManager() - .effectiveCollectionState(plugin).asBoolean())); + super(factory, SpongeConfig.read(plugin, config)); this.plugin = plugin; startSubmitting(); } @Override - protected String getOnboardingMessage() { - return """ - This plugin uses FastStats to collect anonymous usage statistics. - No personal or identifying information is ever collected. - It is recommended to enable metrics by setting 'global-state=TRUE' in the sponge metrics config. - Learn more at: https://faststats.dev/info - """; + protected boolean preSubmissionStart() { + return ((SpongeConfig) getConfig()).preSubmissionStart(); } @Override diff --git a/sponge/src/main/java/module-info.java b/sponge/src/main/java/module-info.java index 8eb72fc5..e709333f 100644 --- a/sponge/src/main/java/module-info.java +++ b/sponge/src/main/java/module-info.java @@ -6,8 +6,8 @@ requires com.google.gson; requires com.google.guice; - requires dev.faststats.config; requires dev.faststats.core; + requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index fadfa558..969ad217 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -34,6 +34,11 @@ private VelocityMetricsImpl( startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { final var pluginVersion = plugin.getDescription().getVersion().orElse("unknown"); From cdf2e1644e46cc1be752f3581025baa0aedb4db8 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 20 Apr 2026 22:33:57 +0200 Subject: [PATCH 040/140] Major metrics schema refactor --- .../main/java/com/example/ExamplePlugin.java | 11 +-- .../dev/faststats/bukkit/BukkitContext.java | 39 +++++++++ .../dev/faststats/bukkit/BukkitMetrics.java | 18 +--- .../faststats/bukkit/BukkitMetricsImpl.java | 25 ++---- bukkit/src/main/java/module-info.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 12 +-- .../dev/faststats/bungee/BungeeContext.java | 25 ++++++ .../dev/faststats/bungee/BungeeMetrics.java | 16 +--- .../faststats/bungee/BungeeMetricsImpl.java | 22 ++--- bungeecord/src/main/java/module-info.java | 2 +- .../dev/faststats/config/SimpleConfig.java | 4 +- config/src/main/java/module-info.java | 2 +- .../example/ErrorTrackerExample.java | 2 +- .../faststats/example/FeatureFlagExample.java | 6 +- .../faststats/example/MetricTypesExample.java | 2 +- .../{core/flags => }/Attributes.java | 2 +- .../java/dev/faststats/{core => }/Config.java | 2 +- .../dev/faststats/{core => }/ErrorHelper.java | 2 +- .../faststats/{core => }/ErrorTracker.java | 42 ++++----- .../java/dev/faststats/FastStatsContext.java | 85 +++++++++++++++++++ .../{core/flags => }/FeatureFlag.java | 2 +- .../{core/flags => }/FeatureFlagService.java | 49 +---------- .../dev/faststats/{core => }/Metrics.java | 47 ++++------ .../dev/faststats/{core => }/MurmurHash3.java | 2 +- .../{core/flags => }/SimpleAttributes.java | 2 +- .../java/dev/faststats/SimpleContext.java | 51 +++++++++++ .../{core => }/SimpleErrorTracker.java | 2 +- .../{core/flags => }/SimpleFeatureFlag.java | 2 +- .../flags => }/SimpleFeatureFlagService.java | 7 +- .../faststats/{core => }/SimpleMetrics.java | 44 +++++----- .../java/dev/faststats/{core => }/Token.java | 4 +- .../{core => }/data/ArrayMetric.java | 2 +- .../faststats/{core => }/data/MapMetric.java | 0 .../dev/faststats/{core => }/data/Metric.java | 22 ++--- .../{core => }/data/SimpleMetric.java | 2 +- .../{core => }/data/SingleValueMetric.java | 2 +- .../faststats/{core => }/data/SourceId.java | 4 +- .../{core => }/internal/Constants.java | 4 +- .../faststats/{core => }/internal/Logger.java | 2 +- .../{core => }/internal/LoggerFactory.java | 2 +- .../{core => }/internal/SimpleLogger.java | 2 +- .../internal/SimpleLoggerFactory.java | 2 +- .../{core => }/internal/package-info.java | 2 +- core/src/main/java/module-info.java | 11 ++- .../java/dev/faststats/AnonymizationTest.java | 1 - .../java/dev/faststats/ErrorTrackerTest.java | 1 - .../test/java/dev/faststats/MockMetrics.java | 3 - .../src/main/java/com/example/ExampleMod.java | 12 +-- .../dev/faststats/fabric/FabricContext.java | 28 ++++++ .../dev/faststats/fabric/FabricMetrics.java | 33 +------ .../faststats/fabric/FabricMetricsImpl.java | 28 +++--- fabric/src/main/java/module-info.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 12 +-- .../dev/faststats/hytale/HytaleContext.java | 22 +++++ .../dev/faststats/hytale/HytaleMetrics.java | 17 +--- .../faststats/hytale/HytaleMetricsImpl.java | 24 +++--- .../faststats/hytale/logger/HytaleLogger.java | 2 +- .../hytale/logger/HytaleLoggerFactory.java | 4 +- hytale/src/main/java/module-info.java | 4 +- .../faststats/minestom/MinestomContext.java | 23 +++++ .../faststats/minestom/MinestomMetrics.java | 16 +--- .../minestom/MinestomMetricsImpl.java | 23 ++--- minestom/src/main/java/module-info.java | 2 +- .../dev/faststats/nukkit/NukkitContext.java | 27 ++++++ .../dev/faststats/nukkit/NukkitMetrics.java | 16 +--- .../faststats/nukkit/NukkitMetricsImpl.java | 21 ++--- nukkit/src/main/java/module-info.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 20 +++-- .../dev/faststats/sponge/SpongeConfig.java | 4 +- .../dev/faststats/sponge/SpongeContext.java | 35 ++++++++ .../dev/faststats/sponge/SpongeMetrics.java | 21 +---- .../faststats/sponge/SpongeMetricsImpl.java | 28 +++--- sponge/src/main/java/module-info.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 22 +++-- .../faststats/velocity/VelocityContext.java | 49 +++++++++++ .../faststats/velocity/VelocityMetrics.java | 6 +- .../velocity/VelocityMetricsImpl.java | 24 ++++-- velocity/src/main/java/module-info.java | 2 +- 78 files changed, 672 insertions(+), 454 deletions(-) create mode 100644 bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java create mode 100644 bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java rename core/src/main/java/dev/faststats/{core/flags => }/Attributes.java (99%) rename core/src/main/java/dev/faststats/{core => }/Config.java (98%) rename core/src/main/java/dev/faststats/{core => }/ErrorHelper.java (99%) rename core/src/main/java/dev/faststats/{core => }/ErrorTracker.java (95%) create mode 100644 core/src/main/java/dev/faststats/FastStatsContext.java rename core/src/main/java/dev/faststats/{core/flags => }/FeatureFlag.java (99%) rename core/src/main/java/dev/faststats/{core/flags => }/FeatureFlagService.java (64%) rename core/src/main/java/dev/faststats/{core => }/Metrics.java (71%) rename core/src/main/java/dev/faststats/{core => }/MurmurHash3.java (99%) rename core/src/main/java/dev/faststats/{core/flags => }/SimpleAttributes.java (96%) create mode 100644 core/src/main/java/dev/faststats/SimpleContext.java rename core/src/main/java/dev/faststats/{core => }/SimpleErrorTracker.java (99%) rename core/src/main/java/dev/faststats/{core/flags => }/SimpleFeatureFlag.java (98%) rename core/src/main/java/dev/faststats/{core/flags => }/SimpleFeatureFlagService.java (98%) rename core/src/main/java/dev/faststats/{core => }/SimpleMetrics.java (91%) rename core/src/main/java/dev/faststats/{core => }/Token.java (93%) rename core/src/main/java/dev/faststats/{core => }/data/ArrayMetric.java (96%) rename core/src/main/java/dev/faststats/{core => }/data/MapMetric.java (100%) rename core/src/main/java/dev/faststats/{core => }/data/Metric.java (96%) rename core/src/main/java/dev/faststats/{core => }/data/SimpleMetric.java (97%) rename core/src/main/java/dev/faststats/{core => }/data/SingleValueMetric.java (95%) rename core/src/main/java/dev/faststats/{core => }/data/SourceId.java (93%) rename core/src/main/java/dev/faststats/{core => }/internal/Constants.java (90%) rename core/src/main/java/dev/faststats/{core => }/internal/Logger.java (95%) rename core/src/main/java/dev/faststats/{core => }/internal/LoggerFactory.java (93%) rename core/src/main/java/dev/faststats/{core => }/internal/SimpleLogger.java (97%) rename core/src/main/java/dev/faststats/{core => }/internal/SimpleLoggerFactory.java (82%) rename core/src/main/java/dev/faststats/{core => }/internal/package-info.java (63%) create mode 100644 fabric/src/main/java/dev/faststats/fabric/FabricContext.java create mode 100644 hytale/src/main/java/dev/faststats/hytale/HytaleContext.java create mode 100644 minestom/src/main/java/dev/faststats/minestom/MinestomContext.java create mode 100644 nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java create mode 100644 sponge/src/main/java/dev/faststats/sponge/SpongeContext.java create mode 100644 velocity/src/main/java/dev/faststats/velocity/VelocityContext.java 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 90884f32..299e0cea 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,16 +1,18 @@ package com.example; +import dev.faststats.ErrorTracker; +import dev.faststats.bukkit.BukkitContext; import dev.faststats.bukkit.BukkitMetrics; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.data.Metric; +import dev.faststats.data.Metric; import org.bukkit.plugin.java.JavaPlugin; import java.util.concurrent.atomic.AtomicInteger; public final class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); + private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE"); - private final BukkitMetrics metrics = BukkitMetrics.factory() + private final BukkitMetrics metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) .addMetric(Metric.string("server_version", () -> "1.0.0")) @@ -22,8 +24,7 @@ public final class ExamplePlugin extends JavaPlugin { // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(this); + .create(); @Override public void onEnable() { diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java new file mode 100644 index 00000000..9fa127b7 --- /dev/null +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -0,0 +1,39 @@ +package dev.faststats.bukkit; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.bukkit.plugin.Plugin; + +import java.nio.file.Path; + +/** + * Bukkit FastStats context. + * + * @since 0.23.0 + */ +public final class BukkitContext extends SimpleContext { + final Plugin plugin; + + public BukkitContext(final Plugin plugin, @Token final String token) { + super(SimpleConfig.read(getConfigPath(plugin)), token); + this.plugin = plugin; + } + + @Override + public BukkitMetrics.Factory metrics() { + return new BukkitMetricsImpl.Factory(this); + } + + private static Path getConfigPath(final Plugin plugin) { + return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties"); + } + + private static Path getPluginsFolder(final Plugin plugin) { + try { + return plugin.getServer().getPluginsFolder().toPath(); + } catch (final NoSuchMethodError e) { + return plugin.getDataFolder().getParentFile().toPath(); + } + } +} diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java index 810cca6a..7c88a426 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java @@ -1,9 +1,8 @@ package dev.faststats.bukkit; -import dev.faststats.core.Metrics; +import dev.faststats.Metrics; import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.Contract; /** * Bukkit metrics implementation. @@ -11,17 +10,6 @@ * @since 0.1.0 */ public sealed interface BukkitMetrics extends Metrics permits BukkitMetricsImpl { - /** - * Creates a new metrics factory for Bukkit. - * - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(pure = true) - static Factory factory() { - return new BukkitMetricsImpl.Factory(); - } - /** * Registers additional exception handlers on Paper-based implementations. * @@ -32,8 +20,8 @@ static Factory factory() { @Override void ready() throws IllegalPluginAccessException; - interface Factory extends Metrics.Factory { + interface Factory extends Metrics.Factory { @Override - BukkitMetrics create(Plugin object) throws IllegalStateException; + BukkitMetrics create() throws IllegalStateException; } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 01685051..b22e4843 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -1,13 +1,12 @@ package dev.faststats.bukkit; import com.google.gson.JsonObject; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.SimpleMetrics; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.nio.file.Path; import java.util.Optional; import java.util.function.Supplier; @@ -21,8 +20,8 @@ final class BukkitMetricsImpl extends SimpleMetrics implements BukkitMetrics { @Async.Schedule @Contract(mutates = "io") @SuppressWarnings({"deprecation", "Convert2MethodRef"}) - private BukkitMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + private BukkitMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException { + super(factory); this.plugin = plugin; final var server = plugin.getServer(); @@ -102,20 +101,14 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { - @Override - public BukkitMetrics create(final Plugin plugin) throws IllegalStateException { - final var dataFolder = getPluginsFolder(plugin).resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); - return new BukkitMetricsImpl(this, plugin, config); + static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { + public Factory(final BukkitContext context) { + super(context); } - private static Path getPluginsFolder(final Plugin plugin) { - try { - return plugin.getServer().getPluginsFolder().toPath(); - } catch (final NoSuchMethodError e) { - return plugin.getDataFolder().getParentFile().toPath(); - } + @Override + public BukkitMetrics create() throws IllegalStateException { + return new BukkitMetricsImpl(this, ((BukkitContext) context).plugin); } } } diff --git a/bukkit/src/main/java/module-info.java b/bukkit/src/main/java/module-info.java index d8eced3e..f74287e8 100644 --- a/bukkit/src/main/java/module-info.java +++ b/bukkit/src/main/java/module-info.java @@ -6,7 +6,7 @@ requires com.google.gson; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires java.logging; requires org.bukkit; diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 223a64dd..091ad34f 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,17 +1,18 @@ package com.example; -import dev.faststats.bungee.BungeeMetrics; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.bungee.BungeeContext; +import dev.faststats.data.Metric; import net.md_5.bungee.api.plugin.Plugin; import java.util.concurrent.atomic.AtomicInteger; public class ExamplePlugin extends Plugin { private final AtomicInteger gameCount = new AtomicInteger(); + private final BungeeContext context = new BungeeContext(this, "YOUR_TOKEN_HERE"); - private final Metrics metrics = BungeeMetrics.factory() + private final Metrics metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) .addMetric(Metric.string("server_version", () -> "1.0.0")) @@ -23,7 +24,6 @@ public class ExamplePlugin extends Plugin { // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); @Override diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java new file mode 100644 index 00000000..a5d813a0 --- /dev/null +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -0,0 +1,25 @@ +package dev.faststats.bungee; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.md_5.bungee.api.plugin.Plugin; + +/** + * BungeeCord FastStats context. + * + * @since 0.23.0 + */ +public final class BungeeContext extends SimpleContext { + final Plugin plugin; + + public BungeeContext(final Plugin plugin, @Token final String token) { + super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), token); + this.plugin = plugin; + } + + @Override + public BungeeMetrics.Factory metrics() { + return new BungeeMetricsImpl.Factory(this); + } +} diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java index 434f6550..1c2176d9 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java @@ -1,7 +1,6 @@ package dev.faststats.bungee; -import dev.faststats.core.Metrics; -import net.md_5.bungee.api.plugin.Plugin; +import dev.faststats.Metrics; import org.jetbrains.annotations.Contract; /** @@ -10,17 +9,6 @@ * @since 0.1.0 */ public sealed interface BungeeMetrics extends Metrics permits BungeeMetricsImpl { - /** - * Creates a new metrics factory for BungeeCord. - * - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(pure = true) - static Factory factory() { - return new BungeeMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { + interface Factory extends Metrics.Factory { } } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index e71045e5..57d0663a 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -1,24 +1,22 @@ package dev.faststats.bungee; import com.google.gson.JsonObject; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.nio.file.Path; - final class BungeeMetricsImpl extends SimpleMetrics implements BungeeMetrics { private final ProxyServer server; private final Plugin plugin; @Async.Schedule @Contract(mutates = "io") - private BungeeMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + private BungeeMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException { + super(factory); this.server = plugin.getProxy(); this.plugin = plugin; @@ -40,12 +38,14 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getName()); } - static final class Factory extends SimpleMetrics.Factory implements BungeeMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory implements BungeeMetrics.Factory { + public Factory(final BungeeContext context) { + super(context); + } + @Override - public Metrics create(final Plugin plugin) throws IllegalStateException { - final var dataFolder = plugin.getProxy().getPluginsFolder().toPath().resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); - return new BungeeMetricsImpl(this, plugin, config); + public Metrics create() throws IllegalStateException { + return new BungeeMetricsImpl(this, ((BungeeContext) context).plugin); } } } diff --git a/bungeecord/src/main/java/module-info.java b/bungeecord/src/main/java/module-info.java index 8380b462..1f8b5d5e 100644 --- a/bungeecord/src/main/java/module-info.java +++ b/bungeecord/src/main/java/module-info.java @@ -6,7 +6,7 @@ requires com.google.gson; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires java.logging; requires static org.jetbrains.annotations; diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index e50548ca..71e0b69f 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -1,7 +1,7 @@ package dev.faststats.config; -import dev.faststats.core.Config; -import dev.faststats.core.internal.LoggerFactory; +import dev.faststats.Config; +import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; diff --git a/config/src/main/java/module-info.java b/config/src/main/java/module-info.java index 2b5cca38..251f4001 100644 --- a/config/src/main/java/module-info.java +++ b/config/src/main/java/module-info.java @@ -4,7 +4,7 @@ module dev.faststats.config { exports dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires java.logging; requires static org.jetbrains.annotations; diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index aa8d6439..b2a37856 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -1,6 +1,6 @@ package dev.faststats.example; -import dev.faststats.core.ErrorTracker; +import dev.faststats.ErrorTracker; import java.lang.reflect.InvocationTargetException; import java.nio.file.AccessDeniedException; diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index 1461d997..556ff7d7 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -1,8 +1,8 @@ package dev.faststats.example; -import dev.faststats.core.flags.Attributes; -import dev.faststats.core.flags.FeatureFlag; -import dev.faststats.core.flags.FeatureFlagService; +import dev.faststats.Attributes; +import dev.faststats.FeatureFlag; +import dev.faststats.FeatureFlagService; import java.time.Duration; import java.time.Instant; diff --git a/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java b/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java index 066b5b4c..5d7ac3e1 100644 --- a/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java +++ b/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java @@ -1,6 +1,6 @@ package dev.faststats.example; -import dev.faststats.core.data.Metric; +import dev.faststats.data.Metric; public final class MetricTypesExample { // Single value metrics diff --git a/core/src/main/java/dev/faststats/core/flags/Attributes.java b/core/src/main/java/dev/faststats/Attributes.java similarity index 99% rename from core/src/main/java/dev/faststats/core/flags/Attributes.java rename to core/src/main/java/dev/faststats/Attributes.java index ad08cb8e..83fddca4 100644 --- a/core/src/main/java/dev/faststats/core/flags/Attributes.java +++ b/core/src/main/java/dev/faststats/Attributes.java @@ -1,4 +1,4 @@ -package dev.faststats.core.flags; +package dev.faststats; import com.google.gson.JsonPrimitive; import org.jetbrains.annotations.Contract; diff --git a/core/src/main/java/dev/faststats/core/Config.java b/core/src/main/java/dev/faststats/Config.java similarity index 98% rename from core/src/main/java/dev/faststats/core/Config.java rename to core/src/main/java/dev/faststats/Config.java index f1e39f5f..0c9cb5d6 100644 --- a/core/src/main/java/dev/faststats/core/Config.java +++ b/core/src/main/java/dev/faststats/Config.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import org.jetbrains.annotations.Contract; diff --git a/core/src/main/java/dev/faststats/core/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java similarity index 99% rename from core/src/main/java/dev/faststats/core/ErrorHelper.java rename to core/src/main/java/dev/faststats/ErrorHelper.java index 1ec3fcaa..60c28466 100644 --- a/core/src/main/java/dev/faststats/core/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonArray; import com.google.gson.JsonObject; diff --git a/core/src/main/java/dev/faststats/core/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java similarity index 95% rename from core/src/main/java/dev/faststats/core/ErrorTracker.java rename to core/src/main/java/dev/faststats/ErrorTracker.java index a02b9284..08731778 100644 --- a/core/src/main/java/dev/faststats/core/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import org.intellij.lang.annotations.RegExp; import org.jetbrains.annotations.Contract; @@ -11,7 +11,7 @@ /** * An error tracker. * - * @since 0.10.0 + * @since 0.23.0 */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** @@ -25,7 +25,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * @see #contextUnaware() * @see #trackError(String, boolean) * @see #trackError(Throwable, boolean) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(value = " -> new") static ErrorTracker contextAware() { @@ -45,7 +45,7 @@ static ErrorTracker contextAware() { * @see #contextAware() * @see #trackError(String) * @see #trackError(Throwable) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(value = " -> new", pure = true) static ErrorTracker contextUnaware() { @@ -58,7 +58,7 @@ static ErrorTracker contextUnaware() { * @param message the error message * @see #trackError(Throwable) * @see #trackError(String, boolean) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(String message); @@ -68,7 +68,7 @@ static ErrorTracker contextUnaware() { * * @param error the error * @see #trackError(Throwable, boolean) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(Throwable error); @@ -81,7 +81,7 @@ static ErrorTracker contextUnaware() { * @param message the error message * @param handled whether the error was handled * @see #trackError(Throwable, boolean) - * @since 0.20.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(String message, boolean handled); @@ -93,7 +93,7 @@ static ErrorTracker contextUnaware() { * * @param error the error * @param handled whether the error was handled - * @since 0.20.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(Throwable error, boolean handled); @@ -106,7 +106,7 @@ static ErrorTracker contextUnaware() { * * @param type the error type * @return the error tracker - * @since 0.22.0 + * @since 0.23.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Class type); @@ -125,7 +125,7 @@ static ErrorTracker contextUnaware() { * * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Pattern pattern); @@ -138,7 +138,7 @@ static ErrorTracker contextUnaware() { * @param pattern the regex pattern string to match against error messages * @return the error tracker * @see #ignoreError(Pattern) - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_ -> this", mutates = "this") default ErrorTracker ignoreError(@RegExp final String pattern) { @@ -156,7 +156,7 @@ default ErrorTracker ignoreError(@RegExp final String pattern) { * @param type the error type * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker ignoreError(Class type, Pattern pattern); @@ -170,7 +170,7 @@ default ErrorTracker ignoreError(@RegExp final String pattern) { * @param pattern the regex pattern string to match against error messages * @return the error tracker * @see #ignoreError(Class, Pattern) - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker ignoreError(final Class type, @RegExp final String pattern) { @@ -187,7 +187,7 @@ default ErrorTracker ignoreError(final Class type, @RegExp * @param replacement the replacement string * @return the error tracker * @see java.util.regex.Matcher#replaceAll(String) - * @since 0.22.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker anonymize(Pattern pattern, String replacement); @@ -200,7 +200,7 @@ default ErrorTracker ignoreError(final Class type, @RegExp * @return the error tracker * @see #anonymize(Pattern, String) * @see java.util.regex.Matcher#replaceAll(String) - * @since 0.22.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker anonymize(@RegExp final String pattern, final String replacement) { @@ -214,7 +214,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * * @param loader the class loader * @throws IllegalStateException if the error context is already attached - * @since 0.10.0 + * @since 0.23.0 */ void attachErrorContext(@Nullable ClassLoader loader) throws IllegalStateException; @@ -227,7 +227,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * This should be called during shutdown to prevent {@link BootstrapMethodError} * when the provider's JAR file is closed. * - * @since 0.13.0 + * @since 0.23.0 */ void detachErrorContext(); @@ -235,7 +235,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * Returns whether an error context is attached. * * @return whether an error context is attached - * @since 0.13.0 + * @since 0.23.0 */ boolean isContextAttached(); @@ -245,7 +245,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * The purpose of this handler is to allow custom error handling like logging. * * @param errorEvent the error event handler - * @since 0.11.0 + * @since 0.23.0 */ @Contract(mutates = "this") void setContextErrorHandler(@Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent); @@ -254,7 +254,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * Returns the error event handler which will be called when an error is tracked automatically. * * @return the error event handler - * @since 0.11.0 + * @since 0.23.0 */ @Contract(pure = true) Optional> getContextErrorHandler(); @@ -265,7 +265,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * @param loader the class loader * @param error the error * @return whether the error occurred in the same class loader - * @since 0.14.0 + * @since 0.23.0 */ @Contract(pure = true) static boolean isSameLoader(final ClassLoader loader, final Throwable error) { diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java new file mode 100644 index 00000000..070759ca --- /dev/null +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -0,0 +1,85 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +import java.time.Duration; + +/** + * Shared FastStats context. + *

+ * Platform-specific contexts should extend this class to provide a shared + * configuration, token, and metrics factory for their environment. + * + * @since 0.23.0 + */ +public interface FastStatsContext { + /** + * Get the metrics configuration shared by services created from this context. + * + * @return the shared configuration + * @since 0.23.0 + */ + @Contract(pure = true) + Config getConfig(); + + /** + * Get the token shared by services created from this context. + * + * @return the shared token + * @since 0.23.0 + */ + @Token + @Contract(pure = true) + String getToken(); + + /** + * Creates a new platform metrics factory bound to this context. + * + * @return a new platform metrics factory + * @since 0.23.0 + */ + @Contract(value = "-> new", pure = true) + Metrics.Factory metrics(); + + /** + * Creates a new feature flag service backed by this context token. + * + * @return the feature flag service + * @since 0.23.0 + */ + @Contract(value = "-> new", pure = true) + FeatureFlagService featureFlags(); + + /** + * Creates a new feature flag service backed by this context token and attributes. + * + * @param attributes the global targeting attributes + * @return the feature flag service + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + FeatureFlagService featureFlags(final Attributes attributes); + + /** + * Creates a new feature flag service backed by this context token, and TTL. + * + * @param ttl the cache time-to-live for resolved flag values + * @return the feature flag service + * @throws IllegalArgumentException if the TTL is negative + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + FeatureFlagService featureFlags(final Duration ttl); + + /** + * Creates a new feature flag service backed by this context token, attributes, and TTL. + * + * @param attributes the global targeting attributes + * @param ttl the cache time-to-live for resolved flag values + * @return the feature flag service + * @throws IllegalArgumentException if the TTL is negative + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + FeatureFlagService featureFlags(final Attributes attributes, final Duration ttl); +} diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java similarity index 99% rename from core/src/main/java/dev/faststats/core/flags/FeatureFlag.java rename to core/src/main/java/dev/faststats/FeatureFlag.java index 77532a14..bc2e7039 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -1,4 +1,4 @@ -package dev.faststats.core.flags; +package dev.faststats; import org.jetbrains.annotations.Contract; diff --git a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java similarity index 64% rename from core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java rename to core/src/main/java/dev/faststats/FeatureFlagService.java index d576a1bb..73cb2fc5 100644 --- a/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -1,8 +1,6 @@ -package dev.faststats.core.flags; +package dev.faststats; -import dev.faststats.core.Token; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.time.Duration; import java.util.Optional; @@ -15,51 +13,6 @@ * @since 0.23.0 */ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { - /** - * Creates a feature flag service for the given environment token - * and a default cache TTL of five minutes. - * - * @param token the environment token - * @return a new feature flag service - * @see #create(String, Attributes) - * @since 0.23.0 - */ - @Contract(value = "_ -> new", pure = true) - static FeatureFlagService create(@Token final String token) { - return create(token, null); - } - - /** - * Creates a feature flag service for the given environment token - * and global targeting attributes with a default cache TTL of five minutes. - * - * @param token the environment token - * @param attributes the global targeting attributes - * @return a new feature flag service - * @see #create(String, Attributes, Duration) - * @since 0.23.0 - */ - @Contract(value = "_, _ -> new", pure = true) - static FeatureFlagService create(@Token final String token, @Nullable final Attributes attributes) { - return create(token, attributes, Duration.ofMinutes(5)); - } - - /** - * Creates a feature flag service for the given environment token, - * global targeting attributes, and cache TTL. - * - * @param token the environment token - * @param attributes the global targeting attributes - * @param ttl the cache time-to-live for resolved flag values - * @return a new feature flag service - * @throws IllegalArgumentException if the TTL is negative - * @since 0.23.0 - */ - @Contract(value = "_, _, _ -> new", pure = true) - static FeatureFlagService create(@Token final String token, @Nullable final Attributes attributes, final Duration ttl) throws IllegalArgumentException { - return new SimpleFeatureFlagService(token, attributes, ttl); - } - /** * Define a boolean feature flag. * diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java similarity index 71% rename from core/src/main/java/dev/faststats/core/Metrics.java rename to core/src/main/java/dev/faststats/Metrics.java index d931cc20..b051fcfe 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -1,6 +1,6 @@ -package dev.faststats.core; +package dev.faststats; -import dev.faststats.core.data.Metric; +import dev.faststats.data.Metric; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -9,14 +9,14 @@ /** * Metrics interface. * - * @since 0.1.0 + * @since 0.23.0 */ public interface Metrics { /** * Get the token used to authenticate with the metrics server and identify the project. * * @return the metrics token - * @since 0.1.0 + * @since 0.23.0 */ @Token @Contract(pure = true) @@ -26,7 +26,7 @@ public interface Metrics { * Get the error tracker for this metrics instance. * * @return the error tracker - * @since 0.10.0 + * @since 0.23.0 */ @Contract(pure = true) Optional getErrorTracker(); @@ -35,7 +35,7 @@ public interface Metrics { * Get the metrics configuration. * * @return the metrics configuration - * @since 0.1.0 + * @since 0.23.0 */ @Contract(pure = true) Config getConfig(); @@ -48,7 +48,7 @@ public interface Metrics { * No-op in most implementations. * * @apiNote Refer to your {@code Metrics} provider's documentation. - * @since 0.14.0 + * @since 0.23.0 */ default void ready() { } @@ -58,7 +58,7 @@ default void ready() { *

* This method should be called when the application is shutting down. * - * @since 0.1.0 + * @since 0.23.0 */ @Contract(mutates = "this") void shutdown(); @@ -66,9 +66,9 @@ default void ready() { /** * A metrics factory. * - * @since 0.1.0 + * @since 0.23.0 */ - interface Factory> { + interface Factory> { /** * Adds a metric to the metrics submission. *

@@ -77,7 +77,7 @@ interface Factory> { * @param metric the metric to add * @return the metrics factory * @throws IllegalArgumentException if the metric is already added - * @since 0.16.0 + * @since 0.23.0 */ @Contract(mutates = "this") F addMetric(Metric metric) throws IllegalArgumentException; @@ -89,7 +89,7 @@ interface Factory> { * * @param flush the flush callback * @return the metrics factory - * @since 0.15.0 + * @since 0.23.0 */ @Contract(mutates = "this") F onFlush(Runnable flush); @@ -101,38 +101,23 @@ interface Factory> { * * @param tracker the error tracker * @return the metrics factory - * @since 0.10.0 + * @since 0.23.0 */ @Contract(mutates = "this") F errorTracker(ErrorTracker tracker); - /** - * Sets the token used to authenticate with the metrics server and identify the project. - *

- * This token can be found in the settings of your project under "Your API Token". - * - * @param token the metrics token - * @return the metrics factory - * @throws IllegalArgumentException if the token does not match the {@link Token#PATTERN} - * @since 0.1.0 - */ - @Contract(mutates = "this") - F token(@Token String token) throws IllegalArgumentException; - /** * Creates a new metrics instance. *

* Metrics submission will start automatically. * - * @param object a required object as defined by the implementation * @return the metrics instance * @throws IllegalStateException if the token is not specified - * @see #token(String) - * @since 0.1.0 + * @since 0.23.0 */ @Async.Schedule - @Contract(value = "_ -> new", mutates = "io") - Metrics create(T object) throws IllegalStateException; + @Contract(value = " -> new", mutates = "io") + Metrics create() throws IllegalStateException; } } diff --git a/core/src/main/java/dev/faststats/core/MurmurHash3.java b/core/src/main/java/dev/faststats/MurmurHash3.java similarity index 99% rename from core/src/main/java/dev/faststats/core/MurmurHash3.java rename to core/src/main/java/dev/faststats/MurmurHash3.java index 157b765f..3d4dcca7 100644 --- a/core/src/main/java/dev/faststats/core/MurmurHash3.java +++ b/core/src/main/java/dev/faststats/MurmurHash3.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonObject; import org.jetbrains.annotations.Contract; diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java b/core/src/main/java/dev/faststats/SimpleAttributes.java similarity index 96% rename from core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java rename to core/src/main/java/dev/faststats/SimpleAttributes.java index a1378011..8413a4e2 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java +++ b/core/src/main/java/dev/faststats/SimpleAttributes.java @@ -1,4 +1,4 @@ -package dev.faststats.core.flags; +package dev.faststats; import com.google.gson.JsonPrimitive; diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java new file mode 100644 index 00000000..9a81f234 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -0,0 +1,51 @@ +package dev.faststats; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; + +@ApiStatus.Internal +public abstract class SimpleContext implements FastStatsContext { + private final Config config; + private final @Token String token; + + // todo: add docs + protected SimpleContext(final Config config, @Token final String token) throws IllegalArgumentException { + if (!token.matches(Token.PATTERN)) { + throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); + } + this.config = config; + this.token = token; + } + + @Override + public final Config getConfig() { + return config; + } + + @Override + public final @Token String getToken() { + return token; + } + + @Override + public final FeatureFlagService featureFlags() { + return new SimpleFeatureFlagService(token, null, Duration.ofMinutes(5)); + } + + @Override + public final FeatureFlagService featureFlags(final Attributes attributes) { + return new SimpleFeatureFlagService(token, attributes, Duration.ofMinutes(5)); + } + + @Override + public final FeatureFlagService featureFlags(final Duration ttl) { + return new SimpleFeatureFlagService(token, null, ttl); + } + + @Override + public final FeatureFlagService featureFlags(@Nullable final Attributes attributes, final Duration ttl) { + return new SimpleFeatureFlagService(token, attributes, ttl); + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java similarity index 99% rename from core/src/main/java/dev/faststats/core/SimpleErrorTracker.java rename to core/src/main/java/dev/faststats/SimpleErrorTracker.java index 45f77250..715db429 100644 --- a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonArray; import com.google.gson.JsonObject; diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java similarity index 98% rename from core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java rename to core/src/main/java/dev/faststats/SimpleFeatureFlag.java index b7f4670f..11067697 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -1,4 +1,4 @@ -package dev.faststats.core.flags; +package dev.faststats; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java similarity index 98% rename from core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java rename to core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index c439117e..c0eb8f51 100644 --- a/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -1,13 +1,12 @@ -package dev.faststats.core.flags; +package dev.faststats; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; -import dev.faststats.core.Token; -import dev.faststats.core.internal.Logger; -import dev.faststats.core.internal.LoggerFactory; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; import org.jspecify.annotations.Nullable; import java.net.URI; diff --git a/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java similarity index 91% rename from core/src/main/java/dev/faststats/core/SimpleMetrics.java rename to core/src/main/java/dev/faststats/SimpleMetrics.java index b16c507c..f21092e6 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -1,10 +1,11 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.core.data.Metric; -import dev.faststats.core.internal.Constants; -import dev.faststats.core.internal.Logger; -import dev.faststats.core.internal.LoggerFactory; +import dev.faststats.data.Metric; +import dev.faststats.internal.Constants; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.MustBeInvokedByOverriders; @@ -32,6 +33,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; +@ApiStatus.Internal public abstract class SimpleMetrics implements Metrics { protected final Logger logger = LoggerFactory.factory().getLogger(getClass()); @@ -50,11 +52,9 @@ public abstract class SimpleMetrics implements Metrics { @Contract(mutates = "io") @SuppressWarnings("PatternValidation") - protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { - if (factory.token == null) throw new IllegalStateException("Token must be specified"); - + protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { this.config = config; - this.token = factory.token; + this.token = factory.context.getToken(); this.metrics = config.additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); @@ -63,6 +63,11 @@ protected SimpleMetrics(final Factory factory, final Config config) throws this.url = getMetricsServerUrl(); } + @Contract(mutates = "io") + protected SimpleMetrics(final Factory factory) throws IllegalStateException { + this(factory, factory.context.getConfig()); + } + private URI getMetricsServerUrl() { final var property = System.getProperty("faststats.metrics-server"); if (property != null) try { @@ -257,7 +262,7 @@ public Optional getErrorTracker() { } @Override - public dev.faststats.core.Config getConfig() { + public dev.faststats.Config getConfig() { return config; } @@ -278,11 +283,15 @@ public void shutdown() { } } - public abstract static class Factory> implements Metrics.Factory { + public abstract static class Factory> implements Metrics.Factory { private final Set> metrics = new HashSet<>(0); + protected final FastStatsContext context; private @Nullable ErrorTracker tracker; private @Nullable Runnable flush; - private @Nullable String token; + + protected Factory(final FastStatsContext context) { + this.context = context; + } @Override @SuppressWarnings("unchecked") @@ -304,16 +313,5 @@ public F errorTracker(final ErrorTracker tracker) { this.tracker = tracker; return (F) this; } - - @Override - @SuppressWarnings("unchecked") - public F token(@Token final String token) throws IllegalArgumentException { - if (!token.matches(Token.PATTERN)) { - throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } - this.token = token; - return (F) this; - } } - } diff --git a/core/src/main/java/dev/faststats/core/Token.java b/core/src/main/java/dev/faststats/Token.java similarity index 93% rename from core/src/main/java/dev/faststats/core/Token.java rename to core/src/main/java/dev/faststats/Token.java index 35ed3d3f..6eb09d99 100644 --- a/core/src/main/java/dev/faststats/core/Token.java +++ b/core/src/main/java/dev/faststats/Token.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import org.intellij.lang.annotations.Pattern; import org.jetbrains.annotations.NonNls; @@ -15,7 +15,7 @@ /** * An annotation to mark a token. * - * @since 0.1.0 + * @since 0.23.0 */ @NonNls @Pattern(Token.PATTERN) diff --git a/core/src/main/java/dev/faststats/core/data/ArrayMetric.java b/core/src/main/java/dev/faststats/data/ArrayMetric.java similarity index 96% rename from core/src/main/java/dev/faststats/core/data/ArrayMetric.java rename to core/src/main/java/dev/faststats/data/ArrayMetric.java index 4a6e027b..00b55b23 100644 --- a/core/src/main/java/dev/faststats/core/data/ArrayMetric.java +++ b/core/src/main/java/dev/faststats/data/ArrayMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonArray; import com.google.gson.JsonElement; diff --git a/core/src/main/java/dev/faststats/core/data/MapMetric.java b/core/src/main/java/dev/faststats/data/MapMetric.java similarity index 100% rename from core/src/main/java/dev/faststats/core/data/MapMetric.java rename to core/src/main/java/dev/faststats/data/MapMetric.java diff --git a/core/src/main/java/dev/faststats/core/data/Metric.java b/core/src/main/java/dev/faststats/data/Metric.java similarity index 96% rename from core/src/main/java/dev/faststats/core/data/Metric.java rename to core/src/main/java/dev/faststats/data/Metric.java index 40ee7830..6ed98116 100644 --- a/core/src/main/java/dev/faststats/core/data/Metric.java +++ b/core/src/main/java/dev/faststats/data/Metric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import org.jetbrains.annotations.Contract; @@ -12,14 +12,14 @@ * A metric. * * @param the metric data type - * @since 0.16.0 + * @since 0.23.0 */ public interface Metric { /** * Get the source id. * * @return the source id - * @since 0.16.0 + * @since 0.23.0 */ @SourceId @Contract(pure = true) @@ -31,7 +31,7 @@ public interface Metric { * @return an optional containing the metric data * @throws Exception if unable to compute the metric data * @implSpec The implementation must be thread-safe and pure (i.e. not modify any shared state). - * @since 0.16.0 + * @since 0.23.0 */ @Contract(pure = true) Optional compute() throws Exception; @@ -44,7 +44,7 @@ public interface Metric { * @implSpec The implementation must call {@link #compute()} to get the metric data * and follow the same thread-safety and pureness requirements. * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(pure = true) Optional getData() throws Exception; @@ -58,7 +58,7 @@ public interface Metric { * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric stringArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -74,7 +74,7 @@ static Metric stringArray(@SourceId final String id, final Callable booleanArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -90,7 +90,7 @@ static Metric booleanArray(@SourceId final String id, final Callable< * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric numberArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -154,7 +154,7 @@ static Metric numberArray(@SourceId final String id, final Callable bool(@SourceId final String id, final Callable<@Nullable Boolean> callable) throws IllegalArgumentException { @@ -170,7 +170,7 @@ static Metric bool(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric string(@SourceId final String id, final Callable<@Nullable String> callable) throws IllegalArgumentException { @@ -186,7 +186,7 @@ static Metric string(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric number(@SourceId final String id, final Callable<@Nullable Number> callable) throws IllegalArgumentException { diff --git a/core/src/main/java/dev/faststats/core/data/SimpleMetric.java b/core/src/main/java/dev/faststats/data/SimpleMetric.java similarity index 97% rename from core/src/main/java/dev/faststats/core/data/SimpleMetric.java rename to core/src/main/java/dev/faststats/data/SimpleMetric.java index e3f55476..28082ade 100644 --- a/core/src/main/java/dev/faststats/core/data/SimpleMetric.java +++ b/core/src/main/java/dev/faststats/data/SimpleMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/dev/faststats/core/data/SingleValueMetric.java b/core/src/main/java/dev/faststats/data/SingleValueMetric.java similarity index 95% rename from core/src/main/java/dev/faststats/core/data/SingleValueMetric.java rename to core/src/main/java/dev/faststats/data/SingleValueMetric.java index cbfc7e69..26034748 100644 --- a/core/src/main/java/dev/faststats/core/data/SingleValueMetric.java +++ b/core/src/main/java/dev/faststats/data/SingleValueMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; diff --git a/core/src/main/java/dev/faststats/core/data/SourceId.java b/core/src/main/java/dev/faststats/data/SourceId.java similarity index 93% rename from core/src/main/java/dev/faststats/core/data/SourceId.java rename to core/src/main/java/dev/faststats/data/SourceId.java index c7295ec0..f702b76f 100644 --- a/core/src/main/java/dev/faststats/core/data/SourceId.java +++ b/core/src/main/java/dev/faststats/data/SourceId.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import org.intellij.lang.annotations.Pattern; import org.jetbrains.annotations.NonNls; @@ -15,7 +15,7 @@ /** * An annotation to mark a source id. * - * @since 0.16.0 + * @since 0.23.0 */ @NonNls @Pattern(SourceId.PATTERN) diff --git a/core/src/main/java/dev/faststats/core/internal/Constants.java b/core/src/main/java/dev/faststats/internal/Constants.java similarity index 90% rename from core/src/main/java/dev/faststats/core/internal/Constants.java rename to core/src/main/java/dev/faststats/internal/Constants.java index b379046d..065c3078 100644 --- a/core/src/main/java/dev/faststats/core/internal/Constants.java +++ b/core/src/main/java/dev/faststats/internal/Constants.java @@ -1,6 +1,6 @@ -package dev.faststats.core.internal; +package dev.faststats.internal; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.SimpleMetrics; import java.io.IOException; import java.util.Properties; diff --git a/core/src/main/java/dev/faststats/core/internal/Logger.java b/core/src/main/java/dev/faststats/internal/Logger.java similarity index 95% rename from core/src/main/java/dev/faststats/core/internal/Logger.java rename to core/src/main/java/dev/faststats/internal/Logger.java index d5fd1d9c..3f5f232e 100644 --- a/core/src/main/java/dev/faststats/core/internal/Logger.java +++ b/core/src/main/java/dev/faststats/internal/Logger.java @@ -1,4 +1,4 @@ -package dev.faststats.core.internal; +package dev.faststats.internal; import org.intellij.lang.annotations.PrintFormat; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java b/core/src/main/java/dev/faststats/internal/LoggerFactory.java similarity index 93% rename from core/src/main/java/dev/faststats/core/internal/LoggerFactory.java rename to core/src/main/java/dev/faststats/internal/LoggerFactory.java index 5a4a1af5..567bd5de 100644 --- a/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java +++ b/core/src/main/java/dev/faststats/internal/LoggerFactory.java @@ -1,4 +1,4 @@ -package dev.faststats.core.internal; +package dev.faststats.internal; import java.util.ServiceLoader; diff --git a/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java b/core/src/main/java/dev/faststats/internal/SimpleLogger.java similarity index 97% rename from core/src/main/java/dev/faststats/core/internal/SimpleLogger.java rename to core/src/main/java/dev/faststats/internal/SimpleLogger.java index 37d96edf..d16cc699 100644 --- a/core/src/main/java/dev/faststats/core/internal/SimpleLogger.java +++ b/core/src/main/java/dev/faststats/internal/SimpleLogger.java @@ -1,4 +1,4 @@ -package dev.faststats.core.internal; +package dev.faststats.internal; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java b/core/src/main/java/dev/faststats/internal/SimpleLoggerFactory.java similarity index 82% rename from core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java rename to core/src/main/java/dev/faststats/internal/SimpleLoggerFactory.java index aab3ef87..dcb8b9cf 100644 --- a/core/src/main/java/dev/faststats/core/internal/SimpleLoggerFactory.java +++ b/core/src/main/java/dev/faststats/internal/SimpleLoggerFactory.java @@ -1,4 +1,4 @@ -package dev.faststats.core.internal; +package dev.faststats.internal; final class SimpleLoggerFactory implements LoggerFactory { @Override diff --git a/core/src/main/java/dev/faststats/core/internal/package-info.java b/core/src/main/java/dev/faststats/internal/package-info.java similarity index 63% rename from core/src/main/java/dev/faststats/core/internal/package-info.java rename to core/src/main/java/dev/faststats/internal/package-info.java index 93362edd..dfe3b560 100644 --- a/core/src/main/java/dev/faststats/core/internal/package-info.java +++ b/core/src/main/java/dev/faststats/internal/package-info.java @@ -1,4 +1,4 @@ @ApiStatus.Internal -package dev.faststats.core.internal; +package dev.faststats.internal; import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index e665b643..0f763101 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -1,11 +1,10 @@ import org.jspecify.annotations.NullMarked; @NullMarked -module dev.faststats.core { - exports dev.faststats.core.data; - exports dev.faststats.core.flags; - exports dev.faststats.core.internal; - exports dev.faststats.core; +module dev.faststats { + exports dev.faststats.data; + exports dev.faststats.internal; + exports dev.faststats; requires com.google.gson; requires java.logging; @@ -14,5 +13,5 @@ requires static org.jetbrains.annotations; requires static org.jspecify; - uses dev.faststats.core.internal.LoggerFactory; + uses dev.faststats.internal.LoggerFactory; } diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index c356144b..c12013de 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -1,7 +1,6 @@ package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.core.ErrorTracker; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index f500cd49..60983aae 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,6 +1,5 @@ package dev.faststats; -import dev.faststats.core.ErrorTracker; import org.junit.jupiter.api.Test; import java.net.URL; diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 4fd403b3..00bdc21e 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -2,9 +2,6 @@ import com.google.gson.JsonObject; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.SimpleMetrics; -import dev.faststats.core.Token; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 779163af..4672e62d 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -1,20 +1,20 @@ package com.example; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.fabric.FabricMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.fabric.FabricContext; import net.fabricmc.api.ModInitializer; public class ExampleMod implements ModInitializer { - private final Metrics metrics = FabricMetrics.factory() + private final FabricContext context = new FabricContext("YOUR_TOKEN_HERE"); + private final Metrics metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create("example-mod"); // your mod id as defined in fabric.mod.json @Override diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java new file mode 100644 index 00000000..2fc32be4 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -0,0 +1,28 @@ +package dev.faststats.fabric; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; + +/** + * Fabric FastStats context. + * + * @since 0.23.0 + */ +public final class FabricContext extends SimpleContext { + final ModContainer mod; + + public FabricContext(final String modId, @Token final String token) { + super(SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), token); + this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { + return new IllegalArgumentException("Mod not found: " + modId); + }); + } + + @Override + public FabricMetrics.Factory metrics() { + return new FabricMetricsImpl.Factory(this); + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java index f6ce2df7..786c5157 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java @@ -1,8 +1,6 @@ package dev.faststats.fabric; -import dev.faststats.core.Metrics; -import org.jetbrains.annotations.Async; -import org.jetbrains.annotations.Contract; +import dev.faststats.Metrics; /** * Fabric metrics implementation. @@ -10,33 +8,6 @@ * @since 0.12.0 */ public sealed interface FabricMetrics extends Metrics permits FabricMetricsImpl { - /** - * Creates a new metrics factory for Fabric. - * - * @return the metrics factory - * @since 0.12.0 - */ - @Contract(pure = true) - static Factory factory() { - return new FabricMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { - /** - * Creates a new metrics instance. - *

- * Metrics submission will start automatically. - * - * @param modId the mod id - * @return the metrics instance - * @throws IllegalStateException if the token is not specified - * @throws IllegalArgumentException if the mod is not found - * @see #token(String) - * @since 0.12.0 - */ - @Override - @Async.Schedule - @Contract(value = "_ -> new", mutates = "io") - Metrics create(String modId) throws IllegalStateException, IllegalArgumentException; + interface Factory extends Metrics.Factory { } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index 8e670869..f6f683fe 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -1,18 +1,16 @@ package dev.faststats.fabric; import com.google.gson.JsonObject; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; -import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import net.minecraft.server.MinecraftServer; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.Nullable; -import java.nio.file.Path; import java.util.Optional; import java.util.function.Supplier; @@ -23,8 +21,8 @@ final class FabricMetricsImpl extends SimpleMetrics implements FabricMetrics { @Async.Schedule @Contract(mutates = "io") - private FabricMetricsImpl(final Factory factory, final ModContainer mod, final Path config) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + private FabricMetricsImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory); this.mod = mod; @@ -58,18 +56,14 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements FabricMetrics.Factory { - @Override - public Metrics create(final String modId) throws IllegalStateException, IllegalArgumentException { - final var fabric = FabricLoader.getInstance(); - final var mod = fabric.getModContainer(modId).orElseThrow(() -> { - return new IllegalArgumentException("Mod not found: " + modId); - }); - - final var dataFolder = fabric.getConfigDir().resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); + static final class Factory extends SimpleMetrics.Factory implements FabricMetrics.Factory { + public Factory(final FabricContext context) { + super(context); + } - return new FabricMetricsImpl(this, mod, config); + @Override + public Metrics create() throws IllegalStateException, IllegalArgumentException { + return new FabricMetricsImpl(this, ((FabricContext) context).mod); } } } diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index d4d65548..2ca33731 100644 --- a/fabric/src/main/java/module-info.java +++ b/fabric/src/main/java/module-info.java @@ -6,7 +6,7 @@ requires com.google.gson; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires net.fabricmc.loader; requires org.slf4j; diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 98a7958f..54453c2a 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,20 +2,20 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.hytale.HytaleMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.hytale.HytaleContext; public class ExamplePlugin extends JavaPlugin { - private final Metrics metrics = HytaleMetrics.factory() + private final HytaleContext context = new HytaleContext(this, "YOUR_TOKEN_HERE"); + private final Metrics metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); public ExamplePlugin(final JavaPluginInit init) { diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java new file mode 100644 index 00000000..47f3407e --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -0,0 +1,22 @@ +package dev.faststats.hytale; + +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; + +/** + * Hytale FastStats context. + * + * @since 0.23.0 + */ +public final class HytaleContext extends SimpleContext { + public HytaleContext(final JavaPlugin plugin, @Token final String token) { + super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), token); + } + + @Override + public HytaleMetrics.Factory metrics() { + return new HytaleMetricsImpl.Factory(this); + } +} diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java index 96748f74..e2178664 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java @@ -1,8 +1,6 @@ package dev.faststats.hytale; -import com.hypixel.hytale.server.core.plugin.JavaPlugin; -import dev.faststats.core.Metrics; -import org.jetbrains.annotations.Contract; +import dev.faststats.Metrics; /** * Hytale metrics implementation. @@ -10,17 +8,6 @@ * @since 0.9.0 */ public sealed interface HytaleMetrics extends Metrics permits HytaleMetricsImpl { - /** - * Creates a new metrics factory for Hytale. - * - * @return the metrics factory - * @since 0.9.0 - */ - @Contract(pure = true) - static Factory factory() { - return new HytaleMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { + interface Factory extends Metrics.Factory { } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index d2538eab..3f0973ed 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -2,21 +2,19 @@ import com.google.gson.JsonObject; import com.hypixel.hytale.server.core.HytaleServer; -import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.universe.Universe; +import dev.faststats.Config; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.nio.file.Path; - final class HytaleMetricsImpl extends SimpleMetrics implements HytaleMetrics { @Async.Schedule @Contract(mutates = "io") - private HytaleMetricsImpl(final Factory factory, final Path config) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + private HytaleMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); startSubmitting(); } @@ -33,12 +31,14 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Hytale"); } - static final class Factory extends SimpleMetrics.Factory implements HytaleMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory implements HytaleMetrics.Factory { + public Factory(final HytaleContext context) { + super(context); + } + @Override - public Metrics create(final JavaPlugin plugin) throws IllegalStateException { - final var mods = plugin.getDataDirectory().toAbsolutePath().getParent(); - final var config = mods.resolve("faststats").resolve("config.properties"); - return new HytaleMetricsImpl(this, config); + public Metrics create() throws IllegalStateException { + return new HytaleMetricsImpl(this); } } } diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java index 3b9ae5ce..7f276d16 100644 --- a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java @@ -6,7 +6,7 @@ import java.util.function.Predicate; import java.util.logging.Level; -final class HytaleLogger implements dev.faststats.core.internal.Logger { +final class HytaleLogger implements dev.faststats.internal.Logger { private final com.hypixel.hytale.logger.HytaleLogger logger; private volatile @Nullable Predicate filter; diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java index 81d2cce9..2a7c420f 100644 --- a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java @@ -1,8 +1,8 @@ package dev.faststats.hytale.logger; -public final class HytaleLoggerFactory implements dev.faststats.core.internal.LoggerFactory { +public final class HytaleLoggerFactory implements dev.faststats.internal.LoggerFactory { @Override - public dev.faststats.core.internal.Logger getLogger(final String name) { + public dev.faststats.internal.Logger getLogger(final String name) { return new HytaleLogger(name); } } diff --git a/hytale/src/main/java/module-info.java b/hytale/src/main/java/module-info.java index 5fa387fb..a9372166 100644 --- a/hytale/src/main/java/module-info.java +++ b/hytale/src/main/java/module-info.java @@ -6,11 +6,11 @@ requires com.google.gson; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; - provides dev.faststats.core.internal.LoggerFactory with dev.faststats.hytale.logger.HytaleLoggerFactory; + provides dev.faststats.internal.LoggerFactory with dev.faststats.hytale.logger.HytaleLoggerFactory; } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java new file mode 100644 index 00000000..2433f738 --- /dev/null +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -0,0 +1,23 @@ +package dev.faststats.minestom; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; + +import java.nio.file.Path; + +/** + * Minestom FastStats context. + * + * @since 0.23.0 + */ +public final class MinestomContext extends SimpleContext { + public MinestomContext(@Token final String token) { + super(SimpleConfig.read(Path.of("faststats", "config.properties")), token); + } + + @Override + public MinestomMetrics.Factory metrics() { + return new MinestomMetricsImpl.Factory(this); + } +} diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java index f4df6b98..aa48ccf5 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java @@ -1,9 +1,8 @@ package dev.faststats.minestom; -import dev.faststats.core.Metrics; +import dev.faststats.Metrics; import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; -import org.jetbrains.annotations.Contract; /** * Minestom metrics implementation. @@ -11,17 +10,6 @@ * @since 0.1.0 */ public sealed interface MinestomMetrics extends Metrics permits MinestomMetricsImpl { - /** - * Creates a new metrics factory forMinestom. - * - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(pure = true) - static Factory factory() { - return new MinestomMetricsImpl.Factory(); - } - /** * Registers additional exception handlers. * @@ -31,6 +19,6 @@ static Factory factory() { @Override void ready(); - interface Factory extends Metrics.Factory { + interface Factory extends Metrics.Factory { } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index e137c388..1577c9d2 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -1,22 +1,20 @@ package dev.faststats.minestom; import com.google.gson.JsonObject; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.nio.file.Path; - final class MinestomMetricsImpl extends SimpleMetrics implements MinestomMetrics { @Async.Schedule @Contract(mutates = "io") - private MinestomMetricsImpl(final Factory factory, final Path config) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + private MinestomMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); startSubmitting(); } @@ -48,11 +46,14 @@ private void registerExceptionHandler(final ErrorTracker errorTracker) { }); } - static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + Factory(final MinestomContext context) { + super(context); + } + @Override - public Metrics create(final MinecraftServer server) throws IllegalStateException { - final var config = Path.of("faststats", "config.properties"); - return new MinestomMetricsImpl(this, config); + public Metrics create() throws IllegalStateException { + return new MinestomMetricsImpl(this); } } } diff --git a/minestom/src/main/java/module-info.java b/minestom/src/main/java/module-info.java index ff847492..e0c4b0c3 100644 --- a/minestom/src/main/java/module-info.java +++ b/minestom/src/main/java/module-info.java @@ -6,7 +6,7 @@ requires com.google.gson; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires net.minestom.server; requires org.slf4j; diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java new file mode 100644 index 00000000..f45e96f5 --- /dev/null +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -0,0 +1,27 @@ +package dev.faststats.nukkit; + +import cn.nukkit.plugin.PluginBase; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; + +import java.nio.file.Path; + +/** + * Nukkit FastStats context. + * + * @since 0.23.0 + */ +public final class NukkitContext extends SimpleContext { + final PluginBase plugin; + + public NukkitContext(final PluginBase plugin, @Token final String token) { + super(SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), token); + this.plugin = plugin; + } + + @Override + public NukkitMetrics.Factory metrics() { + return new NukkitMetricsImpl.Factory(this); + } +} diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java index 2420e2a9..a8cd305a 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java @@ -1,7 +1,6 @@ package dev.faststats.nukkit; -import cn.nukkit.plugin.PluginBase; -import dev.faststats.core.Metrics; +import dev.faststats.Metrics; import org.jetbrains.annotations.Contract; /** @@ -10,17 +9,6 @@ * @since 0.8.0 */ public sealed interface NukkitMetrics extends Metrics permits NukkitMetricsImpl { - /** - * Creates a new metrics factory for Nukkit. - * - * @return the metrics factory - * @since 0.8.0 - */ - @Contract(pure = true) - static Factory factory() { - return new NukkitMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { + interface Factory extends Metrics.Factory { } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index 51afb339..d91ff386 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -3,13 +3,12 @@ import cn.nukkit.Server; import cn.nukkit.plugin.PluginBase; import com.google.gson.JsonObject; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.nio.file.Path; import java.util.Optional; import java.util.function.Supplier; @@ -19,8 +18,8 @@ final class NukkitMetricsImpl extends SimpleMetrics implements NukkitMetrics { @Async.Schedule @Contract(mutates = "io") - private NukkitMetricsImpl(final Factory factory, final PluginBase plugin, final Path config) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + private NukkitMetricsImpl(final Factory factory, final PluginBase plugin) throws IllegalStateException { + super(factory); this.server = plugin.getServer(); this.plugin = plugin; @@ -50,12 +49,14 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements NukkitMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory implements NukkitMetrics.Factory { + Factory(final NukkitContext context) { + super(context); + } + @Override - public Metrics create(final PluginBase plugin) throws IllegalStateException { - final var dataFolder = Path.of(plugin.getServer().getPluginPath(), "faststats"); - final var config = dataFolder.resolve("config.properties"); - return new NukkitMetricsImpl(this, plugin, config); + public Metrics create() throws IllegalStateException { + return new NukkitMetricsImpl(this, ((NukkitContext) context).plugin); } } } diff --git a/nukkit/src/main/java/module-info.java b/nukkit/src/main/java/module-info.java index 1b104b57..c8722c46 100644 --- a/nukkit/src/main/java/module-info.java +++ b/nukkit/src/main/java/module-info.java @@ -6,7 +6,7 @@ requires com.google.gson; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires static org.jetbrains.annotations; requires static org.jspecify; diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index bb6caabc..35d6a6e8 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,35 +1,41 @@ package com.example; import com.google.inject.Inject; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.sponge.SpongeMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.sponge.SpongeContext; +import org.apache.logging.log4j.Logger; import org.jspecify.annotations.Nullable; import org.spongepowered.api.Server; +import org.spongepowered.api.config.ConfigDir; import org.spongepowered.api.event.Listener; import org.spongepowered.api.event.lifecycle.StartedEngineEvent; import org.spongepowered.api.event.lifecycle.StoppingEngineEvent; import org.spongepowered.plugin.PluginContainer; import org.spongepowered.plugin.builtin.jvm.Plugin; +import java.nio.file.Path; + @Plugin("example") public class ExamplePlugin { private @Inject PluginContainer pluginContainer; - private @Inject SpongeMetrics.Factory factory; + private @Inject Logger logger; + private @ConfigDir(sharedRoot = true) + @Inject Path dataDirectory; private @Nullable Metrics metrics = null; @Listener public void onServerStart(final StartedEngineEvent event) { - this.metrics = factory + final var context = new SpongeContext(pluginContainer, logger, dataDirectory, "YOUR_TOKEN_HERE"); + this.metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(pluginContainer); } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java index 9d1d5c52..6dd3a955 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -1,7 +1,7 @@ package dev.faststats.sponge; -import dev.faststats.core.Config; -import dev.faststats.core.internal.LoggerFactory; +import dev.faststats.Config; +import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.spongepowered.api.Sponge; diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java new file mode 100644 index 00000000..b177408e --- /dev/null +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -0,0 +1,35 @@ +package dev.faststats.sponge; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import org.apache.logging.log4j.Logger; +import org.spongepowered.api.config.ConfigDir; +import org.spongepowered.plugin.PluginContainer; + +import java.nio.file.Path; + +/** + * Sponge FastStats context. + * + * @since 0.23.0 + */ +public final class SpongeContext extends SimpleContext { + final PluginContainer plugin; + final Logger logger; + + public SpongeContext( + final PluginContainer plugin, + final Logger logger, + @ConfigDir(sharedRoot = true) final Path dataDirectory, + @Token final String token // fixme: cannot have a token here + ) { + super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), token); + this.plugin = plugin; + this.logger = logger; + } + + @Override + public SpongeMetrics.Factory metrics() { + return new SpongeMetrics.Factory(this); + } +} diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java index 380df380..8dcc1250 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java @@ -1,11 +1,7 @@ package dev.faststats.sponge; -import com.google.inject.Inject; -import dev.faststats.core.Metrics; -import org.apache.logging.log4j.Logger; -import org.spongepowered.api.config.ConfigDir; - -import java.nio.file.Path; +import dev.faststats.FastStatsContext; +import dev.faststats.Metrics; /** * Sponge metrics implementation. @@ -14,17 +10,8 @@ */ public sealed interface SpongeMetrics extends Metrics permits SpongeMetricsImpl { final class Factory extends SpongeMetricsImpl.Factory { - /** - * Creates a new metrics factory for Sponge. - * - * @param logger the logger - * @param dataDirectory the data directory - * @apiNote This instance is automatically injected into your plugin. - * @since 0.12.0 - */ - @Inject - private Factory(final Logger logger, @ConfigDir(sharedRoot = true) final Path dataDirectory) { - super(logger, dataDirectory); + public Factory(final FastStatsContext context) { + super(context); } } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index c96d34cd..d67289c5 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -1,8 +1,9 @@ package dev.faststats.sponge; import com.google.gson.JsonObject; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.FastStatsContext; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -10,8 +11,6 @@ import org.spongepowered.api.Sponge; import org.spongepowered.plugin.PluginContainer; -import java.nio.file.Path; - final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { private final PluginContainer plugin; @@ -21,10 +20,9 @@ final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { private SpongeMetricsImpl( final Factory factory, final Logger logger, - final PluginContainer plugin, - final Path config + final PluginContainer plugin ) throws IllegalStateException { - super(factory, SpongeConfig.read(plugin, config)); + super(factory); this.plugin = plugin; startSubmitting(); } @@ -43,19 +41,15 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", Sponge.platform().container(Platform.Component.IMPLEMENTATION).metadata().id()); } - static class Factory extends SimpleMetrics.Factory { - protected final Logger logger; - protected final Path dataDirectory; - - public Factory(final Logger logger, final Path dataDirectory) { - this.logger = logger; - this.dataDirectory = dataDirectory; + static class Factory extends SimpleMetrics.Factory { + public Factory(final FastStatsContext context) { + super(context); } @Override - public Metrics create(final PluginContainer plugin) throws IllegalStateException, IllegalArgumentException { - final var faststats = dataDirectory.resolve("faststats"); - return new SpongeMetricsImpl(this, logger, plugin, faststats.resolve("config.properties")); + public Metrics create() throws IllegalStateException, IllegalArgumentException { + final var context = (SpongeContext) this.context; + return new SpongeMetricsImpl(this, context.logger, context.plugin); } } } diff --git a/sponge/src/main/java/module-info.java b/sponge/src/main/java/module-info.java index e709333f..61c8a824 100644 --- a/sponge/src/main/java/module-info.java +++ b/sponge/src/main/java/module-info.java @@ -6,7 +6,7 @@ requires com.google.gson; requires com.google.guice; - requires dev.faststats.core; + requires dev.faststats; requires java.logging; requires static org.jetbrains.annotations; diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 29d136d3..50b074e2 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -5,33 +5,37 @@ import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.plugin.Plugin; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.velocity.VelocityMetrics; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.velocity.VelocityContext; import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +import java.nio.file.Path; @Plugin(id = "example", name = "Example Plugin", version = "1.0.0", url = "https://example.com", authors = {"Your Name"}) public class ExamplePlugin { - private final VelocityMetrics.Factory metricsFactory; + private final VelocityContext context; private @Nullable Metrics metrics = null; @Inject - public ExamplePlugin(final VelocityMetrics.Factory factory) { - this.metricsFactory = factory; + public ExamplePlugin(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { + this.context = new VelocityContext(server, logger, dataDirectory, "YOUR_TOKEN_HERE"); } @Subscribe public void onProxyInitialize(final ProxyInitializeEvent event) { - this.metrics = metricsFactory + this.metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project .create(this); } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java new file mode 100644 index 00000000..eab1f885 --- /dev/null +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -0,0 +1,49 @@ +package dev.faststats.velocity; + +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.Config; +import dev.faststats.FastStatsContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.slf4j.Logger; + +import java.nio.file.Path; + +/** + * Velocity FastStats context. + * + * @since 0.23.0 + */ +public final class VelocityContext extends FastStatsContext { + private final ProxyServer server; + private final Logger logger; + private final Path dataDirectory; + + public VelocityContext( + final Config config, + final ProxyServer server, + final Logger logger, + final Path dataDirectory, + @Token final String token + ) { + super(config, token); + this.server = server; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + public VelocityContext( + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory, + @Token final String token + ) { + this(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), server, logger, dataDirectory, token); + } + + @Override + public VelocityMetrics.Factory metrics() { + return new VelocityMetrics.Factory(this, server, logger, dataDirectory); + } +} diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java index d552d332..1aa36b52 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java @@ -3,7 +3,7 @@ import com.google.inject.Inject; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.core.Metrics; +import dev.faststats.Metrics; import org.slf4j.Logger; import java.nio.file.Path; @@ -15,6 +15,10 @@ */ public sealed interface VelocityMetrics extends Metrics permits VelocityMetricsImpl { final class Factory extends VelocityMetricsImpl.Factory { + public Factory(final VelocityContext context, final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { + super(context, server, logger, dataDirectory); + } + /** * Creates a new metrics factory for Velocity. * diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 969ad217..3e3a1a27 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -4,9 +4,11 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.Config; +import dev.faststats.FastStatsContext; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.slf4j.Logger; @@ -23,10 +25,10 @@ private VelocityMetricsImpl( final Factory factory, final Logger logger, final ProxyServer server, - final Path config, + final Config config, final PluginContainer plugin ) throws IllegalStateException { - super(factory, SimpleConfig.read(config)); + super(factory, config); this.server = server; this.plugin = plugin; @@ -49,12 +51,17 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getVersion().getName()); } - static class Factory extends SimpleMetrics.Factory { + static class Factory extends SimpleMetrics.Factory { protected final Logger logger; protected final Path dataDirectory; protected final ProxyServer server; public Factory(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { + this(null, server, logger, dataDirectory); + } + + public Factory(final FastStatsContext context, final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { + super(context); this.logger = logger; this.dataDirectory = dataDirectory; this.server = server; @@ -70,13 +77,16 @@ public Factory(final ProxyServer server, final Logger logger, @DataDirectory fin * @throws IllegalStateException if the token is not specified * @throws IllegalArgumentException if the given object is not a valid plugin * @see #token(String) - * @since 0.1.0 + * @since 0.23.0 */ @Override public Metrics create(final Object plugin) throws IllegalStateException, IllegalArgumentException { final var faststats = dataDirectory.resolveSibling("faststats"); final var container = server.getPluginManager().ensurePluginContainer(plugin); - return new VelocityMetricsImpl(this, logger, server, faststats.resolve("config.properties"), container); + final var config = hasContext() + ? getConfigOrThrow() + : SimpleConfig.read(faststats.resolve("config.properties")); + return new VelocityMetricsImpl(this, logger, server, config, container); } } } diff --git a/velocity/src/main/java/module-info.java b/velocity/src/main/java/module-info.java index 77b01dea..0d80b4fc 100644 --- a/velocity/src/main/java/module-info.java +++ b/velocity/src/main/java/module-info.java @@ -8,7 +8,7 @@ requires com.google.guice; requires com.velocitypowered.api; requires dev.faststats.config; - requires dev.faststats.core; + requires dev.faststats; requires org.slf4j; requires static org.jetbrains.annotations; From 3a02f88fe3ed0bac5eed08d532ee64f843c0cae0 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 17:54:27 +0200 Subject: [PATCH 041/140] Simplified metrics construction flow overhead --- .../dev/faststats/bukkit/BukkitMetrics.java | 13 +++++++++- .../faststats/bukkit/BukkitMetricsImpl.java | 24 ++++++++++++++++++- .../dev/faststats/bungee/BungeeContext.java | 5 ++-- .../dev/faststats/bungee/BungeeMetrics.java | 14 ----------- .../faststats/bungee/BungeeMetricsImpl.java | 4 ++-- .../java/dev/faststats/FastStatsContext.java | 2 +- core/src/main/java/dev/faststats/Metrics.java | 8 +++---- .../java/dev/faststats/SimpleMetrics.java | 21 +++++++--------- .../dev/faststats/fabric/FabricContext.java | 3 ++- .../dev/faststats/fabric/FabricMetrics.java | 13 ---------- .../faststats/fabric/FabricMetricsImpl.java | 4 ++-- .../dev/faststats/hytale/HytaleContext.java | 3 ++- .../dev/faststats/hytale/HytaleMetrics.java | 13 ---------- .../faststats/hytale/HytaleMetricsImpl.java | 4 ++-- .../faststats/minestom/MinestomMetrics.java | 15 +++++++++++- .../minestom/MinestomMetricsImpl.java | 23 ++++++++++++++---- .../dev/faststats/nukkit/NukkitContext.java | 3 ++- .../dev/faststats/nukkit/NukkitMetrics.java | 14 ----------- .../faststats/nukkit/NukkitMetricsImpl.java | 4 ++-- 19 files changed, 99 insertions(+), 91 deletions(-) delete mode 100644 bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java delete mode 100644 fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java delete mode 100644 hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java delete mode 100644 nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java index 7c88a426..2d566d8d 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java @@ -1,6 +1,8 @@ package dev.faststats.bukkit; +import dev.faststats.ErrorTracker; import dev.faststats.Metrics; +import dev.faststats.data.Metric; import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.Plugin; @@ -20,7 +22,16 @@ public sealed interface BukkitMetrics extends Metrics permits BukkitMetricsImpl @Override void ready() throws IllegalPluginAccessException; - interface Factory extends Metrics.Factory { + sealed interface Factory extends Metrics.Factory permits BukkitMetricsImpl.Factory { + @Override + Factory addMetric(Metric metric) throws IllegalArgumentException; + + @Override + Factory onFlush(Runnable flush); + + @Override + Factory errorTracker(ErrorTracker tracker); + @Override BukkitMetrics create() throws IllegalStateException; } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index b22e4843..7ea95a51 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -1,8 +1,11 @@ package dev.faststats.bukkit; import com.google.gson.JsonObject; +import dev.faststats.ErrorTracker; +import dev.faststats.FastStatsContext; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; +import dev.faststats.data.Metric; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -101,11 +104,30 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { + public static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { public Factory(final BukkitContext context) { super(context); } + Factory(final FastStatsContext context) { + super(context); + } + + @Override + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + return (Factory) super.addMetric(metric); + } + + @Override + public Factory onFlush(final Runnable flush) { + return (Factory) super.onFlush(flush); + } + + @Override + public Factory errorTracker(final ErrorTracker tracker) { + return (Factory) super.errorTracker(tracker); + } + @Override public BukkitMetrics create() throws IllegalStateException { return new BukkitMetricsImpl(this, ((BukkitContext) context).plugin); diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index a5d813a0..93e39ce6 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -1,5 +1,6 @@ package dev.faststats.bungee; +import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -12,14 +13,14 @@ */ public final class BungeeContext extends SimpleContext { final Plugin plugin; - + public BungeeContext(final Plugin plugin, @Token final String token) { super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), token); this.plugin = plugin; } @Override - public BungeeMetrics.Factory metrics() { + public Metrics.Factory metrics() { return new BungeeMetricsImpl.Factory(this); } } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java deleted file mode 100644 index 1c2176d9..00000000 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.faststats.bungee; - -import dev.faststats.Metrics; -import org.jetbrains.annotations.Contract; - -/** - * BungeeCord metrics implementation. - * - * @since 0.1.0 - */ -public sealed interface BungeeMetrics extends Metrics permits BungeeMetricsImpl { - interface Factory extends Metrics.Factory { - } -} diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index 57d0663a..87542d3d 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -9,7 +9,7 @@ import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -final class BungeeMetricsImpl extends SimpleMetrics implements BungeeMetrics { +final class BungeeMetricsImpl extends SimpleMetrics { private final ProxyServer server; private final Plugin plugin; @@ -38,7 +38,7 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getName()); } - static final class Factory extends SimpleMetrics.Factory implements BungeeMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory { public Factory(final BungeeContext context) { super(context); } diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 070759ca..5fadbbaa 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -39,7 +39,7 @@ public interface FastStatsContext { * @since 0.23.0 */ @Contract(value = "-> new", pure = true) - Metrics.Factory metrics(); + Metrics.Factory metrics(); /** * Creates a new feature flag service backed by this context token. diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java index b051fcfe..f1372e3e 100644 --- a/core/src/main/java/dev/faststats/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -68,7 +68,7 @@ default void ready() { * * @since 0.23.0 */ - interface Factory> { + interface Factory { /** * Adds a metric to the metrics submission. *

@@ -80,7 +80,7 @@ interface Factory> { * @since 0.23.0 */ @Contract(mutates = "this") - F addMetric(Metric metric) throws IllegalArgumentException; + Factory addMetric(Metric metric) throws IllegalArgumentException; /** * Sets the flush callback for this metrics instance. @@ -92,7 +92,7 @@ interface Factory> { * @since 0.23.0 */ @Contract(mutates = "this") - F onFlush(Runnable flush); + Factory onFlush(Runnable flush); /** * Sets the error tracker for this metrics instance. @@ -104,7 +104,7 @@ interface Factory> { * @since 0.23.0 */ @Contract(mutates = "this") - F errorTracker(ErrorTracker tracker); + Factory errorTracker(ErrorTracker tracker); /** * Creates a new metrics instance. diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index f21092e6..18c4e331 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -52,7 +52,7 @@ public abstract class SimpleMetrics implements Metrics { @Contract(mutates = "io") @SuppressWarnings("PatternValidation") - protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { + protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { this.config = config; this.token = factory.context.getToken(); this.metrics = config.additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); @@ -64,7 +64,7 @@ protected SimpleMetrics(final Factory factory, final Config config) throws Il } @Contract(mutates = "io") - protected SimpleMetrics(final Factory factory) throws IllegalStateException { + protected SimpleMetrics(final Factory factory) throws IllegalStateException { this(factory, factory.context.getConfig()); } @@ -283,7 +283,7 @@ public void shutdown() { } } - public abstract static class Factory> implements Metrics.Factory { + public abstract static class Factory implements Metrics.Factory { private final Set> metrics = new HashSet<>(0); protected final FastStatsContext context; private @Nullable ErrorTracker tracker; @@ -294,24 +294,21 @@ protected Factory(final FastStatsContext context) { } @Override - @SuppressWarnings("unchecked") - public F addMetric(final Metric metric) throws IllegalArgumentException { + public Factory addMetric(final Metric metric) throws IllegalArgumentException { if (!metrics.add(metric)) throw new IllegalArgumentException("Metric already added: " + metric.getId()); - return (F) this; + return this; } @Override - @SuppressWarnings("unchecked") - public F onFlush(final Runnable flush) { + public Factory onFlush(final Runnable flush) { this.flush = flush; - return (F) this; + return this; } @Override - @SuppressWarnings("unchecked") - public F errorTracker(final ErrorTracker tracker) { + public Factory errorTracker(final ErrorTracker tracker) { this.tracker = tracker; - return (F) this; + return this; } } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 2fc32be4..9cbe36d3 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -1,5 +1,6 @@ package dev.faststats.fabric; +import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -22,7 +23,7 @@ public FabricContext(final String modId, @Token final String token) { } @Override - public FabricMetrics.Factory metrics() { + public Metrics.Factory metrics() { return new FabricMetricsImpl.Factory(this); } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java deleted file mode 100644 index 786c5157..00000000 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.faststats.fabric; - -import dev.faststats.Metrics; - -/** - * Fabric metrics implementation. - * - * @since 0.12.0 - */ -public sealed interface FabricMetrics extends Metrics permits FabricMetricsImpl { - interface Factory extends Metrics.Factory { - } -} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index f6f683fe..f92ff170 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -14,7 +14,7 @@ import java.util.Optional; import java.util.function.Supplier; -final class FabricMetricsImpl extends SimpleMetrics implements FabricMetrics { +final class FabricMetricsImpl extends SimpleMetrics { private final ModContainer mod; private @Nullable MinecraftServer server; @@ -56,7 +56,7 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements FabricMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory { public Factory(final FabricContext context) { super(context); } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 47f3407e..f268d785 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -1,6 +1,7 @@ package dev.faststats.hytale; import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -16,7 +17,7 @@ public HytaleContext(final JavaPlugin plugin, @Token final String token) { } @Override - public HytaleMetrics.Factory metrics() { + public Metrics.Factory metrics() { return new HytaleMetricsImpl.Factory(this); } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java deleted file mode 100644 index e2178664..00000000 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.faststats.hytale; - -import dev.faststats.Metrics; - -/** - * Hytale metrics implementation. - * - * @since 0.9.0 - */ -public sealed interface HytaleMetrics extends Metrics permits HytaleMetricsImpl { - interface Factory extends Metrics.Factory { - } -} diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index 3f0973ed..29797e55 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -10,7 +10,7 @@ import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -final class HytaleMetricsImpl extends SimpleMetrics implements HytaleMetrics { +final class HytaleMetricsImpl extends SimpleMetrics { @Async.Schedule @Contract(mutates = "io") private HytaleMetricsImpl(final Factory factory) throws IllegalStateException { @@ -31,7 +31,7 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Hytale"); } - static final class Factory extends SimpleMetrics.Factory implements HytaleMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory { public Factory(final HytaleContext context) { super(context); } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java index aa48ccf5..6037197c 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java @@ -1,6 +1,8 @@ package dev.faststats.minestom; +import dev.faststats.ErrorTracker; import dev.faststats.Metrics; +import dev.faststats.data.Metric; import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; @@ -19,6 +21,17 @@ public sealed interface MinestomMetrics extends Metrics permits MinestomMetricsI @Override void ready(); - interface Factory extends Metrics.Factory { + sealed interface Factory extends Metrics.Factory permits MinestomMetricsImpl.Factory { + @Override + Factory addMetric(Metric metric) throws IllegalArgumentException; + + @Override + Factory onFlush(Runnable flush); + + @Override + Factory errorTracker(ErrorTracker tracker); + + @Override + MinestomMetrics create() throws IllegalStateException; } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 1577c9d2..6aff56d2 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -2,9 +2,9 @@ import com.google.gson.JsonObject; import dev.faststats.ErrorTracker; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; +import dev.faststats.data.Metric; import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; import org.jetbrains.annotations.Async; @@ -46,13 +46,28 @@ private void registerExceptionHandler(final ErrorTracker errorTracker) { }); } - static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { - Factory(final MinestomContext context) { + static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + public Factory(final MinestomContext context) { super(context); } @Override - public Metrics create() throws IllegalStateException { + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + return (Factory) super.addMetric(metric); + } + + @Override + public Factory onFlush(final Runnable flush) { + return (Factory) super.onFlush(flush); + } + + @Override + public Factory errorTracker(final ErrorTracker tracker) { + return (Factory) super.errorTracker(tracker); + } + + @Override + public MinestomMetrics create() throws IllegalStateException { return new MinestomMetricsImpl(this); } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index f45e96f5..29efb2bc 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -1,6 +1,7 @@ package dev.faststats.nukkit; import cn.nukkit.plugin.PluginBase; +import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -21,7 +22,7 @@ public NukkitContext(final PluginBase plugin, @Token final String token) { } @Override - public NukkitMetrics.Factory metrics() { + public Metrics.Factory metrics() { return new NukkitMetricsImpl.Factory(this); } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java deleted file mode 100644 index a8cd305a..00000000 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.faststats.nukkit; - -import dev.faststats.Metrics; -import org.jetbrains.annotations.Contract; - -/** - * Nukkit metrics implementation. - * - * @since 0.8.0 - */ -public sealed interface NukkitMetrics extends Metrics permits NukkitMetricsImpl { - interface Factory extends Metrics.Factory { - } -} diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index d91ff386..7f3ff1bf 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -12,7 +12,7 @@ import java.util.Optional; import java.util.function.Supplier; -final class NukkitMetricsImpl extends SimpleMetrics implements NukkitMetrics { +final class NukkitMetricsImpl extends SimpleMetrics { private final Server server; private final PluginBase plugin; @@ -49,7 +49,7 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements NukkitMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory { Factory(final NukkitContext context) { super(context); } From 5f7296738743d16aee6b5575ae06cc3bcf95a578 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 17:56:24 +0200 Subject: [PATCH 042/140] Added injection support for platform context --- .../main/java/com/example/ExamplePlugin.java | 14 +---- .../dev/faststats/sponge/SpongeContext.java | 46 +++++++++++++- .../faststats/sponge/SpongeMetricsImpl.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 11 +--- .../faststats/velocity/VelocityContext.java | 60 +++++++++++++++++-- .../faststats/velocity/VelocityMetrics.java | 26 +++----- .../velocity/VelocityMetricsImpl.java | 28 ++++----- 7 files changed, 129 insertions(+), 58 deletions(-) diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 35d6a6e8..0738852c 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -5,30 +5,22 @@ import dev.faststats.Metrics; import dev.faststats.data.Metric; import dev.faststats.sponge.SpongeContext; -import org.apache.logging.log4j.Logger; import org.jspecify.annotations.Nullable; import org.spongepowered.api.Server; -import org.spongepowered.api.config.ConfigDir; import org.spongepowered.api.event.Listener; import org.spongepowered.api.event.lifecycle.StartedEngineEvent; import org.spongepowered.api.event.lifecycle.StoppingEngineEvent; -import org.spongepowered.plugin.PluginContainer; import org.spongepowered.plugin.builtin.jvm.Plugin; -import java.nio.file.Path; - @Plugin("example") public class ExamplePlugin { - private @Inject PluginContainer pluginContainer; - private @Inject Logger logger; - private @ConfigDir(sharedRoot = true) - @Inject Path dataDirectory; + private @Inject SpongeContext.Builder contextBuilder; private @Nullable Metrics metrics = null; @Listener public void onServerStart(final StartedEngineEvent event) { - final var context = new SpongeContext(pluginContainer, logger, dataDirectory, "YOUR_TOKEN_HERE"); + final var context = contextBuilder.build("YOUR_TOKEN_HERE"); this.metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) @@ -36,7 +28,7 @@ public void onServerStart(final StartedEngineEvent event) { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .create(pluginContainer); + .create(); } @Listener diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index b177408e..48239aa9 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -1,5 +1,6 @@ package dev.faststats.sponge; +import com.google.inject.Inject; import dev.faststats.SimpleContext; import dev.faststats.Token; import org.apache.logging.log4j.Logger; @@ -21,7 +22,7 @@ public SpongeContext( final PluginContainer plugin, final Logger logger, @ConfigDir(sharedRoot = true) final Path dataDirectory, - @Token final String token // fixme: cannot have a token here + @Token final String token ) { super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), token); this.plugin = plugin; @@ -32,4 +33,47 @@ public SpongeContext( public SpongeMetrics.Factory metrics() { return new SpongeMetrics.Factory(this); } + + /** + * Injectable Sponge context builder. + * + * @since 0.23.0 + */ + public static final class Builder { + private final PluginContainer plugin; + private final Logger logger; + private final Path dataDirectory; + + /** + * Creates a new Sponge context builder. + * + * @param plugin the plugin container + * @param logger the plugin logger + * @param dataDirectory the shared Sponge config directory + * @apiNote This instance can be injected into your plugin. + * @since 0.23.0 + */ + @Inject + public Builder( + final PluginContainer plugin, + final Logger logger, + @ConfigDir(sharedRoot = true) final Path dataDirectory + ) { + this.plugin = plugin; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + /** + * Builds the finalized Sponge context. + * + * @param token the FastStats project token + * @return the Sponge context + * @throws IllegalArgumentException if the token is invalid + * @since 0.23.0 + */ + public SpongeContext build(@Token final String token) throws IllegalArgumentException { + return new SpongeContext(plugin, logger, dataDirectory, token); + } + } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index d67289c5..57460968 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -41,7 +41,7 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", Sponge.platform().container(Platform.Component.IMPLEMENTATION).metadata().id()); } - static class Factory extends SimpleMetrics.Factory { + static class Factory extends SimpleMetrics.Factory { public Factory(final FastStatsContext context) { super(context); } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 50b074e2..80d8a6da 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -5,16 +5,11 @@ import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.plugin.Plugin; -import com.velocitypowered.api.plugin.annotation.DataDirectory; -import com.velocitypowered.api.proxy.ProxyServer; import dev.faststats.ErrorTracker; import dev.faststats.Metrics; import dev.faststats.data.Metric; import dev.faststats.velocity.VelocityContext; import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; - -import java.nio.file.Path; @Plugin(id = "example", name = "Example Plugin", version = "1.0.0", url = "https://example.com", authors = {"Your Name"}) @@ -23,8 +18,8 @@ public class ExamplePlugin { private @Nullable Metrics metrics = null; @Inject - public ExamplePlugin(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { - this.context = new VelocityContext(server, logger, dataDirectory, "YOUR_TOKEN_HERE"); + public ExamplePlugin(final VelocityContext.Builder contextBuilder) { + this.context = contextBuilder.build("YOUR_TOKEN_HERE"); } @Subscribe @@ -36,7 +31,7 @@ public void onProxyInitialize(final ProxyInitializeEvent event) { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .create(this); + .create(); } @Subscribe diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index eab1f885..6dad69d8 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -1,9 +1,11 @@ package dev.faststats.velocity; +import com.google.inject.Inject; +import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import dev.faststats.Config; -import dev.faststats.FastStatsContext; +import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; import org.slf4j.Logger; @@ -15,35 +17,85 @@ * * @since 0.23.0 */ -public final class VelocityContext extends FastStatsContext { +public final class VelocityContext extends SimpleContext { + private final PluginContainer plugin; private final ProxyServer server; private final Logger logger; private final Path dataDirectory; public VelocityContext( final Config config, + final PluginContainer plugin, final ProxyServer server, final Logger logger, final Path dataDirectory, @Token final String token ) { super(config, token); + this.plugin = plugin; this.server = server; this.logger = logger; this.dataDirectory = dataDirectory; } public VelocityContext( + final PluginContainer plugin, final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory, @Token final String token ) { - this(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), server, logger, dataDirectory, token); + this(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), plugin, server, logger, dataDirectory, token); } @Override public VelocityMetrics.Factory metrics() { - return new VelocityMetrics.Factory(this, server, logger, dataDirectory); + return new VelocityMetrics.Factory(this, plugin, server, logger, dataDirectory); + } + + /** + * Injectable Velocity context builder. + * + * @since 0.23.0 + */ + public static final class Builder { + private final PluginContainer plugin; + private final ProxyServer server; + private final Logger logger; + private final Path dataDirectory; + + /** + * Creates a new Velocity context builder. + * + * @param server the velocity server + * @param logger the plugin logger + * @param dataDirectory the plugin data directory + * @apiNote This instance can be injected into your plugin. + * @since 0.23.0 + */ + @Inject + public Builder( + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory + ) { + this.plugin = plugin; + this.server = server; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + /** + * Builds the finalized Velocity context. + * + * @param token the FastStats project token + * @return the Velocity context + * @throws IllegalArgumentException if the token is invalid + * @since 0.23.0 + */ + public VelocityContext build(@Token final String token) throws IllegalArgumentException { + return new VelocityContext(plugin, server, logger, dataDirectory, token); + } } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java index 1aa36b52..d5b36f18 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java @@ -1,6 +1,6 @@ package dev.faststats.velocity; -import com.google.inject.Inject; +import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import dev.faststats.Metrics; @@ -15,22 +15,14 @@ */ public sealed interface VelocityMetrics extends Metrics permits VelocityMetricsImpl { final class Factory extends VelocityMetricsImpl.Factory { - public Factory(final VelocityContext context, final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { - super(context, server, logger, dataDirectory); - } - - /** - * Creates a new metrics factory for Velocity. - * - * @param server the velocity server - * @param logger the logger - * @param dataDirectory the data directory - * @apiNote This instance is automatically injected into your plugin. - * @since 0.1.0 - */ - @Inject - private Factory(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { - super(server, logger, dataDirectory); + public Factory( + final VelocityContext context, + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory + ) { + super(context, plugin, server, logger, dataDirectory); } } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 3e3a1a27..51159fd2 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -51,17 +51,21 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", server.getVersion().getName()); } - static class Factory extends SimpleMetrics.Factory { + static class Factory extends SimpleMetrics.Factory { protected final Logger logger; protected final Path dataDirectory; protected final ProxyServer server; + protected final PluginContainer plugin; - public Factory(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { - this(null, server, logger, dataDirectory); - } - - public Factory(final FastStatsContext context, final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { + public Factory( + final FastStatsContext context, + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory + ) { super(context); + this.plugin = plugin; this.logger = logger; this.dataDirectory = dataDirectory; this.server = server; @@ -72,21 +76,13 @@ public Factory(final FastStatsContext context, final ProxyServer server, final L *

* Metrics submission will start automatically. * - * @param plugin the plugin instance * @return the metrics instance * @throws IllegalStateException if the token is not specified - * @throws IllegalArgumentException if the given object is not a valid plugin - * @see #token(String) * @since 0.23.0 */ @Override - public Metrics create(final Object plugin) throws IllegalStateException, IllegalArgumentException { - final var faststats = dataDirectory.resolveSibling("faststats"); - final var container = server.getPluginManager().ensurePluginContainer(plugin); - final var config = hasContext() - ? getConfigOrThrow() - : SimpleConfig.read(faststats.resolve("config.properties")); - return new VelocityMetricsImpl(this, logger, server, config, container); + public Metrics create() throws IllegalStateException { + return new VelocityMetricsImpl(this, logger, server, context.getConfig(), plugin); } } } From 42a4bb56b754134f57a2571c1eb9da9c3395801d Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 17:56:54 +0200 Subject: [PATCH 043/140] Update examples to reflect the current best practices --- .../src/main/java/com/example/ExamplePlugin.java | 2 +- .../java/dev/faststats/example/FeatureFlagExample.java | 8 ++++++-- .../example-mod/src/main/java/com/example/ExampleMod.java | 7 +++++-- .../src/main/java/com/example/ExamplePlugin.java | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 091ad34f..6c871850 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -24,7 +24,7 @@ public class ExamplePlugin extends Plugin { // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush - .create(this); + .create(); @Override public void onDisable() { diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index 556ff7d7..97bda5d3 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -1,6 +1,7 @@ package dev.faststats.example; import dev.faststats.Attributes; +import dev.faststats.FastStatsContext; import dev.faststats.FeatureFlag; import dev.faststats.FeatureFlagService; @@ -8,8 +9,7 @@ import java.time.Instant; public final class FeatureFlagExample { - public static final FeatureFlagService SERVICE = FeatureFlagService.create( - "YOUR_TOKEN_HERE", // token can be found in the settings of your project + public static final FeatureFlagService SERVICE = getContext().featureFlags( Attributes.create() // Define global attributes .put("version", "1.2.3") .put("java_version", System.getProperty("java.version")) @@ -60,4 +60,8 @@ public static void usage() { } }); } + + private static FastStatsContext getContext() { + return null; + } } diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 4672e62d..e4dd883c 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -7,7 +7,10 @@ import net.fabricmc.api.ModInitializer; public class ExampleMod implements ModInitializer { - private final FabricContext context = new FabricContext("YOUR_TOKEN_HERE"); + private final FabricContext context = new FabricContext( + "example-mod", // your mod id as defined in fabric.mod.json + "YOUR_TOKEN_HERE" + ); private final Metrics metrics = context.metrics() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) @@ -15,7 +18,7 @@ public class ExampleMod implements ModInitializer { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .create("example-mod"); // your mod id as defined in fabric.mod.json + .create(); @Override public void onInitialize() { diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 54453c2a..868bea60 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -16,7 +16,7 @@ public class ExamplePlugin extends JavaPlugin { // Error tracking must be enabled in the project settings .errorTracker(ErrorTracker.contextAware()) - .create(this); + .create(); public ExamplePlugin(final JavaPluginInit init) { super(init); From 21d157bd2cf44cdb03ea1be9324bd213d59cf3fd Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 18:10:11 +0200 Subject: [PATCH 044/140] Pass the server id to feature flag service --- core/src/main/java/dev/faststats/SimpleContext.java | 8 ++++---- .../main/java/dev/faststats/SimpleFeatureFlagService.java | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 9a81f234..3ced4539 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -31,21 +31,21 @@ public final Config getConfig() { @Override public final FeatureFlagService featureFlags() { - return new SimpleFeatureFlagService(token, null, Duration.ofMinutes(5)); + return new SimpleFeatureFlagService(config, token, null, Duration.ofMinutes(5)); } @Override public final FeatureFlagService featureFlags(final Attributes attributes) { - return new SimpleFeatureFlagService(token, attributes, Duration.ofMinutes(5)); + return new SimpleFeatureFlagService(config, token, attributes, Duration.ofMinutes(5)); } @Override public final FeatureFlagService featureFlags(final Duration ttl) { - return new SimpleFeatureFlagService(token, null, ttl); + return new SimpleFeatureFlagService(config, token, null, ttl); } @Override public final FeatureFlagService featureFlags(@Nullable final Attributes attributes, final Duration ttl) { - return new SimpleFeatureFlagService(token, attributes, ttl); + return new SimpleFeatureFlagService(config, token, attributes, ttl); } } diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index c0eb8f51..dfd854f5 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -41,6 +41,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private final Map> fetchesInProgress = new ConcurrentHashMap<>(); SimpleFeatureFlagService( + final Config config, final @Token String token, final @Nullable Attributes attributes, final Duration ttl @@ -49,7 +50,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { this.token = token; this.attributes = attributes; this.ttl = ttl; - this.serverId = UUID.randomUUID(); // todo: DI somehow + this.serverId = config.serverId(); } private static URI getFlagsServerUrl() { From 43b772636bb280c264902e4d05014390b27344d9 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 18:11:07 +0200 Subject: [PATCH 045/140] Document SimpleContext constructor --- core/src/main/java/dev/faststats/SimpleContext.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 3ced4539..7aa54767 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -10,7 +10,14 @@ public abstract class SimpleContext implements FastStatsContext { private final Config config; private final @Token String token; - // todo: add docs + /** + * Creates a new context that stores the shared configuration and token for all FastStats services. + * + * @param config the shared configuration + * @param token the FastStats project token + * @throws IllegalArgumentException if the token is invalid + * @since 0.23.0 + */ protected SimpleContext(final Config config, @Token final String token) throws IllegalArgumentException { if (!token.matches(Token.PATTERN)) { throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); From 7a9ebbab626ebb5c5d464b27924c281e808e9079 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 18:16:25 +0200 Subject: [PATCH 046/140] Fix happy little accident --- core/src/main/java/dev/faststats/SimpleFeatureFlagService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index dfd854f5..5e311d08 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -149,7 +149,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { try { final var body = JsonParser.parseString(response.body()); - if (response.statusCode() < 200 && response.statusCode() >= 300) + if (response.statusCode() < 200 || response.statusCode() >= 300) throw new IllegalStateException("Unexpected response status: %s (%s)".formatted(response.statusCode(), body)); final var value = getValue(flag, body); From 3e6a08b1a921ded499a60a908bfc35359a5dca35 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 19:55:21 +0200 Subject: [PATCH 047/140] Add more test coverage and fixed awful smoke tests --- .../java/dev/faststats/ErrorTrackerTest.java | 255 ++++++++++++++---- 1 file changed, 200 insertions(+), 55 deletions(-) diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 60983aae..bc82940d 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -4,16 +4,15 @@ import java.net.URL; import java.net.URLClassLoader; -import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { - // todo: add redaction tests - // todo: add nesting tests - // todo: add duplicate tests - @Test public void sameClassLoader() { final var loader = getClass().getClassLoader(); @@ -126,73 +125,219 @@ private IllegalArgumentException createExceptionWithStack() { } @Test - // todo: fix this mess - public void testCompile() throws InterruptedException { - final var tracker = ErrorTracker.contextUnaware(); - tracker.attachErrorContext(null); + public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - try { - roundAndRound(10); - } catch (final Throwable t) { - tracker.trackError(t); - } - try { - recursiveError(); - } catch (final Throwable t) { - tracker.trackError("↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ"); - tracker.trackError(t); - } - try { - aroundAndAround(); - } catch (final Throwable t) { - tracker.trackError(t); - return; - } + tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - tracker.trackError("Test error"); - final var nestedError = new RuntimeException("Nested error"); - final var error = new RuntimeException(null, nestedError); - tracker.trackError(error); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var message = report.get("message").getAsString(); + final var header = report.getAsJsonArray("stack").get(0).getAsString(); + + assertEquals("connect jdbc:postgresql://localhost:[password hidden]@db from [IP hidden]", message); + assertEquals("java.lang.RuntimeException: " + message, header); + } + + @Test + public void appliesCustomRedactionAfterBuiltInRedaction() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + tracker.anonymize("session=[^ ]+", "session=[hidden]"); + + tracker.trackError("failed with session=abc123 from 10.0.0.1"); + + final var message = tracker.getData("build") + .get(0) + .getAsJsonObject() + .get("message") + .getAsString(); + + assertEquals("failed with session=[hidden] from [IP hidden]", message); + } + + @Test + public void nullMessagesAreNotSerializedAsMessageProperty() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - 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"); + tracker.trackError(new RuntimeException((String) null)); - final var deepAsyncError = new RuntimeException("deep async error"); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + assertFalse(report.has("message")); + assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); + } - final var thisIsANiceError = new Thread(() -> { - final var nestedAsyncError = new RuntimeException("nested async error", deepAsyncError); - throw new CompletionException("async error", nestedAsyncError); + @Test + public void nestedCausesAreSerializedInOrder() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var root = new IllegalArgumentException("root secret 172.16.0.9"); + root.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Root", "fail", "Root.java", 10) + }); + final var middle = new IllegalStateException("middle", root); + middle.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Middle", "call", "Middle.java", 20), + new StackTraceElement("example.Root", "fail", "Root.java", 10) }); - thisIsANiceError.start(); - thisIsANiceError.join(1000); + final var top = new RuntimeException("top", middle); + top.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Top", "run", "Top.java", 30), + new StackTraceElement("example.Middle", "call", "Middle.java", 20), + new StackTraceElement("example.Root", "fail", "Root.java", 10) + }); + + tracker.trackError(top, false); - Thread.sleep(1000); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var stack = report.getAsJsonArray("stack"); - tracker.trackError("Test error"); + assertEquals(RuntimeException.class.getName(), report.get("error").getAsString()); + assertFalse(report.get("handled").getAsBoolean()); + assertEquals("java.lang.RuntimeException: top", stack.get(0).getAsString()); + assertEquals(" at example.Top.run(Top.java:30)", stack.get(1).getAsString()); + assertEquals(" at example.Middle.call(Middle.java:20)", stack.get(2).getAsString()); + assertEquals(" at example.Root.fail(Root.java:10)", stack.get(3).getAsString()); + assertEquals("Caused by: java.lang.IllegalStateException: middle", stack.get(4).getAsString()); + assertEquals(" ... 2 more", stack.get(5).getAsString()); + assertEquals("Caused by: java.lang.IllegalArgumentException: root secret [IP hidden]", stack.get(6).getAsString()); } - public void recursiveError() throws StackOverflowError { - goRoundAndRound(); + @Test + public void cyclicCauseChainStopsAfterFirstVisit() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var first = new RuntimeException("first"); + final var second = new IllegalStateException("second", first); + first.initCause(second); + + tracker.trackError(first); + + final var stack = tracker.getData("build").get(0).getAsJsonObject().getAsJsonArray("stack"); + var firstCauseCount = 0; + var secondCauseCount = 0; + for (final var element : stack) { + final var line = element.getAsString(); + if (line.equals("Caused by: java.lang.RuntimeException: first")) firstCauseCount++; + if (line.equals("Caused by: java.lang.IllegalStateException: second")) secondCauseCount++; + } + + assertEquals(1, firstCauseCount); + assertEquals(1, secondCauseCount); } - public void goRoundAndRound() throws StackOverflowError { - andRoundAndRound(); + @Test + public void duplicateErrorsAreAggregatedWithCount() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var first = createStableError(); + final var second = createStableError(); + + tracker.trackError(first); + tracker.trackError(second); + + final var reports = tracker.getData("build"); + final var report = reports.get(0).getAsJsonObject(); + + assertEquals(1, reports.size()); + assertEquals(2, report.get("count").getAsInt()); + assertEquals("build", report.get("buildId").getAsString()); + assertEquals("duplicate", report.get("message").getAsString()); } - public void andRoundAndRound() throws StackOverflowError { - goRoundAndRound(); + @Test + public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + tracker.trackError(createStableError()); + tracker.trackError(createStableError()); + + tracker.clear(); + + assertFalse(tracker.needsFlushing()); + assertEquals(0, tracker.getData("build").size()); + + tracker.trackError(createStableError()); + + final var report = tracker.getData("build").get(0).getAsJsonObject(); + assertEquals("duplicate", report.get("message").getAsString()); + assertNull(report.get("count")); + } + + @Test + public void ignoredNestedCauseSuppressesWholeReport() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + tracker.ignoreError(IllegalArgumentException.class, "ignore me"); + + tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); + + assertEquals(0, tracker.getData("build").size()); + assertFalse(tracker.needsFlushing()); } - public void aroundAndAround() throws StackOverflowError { - aroundAndAround(); + @Test + public void repeatingStackFramesAreCollapsed() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var error = new StackOverflowError("recursive"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), + new StackTraceElement("example.Recursive", "b", "Recursive.java", 2), + new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), + new StackTraceElement("example.Recursive", "b", "Recursive.java", 2), + new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), + new StackTraceElement("example.Recursive", "b", "Recursive.java", 2) + }); + + tracker.trackError(error); + + final var stack = tracker.getData("build").get(0).getAsJsonObject().getAsJsonArray("stack"); + assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); + assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); + assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); + assertEquals(" ... 4 more", stack.get(3).getAsString()); + assertEquals(4, stack.size()); } - public void roundAndRound(final int i) throws RuntimeException { - if (i <= 0) throw new RuntimeException("out of stack"); - roundAndRound(i - 1); + @Test + public void longMessagesAreTruncatedBeforeSerialization() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var message = "a".repeat(600); + + tracker.trackError(message); + + final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var serialized = report.get("message").getAsString(); + assertEquals(503, serialized.length()); + assertTrue(serialized.endsWith("...")); + assertEquals("java.lang.RuntimeException: " + serialized, report.getAsJsonArray("stack").get(0).getAsString()); + } + + @Test + public void attachedContextTracksUnhandledThreadError() throws InterruptedException { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var handled = new CountDownLatch(1); + final var thrown = new RuntimeException("async failure"); + thrown.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Async", "run", "Async.java", 7) + }); + + tracker.setContextErrorHandler((loader, error) -> handled.countDown()); + tracker.attachErrorContext(null); + try { + final var thread = new Thread(() -> { + throw thrown; + }); + thread.start(); + thread.join(1000); + + assertTrue(handled.await(1, TimeUnit.SECONDS)); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + assertEquals("async failure", report.get("message").getAsString()); + assertFalse(report.get("handled").getAsBoolean()); + } finally { + tracker.detachErrorContext(); + } + } + + private RuntimeException createStableError() { + final var error = new RuntimeException("duplicate"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) + }); + return error; } } From 439a2fc4cadcfd33e4d1917fd47df2b44b8477ea Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 20:32:08 +0200 Subject: [PATCH 048/140] Add fabric client support --- .../fabric/FabricMetricsClientImpl.java | 46 +++++++++++++++++++ .../faststats/fabric/FabricMetricsImpl.java | 44 +++++------------- .../fabric/FabricMetricsServerImpl.java | 34 ++++++++++++++ 3 files changed, 91 insertions(+), 33 deletions(-) create mode 100644 fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java create mode 100644 fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java new file mode 100644 index 00000000..b5f427b5 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java @@ -0,0 +1,46 @@ +package dev.faststats.fabric; + +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.SharedConstants; +import net.minecraft.client.Minecraft; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +final class FabricMetricsClientImpl extends FabricMetricsImpl { + private @Nullable Minecraft client; + + @Async.Schedule + @Contract(mutates = "io") + FabricMetricsClientImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory, mod); + + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + this.client = client; + startSubmitting(); + }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> shutdown()); + } + + @Override + protected void appendDefaultData(final JsonObject metrics) { + assert client != null : "Client not initialized"; + metrics.addProperty("minecraft_version", SharedConstants.getCurrentVersion().name()); // todo: doublecheck + metrics.addProperty("online_mode", client.getUser().getXuid().isPresent() && !client.isOfflineDeveloperMode()); // todo: doublecheck + metrics.addProperty("player_count", getPlayerCount()); + appendFabricData(metrics, "Fabric Client"); + } + + private int getPlayerCount() { + assert client != null : "Client not initialized"; + final var connection = client.getConnection(); + if (connection != null) return connection.getOnlinePlayers().size(); + + final var server = client.getSingleplayerServer(); + if (server != null) return server.getPlayerCount(); + + return client.player == null ? 0 : 1; + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index f92ff170..5b227f6a 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -4,33 +4,20 @@ import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; -import net.minecraft.server.MinecraftServer; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; -import java.util.Optional; -import java.util.function.Supplier; - -final class FabricMetricsImpl extends SimpleMetrics { - private final ModContainer mod; - - private @Nullable MinecraftServer server; +abstract class FabricMetricsImpl extends SimpleMetrics { + protected final ModContainer mod; @Async.Schedule @Contract(mutates = "io") - private FabricMetricsImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + FabricMetricsImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { super(factory); this.mod = mod; - - ServerLifecycleEvents.SERVER_STARTED.register(server -> { // todo: client support - this.server = server; - startSubmitting(); - }); - ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); } @Override @@ -38,22 +25,9 @@ protected boolean preSubmissionStart() { return ((SimpleConfig) getConfig()).preSubmissionStart(); } - @Override - protected void appendDefaultData(final JsonObject metrics) { - assert server != null : "Server not initialized"; - metrics.addProperty("minecraft_version", server.getServerVersion()); - metrics.addProperty("online_mode", server.usesAuthentication()); - metrics.addProperty("player_count", server.getPlayerCount()); + protected void appendFabricData(final JsonObject metrics, final String serverType) { metrics.addProperty("plugin_version", mod.getMetadata().getVersion().getFriendlyString()); - metrics.addProperty("server_type", "Fabric"); - } - - private Optional tryOrEmpty(final Supplier supplier) { - try { - return Optional.of(supplier.get()); - } catch (final NoSuchMethodError | Exception e) { - return Optional.empty(); - } + metrics.addProperty("server_type", serverType); } static final class Factory extends SimpleMetrics.Factory { @@ -63,7 +37,11 @@ public Factory(final FabricContext context) { @Override public Metrics create() throws IllegalStateException, IllegalArgumentException { - return new FabricMetricsImpl(this, ((FabricContext) context).mod); + final var mod = ((FabricContext) context).mod; + return switch (FabricLoader.getInstance().getEnvironmentType()) { + case CLIENT -> new FabricMetricsClientImpl(this, mod); + case SERVER -> new FabricMetricsServerImpl(this, mod); + }; } } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java new file mode 100644 index 00000000..ebc9e441 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java @@ -0,0 +1,34 @@ +package dev.faststats.fabric; + +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +final class FabricMetricsServerImpl extends FabricMetricsImpl { + private @Nullable MinecraftServer server; + + @Async.Schedule + @Contract(mutates = "io") + FabricMetricsServerImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory, mod); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + this.server = server; + startSubmitting(); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); + } + + @Override + protected void appendDefaultData(final JsonObject metrics) { + assert server != null : "Server not initialized"; + metrics.addProperty("minecraft_version", server.getServerVersion()); + metrics.addProperty("online_mode", server.usesAuthentication()); + metrics.addProperty("player_count", server.getPlayerCount()); + appendFabricData(metrics, "Fabric"); + } +} From 20d29b7553c002dc925b5bcb506de65cf9311def Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 20:47:03 +0200 Subject: [PATCH 049/140] Stacktrace fingerprinting --- .../dev/faststats/StackTraceFingerprint.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 core/src/main/java/dev/faststats/StackTraceFingerprint.java diff --git a/core/src/main/java/dev/faststats/StackTraceFingerprint.java b/core/src/main/java/dev/faststats/StackTraceFingerprint.java new file mode 100644 index 00000000..2ed0f89a --- /dev/null +++ b/core/src/main/java/dev/faststats/StackTraceFingerprint.java @@ -0,0 +1,41 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +final class StackTraceFingerprint { + private StackTraceFingerprint() { + } + + public static String hash(final Throwable error) { + return MurmurHash3.hash(normalize(error)); + } + + public static String normalize(final Throwable error) { + final var visited = Collections.newSetFromMap(new IdentityHashMap<>()); + final var builder = new StringBuilder(); + append(error, builder, visited); + return builder.toString(); + } + + private static void append(@Nullable final Throwable error, final StringBuilder builder, + final Set visited) { + if (error == null || !visited.add(error)) return; + + append(builder, error.getClass().getName() + ": " + error.getMessage()); + for (final var element : error.getStackTrace()) { + if (ErrorHelper.isLibraryClass(element.getClassName())) continue; + append(builder, " " + element.getClassName() + "." + element.getMethodName()); + } + + append(error.getCause(), builder, visited); + } + + private static void append(final StringBuilder builder, final String value) { + if (!builder.isEmpty()) builder.append('\n'); + builder.append(value); + } +} From ec1d11c0be04300c0f152e300fb065aee28bcd9b Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 21:07:55 +0200 Subject: [PATCH 050/140] Rename hash method to hash128 and replace JsonObject with String parameter in hash function --- core/src/main/java/dev/faststats/MurmurHash3.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/faststats/MurmurHash3.java b/core/src/main/java/dev/faststats/MurmurHash3.java index 3d4dcca7..a78c26b3 100644 --- a/core/src/main/java/dev/faststats/MurmurHash3.java +++ b/core/src/main/java/dev/faststats/MurmurHash3.java @@ -21,8 +21,8 @@ *

*/ final class MurmurHash3 { - public static String hash(final JsonObject object) { - final var hash = MurmurHash3.hash(object.toString()); + public static String hash(final String data) { + final var hash = MurmurHash3.hash128(data); return Long.toHexString(hash[0]) + Long.toHexString(hash[1]); } @@ -38,7 +38,7 @@ public static String hash(final JsonObject object) { * @see MurmurHash on Wikipedia */ @Contract(value = "_ -> new", pure = true) - private static long[] hash(final String data) { + private static long[] hash128(final String data) { final var bytes = data.getBytes(StandardCharsets.UTF_8); var h1 = 0L; var h2 = 0L; From 8186a3dba16a1501a719c856929419f4a4895821 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 21:09:06 +0200 Subject: [PATCH 051/140] Rename isLibraryClass to isLibraryFrame --- .../src/main/java/dev/faststats/ErrorHelper.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 60c28466..e4b3363f 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -154,7 +154,7 @@ private static boolean isSameLoader(final ClassLoader loader, @Nullable final Th for (var i = 0; i < framesToCheck; i++) { final var frame = stackTrace[firstNonLibraryIndex + i]; - if (isLibraryClass(frame.getClassName())) continue; + if (isLibraryFrame(frame.getClassName())) continue; if (!isFromLoader(frame, loader)) return isSameLoader(loader, error.getCause(), visited); } @@ -163,17 +163,17 @@ private static boolean isSameLoader(final ClassLoader loader, @Nullable final Th private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { for (var i = 0; i < stackTrace.length; i++) { - if (!isLibraryClass(stackTrace[i].getClassName())) return i; + if (!isLibraryFrame(stackTrace[i].getClassName())) return i; } return -1; } - private static boolean isLibraryClass(final String className) { - return className.startsWith("java.") - || className.startsWith("javax.") - || className.startsWith("sun.") - || className.startsWith("com.sun.") - || className.startsWith("jdk."); + static boolean isLibraryFrame(final String frame) { + return frame.startsWith("java.") + || frame.startsWith("javax.") + || frame.startsWith("sun.") + || frame.startsWith("com.sun.") + || frame.startsWith("jdk."); } private static boolean isFromLoader(final StackTraceElement frame, final ClassLoader loader) { From b4a87a3e7b15e4692b77f52fa61c4cbcf2f2664b Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 21:40:36 +0200 Subject: [PATCH 052/140] Integrate stacktrace fingerprinting --- .../dev/faststats/SimpleErrorTracker.java | 4 ++-- .../dev/faststats/StackTraceFingerprint.java | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 715db429..0c9ebdf0 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -59,11 +59,11 @@ public void trackError(final String message, final boolean handled) { public void trackError(final Throwable error, final boolean handled) { try { if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; - final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - final var hashed = MurmurHash3.hash(compiled); // todo: replace with minimization and normalization algorithm + final var hashed = StackTraceFingerprint.hash(error); // todo: report duplicate errors with different messages if (collected.compute(hashed, (k, v) -> { return v == null ? 1 : v + 1; }) > 1) return; + final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); reports.put(hashed, compiled); } catch (final NoClassDefFoundError ignored) { } diff --git a/core/src/main/java/dev/faststats/StackTraceFingerprint.java b/core/src/main/java/dev/faststats/StackTraceFingerprint.java index 2ed0f89a..d86258ee 100644 --- a/core/src/main/java/dev/faststats/StackTraceFingerprint.java +++ b/core/src/main/java/dev/faststats/StackTraceFingerprint.java @@ -7,6 +7,8 @@ import java.util.Set; final class StackTraceFingerprint { + private static final int STACK_TRACE_LIMIT = 5; + private StackTraceFingerprint() { } @@ -21,21 +23,19 @@ public static String normalize(final Throwable error) { return builder.toString(); } - private static void append(@Nullable final Throwable error, final StringBuilder builder, - final Set visited) { + private static void append(@Nullable final Throwable error, final StringBuilder builder, final Set visited) { if (error == null || !visited.add(error)) return; - - append(builder, error.getClass().getName() + ": " + error.getMessage()); + + if (!builder.isEmpty()) builder.append('\n'); + builder.append("e").append(error.getClass().getName()); + + var frames = 0; for (final var element : error.getStackTrace()) { - if (ErrorHelper.isLibraryClass(element.getClassName())) continue; - append(builder, " " + element.getClassName() + "." + element.getMethodName()); + if (ErrorHelper.isLibraryFrame(element.getClassName())) continue; + builder.append("\nf").append(element.getClassName()).append('.').append(element.getMethodName()); + if (++frames >= STACK_TRACE_LIMIT) break; } append(error.getCause(), builder, visited); } - - private static void append(final StringBuilder builder, final String value) { - if (!builder.isEmpty()) builder.append('\n'); - builder.append(value); - } } From b04c84020352f4d5c819caaa35ecf1274ee7af9e Mon Sep 17 00:00:00 2001 From: david Date: Tue, 21 Apr 2026 21:47:34 +0200 Subject: [PATCH 053/140] Add stacktrace fingerprinting tests --- .../faststats/StackTraceFingerprintTest.java | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 core/src/test/java/dev/faststats/StackTraceFingerprintTest.java diff --git a/core/src/test/java/dev/faststats/StackTraceFingerprintTest.java b/core/src/test/java/dev/faststats/StackTraceFingerprintTest.java new file mode 100644 index 00000000..31363aa5 --- /dev/null +++ b/core/src/test/java/dev/faststats/StackTraceFingerprintTest.java @@ -0,0 +1,141 @@ +package dev.faststats; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +public class StackTraceFingerprintTest { + @Test + public void normalizeIncludesExceptionClassAndFrameOwnersOnly() { + final var error = new RuntimeException("message is ignored"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42), + new StackTraceElement("example.Worker", "call", "Worker.java", 7) + }); + + assertEquals(""" + ejava.lang.RuntimeException + fexample.Plugin.run + fexample.Worker.call""", StackTraceFingerprint.normalize(error)); + } + + @Test + public void normalizeExcludesLibraryFrames() { + final var error = new RuntimeException("message is ignored"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("java.util.ArrayList", "get", "ArrayList.java", 427), + new StackTraceElement("javax.script.ScriptEngine", "eval", "ScriptEngine.java", 1), + new StackTraceElement("sun.reflect.NativeMethodAccessorImpl", "invoke0", "NativeMethodAccessorImpl.java", -2), + new StackTraceElement("com.sun.proxy.Proxy", "invoke", "Proxy.java", 1), + new StackTraceElement("jdk.internal.reflect.DirectMethodHandleAccessor", "invoke", "DirectMethodHandleAccessor.java", 104), + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) + }); + + assertEquals(""" + ejava.lang.RuntimeException + fexample.Plugin.run""", StackTraceFingerprint.normalize(error)); + } + + @Test + public void normalizeIncludesOnlyFirstFiveNonLibraryFrames() { + final var error = new RuntimeException("message is ignored"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("java.util.ArrayList", "get", "ArrayList.java", 427), + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42), + new StackTraceElement("example.Worker", "call", "Worker.java", 7), + new StackTraceElement("example.Service", "execute", "Service.java", 15), + new StackTraceElement("example.Repository", "load", "Repository.java", 23), + new StackTraceElement("example.Database", "query", "Database.java", 31), + new StackTraceElement("example.Ignored", "extra", "Ignored.java", 39) + }); + + assertEquals(""" + ejava.lang.RuntimeException + fexample.Plugin.run + fexample.Worker.call + fexample.Service.execute + fexample.Repository.load + fexample.Database.query""", StackTraceFingerprint.normalize(error)); + } + + @Test + public void normalizeIgnoresMessageFileAndLineNumberDifferences() { + final var first = new RuntimeException("This is error #23f4"); + first.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) + }); + final var second = new RuntimeException("This is error #93dsff"); + second.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Generated.java", 99) + }); + + assertEquals(StackTraceFingerprint.normalize(first), StackTraceFingerprint.normalize(second)); + assertEquals(StackTraceFingerprint.hash(first), StackTraceFingerprint.hash(second)); + } + + @Test + public void differentExceptionClassChangesFingerprint() { + final var first = new RuntimeException("same"); + first.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) + }); + final var second = new IllegalStateException("same"); + second.setStackTrace(first.getStackTrace()); + + assertNotEquals(StackTraceFingerprint.normalize(first), StackTraceFingerprint.normalize(second)); + assertNotEquals(StackTraceFingerprint.hash(first), StackTraceFingerprint.hash(second)); + } + + @Test + public void differentFrameMethodChangesFingerprint() { + final var first = new RuntimeException("same"); + first.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) + }); + final var second = new RuntimeException("same"); + second.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "stop", "Plugin.java", 42) + }); + + assertNotEquals(StackTraceFingerprint.normalize(first), StackTraceFingerprint.normalize(second)); + assertNotEquals(StackTraceFingerprint.hash(first), StackTraceFingerprint.hash(second)); + } + + @Test + public void normalizeIncludesNestedCausesInOrder() { + final var root = new IllegalArgumentException("root"); + root.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Root", "fail", "Root.java", 10) + }); + final var top = new RuntimeException("top", root); + top.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Top", "run", "Top.java", 30) + }); + + assertEquals(""" + ejava.lang.RuntimeException + fexample.Top.run + ejava.lang.IllegalArgumentException + fexample.Root.fail""", StackTraceFingerprint.normalize(top)); + } + + @Test + public void cyclicCauseChainStopsAfterFirstVisit() { + final var first = new RuntimeException("first"); + first.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.First", "run", "First.java", 1) + }); + final var second = new IllegalStateException("second", first); + second.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Second", "call", "Second.java", 2) + }); + first.initCause(second); + + assertEquals(""" + ejava.lang.RuntimeException + fexample.First.run + ejava.lang.IllegalStateException + fexample.Second.call""", StackTraceFingerprint.normalize(first)); + } +} From 8e8447bc792e0511edc7cc4d55b5a19d7cd2eca0 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 15:56:38 +0200 Subject: [PATCH 054/140] Fix inverted condition --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8949609d..c4183240 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,7 @@ subprojects { plugin("java-library") } - val noPublish = project.name.startsWith("example") || project.name != "config" + val noPublish = project.name.startsWith("example") || project.name == "config" if (noPublish) { apply { plugin("com.gradleup.shadow") } } else { From 6de87a791e53ed39039dce28a29115f08b6f3803 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 16:19:07 +0200 Subject: [PATCH 055/140] Add method contracts --- core/src/main/java/dev/faststats/internal/LoggerFactory.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/src/main/java/dev/faststats/internal/LoggerFactory.java b/core/src/main/java/dev/faststats/internal/LoggerFactory.java index 567bd5de..eb927e03 100644 --- a/core/src/main/java/dev/faststats/internal/LoggerFactory.java +++ b/core/src/main/java/dev/faststats/internal/LoggerFactory.java @@ -1,8 +1,11 @@ package dev.faststats.internal; +import org.jetbrains.annotations.Contract; + import java.util.ServiceLoader; public interface LoggerFactory { + @Contract(pure = true) static LoggerFactory factory() { final class Holder { private static final LoggerFactory INSTANCE = ServiceLoader.load(LoggerFactory.class) @@ -12,9 +15,11 @@ final class Holder { return Holder.INSTANCE; } + @Contract(value = "_ -> new", pure = true) default Logger getLogger(final Class clazz) { return getLogger(clazz.getName()); } + @Contract(value = "_ -> new", pure = true) Logger getLogger(String name); } From dc52d5a4c45625ef29613dcf8159dc968daea0c2 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 16:19:14 +0200 Subject: [PATCH 056/140] Do not fetch eagerly --- core/src/main/java/dev/faststats/SimpleFeatureFlag.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java index 11067697..f1de752f 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -32,7 +32,6 @@ final class SimpleFeatureFlag implements FeatureFlag { } else if (defaultValue instanceof final Boolean bool) { this.type = Type.BOOLEAN; } else throw new IllegalArgumentException("Unsupported type: " + defaultValue.getClass().getName()); - service.fetch(this); } @Override From 779c6167b32488a3f4b33220d511532c9ff15836 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 16:19:31 +0200 Subject: [PATCH 057/140] No need to cache the logger --- core/src/main/java/dev/faststats/SimpleFeatureFlagService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 5e311d08..0624dc4a 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -5,7 +5,6 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; -import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; import org.jspecify.annotations.Nullable; @@ -23,7 +22,6 @@ import java.util.concurrent.ConcurrentHashMap; final class SimpleFeatureFlagService implements FeatureFlagService { - private static final Logger logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class); private static final URI url = getFlagsServerUrl(); private final HttpClient httpClient = HttpClient.newBuilder() @@ -58,6 +56,7 @@ private static URI getFlagsServerUrl() { if (property != null) try { return new URI(property); } catch (final URISyntaxException e) { + final var logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class); logger.error("Failed to parse flags server url: %s", e, property); } return URI.create("https://flags.faststats.dev/v1"); From 62dbe9dce7f1a78d405f01154a6a84bfd3c398bf Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 16:23:09 +0200 Subject: [PATCH 058/140] Reword feature-flags virtual constructor javadocs description --- core/src/main/java/dev/faststats/FastStatsContext.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 5fadbbaa..9b8ddf45 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -42,7 +42,7 @@ public interface FastStatsContext { Metrics.Factory metrics(); /** - * Creates a new feature flag service backed by this context token. + * Creates a new feature flag service backed by the context's token. * * @return the feature flag service * @since 0.23.0 @@ -51,7 +51,7 @@ public interface FastStatsContext { FeatureFlagService featureFlags(); /** - * Creates a new feature flag service backed by this context token and attributes. + * Creates a new feature flag service backed by the context's token and attributes. * * @param attributes the global targeting attributes * @return the feature flag service @@ -61,7 +61,7 @@ public interface FastStatsContext { FeatureFlagService featureFlags(final Attributes attributes); /** - * Creates a new feature flag service backed by this context token, and TTL. + * Creates a new feature flag service backed by the context's token, and TTL. * * @param ttl the cache time-to-live for resolved flag values * @return the feature flag service @@ -72,7 +72,7 @@ public interface FastStatsContext { FeatureFlagService featureFlags(final Duration ttl); /** - * Creates a new feature flag service backed by this context token, attributes, and TTL. + * Creates a new feature flag service backed by the context's token, attributes, and TTL. * * @param attributes the global targeting attributes * @param ttl the cache time-to-live for resolved flag values From 3a6f39569d767a5e960a3eae4be376a1d7d384f1 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 16:48:12 +0200 Subject: [PATCH 059/140] Move fetch times and cache to flag implementation --- .../main/java/dev/faststats/FeatureFlag.java | 14 +++++++ .../java/dev/faststats/SimpleFeatureFlag.java | 30 +++++++++++++-- .../faststats/SimpleFeatureFlagService.java | 37 +------------------ 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index bc2e7039..a0915072 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -73,6 +73,20 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { @Contract(pure = true) Optional getExpiration(); + /** + * Returns whether the current cached value is expired. + *

+ * A value is expired when no fetch has completed yet or when the + * configured TTL has elapsed since the last fetch. + * + * @return {@code true} if the cached value is absent or stale + * @see #getExpiration() + * @see #isValid() + * @since 0.23.0 + */ + @Contract(pure = true) + boolean isExpired(); + /** * Returns whether the current cached value is still valid. *

diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java index f1de752f..b60b6d4e 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -15,6 +15,9 @@ final class SimpleFeatureFlag implements FeatureFlag { private final @Nullable Attributes attributes; private final Type type; + private volatile @Nullable T value; + private volatile @Nullable Long lastFetch; + SimpleFeatureFlag( final String id, final T defaultValue, @@ -54,24 +57,43 @@ public Class getTypeClass() { }; } + public void setValue(@Nullable final T value) { + this.value = value; + } + + public void setLastFetch(@Nullable final Long lastFetch) { + this.lastFetch = lastFetch; + } + @Override public Optional getCached() { - return service.get(this); + return Optional.ofNullable(value); } @Override public Optional getExpiration() { - return service.getExpiration(this); + final var lastFetch = this.lastFetch; + if (lastFetch == null) return Optional.empty(); + return Optional.of(Instant.ofEpochMilli(lastFetch).plus(service.getTTL())); } @Override public boolean isValid() { - return service.isValid(this); + return value != null && !isExpired(); + } + + @Override + public boolean isExpired() { + final var lastFetch = this.lastFetch; + if (lastFetch == null) return true; + return System.currentTimeMillis() - lastFetch > service.getTTL().toMillis(); } @Override public CompletableFuture whenReady() { - return service.whenReady(this); + final var cached = value; + if (cached == null || isExpired()) return fetch(); + return CompletableFuture.completedFuture(cached); } @Override diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 0624dc4a..e07bf6d6 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -14,7 +14,6 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -34,8 +33,6 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private final @Nullable Attributes attributes; private final Duration ttl; - private final Map cache = new ConcurrentHashMap<>(); - private final Map fetchTimes = new ConcurrentHashMap<>(); private final Map> fetchesInProgress = new ConcurrentHashMap<>(); SimpleFeatureFlagService( @@ -62,18 +59,6 @@ private static URI getFlagsServerUrl() { return URI.create("https://flags.faststats.dev/v1"); } - @SuppressWarnings("unchecked") - Optional get(final SimpleFeatureFlag flag) { - return Optional.ofNullable((T) cache.get(flag.getId())); - } - - @SuppressWarnings("unchecked") - CompletableFuture whenReady(final SimpleFeatureFlag flag) { - final var cached = cache.get(flag.getId()); - if (cached == null || isExpired(flag)) return fetch(flag); - return CompletableFuture.completedFuture((T) cached); - } - @SuppressWarnings("unchecked") CompletableFuture fetch(final SimpleFeatureFlag flag) { return (CompletableFuture) fetchesInProgress.computeIfAbsent(flag.getId(), ignored -> createFetch(flag)); @@ -110,22 +95,6 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, }); } - Optional getExpiration(final SimpleFeatureFlag flag) { - final var lastFetch = fetchTimes.get(flag.getId()); - if (lastFetch == null) return Optional.empty(); - return Optional.of(Instant.ofEpochMilli(lastFetch).plus(ttl)); - } - - boolean isValid(final SimpleFeatureFlag flag) { - return cache.containsKey(flag.getId()) && !isExpired(flag); - } - - boolean isExpired(final SimpleFeatureFlag flag) { - final var lastFetch = fetchTimes.get(flag.getId()); - if (lastFetch == null) return true; - return System.currentTimeMillis() - lastFetch > ttl.toMillis(); - } - private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); requestBody.addProperty("serverId", serverId.toString()); @@ -152,8 +121,8 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { throw new IllegalStateException("Unexpected response status: %s (%s)".formatted(response.statusCode(), body)); final var value = getValue(flag, body); - cache.put(flag.getId(), value); - fetchTimes.put(flag.getId(), System.currentTimeMillis()); + flag.setLastFetch(System.currentTimeMillis()); + flag.setValue(value); return value; } catch (final JsonParseException e) { throw new IllegalStateException("Unexpected response body: %s (%s)".formatted(response.body(), response.statusCode()), e); @@ -219,7 +188,5 @@ public Duration getTTL() { public void shutdown() { fetchesInProgress.values().forEach(fetch -> fetch.cancel(true)); fetchesInProgress.clear(); - fetchTimes.clear(); - cache.clear(); } } From bacef36973a79aa05ec9e24b636d70fd528b6b4e Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 17:07:54 +0200 Subject: [PATCH 060/140] Add logger name to error log record --- core/src/main/java/dev/faststats/internal/SimpleLogger.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/dev/faststats/internal/SimpleLogger.java b/core/src/main/java/dev/faststats/internal/SimpleLogger.java index d16cc699..147b410d 100644 --- a/core/src/main/java/dev/faststats/internal/SimpleLogger.java +++ b/core/src/main/java/dev/faststats/internal/SimpleLogger.java @@ -33,6 +33,7 @@ public void error(final String message, @Nullable final Throwable throwable, @Nu if (throwable != null) { if (!logger.isLoggable(Level.SEVERE)) return; final var logRecord = new LogRecord(Level.SEVERE, message.formatted(args)); + logRecord.setLoggerName(logger.getName()); logRecord.setThrown(throwable); logger.log(logRecord); } else log(Level.SEVERE, message, args); From f7a2bba5a776a57280a5d8a65fab366d94c11d7c Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 17:08:19 +0200 Subject: [PATCH 061/140] Add debug logs to feature flag fetches --- .../dev/faststats/SimpleFeatureFlagService.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index e07bf6d6..965c3d0c 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -5,6 +5,7 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; +import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; import org.jspecify.annotations.Nullable; @@ -21,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap; final class SimpleFeatureFlagService implements FeatureFlagService { + private static final Logger logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class); private static final URI url = getFlagsServerUrl(); private final HttpClient httpClient = HttpClient.newBuilder() @@ -53,7 +55,6 @@ private static URI getFlagsServerUrl() { if (property != null) try { return new URI(property); } catch (final URISyntaxException e) { - final var logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class); logger.error("Failed to parse flags server url: %s", e, property); } return URI.create("https://flags.faststats.dev/v1"); @@ -87,6 +88,7 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { if (response.statusCode() < 200 || response.statusCode() >= 300) { + logger.error("Feature flag opt request failed with status %s", null, response.statusCode()); return CompletableFuture.failedFuture(new IllegalStateException( "Feature flag opt request failed with status " + response.statusCode() )); @@ -117,14 +119,17 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { try { final var body = JsonParser.parseString(response.body()); - if (response.statusCode() < 200 || response.statusCode() >= 300) + if (response.statusCode() < 200 || response.statusCode() >= 300) { + logger.warn("Unexpected response status: %s (%s)", response.statusCode(), body); throw new IllegalStateException("Unexpected response status: %s (%s)".formatted(response.statusCode(), body)); + } final var value = getValue(flag, body); flag.setLastFetch(System.currentTimeMillis()); flag.setValue(value); return value; } catch (final JsonParseException e) { + logger.error("Unexpected response body: %s (%s)", e, response.body(), response.statusCode()); throw new IllegalStateException("Unexpected response body: %s (%s)".formatted(response.body(), response.statusCode()), e); } }).whenComplete((ignored, throwable) -> fetchesInProgress.remove(flag.getId())); @@ -132,10 +137,14 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { @SuppressWarnings("unchecked") private static T getValue(final SimpleFeatureFlag flag, final JsonElement body) { - if (!(body instanceof final JsonObject object)) + if (!(body instanceof final JsonObject object)) { + logger.warn("Unexpected JSON response: %s", body); throw new IllegalStateException("Unexpected JSON response: " + body); - if (!(object.get("value") instanceof final JsonPrimitive primitive)) + } + if (!(object.get("value") instanceof final JsonPrimitive primitive)) { + logger.warn("Missing or invalid 'value' in JSON response: %s", body); throw new IllegalStateException("Missing or invalid 'value' in JSON response: " + body); + } return (T) switch (flag.getType()) { case STRING -> primitive.getAsString(); From c47a132f0e938809cc5fca4513ca2088c549644a Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 17:25:18 +0200 Subject: [PATCH 062/140] Note exceptional behavior for feature flag fetches --- core/src/main/java/dev/faststats/FeatureFlag.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index a0915072..c03eb330 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -118,6 +118,14 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * Force a fresh fetch of the flag value from the server. *

* Unlike {@link #whenReady()}, this always performs a server request. + *

+ * The returned future may complete exceptionally if the request fails, the + * server returns a non-successful response, or the response body cannot be + * parsed as the requested flag type. The most common non-successful response + * is an unknown flag identifier. + *

+ * Failed fetches do not update the cached value. Failure details are only + * logged when debug logging is enabled. * * @return a future completing with the latest server value * @since 0.23.0 From fbbbb971290f2561fe10b6c7f32c526c9ddcd5a0 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 17:25:30 +0200 Subject: [PATCH 063/140] Link to #fetch --- core/src/main/java/dev/faststats/FeatureFlag.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index c03eb330..546a5a9e 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -144,6 +144,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * cache has been reset and the follow-up fetch finishes. * * @return a future completing with the updated flag value + * @see #fetch() * @since 0.23.0 */ @Contract(mutates = "this") @@ -159,6 +160,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * cache has been reset and the follow-up fetch finishes. * * @return a future completing with the updated flag value + * @see #fetch() * @since 0.23.0 */ @Contract(mutates = "this") From a4f44787ee3a39a0a90a7d19b8b3079d42ad8d34 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 17:28:52 +0200 Subject: [PATCH 064/140] Link to #whenReady --- core/src/main/java/dev/faststats/FeatureFlag.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index 546a5a9e..5cb560a6 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -128,6 +128,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * logged when debug logging is enabled. * * @return a future completing with the latest server value + * @see #whenReady() * @since 0.23.0 */ @Contract(mutates = "this") From 02adc699edddbdf4b6349d1b24352bf03050860f Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 17:31:26 +0200 Subject: [PATCH 065/140] Clarify #getChaged docs --- core/src/main/java/dev/faststats/FeatureFlag.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index 5cb560a6..2c322969 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -49,11 +49,11 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { /** * Get the current cached flag value. *

- * This method is non-blocking and never performs a network request. It - * returns {@link Optional#empty()} until a value has been fetched and + * It returns {@link Optional#empty() empty} until a value has been fetched and * stored locally. * * @return the cached value, if present + * @see #fetch() * @since 0.23.0 */ @Contract(pure = true) From 592959fe5d2868b69e6530d7d4f0b2840e17dea0 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 19:11:20 +0200 Subject: [PATCH 066/140] Simplified code structure --- .../faststats/bukkit/BukkitMetricsImpl.java | 9 +-- .../dev/faststats/bungee/BungeeContext.java | 8 ++- .../faststats/bungee/BungeeMetricsImpl.java | 16 +---- core/src/main/java/dev/faststats/Metrics.java | 19 ------ .../java/dev/faststats/SimpleMetrics.java | 62 ++++++------------- .../java/dev/faststats/AnonymizationTest.java | 3 +- .../test/java/dev/faststats/MetricsTest.java | 5 +- .../test/java/dev/faststats/MockContext.java | 39 ++++++++++++ .../test/java/dev/faststats/MockMetrics.java | 12 ++-- .../dev/faststats/fabric/FabricContext.java | 12 +++- .../faststats/fabric/FabricMetricsImpl.java | 19 +----- .../dev/faststats/hytale/HytaleContext.java | 9 ++- .../faststats/hytale/HytaleMetricsImpl.java | 15 +---- .../faststats/minestom/MinestomContext.java | 2 +- .../minestom/MinestomMetricsImpl.java | 6 +- .../dev/faststats/nukkit/NukkitContext.java | 8 ++- .../faststats/nukkit/NukkitMetricsImpl.java | 16 +---- .../dev/faststats/sponge/SpongeContext.java | 23 ++++--- .../dev/faststats/sponge/SpongeMetrics.java | 17 ----- .../faststats/sponge/SpongeMetricsImpl.java | 28 ++------- .../faststats/velocity/VelocityContext.java | 45 +++++--------- .../faststats/velocity/VelocityMetrics.java | 28 --------- .../velocity/VelocityMetricsImpl.java | 61 +++--------------- 23 files changed, 149 insertions(+), 313 deletions(-) create mode 100644 core/src/test/java/dev/faststats/MockContext.java delete mode 100644 sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java delete mode 100644 velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 7ea95a51..dd8b5266 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -2,7 +2,6 @@ import com.google.gson.JsonObject; import dev.faststats.ErrorTracker; -import dev.faststats.FastStatsContext; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import dev.faststats.data.Metric; @@ -66,7 +65,7 @@ private boolean isProxyOnlineMode() { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -105,11 +104,7 @@ private Optional tryOrEmpty(final Supplier supplier) { } public static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { - public Factory(final BukkitContext context) { - super(context); - } - - Factory(final FastStatsContext context) { + Factory(final BukkitContext context) { super(context); } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index 93e39ce6..01579d64 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -2,6 +2,7 @@ import dev.faststats.Metrics; import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; import net.md_5.bungee.api.plugin.Plugin; @@ -21,6 +22,11 @@ public BungeeContext(final Plugin plugin, @Token final String token) { @Override public Metrics.Factory metrics() { - return new BungeeMetricsImpl.Factory(this); + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new BungeeMetricsImpl(this, plugin); + } + }; } } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index 87542d3d..e38075e4 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -1,7 +1,6 @@ package dev.faststats.bungee; import com.google.gson.JsonObject; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import net.md_5.bungee.api.ProxyServer; @@ -15,7 +14,7 @@ final class BungeeMetricsImpl extends SimpleMetrics { @Async.Schedule @Contract(mutates = "io") - private BungeeMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException { + BungeeMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException { super(factory); this.server = plugin.getProxy(); @@ -26,7 +25,7 @@ private BungeeMetricsImpl(final Factory factory, final Plugin plugin) throws Ill @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -37,15 +36,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("proxy_version", server.getVersion()); metrics.addProperty("server_type", server.getName()); } - - static final class Factory extends SimpleMetrics.Factory { - public Factory(final BungeeContext context) { - super(context); - } - - @Override - public Metrics create() throws IllegalStateException { - return new BungeeMetricsImpl(this, ((BungeeContext) context).plugin); - } - } } diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java index f1372e3e..2185d503 100644 --- a/core/src/main/java/dev/faststats/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -12,16 +12,6 @@ * @since 0.23.0 */ public interface Metrics { - /** - * Get the token used to authenticate with the metrics server and identify the project. - * - * @return the metrics token - * @since 0.23.0 - */ - @Token - @Contract(pure = true) - String getToken(); - /** * Get the error tracker for this metrics instance. * @@ -31,15 +21,6 @@ public interface Metrics { @Contract(pure = true) Optional getErrorTracker(); - /** - * Get the metrics configuration. - * - * @return the metrics configuration - * @since 0.23.0 - */ - @Contract(pure = true) - Config getConfig(); - /** * Performs additional post-startup tasks. *

diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 18c4e331..63480987 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -43,36 +43,24 @@ public abstract class SimpleMetrics implements Metrics { .build(); private @Nullable ScheduledExecutorService executor = null; - private final URI url; - private final Set> metrics; - private final Config config; - private final @Token String token; private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; + private final Set> metrics; + private final URI url; - @Contract(mutates = "io") - @SuppressWarnings("PatternValidation") - protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { - this.config = config; - this.token = factory.context.getToken(); - this.metrics = config.additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); - final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); - this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); - this.tracker = config.errorTracking() ? factory.tracker : null; - this.flush = factory.flush; - this.url = getMetricsServerUrl(); - } + protected final FastStatsContext context; @Contract(mutates = "io") protected SimpleMetrics(final Factory factory) throws IllegalStateException { - this(factory, factory.context.getConfig()); + this(factory, getMetricsServerUrl()); } - private URI getMetricsServerUrl() { + private static URI getMetricsServerUrl() { final var property = System.getProperty("faststats.metrics-server"); if (property != null) try { return new URI(property); } catch (final URISyntaxException e) { + final var logger = LoggerFactory.factory().getLogger(SimpleMetrics.class); logger.error("Failed to parse metrics server url: %s", e, property); } return URI.create("https://metrics.faststats.dev/v1/collect"); @@ -80,20 +68,15 @@ private URI getMetricsServerUrl() { @VisibleForTesting protected SimpleMetrics( - final Config config, - final Set> metrics, - @Token final String token, - @Nullable final ErrorTracker tracker, - @Nullable final Runnable flush, - final URI url, - final boolean debug + final Factory factory, + final URI url ) { - this.metrics = config.additionalMetrics() ? Set.copyOf(metrics) : Set.of(); - this.config = config; - this.logger.setLevel(debug ? Level.ALL : Level.OFF); - this.token = token; - this.tracker = tracker; - this.flush = flush; + this.context = factory.context; + this.metrics = context.getConfig().additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); + final var debug = context.getConfig().debug() || Boolean.getBoolean("faststats.debug"); + this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); + this.tracker = context.getConfig().errorTracking() ? factory.tracker : null; + this.flush = factory.flush; this.url = url; } @@ -118,7 +101,7 @@ private void startSubmitting(final long initialDelay, final long period, final T final var enabled = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); - if (!config.enabled() || !enabled) { + if (!context.getConfig().enabled() || !enabled) { logger.warn("Metrics disabled, not starting submission"); return; } @@ -142,6 +125,7 @@ protected boolean isSubmitting() { return executor != null && !executor.isShutdown(); } + @VisibleForTesting public boolean submit() { try { return submitNow(); @@ -170,7 +154,7 @@ private boolean submitNow() throws IOException { .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") - .header("Authorization", "Bearer " + token) + .header("Authorization", "Bearer " + context.getToken()) .header("User-Agent", "FastStats Metrics " + Constants.SDK_NAME + "/" + Constants.SDK_VERSION) .timeout(Duration.ofSeconds(3)) .uri(url) @@ -241,7 +225,7 @@ protected JsonObject createData() { } }); - data.addProperty("identifier", config.serverId().toString()); + data.addProperty("identifier", context.getConfig().serverId().toString()); data.add("data", metrics); getErrorTracker().map(SimpleErrorTracker.class::cast) @@ -251,21 +235,11 @@ protected JsonObject createData() { return data; } - @Override - public @Token String getToken() { - return token; - } - @Override public Optional getErrorTracker() { return Optional.ofNullable(tracker); } - @Override - public dev.faststats.Config getConfig() { - return config; - } - @Contract(mutates = "param1") protected abstract void appendDefaultData(JsonObject metrics); diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index c12013de..da64dc24 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -14,7 +14,8 @@ @NullMarked public final class AnonymizationTest { private static MockMetrics createMetrics(final ErrorTracker tracker) { - return new MockMetrics(UUID.randomUUID(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", tracker, false); + final var context = new MockContext(UUID.randomUUID(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false); + return (MockMetrics) context.metrics().errorTracker(tracker).create(); } private static JsonObject getError(final MockMetrics metrics) { diff --git a/core/src/test/java/dev/faststats/MetricsTest.java b/core/src/test/java/dev/faststats/MetricsTest.java index e0533186..8cd1bec8 100644 --- a/core/src/test/java/dev/faststats/MetricsTest.java +++ b/core/src/test/java/dev/faststats/MetricsTest.java @@ -9,7 +9,8 @@ public class MetricsTest { @Test public void testCreateData() { - final var mock = new MockMetrics(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", null, true); - assumeTrue(mock.submit(), "For this test to run, the server must be running"); + final var context = new MockContext(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", true); + final var metrics = (SimpleMetrics) context.metrics().create(); + assumeTrue(metrics.submit(), "For this test to run, the server must be running"); } } diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java new file mode 100644 index 00000000..1f58ab6b --- /dev/null +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -0,0 +1,39 @@ +package dev.faststats; + +import org.jspecify.annotations.NullMarked; + +import java.util.UUID; + +@NullMarked +public final class MockContext extends SimpleContext { + public MockContext(final UUID serverId, @Token final String token, final boolean debug) throws IllegalArgumentException { + super(new MockConfig(serverId, debug), token); + } + + @Override + public Metrics.Factory metrics() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new MockMetrics(this); + } + }; + } + + private record MockConfig(UUID serverId, boolean debug) implements dev.faststats.Config { + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean errorTracking() { + return true; + } + + @Override + public boolean additionalMetrics() { + return true; + } + } +} diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 00bdc21e..dca9cfd1 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -1,23 +1,19 @@ package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.config.SimpleConfig; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; import java.net.URI; -import java.util.Set; -import java.util.UUID; @NullMarked -public final class MockMetrics extends SimpleMetrics { - public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new SimpleConfig(serverId, true, debug, true, true, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); +final class MockMetrics extends SimpleMetrics { + MockMetrics(final Factory factory) { + super(factory, URI.create("http://localhost:5000/v1/collect")); } @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return true; } @Override diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 9cbe36d3..71324aa1 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -2,6 +2,7 @@ import dev.faststats.Metrics; import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; import net.fabricmc.loader.api.FabricLoader; @@ -24,6 +25,15 @@ public FabricContext(final String modId, @Token final String token) { @Override public Metrics.Factory metrics() { - return new FabricMetricsImpl.Factory(this); + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + final var mod = ((FabricContext) context).mod; + return switch (FabricLoader.getInstance().getEnvironmentType()) { + case CLIENT -> new FabricMetricsClientImpl(this, mod); + case SERVER -> new FabricMetricsServerImpl(this, mod); + }; + } + }; } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index 5b227f6a..9f0f992d 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -1,10 +1,8 @@ package dev.faststats.fabric; import com.google.gson.JsonObject; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; -import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; @@ -22,26 +20,11 @@ abstract class FabricMetricsImpl extends SimpleMetrics { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } protected void appendFabricData(final JsonObject metrics, final String serverType) { metrics.addProperty("plugin_version", mod.getMetadata().getVersion().getFriendlyString()); metrics.addProperty("server_type", serverType); } - - static final class Factory extends SimpleMetrics.Factory { - public Factory(final FabricContext context) { - super(context); - } - - @Override - public Metrics create() throws IllegalStateException, IllegalArgumentException { - final var mod = ((FabricContext) context).mod; - return switch (FabricLoader.getInstance().getEnvironmentType()) { - case CLIENT -> new FabricMetricsClientImpl(this, mod); - case SERVER -> new FabricMetricsServerImpl(this, mod); - }; - } - } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index f268d785..238df152 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -3,6 +3,7 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import dev.faststats.Metrics; import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -18,6 +19,12 @@ public HytaleContext(final JavaPlugin plugin, @Token final String token) { @Override public Metrics.Factory metrics() { - return new HytaleMetricsImpl.Factory(this); + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + // todo: add client support? + return new HytaleMetricsImpl(this); + } + }; } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index 29797e55..f9d60c2a 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -13,7 +13,7 @@ final class HytaleMetricsImpl extends SimpleMetrics { @Async.Schedule @Contract(mutates = "io") - private HytaleMetricsImpl(final Factory factory) throws IllegalStateException { + HytaleMetricsImpl(final Factory factory) throws IllegalStateException { super(factory); startSubmitting(); @@ -21,7 +21,7 @@ private HytaleMetricsImpl(final Factory factory) throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -30,15 +30,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("player_count", Universe.get().getPlayerCount()); metrics.addProperty("server_type", "Hytale"); } - - static final class Factory extends SimpleMetrics.Factory { - public Factory(final HytaleContext context) { - super(context); - } - - @Override - public Metrics create() throws IllegalStateException { - return new HytaleMetricsImpl(this); - } - } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 2433f738..5d012aca 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -12,7 +12,7 @@ * @since 0.23.0 */ public final class MinestomContext extends SimpleContext { - public MinestomContext(@Token final String token) { + MinestomContext(@Token final String token) { super(SimpleConfig.read(Path.of("faststats", "config.properties")), token); } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 6aff56d2..b4f9636a 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -21,7 +21,7 @@ private MinestomMetricsImpl(final Factory factory) throws IllegalStateException @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -46,8 +46,8 @@ private void registerExceptionHandler(final ErrorTracker errorTracker) { }); } - static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { - public Factory(final MinestomContext context) { + public static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + Factory(final MinestomContext context) { super(context); } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 29efb2bc..ad5bd96d 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -3,6 +3,7 @@ import cn.nukkit.plugin.PluginBase; import dev.faststats.Metrics; import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -23,6 +24,11 @@ public NukkitContext(final PluginBase plugin, @Token final String token) { @Override public Metrics.Factory metrics() { - return new NukkitMetricsImpl.Factory(this); + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new NukkitMetricsImpl(this, ((NukkitContext) context).plugin); + } + }; } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index 7f3ff1bf..75da2a3a 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -3,7 +3,6 @@ import cn.nukkit.Server; import cn.nukkit.plugin.PluginBase; import com.google.gson.JsonObject; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import org.jetbrains.annotations.Async; @@ -18,7 +17,7 @@ final class NukkitMetricsImpl extends SimpleMetrics { @Async.Schedule @Contract(mutates = "io") - private NukkitMetricsImpl(final Factory factory, final PluginBase plugin) throws IllegalStateException { + public NukkitMetricsImpl(final Factory factory, final PluginBase plugin) throws IllegalStateException { super(factory); this.server = plugin.getServer(); @@ -29,7 +28,7 @@ private NukkitMetricsImpl(final Factory factory, final PluginBase plugin) throws @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -48,15 +47,4 @@ private Optional tryOrEmpty(final Supplier supplier) { return Optional.empty(); } } - - static final class Factory extends SimpleMetrics.Factory { - Factory(final NukkitContext context) { - super(context); - } - - @Override - public Metrics create() throws IllegalStateException { - return new NukkitMetricsImpl(this, ((NukkitContext) context).plugin); - } - } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 48239aa9..8f0fd324 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -1,9 +1,10 @@ package dev.faststats.sponge; import com.google.inject.Inject; +import dev.faststats.Metrics; import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import org.apache.logging.log4j.Logger; import org.spongepowered.api.config.ConfigDir; import org.spongepowered.plugin.PluginContainer; @@ -16,22 +17,24 @@ */ public final class SpongeContext extends SimpleContext { final PluginContainer plugin; - final Logger logger; - public SpongeContext( + private SpongeContext( final PluginContainer plugin, - final Logger logger, @ConfigDir(sharedRoot = true) final Path dataDirectory, @Token final String token ) { super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), token); this.plugin = plugin; - this.logger = logger; } @Override - public SpongeMetrics.Factory metrics() { - return new SpongeMetrics.Factory(this); + public Metrics.Factory metrics() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new SpongeMetricsImpl(this); + } + }; } /** @@ -41,14 +44,12 @@ public SpongeMetrics.Factory metrics() { */ public static final class Builder { private final PluginContainer plugin; - private final Logger logger; private final Path dataDirectory; /** * Creates a new Sponge context builder. * * @param plugin the plugin container - * @param logger the plugin logger * @param dataDirectory the shared Sponge config directory * @apiNote This instance can be injected into your plugin. * @since 0.23.0 @@ -56,11 +57,9 @@ public static final class Builder { @Inject public Builder( final PluginContainer plugin, - final Logger logger, @ConfigDir(sharedRoot = true) final Path dataDirectory ) { this.plugin = plugin; - this.logger = logger; this.dataDirectory = dataDirectory; } @@ -73,7 +72,7 @@ public Builder( * @since 0.23.0 */ public SpongeContext build(@Token final String token) throws IllegalArgumentException { - return new SpongeContext(plugin, logger, dataDirectory, token); + return new SpongeContext(plugin, dataDirectory, token); } } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java deleted file mode 100644 index 8dcc1250..00000000 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.faststats.sponge; - -import dev.faststats.FastStatsContext; -import dev.faststats.Metrics; - -/** - * Sponge metrics implementation. - * - * @since 0.12.0 - */ -public sealed interface SpongeMetrics extends Metrics permits SpongeMetricsImpl { - final class Factory extends SpongeMetricsImpl.Factory { - public Factory(final FastStatsContext context) { - super(context); - } - } -} diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index 57460968..05919e5a 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -1,35 +1,27 @@ package dev.faststats.sponge; import com.google.gson.JsonObject; -import dev.faststats.FastStatsContext; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; -import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.spongepowered.api.Platform; import org.spongepowered.api.Sponge; import org.spongepowered.plugin.PluginContainer; -final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { - +final class SpongeMetricsImpl extends SimpleMetrics { private final PluginContainer plugin; @Async.Schedule @Contract(mutates = "io") - private SpongeMetricsImpl( - final Factory factory, - final Logger logger, - final PluginContainer plugin - ) throws IllegalStateException { + SpongeMetricsImpl(final Factory factory) throws IllegalStateException { super(factory); - this.plugin = plugin; + this.plugin = ((SpongeContext) this.context).plugin; startSubmitting(); } @Override protected boolean preSubmissionStart() { - return ((SpongeConfig) getConfig()).preSubmissionStart(); + return ((SpongeConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -40,16 +32,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", Sponge.platform().minecraftVersion().name()); metrics.addProperty("server_type", Sponge.platform().container(Platform.Component.IMPLEMENTATION).metadata().id()); } - - static class Factory extends SimpleMetrics.Factory { - public Factory(final FastStatsContext context) { - super(context); - } - - @Override - public Metrics create() throws IllegalStateException, IllegalArgumentException { - final var context = (SpongeContext) this.context; - return new SpongeMetricsImpl(this, context.logger, context.plugin); - } - } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 6dad69d8..e037d7dc 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -4,11 +4,11 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.Config; +import dev.faststats.Metrics; import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; -import org.slf4j.Logger; import java.nio.file.Path; @@ -18,39 +18,28 @@ * @since 0.23.0 */ public final class VelocityContext extends SimpleContext { - private final PluginContainer plugin; - private final ProxyServer server; - private final Logger logger; - private final Path dataDirectory; + final PluginContainer plugin; + final ProxyServer server; - public VelocityContext( - final Config config, + private VelocityContext( final PluginContainer plugin, final ProxyServer server, - final Logger logger, - final Path dataDirectory, + @DataDirectory final Path dataDirectory, @Token final String token ) { - super(config, token); + super(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), token); this.plugin = plugin; this.server = server; - this.logger = logger; - this.dataDirectory = dataDirectory; - } - - public VelocityContext( - final PluginContainer plugin, - final ProxyServer server, - final Logger logger, - @DataDirectory final Path dataDirectory, - @Token final String token - ) { - this(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), plugin, server, logger, dataDirectory, token); } @Override - public VelocityMetrics.Factory metrics() { - return new VelocityMetrics.Factory(this, plugin, server, logger, dataDirectory); + public Metrics.Factory metrics() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new VelocityMetricsImpl(this); + } + }; } /** @@ -61,14 +50,12 @@ public VelocityMetrics.Factory metrics() { public static final class Builder { private final PluginContainer plugin; private final ProxyServer server; - private final Logger logger; private final Path dataDirectory; /** * Creates a new Velocity context builder. * * @param server the velocity server - * @param logger the plugin logger * @param dataDirectory the plugin data directory * @apiNote This instance can be injected into your plugin. * @since 0.23.0 @@ -77,12 +64,10 @@ public static final class Builder { public Builder( final PluginContainer plugin, final ProxyServer server, - final Logger logger, @DataDirectory final Path dataDirectory ) { this.plugin = plugin; this.server = server; - this.logger = logger; this.dataDirectory = dataDirectory; } @@ -95,7 +80,7 @@ public Builder( * @since 0.23.0 */ public VelocityContext build(@Token final String token) throws IllegalArgumentException { - return new VelocityContext(plugin, server, logger, dataDirectory, token); + return new VelocityContext(plugin, server, dataDirectory, token); } } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java deleted file mode 100644 index d5b36f18..00000000 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.faststats.velocity; - -import com.velocitypowered.api.plugin.PluginContainer; -import com.velocitypowered.api.plugin.annotation.DataDirectory; -import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.Metrics; -import org.slf4j.Logger; - -import java.nio.file.Path; - -/** - * Velocity metrics implementation. - * - * @since 0.1.0 - */ -public sealed interface VelocityMetrics extends Metrics permits VelocityMetricsImpl { - final class Factory extends VelocityMetricsImpl.Factory { - public Factory( - final VelocityContext context, - final PluginContainer plugin, - final ProxyServer server, - final Logger logger, - @DataDirectory final Path dataDirectory - ) { - super(context, plugin, server, logger, dataDirectory); - } - } -} diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 51159fd2..47b05cec 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -2,43 +2,31 @@ import com.google.gson.JsonObject; import com.velocitypowered.api.plugin.PluginContainer; -import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.Config; -import dev.faststats.FastStatsContext; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.slf4j.Logger; -import java.nio.file.Path; - -final class VelocityMetricsImpl extends SimpleMetrics implements VelocityMetrics { +final class VelocityMetricsImpl extends SimpleMetrics { private final ProxyServer server; private final PluginContainer plugin; @Async.Schedule @Contract(mutates = "io") - private VelocityMetricsImpl( - final Factory factory, - final Logger logger, - final ProxyServer server, - final Config config, - final PluginContainer plugin - ) throws IllegalStateException { - super(factory, config); + VelocityMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); - this.server = server; - this.plugin = plugin; + final var context = (VelocityContext) this.context; + this.server = context.server; + this.plugin = context.plugin; startSubmitting(); } @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(); + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } @Override @@ -50,39 +38,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("proxy_version", server.getVersion().getVersion()); metrics.addProperty("server_type", server.getVersion().getName()); } - - static class Factory extends SimpleMetrics.Factory { - protected final Logger logger; - protected final Path dataDirectory; - protected final ProxyServer server; - protected final PluginContainer plugin; - - public Factory( - final FastStatsContext context, - final PluginContainer plugin, - final ProxyServer server, - final Logger logger, - @DataDirectory final Path dataDirectory - ) { - super(context); - this.plugin = plugin; - this.logger = logger; - this.dataDirectory = dataDirectory; - this.server = server; - } - - /** - * Creates a new metrics instance. - *

- * Metrics submission will start automatically. - * - * @return the metrics instance - * @throws IllegalStateException if the token is not specified - * @since 0.23.0 - */ - @Override - public Metrics create() throws IllegalStateException { - return new VelocityMetricsImpl(this, logger, server, context.getConfig(), plugin); - } - } } From a91722c680d46c747459c9277b3faa8adc6a8a46 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 20:55:50 +0200 Subject: [PATCH 067/140] Update feature flags example --- .../java/dev/faststats/example/FeatureFlagExample.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index 97bda5d3..af9eb492 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -6,7 +6,6 @@ import dev.faststats.FeatureFlagService; import java.time.Duration; -import java.time.Instant; public final class FeatureFlagExample { public static final FeatureFlagService SERVICE = getContext().featureFlags( @@ -44,8 +43,10 @@ public static void usage() { }); // Refresh stale values explicitly when your code decides it is needed - if (COMPRESSION.getExpiration().filter(Instant.now()::isAfter).isPresent()) { - COMPRESSION.fetch(); + if (COMPRESSION.isExpired()) { + COMPRESSION.fetch().thenAccept(string -> { + // do stuff with the value + }); } // Opt-in/out (requires allow_specific_opt_in on server) From dfc38e95cbdc5ac4e295f31661c19a31f566e422 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 21:41:49 +0200 Subject: [PATCH 068/140] Fix url resolving --- .../main/java/dev/faststats/SimpleFeatureFlagService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 965c3d0c..056ac4e6 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -57,7 +57,7 @@ private static URI getFlagsServerUrl() { } catch (final URISyntaxException e) { logger.error("Failed to parse flags server url: %s", e, property); } - return URI.create("https://flags.faststats.dev/v1"); + return URI.create("https://flags.faststats.dev"); } @SuppressWarnings("unchecked") @@ -66,11 +66,11 @@ CompletableFuture fetch(final SimpleFeatureFlag flag) { } CompletableFuture optIn(final SimpleFeatureFlag flag) { - return sendOptRequest(flag, "/opt-in"); + return sendOptRequest(flag, "/v1/opt-in"); } CompletableFuture optOut(final SimpleFeatureFlag flag) { - return sendOptRequest(flag, "/opt-out"); + return sendOptRequest(flag, "/v1/opt-out"); } private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { @@ -112,7 +112,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { .header("Content-Type", "application/json") .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(3)) - .uri(url.resolve("/check")) + .uri(url.resolve("/v1/check")) .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { From f76c78ae5038bd3aedc575e7db0f7bee7c410fa4 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 21:42:05 +0200 Subject: [PATCH 069/140] Rename serverId to identifier --- .../src/main/java/dev/faststats/SimpleFeatureFlagService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 056ac4e6..d5552bf7 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -75,7 +75,7 @@ CompletableFuture optOut(final SimpleFeatureFlag flag) { private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { final var requestBody = new JsonObject(); - requestBody.addProperty("serverId", serverId.toString()); + requestBody.addProperty("identifier", serverId.toString()); requestBody.addProperty("flag", flag.getId()); final var request = HttpRequest.newBuilder() @@ -99,7 +99,7 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); - requestBody.addProperty("serverId", serverId.toString()); + requestBody.addProperty("identifier", serverId.toString()); requestBody.addProperty("key", flag.getId()); final var attributes = new JsonObject(); From 977f03778aa71f8cd60a6d35fec303e755679cbf Mon Sep 17 00:00:00 2001 From: david Date: Wed, 22 Apr 2026 21:42:17 +0200 Subject: [PATCH 070/140] Add more debug logs --- .../main/java/dev/faststats/SimpleFeatureFlagService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index d5552bf7..03b9e33b 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -88,9 +88,9 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { if (response.statusCode() < 200 || response.statusCode() >= 300) { - logger.error("Feature flag opt request failed with status %s", null, response.statusCode()); + logger.error("Feature flag opt request failed with status %s (%s)", null, response.statusCode(), response.body()); return CompletableFuture.failedFuture(new IllegalStateException( - "Feature flag opt request failed with status " + response.statusCode() + "Feature flag opt request failed with status %s (%s)".formatted(response.statusCode(), response.body()) )); } return fetch(flag); @@ -115,6 +115,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { .uri(url.resolve("/v1/check")) .build(); + logger.info("Fetching %s: %s", request.uri(), requestBody.toString()); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { try { final var body = JsonParser.parseString(response.body()); @@ -125,6 +126,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { } final var value = getValue(flag, body); + logger.info("Fetch returned body: %s (value: %s)", body, value); flag.setLastFetch(System.currentTimeMillis()); flag.setValue(value); return value; From bc265eb5f55b315240b8d592e6f3c53f785faa2b Mon Sep 17 00:00:00 2001 From: david Date: Fri, 24 Apr 2026 20:34:49 +0200 Subject: [PATCH 071/140] Simplify opt request callback --- .../java/dev/faststats/SimpleFeatureFlagService.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 03b9e33b..6934c2ed 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -87,13 +87,11 @@ private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, .build(); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { - if (response.statusCode() < 200 || response.statusCode() >= 300) { - logger.error("Feature flag opt request failed with status %s (%s)", null, response.statusCode(), response.body()); - return CompletableFuture.failedFuture(new IllegalStateException( - "Feature flag opt request failed with status %s (%s)".formatted(response.statusCode(), response.body()) - )); - } - return fetch(flag); + if (response.statusCode() >= 200 && response.statusCode() < 300) return fetch(flag); + logger.error("Feature flag opt request failed with status %s (%s)", null, response.statusCode(), response.body()); + return CompletableFuture.failedFuture(new IllegalStateException( + "Feature flag opt request failed with status %s (%s)".formatted(response.statusCode(), response.body()) + )); }); } From 409f3df1d1073016be95006f0743b7921f2a437e Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Apr 2026 19:49:55 +0200 Subject: [PATCH 072/140] Simplify error tracker entry creation --- .../dev/faststats/SimpleErrorTracker.java | 23 +++++++++--------- .../java/dev/faststats/SimpleMetrics.java | 2 +- .../java/dev/faststats/ErrorTrackerTest.java | 24 +++++++++---------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 0c9ebdf0..2be08281 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -2,6 +2,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import dev.faststats.internal.Constants; import org.jspecify.annotations.Nullable; import java.lang.Thread.UncaughtExceptionHandler; @@ -108,31 +109,29 @@ public ErrorTracker anonymize(final Pattern pattern, final String replacement) { return this; } - public JsonArray getData(final String buildId) { + public JsonArray getData() { final var report = new JsonArray(reports.size()); reports.forEach((hash, object) -> { - final var copy = object.deepCopy(); - copy.addProperty("hash", hash); - copy.addProperty("buildId", buildId); final var count = collected.getOrDefault(hash, 1); - if (count > 1) copy.addProperty("count", count); - report.add(copy); + report.add(fillEntry(object.deepCopy(), hash, count)); }); collected.forEach((hash, count) -> { if (count <= 0 || reports.containsKey(hash)) return; - final var entry = new JsonObject(); - - entry.addProperty("hash", hash); - if (count > 1) entry.addProperty("count", count); - - report.add(entry); + report.add(fillEntry(new JsonObject(), hash, count)); }); return report; } + private JsonObject fillEntry(final JsonObject entry, final String hash, final int count) { + entry.addProperty("hash", hash); + entry.addProperty("buildId", Constants.BUILD_ID); + if (count > 1) entry.addProperty("count", count); + return entry; + } + public void clear() { collected.replaceAll((k, v) -> 0); reports.clear(); diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 63480987..c4efdf33 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -229,7 +229,7 @@ protected JsonObject createData() { data.add("data", metrics); getErrorTracker().map(SimpleErrorTracker.class::cast) - .map(tracker -> tracker.getData(Constants.BUILD_ID)) + .map(SimpleErrorTracker::getData) .filter(errors -> !errors.isEmpty()) .ifPresent(errors -> data.add("errors", errors)); return data; diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index bc82940d..8df4f3ed 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -130,7 +130,7 @@ public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); final var message = report.get("message").getAsString(); final var header = report.getAsJsonArray("stack").get(0).getAsString(); @@ -145,7 +145,7 @@ public void appliesCustomRedactionAfterBuiltInRedaction() { tracker.trackError("failed with session=abc123 from 10.0.0.1"); - final var message = tracker.getData("build") + final var message = tracker.getData() .get(0) .getAsJsonObject() .get("message") @@ -160,7 +160,7 @@ public void nullMessagesAreNotSerializedAsMessageProperty() { tracker.trackError(new RuntimeException((String) null)); - final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); assertFalse(report.has("message")); assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); } @@ -186,7 +186,7 @@ public void nestedCausesAreSerializedInOrder() { tracker.trackError(top, false); - final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); final var stack = report.getAsJsonArray("stack"); assertEquals(RuntimeException.class.getName(), report.get("error").getAsString()); @@ -209,7 +209,7 @@ public void cyclicCauseChainStopsAfterFirstVisit() { tracker.trackError(first); - final var stack = tracker.getData("build").get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); var firstCauseCount = 0; var secondCauseCount = 0; for (final var element : stack) { @@ -231,7 +231,7 @@ public void duplicateErrorsAreAggregatedWithCount() { tracker.trackError(first); tracker.trackError(second); - final var reports = tracker.getData("build"); + final var reports = tracker.getData(); final var report = reports.get(0).getAsJsonObject(); assertEquals(1, reports.size()); @@ -249,11 +249,11 @@ public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { tracker.clear(); assertFalse(tracker.needsFlushing()); - assertEquals(0, tracker.getData("build").size()); + assertEquals(0, tracker.getData().size()); tracker.trackError(createStableError()); - final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); assertEquals("duplicate", report.get("message").getAsString()); assertNull(report.get("count")); } @@ -265,7 +265,7 @@ public void ignoredNestedCauseSuppressesWholeReport() { tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); - assertEquals(0, tracker.getData("build").size()); + assertEquals(0, tracker.getData().size()); assertFalse(tracker.needsFlushing()); } @@ -284,7 +284,7 @@ public void repeatingStackFramesAreCollapsed() { tracker.trackError(error); - final var stack = tracker.getData("build").get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); @@ -299,7 +299,7 @@ public void longMessagesAreTruncatedBeforeSerialization() { tracker.trackError(message); - final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); final var serialized = report.get("message").getAsString(); assertEquals(503, serialized.length()); assertTrue(serialized.endsWith("...")); @@ -325,7 +325,7 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept thread.join(1000); assertTrue(handled.await(1, TimeUnit.SECONDS)); - final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); assertEquals("async failure", report.get("message").getAsString()); assertFalse(report.get("handled").getAsBoolean()); } finally { From 18e35b976e8778ebe56b23222642d77a8adf7972 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Apr 2026 19:51:33 +0200 Subject: [PATCH 073/140] Rename reported "hash" to "group_hash" --- core/src/main/java/dev/faststats/SimpleErrorTracker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 2be08281..ed7a9b79 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -126,7 +126,7 @@ public JsonArray getData() { } private JsonObject fillEntry(final JsonObject entry, final String hash, final int count) { - entry.addProperty("hash", hash); + entry.addProperty("group_hash", hash); entry.addProperty("buildId", Constants.BUILD_ID); if (count > 1) entry.addProperty("count", count); return entry; From 14fdf5dc545c73de50d8b36fb96e930bbc3710a5 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Apr 2026 20:03:04 +0200 Subject: [PATCH 074/140] remove useless test --- core/src/test/java/dev/faststats/ErrorTrackerTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 8df4f3ed..b97c0169 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -236,7 +236,6 @@ public void duplicateErrorsAreAggregatedWithCount() { assertEquals(1, reports.size()); assertEquals(2, report.get("count").getAsInt()); - assertEquals("build", report.get("buildId").getAsString()); assertEquals("duplicate", report.get("message").getAsString()); } From 1f4edb2c85fe5778a2c9d97b78b305c6786ddaf1 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Apr 2026 20:03:36 +0200 Subject: [PATCH 075/140] Remove opt-in and out API --- .../faststats/example/FeatureFlagExample.java | 12 ------- .../main/java/dev/faststats/FeatureFlag.java | 33 ------------------- .../java/dev/faststats/SimpleFeatureFlag.java | 10 ------ .../faststats/SimpleFeatureFlagService.java | 30 ----------------- 4 files changed, 85 deletions(-) diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index af9eb492..00364cfe 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -48,18 +48,6 @@ public static void usage() { // do stuff with the value }); } - - // Opt-in/out (requires allow_specific_opt_in on server) - NEW_COMMANDS.optIn().thenAccept(updatedValue -> { - if (updatedValue) { - // react to the updated server value - } - }); - NEW_COMMANDS.optOut().thenAccept(updatedValue -> { - if (!updatedValue) { - // react to the updated server value - } - }); } private static FastStatsContext getContext() { diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index 2c322969..efd80c8c 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -134,39 +134,6 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { @Contract(mutates = "this") CompletableFuture fetch(); - /** - * Request that the server opt in to this flag, then invalidate the local - * value and fetch the current server value again. - *

- * This sends a {@code POST /v1/flag/opt-in} request. The server determines - * the resulting flag value based on its own conditions. - *

- * The returned future completes with the updated value after the local - * cache has been reset and the follow-up fetch finishes. - * - * @return a future completing with the updated flag value - * @see #fetch() - * @since 0.23.0 - */ - @Contract(mutates = "this") - CompletableFuture optIn(); - - /** - * Request that the server opt out of this flag, then invalidate the local - * value and fetch the current server value again. - *

- * This sends a {@code POST /v1/flag/opt-out} request. - *

- * The returned future completes with the updated value after the local - * cache has been reset and the follow-up fetch finishes. - * - * @return a future completing with the updated flag value - * @see #fetch() - * @since 0.23.0 - */ - @Contract(mutates = "this") - CompletableFuture optOut(); - /** * Get the default value for this flag. * diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java index b60b6d4e..235a2f1f 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -101,16 +101,6 @@ public CompletableFuture fetch() { return service.fetch(this); } - @Override - public CompletableFuture optIn() { - return service.optIn(this); - } - - @Override - public CompletableFuture optOut() { - return service.optOut(this); - } - @Override public T getDefaultValue() { return defaultValue; diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 6934c2ed..5f493fc4 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -65,36 +65,6 @@ CompletableFuture fetch(final SimpleFeatureFlag flag) { return (CompletableFuture) fetchesInProgress.computeIfAbsent(flag.getId(), ignored -> createFetch(flag)); } - CompletableFuture optIn(final SimpleFeatureFlag flag) { - return sendOptRequest(flag, "/v1/opt-in"); - } - - CompletableFuture optOut(final SimpleFeatureFlag flag) { - return sendOptRequest(flag, "/v1/opt-out"); - } - - private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { - final var requestBody = new JsonObject(); - requestBody.addProperty("identifier", serverId.toString()); - requestBody.addProperty("flag", flag.getId()); - - final var request = HttpRequest.newBuilder() - .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + token) - .timeout(Duration.ofSeconds(3)) - .uri(url.resolve(path)) - .build(); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { - if (response.statusCode() >= 200 && response.statusCode() < 300) return fetch(flag); - logger.error("Feature flag opt request failed with status %s (%s)", null, response.statusCode(), response.body()); - return CompletableFuture.failedFuture(new IllegalStateException( - "Feature flag opt request failed with status %s (%s)".formatted(response.statusCode(), response.body()) - )); - }); - } - private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); requestBody.addProperty("identifier", serverId.toString()); From 662b8bbebaddd08a4b8afa1aac0be056ab7e2974 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 1 May 2026 11:48:04 +0200 Subject: [PATCH 076/140] Improve number and boolean parsing --- .../faststats/SimpleFeatureFlagService.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 5f493fc4..1b40f64b 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -9,6 +9,7 @@ import dev.faststats.internal.LoggerFactory; import org.jspecify.annotations.Nullable; +import java.math.BigDecimal; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; @@ -118,8 +119,30 @@ private static T getValue(final SimpleFeatureFlag flag, final JsonElement return (T) switch (flag.getType()) { case STRING -> primitive.getAsString(); - case NUMBER -> primitive.getAsNumber(); - case BOOLEAN -> primitive.getAsBoolean(); + case NUMBER -> getAsNumber(primitive); + case BOOLEAN -> getAsBoolean(primitive); + }; + } + + private static Number getAsNumber(final JsonPrimitive primitive) { + try { + if (primitive.isNumber()) return primitive.getAsNumber(); + return new BigDecimal(primitive.getAsString()); + } catch (final NumberFormatException e) { + logger.warn("Expected a number but got: %s", primitive.getAsString()); + throw new IllegalStateException("Expected a number but got: " + primitive.getAsString(), e); + } + } + + private static boolean getAsBoolean(final JsonPrimitive primitive) { + if (primitive.isBoolean()) return primitive.getAsBoolean(); + return switch (primitive.getAsString()) { + case "true" -> true; + case "false" -> false; + default -> { + logger.warn("Expected a boolean but got: %s", primitive.getAsString()); + throw new IllegalStateException("Expected a boolean but got: " + primitive.getAsString()); + } }; } From 32ffa48d47ffa6886972ffb9d689eaacf3cab28d Mon Sep 17 00:00:00 2001 From: david Date: Tue, 5 May 2026 16:22:26 +0200 Subject: [PATCH 077/140] Remove error fingerprinting Moved to a separate branch and PR --- .../dev/faststats/SimpleErrorTracker.java | 4 +- .../dev/faststats/StackTraceFingerprint.java | 41 ----- .../faststats/StackTraceFingerprintTest.java | 141 ------------------ 3 files changed, 2 insertions(+), 184 deletions(-) delete mode 100644 core/src/main/java/dev/faststats/StackTraceFingerprint.java delete mode 100644 core/src/test/java/dev/faststats/StackTraceFingerprintTest.java diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index ed7a9b79..da50bd03 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -60,11 +60,11 @@ public void trackError(final String message, final boolean handled) { public void trackError(final Throwable error, final boolean handled) { try { if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; - final var hashed = StackTraceFingerprint.hash(error); // todo: report duplicate errors with different messages + final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); + final var hashed = MurmurHash3.hash(compiled.toString()); if (collected.compute(hashed, (k, v) -> { return v == null ? 1 : v + 1; }) > 1) return; - final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); reports.put(hashed, compiled); } catch (final NoClassDefFoundError ignored) { } diff --git a/core/src/main/java/dev/faststats/StackTraceFingerprint.java b/core/src/main/java/dev/faststats/StackTraceFingerprint.java deleted file mode 100644 index d86258ee..00000000 --- a/core/src/main/java/dev/faststats/StackTraceFingerprint.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.faststats; - -import org.jspecify.annotations.Nullable; - -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.Set; - -final class StackTraceFingerprint { - private static final int STACK_TRACE_LIMIT = 5; - - private StackTraceFingerprint() { - } - - public static String hash(final Throwable error) { - return MurmurHash3.hash(normalize(error)); - } - - public static String normalize(final Throwable error) { - final var visited = Collections.newSetFromMap(new IdentityHashMap<>()); - final var builder = new StringBuilder(); - append(error, builder, visited); - return builder.toString(); - } - - private static void append(@Nullable final Throwable error, final StringBuilder builder, final Set visited) { - if (error == null || !visited.add(error)) return; - - if (!builder.isEmpty()) builder.append('\n'); - builder.append("e").append(error.getClass().getName()); - - var frames = 0; - for (final var element : error.getStackTrace()) { - if (ErrorHelper.isLibraryFrame(element.getClassName())) continue; - builder.append("\nf").append(element.getClassName()).append('.').append(element.getMethodName()); - if (++frames >= STACK_TRACE_LIMIT) break; - } - - append(error.getCause(), builder, visited); - } -} diff --git a/core/src/test/java/dev/faststats/StackTraceFingerprintTest.java b/core/src/test/java/dev/faststats/StackTraceFingerprintTest.java deleted file mode 100644 index 31363aa5..00000000 --- a/core/src/test/java/dev/faststats/StackTraceFingerprintTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package dev.faststats; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; - -public class StackTraceFingerprintTest { - @Test - public void normalizeIncludesExceptionClassAndFrameOwnersOnly() { - final var error = new RuntimeException("message is ignored"); - error.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Plugin", "run", "Plugin.java", 42), - new StackTraceElement("example.Worker", "call", "Worker.java", 7) - }); - - assertEquals(""" - ejava.lang.RuntimeException - fexample.Plugin.run - fexample.Worker.call""", StackTraceFingerprint.normalize(error)); - } - - @Test - public void normalizeExcludesLibraryFrames() { - final var error = new RuntimeException("message is ignored"); - error.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("java.util.ArrayList", "get", "ArrayList.java", 427), - new StackTraceElement("javax.script.ScriptEngine", "eval", "ScriptEngine.java", 1), - new StackTraceElement("sun.reflect.NativeMethodAccessorImpl", "invoke0", "NativeMethodAccessorImpl.java", -2), - new StackTraceElement("com.sun.proxy.Proxy", "invoke", "Proxy.java", 1), - new StackTraceElement("jdk.internal.reflect.DirectMethodHandleAccessor", "invoke", "DirectMethodHandleAccessor.java", 104), - new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) - }); - - assertEquals(""" - ejava.lang.RuntimeException - fexample.Plugin.run""", StackTraceFingerprint.normalize(error)); - } - - @Test - public void normalizeIncludesOnlyFirstFiveNonLibraryFrames() { - final var error = new RuntimeException("message is ignored"); - error.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("java.util.ArrayList", "get", "ArrayList.java", 427), - new StackTraceElement("example.Plugin", "run", "Plugin.java", 42), - new StackTraceElement("example.Worker", "call", "Worker.java", 7), - new StackTraceElement("example.Service", "execute", "Service.java", 15), - new StackTraceElement("example.Repository", "load", "Repository.java", 23), - new StackTraceElement("example.Database", "query", "Database.java", 31), - new StackTraceElement("example.Ignored", "extra", "Ignored.java", 39) - }); - - assertEquals(""" - ejava.lang.RuntimeException - fexample.Plugin.run - fexample.Worker.call - fexample.Service.execute - fexample.Repository.load - fexample.Database.query""", StackTraceFingerprint.normalize(error)); - } - - @Test - public void normalizeIgnoresMessageFileAndLineNumberDifferences() { - final var first = new RuntimeException("This is error #23f4"); - first.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) - }); - final var second = new RuntimeException("This is error #93dsff"); - second.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Plugin", "run", "Generated.java", 99) - }); - - assertEquals(StackTraceFingerprint.normalize(first), StackTraceFingerprint.normalize(second)); - assertEquals(StackTraceFingerprint.hash(first), StackTraceFingerprint.hash(second)); - } - - @Test - public void differentExceptionClassChangesFingerprint() { - final var first = new RuntimeException("same"); - first.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) - }); - final var second = new IllegalStateException("same"); - second.setStackTrace(first.getStackTrace()); - - assertNotEquals(StackTraceFingerprint.normalize(first), StackTraceFingerprint.normalize(second)); - assertNotEquals(StackTraceFingerprint.hash(first), StackTraceFingerprint.hash(second)); - } - - @Test - public void differentFrameMethodChangesFingerprint() { - final var first = new RuntimeException("same"); - first.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) - }); - final var second = new RuntimeException("same"); - second.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Plugin", "stop", "Plugin.java", 42) - }); - - assertNotEquals(StackTraceFingerprint.normalize(first), StackTraceFingerprint.normalize(second)); - assertNotEquals(StackTraceFingerprint.hash(first), StackTraceFingerprint.hash(second)); - } - - @Test - public void normalizeIncludesNestedCausesInOrder() { - final var root = new IllegalArgumentException("root"); - root.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Root", "fail", "Root.java", 10) - }); - final var top = new RuntimeException("top", root); - top.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Top", "run", "Top.java", 30) - }); - - assertEquals(""" - ejava.lang.RuntimeException - fexample.Top.run - ejava.lang.IllegalArgumentException - fexample.Root.fail""", StackTraceFingerprint.normalize(top)); - } - - @Test - public void cyclicCauseChainStopsAfterFirstVisit() { - final var first = new RuntimeException("first"); - first.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.First", "run", "First.java", 1) - }); - final var second = new IllegalStateException("second", first); - second.setStackTrace(new StackTraceElement[]{ - new StackTraceElement("example.Second", "call", "Second.java", 2) - }); - first.initCause(second); - - assertEquals(""" - ejava.lang.RuntimeException - fexample.First.run - ejava.lang.IllegalStateException - fexample.Second.call""", StackTraceFingerprint.normalize(first)); - } -} From 79f1497bcc351f701deeea21f5effe7c709ab793 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 20 May 2026 15:52:17 +0200 Subject: [PATCH 078/140] Revert "Remove opt-in and out API" This reverts commit f2429f1fd87357339f0d0123050cbe3a85958da9. --- .../faststats/example/FeatureFlagExample.java | 12 +++++++ .../main/java/dev/faststats/FeatureFlag.java | 33 +++++++++++++++++++ .../java/dev/faststats/SimpleFeatureFlag.java | 10 ++++++ .../faststats/SimpleFeatureFlagService.java | 30 +++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index 00364cfe..af9eb492 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -48,6 +48,18 @@ public static void usage() { // do stuff with the value }); } + + // Opt-in/out (requires allow_specific_opt_in on server) + NEW_COMMANDS.optIn().thenAccept(updatedValue -> { + if (updatedValue) { + // react to the updated server value + } + }); + NEW_COMMANDS.optOut().thenAccept(updatedValue -> { + if (!updatedValue) { + // react to the updated server value + } + }); } private static FastStatsContext getContext() { diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index efd80c8c..2c322969 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -134,6 +134,39 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { @Contract(mutates = "this") CompletableFuture fetch(); + /** + * Request that the server opt in to this flag, then invalidate the local + * value and fetch the current server value again. + *

+ * This sends a {@code POST /v1/flag/opt-in} request. The server determines + * the resulting flag value based on its own conditions. + *

+ * The returned future completes with the updated value after the local + * cache has been reset and the follow-up fetch finishes. + * + * @return a future completing with the updated flag value + * @see #fetch() + * @since 0.23.0 + */ + @Contract(mutates = "this") + CompletableFuture optIn(); + + /** + * Request that the server opt out of this flag, then invalidate the local + * value and fetch the current server value again. + *

+ * This sends a {@code POST /v1/flag/opt-out} request. + *

+ * The returned future completes with the updated value after the local + * cache has been reset and the follow-up fetch finishes. + * + * @return a future completing with the updated flag value + * @see #fetch() + * @since 0.23.0 + */ + @Contract(mutates = "this") + CompletableFuture optOut(); + /** * Get the default value for this flag. * diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java index 235a2f1f..b60b6d4e 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -101,6 +101,16 @@ public CompletableFuture fetch() { return service.fetch(this); } + @Override + public CompletableFuture optIn() { + return service.optIn(this); + } + + @Override + public CompletableFuture optOut() { + return service.optOut(this); + } + @Override public T getDefaultValue() { return defaultValue; diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 1b40f64b..93faa187 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -66,6 +66,36 @@ CompletableFuture fetch(final SimpleFeatureFlag flag) { return (CompletableFuture) fetchesInProgress.computeIfAbsent(flag.getId(), ignored -> createFetch(flag)); } + CompletableFuture optIn(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/v1/opt-in"); + } + + CompletableFuture optOut(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/v1/opt-out"); + } + + private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { + final var requestBody = new JsonObject(); + requestBody.addProperty("identifier", serverId.toString()); + requestBody.addProperty("flag", flag.getId()); + + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .timeout(Duration.ofSeconds(3)) + .uri(url.resolve(path)) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { + if (response.statusCode() >= 200 && response.statusCode() < 300) return fetch(flag); + logger.error("Feature flag opt request failed with status %s (%s)", null, response.statusCode(), response.body()); + return CompletableFuture.failedFuture(new IllegalStateException( + "Feature flag opt request failed with status %s (%s)".formatted(response.statusCode(), response.body()) + )); + }); + } + private CompletableFuture createFetch(final SimpleFeatureFlag flag) { final var requestBody = new JsonObject(); requestBody.addProperty("identifier", serverId.toString()); From a9c62678ecab59025905abfabf9c65b3dd6a5ab9 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 20 May 2026 15:59:44 +0200 Subject: [PATCH 079/140] Add feature flag tests --- .../java/dev/faststats/FeatureFlagTest.java | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 core/src/test/java/dev/faststats/FeatureFlagTest.java diff --git a/core/src/test/java/dev/faststats/FeatureFlagTest.java b/core/src/test/java/dev/faststats/FeatureFlagTest.java new file mode 100644 index 00000000..b9f35a43 --- /dev/null +++ b/core/src/test/java/dev/faststats/FeatureFlagTest.java @@ -0,0 +1,337 @@ +package dev.faststats; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@NullMarked +public final class FeatureFlagTest { + private static final UUID SERVER_ID = UUID.fromString("76a88a60-1329-4913-9525-fb16b588d07e"); + private static FlagServer server; + + @BeforeAll + public static void startServer() throws IOException { + server = new FlagServer(); + System.setProperty("faststats.flags-server", server.url()); + } + + @AfterAll + public static void stopServer() throws IOException { + System.clearProperty("faststats.flags-server"); + server.close(); + } + + @BeforeEach + public void resetServer() { + server.reset(); + } + + @Test + public void booleanFlagFetchesAndCachesValue() throws Exception { + server.enqueue(200, "{\"value\":true}"); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("new_commands", false); + + assertEquals("new_commands", flag.getId()); + assertEquals(FeatureFlag.Type.BOOLEAN, flag.getType()); + assertEquals(Boolean.class, flag.getTypeClass()); + assertFalse(flag.getDefaultValue()); + assertEquals(true, flag.whenReady().get(1, TimeUnit.SECONDS)); + assertEquals(Optional.of(true), flag.getCached()); + assertTrue(flag.isValid()); + assertTrue(flag.getExpiration().isPresent()); + + final var request = server.takeRequest(); + assertEquals("/v1/check", request.path()); + assertEquals("Bearer test-token", request.headers().get("authorization").getAsString()); + assertEquals(SERVER_ID.toString(), request.body().get("identifier").getAsString()); + assertEquals("new_commands", request.body().get("key").getAsString()); + } + + @Test + public void stringAndNumberFlagsUseDefaultValueTypes() throws Exception { + server.enqueue(200, "{\"value\":\"zstd\"}"); + server.enqueue(200, "{\"value\":12.5}"); + + final var service = service(Duration.ofMinutes(5)); + final var stringFlag = service.define("compression", "gzip"); + + assertEquals(FeatureFlag.Type.STRING, stringFlag.getType()); + assertEquals(String.class, stringFlag.getTypeClass()); + assertEquals("gzip", stringFlag.getDefaultValue()); + assertEquals("zstd", stringFlag.whenReady().get(1, TimeUnit.SECONDS)); + + final var numberFlag = service.define("sample_rate", 1); + assertEquals(FeatureFlag.Type.NUMBER, numberFlag.getType()); + assertEquals(Number.class, numberFlag.getTypeClass()); + assertEquals(1, numberFlag.getDefaultValue()); + assertEquals(12.5, numberFlag.whenReady().get(1, TimeUnit.SECONDS).doubleValue()); + } + + @Test + public void serviceAndFlagAttributesAreMergedInFetchRequest() throws Exception { + server.enqueue(200, "{\"value\":true}"); + + final var serviceAttributes = Attributes.create() + .put("region", "global") + .put("players", 20) + .put("premium", false); + final var flagAttributes = Attributes.create() + .put("region", "flag") + .put("beta", true); + final var service = service(serviceAttributes, Duration.ofMinutes(5)); + final var flag = service.define("targeted", false, flagAttributes); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + + final var attributes = server.takeRequest().body().getAsJsonObject("attributes"); + assertEquals("flag", attributes.get("region").getAsString()); + assertEquals(20, attributes.get("players").getAsInt()); + assertTrue(attributes.get("beta").getAsBoolean()); + assertFalse(attributes.get("premium").getAsBoolean()); + } + + @Test + public void whenReadyUsesValidCachedValueWithoutFetchingAgain() throws Exception { + server.enqueue(200, "{\"value\":true}"); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("cached", false); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + server.takeRequest(); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + assertEquals(0, server.requestCountAfterWaiting(Duration.ofMillis(150))); + } + + @Test + public void whenReadyRefetchesExpiredCachedValue() throws Exception { + server.enqueue(200, "{\"value\":false}"); + server.enqueue(200, "{\"value\":true}"); + + final var service = service(Duration.ofMillis(1)); + final var flag = service.define("expired", false); + + assertFalse(flag.whenReady().get(1, TimeUnit.SECONDS)); + server.takeRequest(); + Thread.sleep(5); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + assertEquals("/v1/check", server.takeRequest().path()); + assertEquals(Optional.of(true), flag.getCached()); + Thread.sleep(5); + assertFalse(flag.isValid()); + } + + @Test + public void concurrentFetchesShareInProgressRequest() throws Exception { + final var releaseResponse = new CountDownLatch(1); + server.enqueue(200, "{\"value\":true}", releaseResponse); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("shared", false); + + final CompletableFuture first = flag.fetch(); + final CompletableFuture second = flag.fetch(); + + assertSame(first, second); + assertEquals(1, server.requestCountAfterWaiting(Duration.ofMillis(150))); + + releaseResponse.countDown(); + assertTrue(first.get(1, TimeUnit.SECONDS)); + } + + @Test + public void nonSuccessfulFetchResponseFails() { + server.enqueue(500, "{\"value\":true}"); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("broken", false); + + final var error = assertThrows(Exception.class, () -> flag.whenReady().get(1, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, error.getCause()); + } + + private static SimpleFeatureFlagService service(final Duration ttl) { + return service(null, ttl); + } + + private static SimpleFeatureFlagService service(@Nullable final Attributes attributes, final Duration ttl) { + return new SimpleFeatureFlagService(new TestConfig(), "test-token", attributes, ttl); + } + + private record TestConfig() implements Config { + @Override + public UUID serverId() { + return SERVER_ID; + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean errorTracking() { + return true; + } + + @Override + public boolean additionalMetrics() { + return true; + } + + @Override + public boolean debug() { + return false; + } + } + + private static final class FlagServer implements AutoCloseable { + private final ServerSocket socket; + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final LinkedBlockingQueue responses = new LinkedBlockingQueue<>(); + private final LinkedBlockingQueue requests = new LinkedBlockingQueue<>(); + + FlagServer() throws IOException { + socket = new ServerSocket(0); + executor.execute(() -> { + while (!socket.isClosed()) { + try { + final var client = socket.accept(); + executor.execute(() -> handle(client)); + } catch (final IOException e) { + if (!socket.isClosed()) throw new UncheckedIOException(e); + } + } + }); + } + + String url() { + return "http://127.0.0.1:" + socket.getLocalPort(); + } + + void enqueue(final int status, final String body) { + enqueue(status, body, null); + } + + void enqueue(final int status, final String body, @Nullable final CountDownLatch release) { + responses.add(new Response(status, body, release)); + } + + void reset() { + requests.clear(); + responses.clear(); + } + + Request takeRequest() throws InterruptedException { + final var request = requests.poll(1, TimeUnit.SECONDS); + if (request == null) throw new AssertionError("Timed out waiting for request"); + return request; + } + + int requestCountAfterWaiting(final Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); + return requests.size(); + } + + private void handle(final Socket client) { + try (client) { + final var reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + final var requestLine = reader.readLine(); + if (requestLine == null) return; + + final var path = requestLine.split(" ")[1]; + final var headers = new JsonObject(); + int contentLength = 0; + + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + final var separator = line.indexOf(':'); + final var name = line.substring(0, separator).toLowerCase(); + final var value = line.substring(separator + 1).trim(); + headers.addProperty(name, value); + if (name.equals("content-length")) contentLength = Integer.parseInt(value); + } + + final var bodyChars = new char[contentLength]; + var read = 0; + while (read < contentLength) { + final var count = reader.read(bodyChars, read, contentLength - read); + if (count == -1) break; + read += count; + } + + final var body = new String(bodyChars, 0, read); + final var e = new Request(path, headers, body.isEmpty() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject()); + System.out.println("parsed body: " + body + ", " + e.body); + requests.add(e); + + final var response = responses.poll(1, TimeUnit.SECONDS); + if (response == null) throw new AssertionError("No response enqueued"); + if (response.release() != null) response.release().await(1, TimeUnit.SECONDS); + writeResponse(client.getOutputStream(), response); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + + private void writeResponse(final OutputStream output, final Response response) throws IOException { + final var bytes = response.body().getBytes(StandardCharsets.UTF_8); + final var headers = "HTTP/1.1 " + response.status() + " OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + bytes.length + "\r\n" + + "Connection: close\r\n" + + "\r\n"; + output.write(headers.getBytes(StandardCharsets.UTF_8)); + output.write(bytes); + } + + @Override + public void close() throws IOException { + socket.close(); + executor.shutdownNow(); + } + } + + private record Request(String path, JsonObject headers, JsonObject body) { + } + + private record Response(int status, String body, @Nullable CountDownLatch release) { + } +} From 76dc9ecd23f0ec95af5b8b2a5cda5b812912a1a1 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 20 May 2026 16:12:33 +0200 Subject: [PATCH 080/140] Add method contracts to implementations --- .../src/main/java/dev/faststats/bukkit/BukkitContext.java | 4 +++- .../src/main/java/dev/faststats/bungee/BungeeContext.java | 2 ++ core/src/main/java/dev/faststats/MurmurHash3.java | 1 - core/src/main/java/dev/faststats/SimpleContext.java | 7 +++++++ .../src/main/java/dev/faststats/fabric/FabricContext.java | 2 ++ .../src/main/java/dev/faststats/hytale/HytaleContext.java | 2 ++ .../main/java/dev/faststats/hytale/HytaleMetricsImpl.java | 2 -- .../main/java/dev/faststats/minestom/MinestomContext.java | 2 ++ .../src/main/java/dev/faststats/nukkit/NukkitContext.java | 2 ++ .../src/main/java/dev/faststats/sponge/SpongeContext.java | 2 ++ .../main/java/dev/faststats/velocity/VelocityContext.java | 2 ++ 11 files changed, 24 insertions(+), 4 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 9fa127b7..93ecf057 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -4,6 +4,7 @@ import dev.faststats.Token; import dev.faststats.config.SimpleConfig; import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Contract; import java.nio.file.Path; @@ -14,13 +15,14 @@ */ public final class BukkitContext extends SimpleContext { final Plugin plugin; - + public BukkitContext(final Plugin plugin, @Token final String token) { super(SimpleConfig.read(getConfigPath(plugin)), token); this.plugin = plugin; } @Override + @Contract(value = " -> new", pure = true) public BukkitMetrics.Factory metrics() { return new BukkitMetricsImpl.Factory(this); } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index 01579d64..e0a51b7d 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -6,6 +6,7 @@ import dev.faststats.Token; import dev.faststats.config.SimpleConfig; import net.md_5.bungee.api.plugin.Plugin; +import org.jetbrains.annotations.Contract; /** * BungeeCord FastStats context. @@ -21,6 +22,7 @@ public BungeeContext(final Plugin plugin, @Token final String token) { } @Override + @Contract(value = " -> new", pure = true) public Metrics.Factory metrics() { return new SimpleMetrics.Factory(this) { @Override diff --git a/core/src/main/java/dev/faststats/MurmurHash3.java b/core/src/main/java/dev/faststats/MurmurHash3.java index a78c26b3..021e7c37 100644 --- a/core/src/main/java/dev/faststats/MurmurHash3.java +++ b/core/src/main/java/dev/faststats/MurmurHash3.java @@ -1,6 +1,5 @@ package dev.faststats; -import com.google.gson.JsonObject; import org.jetbrains.annotations.Contract; import java.nio.charset.StandardCharsets; diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 7aa54767..fdc6e958 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -1,6 +1,7 @@ package dev.faststats; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; import org.jspecify.annotations.Nullable; import java.time.Duration; @@ -27,31 +28,37 @@ protected SimpleContext(final Config config, @Token final String token) throws I } @Override + @Contract(pure = true) public final Config getConfig() { return config; } @Override + @Contract(pure = true) public final @Token String getToken() { return token; } @Override + @Contract(value = " -> new", pure = true) public final FeatureFlagService featureFlags() { return new SimpleFeatureFlagService(config, token, null, Duration.ofMinutes(5)); } @Override + @Contract(value = "_ -> new", pure = true) public final FeatureFlagService featureFlags(final Attributes attributes) { return new SimpleFeatureFlagService(config, token, attributes, Duration.ofMinutes(5)); } @Override + @Contract(value = "_ -> new", pure = true) public final FeatureFlagService featureFlags(final Duration ttl) { return new SimpleFeatureFlagService(config, token, null, ttl); } @Override + @Contract(value = "_, _ -> new", pure = true) public final FeatureFlagService featureFlags(@Nullable final Attributes attributes, final Duration ttl) { return new SimpleFeatureFlagService(config, token, attributes, ttl); } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 71324aa1..e1a16528 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -7,6 +7,7 @@ import dev.faststats.config.SimpleConfig; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; +import org.jetbrains.annotations.Contract; /** * Fabric FastStats context. @@ -24,6 +25,7 @@ public FabricContext(final String modId, @Token final String token) { } @Override + @Contract(value = " -> new", pure = true) public Metrics.Factory metrics() { return new SimpleMetrics.Factory(this) { @Override diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 238df152..56b5c185 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -6,6 +6,7 @@ import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; /** * Hytale FastStats context. @@ -18,6 +19,7 @@ public HytaleContext(final JavaPlugin plugin, @Token final String token) { } @Override + @Contract(value = " -> new", pure = true) public Metrics.Factory metrics() { return new SimpleMetrics.Factory(this) { @Override diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index f9d60c2a..88613a74 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -3,8 +3,6 @@ import com.google.gson.JsonObject; import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.server.core.universe.Universe; -import dev.faststats.Config; -import dev.faststats.Metrics; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import org.jetbrains.annotations.Async; diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 5d012aca..57d77e93 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -3,6 +3,7 @@ import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; import java.nio.file.Path; @@ -17,6 +18,7 @@ public final class MinestomContext extends SimpleContext { } @Override + @Contract(value = " -> new", pure = true) public MinestomMetrics.Factory metrics() { return new MinestomMetricsImpl.Factory(this); } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index ad5bd96d..a93f51a7 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -6,6 +6,7 @@ import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; import java.nio.file.Path; @@ -23,6 +24,7 @@ public NukkitContext(final PluginBase plugin, @Token final String token) { } @Override + @Contract(value = " -> new", pure = true) public Metrics.Factory metrics() { return new SimpleMetrics.Factory(this) { @Override diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 8f0fd324..f1bf4c62 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -5,6 +5,7 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; +import org.jetbrains.annotations.Contract; import org.spongepowered.api.config.ConfigDir; import org.spongepowered.plugin.PluginContainer; @@ -28,6 +29,7 @@ private SpongeContext( } @Override + @Contract(value = " -> new", pure = true) public Metrics.Factory metrics() { return new SimpleMetrics.Factory(this) { @Override diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index e037d7dc..59a9cdbe 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -9,6 +9,7 @@ import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; import java.nio.file.Path; @@ -33,6 +34,7 @@ private VelocityContext( } @Override + @Contract(value = " -> new", pure = true) public Metrics.Factory metrics() { return new SimpleMetrics.Factory(this) { @Override From d5421f81a531b2103e389bedd45b87a1e1a95e09 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 21 May 2026 19:06:40 +0200 Subject: [PATCH 081/140] Remove #needsFlushing --- core/src/test/java/dev/faststats/ErrorTrackerTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index b97c0169..7390aee8 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -247,7 +247,6 @@ public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { tracker.clear(); - assertFalse(tracker.needsFlushing()); assertEquals(0, tracker.getData().size()); tracker.trackError(createStableError()); @@ -265,7 +264,6 @@ public void ignoredNestedCauseSuppressesWholeReport() { tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); assertEquals(0, tracker.getData().size()); - assertFalse(tracker.needsFlushing()); } @Test From 456b80cb73ca5a6b6dd82004fbb168143c6f560e Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 09:17:24 +0200 Subject: [PATCH 082/140] Rename `FastStatsContext#metrics` to `#metricsFactory` --- .../example-plugin/src/main/java/com/example/ExamplePlugin.java | 2 +- bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java | 2 +- .../example-plugin/src/main/java/com/example/ExamplePlugin.java | 2 +- .../src/main/java/dev/faststats/bungee/BungeeContext.java | 2 +- core/src/main/java/dev/faststats/FastStatsContext.java | 2 +- core/src/test/java/dev/faststats/AnonymizationTest.java | 2 +- core/src/test/java/dev/faststats/MetricsTest.java | 2 +- core/src/test/java/dev/faststats/MockContext.java | 2 +- fabric/example-mod/src/main/java/com/example/ExampleMod.java | 2 +- fabric/src/main/java/dev/faststats/fabric/FabricContext.java | 2 +- .../example-plugin/src/main/java/com/example/ExamplePlugin.java | 2 +- hytale/src/main/java/dev/faststats/hytale/HytaleContext.java | 2 +- .../src/main/java/dev/faststats/minestom/MinestomContext.java | 2 +- nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java | 2 +- .../example-plugin/src/main/java/com/example/ExamplePlugin.java | 2 +- sponge/src/main/java/dev/faststats/sponge/SpongeContext.java | 2 +- .../example-plugin/src/main/java/com/example/ExamplePlugin.java | 2 +- .../src/main/java/dev/faststats/velocity/VelocityContext.java | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) 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 299e0cea..4c22fc28 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -12,7 +12,7 @@ public final class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE"); - private final BukkitMetrics metrics = context.metrics() + private final BukkitMetrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) .addMetric(Metric.string("server_version", () -> "1.0.0")) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 93ecf057..8f529931 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -23,7 +23,7 @@ public BukkitContext(final Plugin plugin, @Token final String token) { @Override @Contract(value = " -> new", pure = true) - public BukkitMetrics.Factory metrics() { + public BukkitMetrics.Factory metricsFactory() { return new BukkitMetricsImpl.Factory(this); } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 6c871850..20f69080 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -12,7 +12,7 @@ public class ExamplePlugin extends Plugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BungeeContext context = new BungeeContext(this, "YOUR_TOKEN_HERE"); - private final Metrics metrics = context.metrics() + private final Metrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) .addMetric(Metric.string("server_version", () -> "1.0.0")) diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index e0a51b7d..5eb83e34 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -23,7 +23,7 @@ public BungeeContext(final Plugin plugin, @Token final String token) { @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 9b8ddf45..a53ceb7c 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -39,7 +39,7 @@ public interface FastStatsContext { * @since 0.23.0 */ @Contract(value = "-> new", pure = true) - Metrics.Factory metrics(); + Metrics.Factory metricsFactory(); /** * Creates a new feature flag service backed by the context's token. diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index da64dc24..c8109bf5 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -15,7 +15,7 @@ public final class AnonymizationTest { private static MockMetrics createMetrics(final ErrorTracker tracker) { final var context = new MockContext(UUID.randomUUID(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false); - return (MockMetrics) context.metrics().errorTracker(tracker).create(); + return (MockMetrics) context.metricsFactory().errorTracker(tracker).create(); } private static JsonObject getError(final MockMetrics metrics) { diff --git a/core/src/test/java/dev/faststats/MetricsTest.java b/core/src/test/java/dev/faststats/MetricsTest.java index 8cd1bec8..cd590afc 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() { final var context = new MockContext(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", true); - final var metrics = (SimpleMetrics) context.metrics().create(); + final var metrics = (SimpleMetrics) context.metricsFactory().create(); assumeTrue(metrics.submit(), "For this test to run, the server must be running"); } } diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 1f58ab6b..6b5e03a5 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -11,7 +11,7 @@ public MockContext(final UUID serverId, @Token final String token, final boolean } @Override - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index e4dd883c..465e15ed 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -11,7 +11,7 @@ public class ExampleMod implements ModInitializer { "example-mod", // your mod id as defined in fabric.mod.json "YOUR_TOKEN_HERE" ); - private final Metrics metrics = context.metrics() + private final Metrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index e1a16528..de1633c0 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -26,7 +26,7 @@ public FabricContext(final String modId, @Token final String token) { @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 868bea60..28dde103 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -9,7 +9,7 @@ public class ExamplePlugin extends JavaPlugin { private final HytaleContext context = new HytaleContext(this, "YOUR_TOKEN_HERE"); - private final Metrics metrics = context.metrics() + private final Metrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 56b5c185..d87b2ae3 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -20,7 +20,7 @@ public HytaleContext(final JavaPlugin plugin, @Token final String token) { @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 57d77e93..c23f0d8c 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -19,7 +19,7 @@ public final class MinestomContext extends SimpleContext { @Override @Contract(value = " -> new", pure = true) - public MinestomMetrics.Factory metrics() { + public MinestomMetrics.Factory metricsFactory() { return new MinestomMetricsImpl.Factory(this); } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index a93f51a7..79da27b9 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -25,7 +25,7 @@ public NukkitContext(final PluginBase plugin, @Token final String token) { @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 0738852c..7ff88033 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -21,7 +21,7 @@ public class ExamplePlugin { @Listener public void onServerStart(final StartedEngineEvent event) { final var context = contextBuilder.build("YOUR_TOKEN_HERE"); - this.metrics = context.metrics() + this.metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index f1bf4c62..ff8db20f 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -30,7 +30,7 @@ private SpongeContext( @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 80d8a6da..a70b8789 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -24,7 +24,7 @@ public ExamplePlugin(final VelocityContext.Builder contextBuilder) { @Subscribe public void onProxyInitialize(final ProxyInitializeEvent event) { - this.metrics = context.metrics() + this.metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 59a9cdbe..35bc9e16 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -35,7 +35,7 @@ private VelocityContext( @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metrics() { + public Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { From 492d0f717f9b76c7e8644de297f1e7eb9fe1c4ff Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 11:50:56 +0200 Subject: [PATCH 083/140] Couple error tracker and context --- build.gradle.kts | 2 +- .../dev/faststats/bukkit/BukkitContext.java | 2 +- .../dev/faststats/bungee/BungeeContext.java | 2 +- .../main/java/dev/faststats/ErrorTracker.java | 38 ------------ .../java/dev/faststats/FastStatsContext.java | 39 ++++++++++++- core/src/main/java/dev/faststats/SdkInfo.java | 11 ++++ .../java/dev/faststats/SimpleContext.java | 58 ++++++++++++++++++- .../dev/faststats/SimpleErrorTracker.java | 44 +++++++------- .../java/dev/faststats/SimpleMetrics.java | 4 +- .../java/dev/faststats/SimpleSdkInfo.java | 34 +++++++++++ .../dev/faststats/internal/Constants.java | 23 -------- .../test/java/dev/faststats/MockContext.java | 2 +- .../dev/faststats/fabric/FabricContext.java | 2 +- .../dev/faststats/hytale/HytaleContext.java | 2 +- .../faststats/minestom/MinestomContext.java | 2 +- .../dev/faststats/nukkit/NukkitContext.java | 2 +- .../dev/faststats/sponge/SpongeContext.java | 2 +- .../faststats/velocity/VelocityContext.java | 2 +- 18 files changed, 175 insertions(+), 96 deletions(-) create mode 100644 core/src/main/java/dev/faststats/SdkInfo.java create mode 100644 core/src/main/java/dev/faststats/SimpleSdkInfo.java delete mode 100644 core/src/main/java/dev/faststats/internal/Constants.java diff --git a/build.gradle.kts b/build.gradle.kts index c4183240..2059308a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,7 +53,7 @@ subprojects { doLast { val file = outputDir.get().file("META-INF/faststats.properties").asFile file.parentFile.mkdirs() - file.writeText("name=${project.name}\nversion=${project.version}\n") + file.writeText("version=${project.version}\n") } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 8f529931..e462afc9 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -17,7 +17,7 @@ public final class BukkitContext extends SimpleContext { final Plugin plugin; public BukkitContext(final Plugin plugin, @Token final String token) { - super(SimpleConfig.read(getConfigPath(plugin)), token); + super(SimpleConfig.read(getConfigPath(plugin)), "bukkit", token); this.plugin = plugin; } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index 5eb83e34..b263be4a 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -17,7 +17,7 @@ public final class BungeeContext extends SimpleContext { final Plugin plugin; public BungeeContext(final Plugin plugin, @Token final String token) { - super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), token); + super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), "bungeecord", token); this.plugin = plugin; } diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 08731778..3e6f466c 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -14,44 +14,6 @@ * @since 0.23.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, boolean) - * @see #trackError(Throwable, boolean) - * @since 0.23.0 - */ - @Contract(value = " -> new") - static ErrorTracker contextAware() { - final 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.23.0 - */ - @Contract(value = " -> new", pure = true) - static ErrorTracker contextUnaware() { - return new SimpleErrorTracker(); - } - /** * Tracks a handled error. * diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index a53ceb7c..6795b25e 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -12,7 +12,7 @@ * * @since 0.23.0 */ -public interface FastStatsContext { +public sealed interface FastStatsContext permits SimpleContext { /** * Get the metrics configuration shared by services created from this context. * @@ -82,4 +82,41 @@ public interface FastStatsContext { */ @Contract(value = "_, _ -> new", pure = true) FeatureFlagService featureFlags(final Attributes attributes, final Duration ttl); + + /** + * 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 #unawareErrorTracker() + * @see ErrorTracker#attachErrorContext(ClassLoader) + * @see ErrorTracker#trackError(String, boolean) + * @see ErrorTracker#trackError(Throwable, boolean) + * @since 0.23.0 + */ + @Contract(value = " -> new") + ErrorTracker awareErrorTracker(); + + /** + * 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 #awareErrorTracker() + * @see ErrorTracker#trackError(String) + * @see ErrorTracker#trackError(Throwable) + * @since 0.23.0 + */ + @Contract(value = " -> new") + ErrorTracker unawareErrorTracker(); + + // todo: add docs + @Contract(pure = true) + SdkInfo getSdkInfo(); } diff --git a/core/src/main/java/dev/faststats/SdkInfo.java b/core/src/main/java/dev/faststats/SdkInfo.java new file mode 100644 index 00000000..6676866f --- /dev/null +++ b/core/src/main/java/dev/faststats/SdkInfo.java @@ -0,0 +1,11 @@ +package dev.faststats; + +import java.util.Optional; + +public sealed interface SdkInfo permits SimpleSdkInfo { + Optional getBuildId(); + + String getName(); + + String getVersion(); +} diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index fdc6e958..24744ffe 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -4,22 +4,35 @@ import org.jetbrains.annotations.Contract; import org.jspecify.annotations.Nullable; +import java.io.IOException; +import java.io.UncheckedIOException; import java.time.Duration; +import java.util.Collections; +import java.util.Properties; +import java.util.Set; +import java.util.WeakHashMap; @ApiStatus.Internal -public abstract class SimpleContext implements FastStatsContext { +public non-sealed abstract class SimpleContext implements FastStatsContext { + private final Set errorTrackers = Collections.newSetFromMap(new WeakHashMap<>()); // todo: a set of weak references to error trackers; must also be thread safe private final Config config; private final @Token String token; + private final SdkInfo sdkInfo; /** * Creates a new context that stores the shared configuration and token for all FastStats services. * * @param config the shared configuration + * @param name the name of the SDK * @param token the FastStats project token * @throws IllegalArgumentException if the token is invalid + * @throws IllegalArgumentException if the SDK information is invalid + * @throws IllegalStateException if the SDK information is incomplete or missing + * @throws UncheckedIOException if an IO error occurs * @since 0.23.0 */ - protected SimpleContext(final Config config, @Token final String token) throws IllegalArgumentException { + protected SimpleContext(final Config config, final String name, @Token final String token) throws IllegalArgumentException { + this.sdkInfo = constructSdkInfo(name); if (!token.matches(Token.PATTERN)) { throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); } @@ -27,6 +40,24 @@ protected SimpleContext(final Config config, @Token final String token) throws I this.token = token; } + private SdkInfo constructSdkInfo(final String name) throws UncheckedIOException, IllegalStateException, IllegalArgumentException { + try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { + if (stream == null) throw new IllegalStateException("Resource '/META-INF/faststats.properties' not found"); + + final var properties = new Properties(); + properties.load(stream); + + final var version = properties.getProperty("version", null); + if (version == null) throw new IllegalStateException("Missing 'version' in faststats.properties"); + + final var buildId = properties.getProperty("build-id", null); + + return new SimpleSdkInfo(name, version, buildId); + } catch (final IOException e) { + throw new UncheckedIOException("Failed to read faststats.properties from META-INF", e); + } + } + @Override @Contract(pure = true) public final Config getConfig() { @@ -62,4 +93,27 @@ public final FeatureFlagService featureFlags(final Duration ttl) { public final FeatureFlagService featureFlags(@Nullable final Attributes attributes, final Duration ttl) { return new SimpleFeatureFlagService(config, token, attributes, ttl); } + + @Override + @Contract(value = " -> new") + public final ErrorTracker awareErrorTracker() { + final var tracker = new SimpleErrorTracker(this); + tracker.attachErrorContext(ErrorTracker.class.getClassLoader()); + errorTrackers.add(tracker); + return tracker; + } + + @Override + @Contract(value = " -> new") + public final ErrorTracker unawareErrorTracker() { + final var tracker = new SimpleErrorTracker(this); + errorTrackers.add(tracker); + return tracker; + } + + @Override + @Contract(pure = true) + public SdkInfo getSdkInfo() { + return sdkInfo; + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index da50bd03..dc58494f 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -2,7 +2,6 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import dev.faststats.internal.Constants; import org.jspecify.annotations.Nullable; import java.lang.Thread.UncaughtExceptionHandler; @@ -15,13 +14,13 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.regex.Matcher; import java.util.regex.Pattern; final class SimpleErrorTracker implements ErrorTracker { - private final Map collected = new ConcurrentHashMap<>(); - private final Map reports = new ConcurrentHashMap<>(); + private final Map reports = new ConcurrentHashMap<>(); private final Map, Set> ignoredTypedPatterns = new ConcurrentHashMap<>(); private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); @@ -37,7 +36,10 @@ final class SimpleErrorTracker implements ErrorTracker { private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent = null; private volatile @Nullable UncaughtExceptionHandler originalHandler = null; - public SimpleErrorTracker() { + private final FastStatsContext context; + + public SimpleErrorTracker(final FastStatsContext context) { + this.context = context; ErrorHelper.usernamePattern().ifPresent(pattern -> anonymizationEntries.add(Map.entry(pattern, "[username hidden]"))); } @@ -61,11 +63,12 @@ public void trackError(final Throwable error, final boolean handled) { try { if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - final var hashed = MurmurHash3.hash(compiled.toString()); - if (collected.compute(hashed, (k, v) -> { - return v == null ? 1 : v + 1; - }) > 1) return; - reports.put(hashed, compiled); + final var identity = error.getClass().getName() + "@" + error.hashCode() + ":" + (handled ? 1 : 0); + reports.compute(identity, (s, report) -> { + if (report == null) return new Report(compiled); + report.counter.incrementAndGet(); + return report; + }); } catch (final NoClassDefFoundError ignored) { } } @@ -113,27 +116,19 @@ public JsonArray getData() { final var report = new JsonArray(reports.size()); reports.forEach((hash, object) -> { - final var count = collected.getOrDefault(hash, 1); - report.add(fillEntry(object.deepCopy(), hash, count)); - }); - - collected.forEach((hash, count) -> { - if (count <= 0 || reports.containsKey(hash)) return; - report.add(fillEntry(new JsonObject(), hash, count)); + report.add(fillEntry(object.json, object.counter.get())); }); return report; } - private JsonObject fillEntry(final JsonObject entry, final String hash, final int count) { - entry.addProperty("group_hash", hash); - entry.addProperty("buildId", Constants.BUILD_ID); + private JsonObject fillEntry(final JsonObject entry, final int count) { + context.getSdkInfo().getBuildId().ifPresent(id -> entry.addProperty("buildId", id)); if (count > 1) entry.addProperty("count", count); return entry; } public void clear() { - collected.replaceAll((k, v) -> 0); reports.clear(); } @@ -176,4 +171,13 @@ public synchronized void setContextErrorHandler(@Nullable final BiConsumer<@Null public synchronized Optional> getContextErrorHandler() { return Optional.ofNullable(errorEvent); } + + private static class Report { + private final JsonObject json; + private final AtomicInteger counter = new AtomicInteger(1); + + private Report(final JsonObject json) { + this.json = json; + } + } } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index c4efdf33..6129c66c 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -2,7 +2,6 @@ import com.google.gson.JsonObject; import dev.faststats.data.Metric; -import dev.faststats.internal.Constants; import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; @@ -150,12 +149,13 @@ private boolean submitNow() throws IOException { final var compressed = byteOutput.toByteArray(); logger.info("Compressed size: %s bytes", compressed.length); + final var sdk = context.getSdkInfo(); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") .header("Authorization", "Bearer " + context.getToken()) - .header("User-Agent", "FastStats Metrics " + Constants.SDK_NAME + "/" + Constants.SDK_VERSION) + .header("User-Agent", "FastStats Metrics " + sdk.getName() + "/" + sdk.getVersion()) .timeout(Duration.ofSeconds(3)) .uri(url) .build(); diff --git a/core/src/main/java/dev/faststats/SimpleSdkInfo.java b/core/src/main/java/dev/faststats/SimpleSdkInfo.java new file mode 100644 index 00000000..53fe15ac --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleSdkInfo.java @@ -0,0 +1,34 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +final class SimpleSdkInfo implements SdkInfo { + private final @Nullable String buildId; + private final String name; + private final String version; + + SimpleSdkInfo(final String name, final String version, @Nullable final String buildId) throws IllegalArgumentException { + if (name.isBlank()) throw new IllegalArgumentException("name must not be blank"); + if (version.isBlank()) throw new IllegalArgumentException("version must not be blank"); + this.name = name; + this.version = version; + this.buildId = buildId; + } + + @Override + public Optional getBuildId() { + return Optional.ofNullable(buildId); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getVersion() { + return version; + } +} diff --git a/core/src/main/java/dev/faststats/internal/Constants.java b/core/src/main/java/dev/faststats/internal/Constants.java deleted file mode 100644 index 065c3078..00000000 --- a/core/src/main/java/dev/faststats/internal/Constants.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.faststats.internal; - -import dev.faststats.SimpleMetrics; - -import java.io.IOException; -import java.util.Properties; - -public final class Constants { - public static final String SDK_NAME; - public static final String SDK_VERSION; - public static final String BUILD_ID; - - static { - final var properties = new Properties(); - try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { - if (stream != null) properties.load(stream); - } catch (final IOException ignored) { - } - SDK_NAME = properties.getProperty("name", "unknown"); - SDK_VERSION = properties.getProperty("version", "unknown"); - BUILD_ID = properties.getProperty("build-id", "unknown"); - } -} diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 6b5e03a5..538709f0 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -7,7 +7,7 @@ @NullMarked public final class MockContext extends SimpleContext { public MockContext(final UUID serverId, @Token final String token, final boolean debug) throws IllegalArgumentException { - super(new MockConfig(serverId, debug), token); + super(new MockConfig(serverId, debug), "core:test", token); } @Override diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index de1633c0..08e59941 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -18,7 +18,7 @@ public final class FabricContext extends SimpleContext { final ModContainer mod; public FabricContext(final String modId, @Token final String token) { - super(SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), token); + super(SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index d87b2ae3..1b58d304 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -15,7 +15,7 @@ */ public final class HytaleContext extends SimpleContext { public HytaleContext(final JavaPlugin plugin, @Token final String token) { - super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), token); + super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); } @Override diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index c23f0d8c..545c5afd 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -14,7 +14,7 @@ */ public final class MinestomContext extends SimpleContext { MinestomContext(@Token final String token) { - super(SimpleConfig.read(Path.of("faststats", "config.properties")), token); + super(SimpleConfig.read(Path.of("faststats", "config.properties")), "minestom", token); } @Override diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 79da27b9..e0f15d48 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -19,7 +19,7 @@ public final class NukkitContext extends SimpleContext { final PluginBase plugin; public NukkitContext(final PluginBase plugin, @Token final String token) { - super(SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), token); + super(SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), "nukkit", token); this.plugin = plugin; } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index ff8db20f..0dbe6f13 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -24,7 +24,7 @@ private SpongeContext( @ConfigDir(sharedRoot = true) final Path dataDirectory, @Token final String token ) { - super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), token); + super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), "sponge", token); this.plugin = plugin; } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 35bc9e16..3cc2e747 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -28,7 +28,7 @@ private VelocityContext( @DataDirectory final Path dataDirectory, @Token final String token ) { - super(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), token); + super(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), "velocity", token); this.plugin = plugin; this.server = server; } From 584455a38133a4ce6e0fade0c57f6b23624c7a80 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:36:50 +0200 Subject: [PATCH 084/140] Bump version to 0.24.0 --- .../dev/faststats/bukkit/BukkitContext.java | 2 +- .../dev/faststats/bungee/BungeeContext.java | 2 +- .../main/java/dev/faststats/Attributes.java | 18 +++++----- core/src/main/java/dev/faststats/Config.java | 8 ++--- .../main/java/dev/faststats/ErrorTracker.java | 24 ++++++------- .../java/dev/faststats/FastStatsContext.java | 16 ++++----- .../main/java/dev/faststats/FeatureFlag.java | 34 +++++++++---------- .../dev/faststats/FeatureFlagService.java | 20 +++++------ core/src/main/java/dev/faststats/Metrics.java | 14 ++++---- .../java/dev/faststats/SimpleContext.java | 2 +- core/src/main/java/dev/faststats/Token.java | 2 +- .../main/java/dev/faststats/data/Metric.java | 26 +++++++------- .../java/dev/faststats/data/SourceId.java | 2 +- .../dev/faststats/fabric/FabricContext.java | 2 +- .../dev/faststats/hytale/HytaleContext.java | 2 +- .../faststats/minestom/MinestomContext.java | 2 +- .../dev/faststats/nukkit/NukkitContext.java | 2 +- .../dev/faststats/sponge/SpongeContext.java | 8 ++--- .../faststats/velocity/VelocityContext.java | 8 ++--- 19 files changed, 97 insertions(+), 97 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index e462afc9..46b860a1 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -11,7 +11,7 @@ /** * Bukkit FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class BukkitContext extends SimpleContext { final Plugin plugin; diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index b263be4a..dab0dbcb 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -11,7 +11,7 @@ /** * BungeeCord FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class BungeeContext extends SimpleContext { final Plugin plugin; diff --git a/core/src/main/java/dev/faststats/Attributes.java b/core/src/main/java/dev/faststats/Attributes.java index 83fddca4..c470209d 100644 --- a/core/src/main/java/dev/faststats/Attributes.java +++ b/core/src/main/java/dev/faststats/Attributes.java @@ -13,14 +13,14 @@ * Attributes are sent to the server on each flag fetch * so that targeting rules can be evaluated server-side. * - * @since 0.23.0 + * @since 0.24.0 */ public sealed interface Attributes permits SimpleAttributes { /** * Create new empty attributes. * * @return new attributes - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = " -> new", pure = true) static Attributes create() { @@ -32,7 +32,7 @@ static Attributes create() { * * @param attributes the source attributes to copy * @return new attributes containing the copied entries - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_ -> new", pure = true) static Attributes copyOf(final Attributes attributes) { @@ -46,7 +46,7 @@ static Attributes copyOf(final Attributes attributes) { * @param key the key * @param value the value * @return these attributes - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") Attributes put(String key, String value); @@ -58,7 +58,7 @@ static Attributes copyOf(final Attributes attributes) { * @param value the value * @return these attributes * @throws IllegalArgumentException if the given value is not {@link Double#isFinite(double) finite} - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") Attributes put(String key, Number value) throws IllegalArgumentException; @@ -69,7 +69,7 @@ static Attributes copyOf(final Attributes attributes) { * @param key the key * @param value the value * @return these attributes - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") Attributes put(String key, boolean value); @@ -79,7 +79,7 @@ static Attributes copyOf(final Attributes attributes) { * * @param key the key * @return these attributes - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") Attributes remove(String key); @@ -88,7 +88,7 @@ static Attributes copyOf(final Attributes attributes) { * Visit each stored attribute as its underlying JSON primitive value. * * @param action the action to invoke for each key-value pair - * @since 0.23.0 + * @since 0.24.0 */ void forEachPrimitive(BiConsumer action); @@ -100,7 +100,7 @@ static Attributes copyOf(final Attributes attributes) { * @param first the first attributes * @param second the second attributes, takes precedence on conflicts * @return new merged attributes - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Attributes join(@Nullable final Attributes first, @Nullable final Attributes second) { diff --git a/core/src/main/java/dev/faststats/Config.java b/core/src/main/java/dev/faststats/Config.java index 0c9cb5d6..f7d24b5e 100644 --- a/core/src/main/java/dev/faststats/Config.java +++ b/core/src/main/java/dev/faststats/Config.java @@ -7,14 +7,14 @@ /** * A representation of the metrics configuration. * - * @since 0.23.0 + * @since 0.24.0 */ public interface Config { /** * The server id. * * @return the server id - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) UUID serverId(); @@ -26,7 +26,7 @@ public interface Config { * Users have to be able to opt out from metrics submission. * * @return {@code true} if metrics submission is enabled, {@code false} otherwise - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) boolean enabled(); @@ -35,7 +35,7 @@ public interface Config { * Whether error tracking is enabled across all metrics instances. * * @return {@code true} if error tracking is enabled, {@code false} otherwise - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) boolean errorTracking(); diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 3e6f466c..03951e6c 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -11,7 +11,7 @@ /** * An error tracker. * - * @since 0.23.0 + * @since 0.24.0 */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** @@ -20,7 +20,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * @param message the error message * @see #trackError(Throwable) * @see #trackError(String, boolean) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") void trackError(String message); @@ -30,7 +30,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * * @param error the error * @see #trackError(Throwable, boolean) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") void trackError(Throwable error); @@ -43,7 +43,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * @param message the error message * @param handled whether the error was handled * @see #trackError(Throwable, boolean) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") void trackError(String message, boolean handled); @@ -55,7 +55,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * * @param error the error * @param handled whether the error was handled - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") void trackError(Throwable error, boolean handled); @@ -68,7 +68,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * * @param type the error type * @return the error tracker - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Class type); @@ -87,7 +87,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Pattern pattern); @@ -100,7 +100,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * @param pattern the regex pattern string to match against error messages * @return the error tracker * @see #ignoreError(Pattern) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") default ErrorTracker ignoreError(@RegExp final String pattern) { @@ -118,7 +118,7 @@ default ErrorTracker ignoreError(@RegExp final String pattern) { * @param type the error type * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker ignoreError(Class type, Pattern pattern); @@ -132,7 +132,7 @@ default ErrorTracker ignoreError(@RegExp final String pattern) { * @param pattern the regex pattern string to match against error messages * @return the error tracker * @see #ignoreError(Class, Pattern) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker ignoreError(final Class type, @RegExp final String pattern) { @@ -149,7 +149,7 @@ default ErrorTracker ignoreError(final Class type, @RegExp * @param replacement the replacement string * @return the error tracker * @see java.util.regex.Matcher#replaceAll(String) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker anonymize(Pattern pattern, String replacement); @@ -162,7 +162,7 @@ default ErrorTracker ignoreError(final Class type, @RegExp * @return the error tracker * @see #anonymize(Pattern, String) * @see java.util.regex.Matcher#replaceAll(String) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker anonymize(@RegExp final String pattern, final String replacement) { diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 6795b25e..c8910809 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -10,14 +10,14 @@ * Platform-specific contexts should extend this class to provide a shared * configuration, token, and metrics factory for their environment. * - * @since 0.23.0 + * @since 0.24.0 */ public sealed interface FastStatsContext permits SimpleContext { /** * Get the metrics configuration shared by services created from this context. * * @return the shared configuration - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Config getConfig(); @@ -26,7 +26,7 @@ public sealed interface FastStatsContext permits SimpleContext { * Get the token shared by services created from this context. * * @return the shared token - * @since 0.23.0 + * @since 0.24.0 */ @Token @Contract(pure = true) @@ -36,7 +36,7 @@ public sealed interface FastStatsContext permits SimpleContext { * Creates a new platform metrics factory bound to this context. * * @return a new platform metrics factory - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "-> new", pure = true) Metrics.Factory metricsFactory(); @@ -44,8 +44,8 @@ public sealed interface FastStatsContext permits SimpleContext { /** * Creates a new feature flag service backed by the context's token. * - * @return the feature flag service - * @since 0.23.0 + * @return a new feature flag service factory + * @since 0.24.0 */ @Contract(value = "-> new", pure = true) FeatureFlagService featureFlags(); @@ -95,7 +95,7 @@ public sealed interface FastStatsContext permits SimpleContext { * @see ErrorTracker#attachErrorContext(ClassLoader) * @see ErrorTracker#trackError(String, boolean) * @see ErrorTracker#trackError(Throwable, boolean) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = " -> new") ErrorTracker awareErrorTracker(); @@ -111,7 +111,7 @@ public sealed interface FastStatsContext permits SimpleContext { * @see #awareErrorTracker() * @see ErrorTracker#trackError(String) * @see ErrorTracker#trackError(Throwable) - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = " -> new") ErrorTracker unawareErrorTracker(); diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java index 2c322969..e713dce9 100644 --- a/core/src/main/java/dev/faststats/FeatureFlag.java +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -13,14 +13,14 @@ * the service's cache and lifecycle. * * @param the flag value type - * @since 0.23.0 + * @since 0.24.0 */ public sealed interface FeatureFlag permits SimpleFeatureFlag { /** * Get the flag identifier. * * @return the flag id - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) String getId(); @@ -29,7 +29,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * Returns the type representing the value type of this flag. * * @return the value type class - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Type getType(); @@ -41,7 +41,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * {@link Number}.class, or {@link Boolean}.class, matching {@link #getType()}. * * @return the value type class - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Class getTypeClass(); @@ -54,7 +54,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return the cached value, if present * @see #fetch() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Optional getCached(); @@ -68,7 +68,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return the expiration time of the cached value, if present * @see #isValid() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Optional getExpiration(); @@ -82,7 +82,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * @return {@code true} if the cached value is absent or stale * @see #getExpiration() * @see #isValid() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) boolean isExpired(); @@ -96,7 +96,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return {@code true} if a non-expired cached value is available * @see #getExpiration() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) boolean isValid(); @@ -110,7 +110,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return a future completing with the flag value * @see #fetch() - * @since 0.23.0 + * @since 0.24.0 */ CompletableFuture whenReady(); @@ -129,7 +129,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return a future completing with the latest server value * @see #whenReady() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") CompletableFuture fetch(); @@ -146,7 +146,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return a future completing with the updated flag value * @see #fetch() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") CompletableFuture optIn(); @@ -162,7 +162,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * * @return a future completing with the updated flag value * @see #fetch() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") CompletableFuture optOut(); @@ -171,7 +171,7 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { * Get the default value for this flag. * * @return the default value - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) T getDefaultValue(); @@ -179,27 +179,27 @@ public sealed interface FeatureFlag permits SimpleFeatureFlag { /** * Supported value types for feature flags. * - * @since 0.23.0 + * @since 0.24.0 */ enum Type { /** * A string-valued flag. * - * @since 0.23.0 + * @since 0.24.0 */ STRING, /** * A boolean-valued flag. * - * @since 0.23.0 + * @since 0.24.0 */ BOOLEAN, /** * A numeric flag. * - * @since 0.23.0 + * @since 0.24.0 */ NUMBER } diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java index 73cb2fc5..1239319d 100644 --- a/core/src/main/java/dev/faststats/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -10,7 +10,7 @@ *

* Use one of the static {@code create} methods to construct a service instance. * - * @since 0.23.0 + * @since 0.24.0 */ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { /** @@ -19,7 +19,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @param id the flag identifier * @param defaultValue the default value * @return the feature flag - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) FeatureFlag define(String id, boolean defaultValue); @@ -31,7 +31,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @param defaultValue the default value * @param attributes the per-flag targeting attributes, merged with the service attributes * @return the feature flag - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _, _ -> new", pure = true) FeatureFlag define(String id, boolean defaultValue, Attributes attributes); @@ -42,7 +42,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @param id the flag identifier * @param defaultValue the default value * @return the feature flag - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) FeatureFlag define(String id, String defaultValue); @@ -54,7 +54,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @param defaultValue the default value * @param attributes the per-flag targeting attributes, merged with the service attributes * @return the feature flag - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _, _ -> new", pure = true) FeatureFlag define(String id, String defaultValue, Attributes attributes); @@ -65,7 +65,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @param id the flag identifier * @param defaultValue the default value * @return the feature flag - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) FeatureFlag define(String id, Number defaultValue); @@ -77,7 +77,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @param defaultValue the default value * @param attributes the per-flag targeting attributes, merged with the service attributes * @return the feature flag - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _, _ -> new", pure = true) FeatureFlag define(String id, Number defaultValue, Attributes attributes); @@ -89,7 +89,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * merged with any per-flag attributes supplied during definition. * * @return the global targeting attributes, if configured - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Optional getAttributes(); @@ -98,14 +98,14 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * Returns the cache time-to-live used for resolved flag values. * * @return the configured cache time-to-live - * @since 0.23.0 + * @since 0.24.0 */ Duration getTTL(); /** * Shuts down the feature flag service. * - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") void shutdown(); diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java index 2185d503..5e9d7f8b 100644 --- a/core/src/main/java/dev/faststats/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -9,7 +9,7 @@ /** * Metrics interface. * - * @since 0.23.0 + * @since 0.24.0 */ public interface Metrics { /** @@ -29,7 +29,7 @@ public interface Metrics { * No-op in most implementations. * * @apiNote Refer to your {@code Metrics} provider's documentation. - * @since 0.23.0 + * @since 0.24.0 */ default void ready() { } @@ -39,7 +39,7 @@ default void ready() { *

* This method should be called when the application is shutting down. * - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") void shutdown(); @@ -47,7 +47,7 @@ default void ready() { /** * A metrics factory. * - * @since 0.23.0 + * @since 0.24.0 */ interface Factory { /** @@ -58,7 +58,7 @@ interface Factory { * @param metric the metric to add * @return the metrics factory * @throws IllegalArgumentException if the metric is already added - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") Factory addMetric(Metric metric) throws IllegalArgumentException; @@ -70,7 +70,7 @@ interface Factory { * * @param flush the flush callback * @return the metrics factory - * @since 0.23.0 + * @since 0.24.0 */ @Contract(mutates = "this") Factory onFlush(Runnable flush); @@ -94,7 +94,7 @@ interface Factory { * * @return the metrics instance * @throws IllegalStateException if the token is not specified - * @since 0.23.0 + * @since 0.24.0 */ @Async.Schedule @Contract(value = " -> new", mutates = "io") diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 24744ffe..6ec81106 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -29,7 +29,7 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { * @throws IllegalArgumentException if the SDK information is invalid * @throws IllegalStateException if the SDK information is incomplete or missing * @throws UncheckedIOException if an IO error occurs - * @since 0.23.0 + * @since 0.24.0 */ protected SimpleContext(final Config config, final String name, @Token final String token) throws IllegalArgumentException { this.sdkInfo = constructSdkInfo(name); diff --git a/core/src/main/java/dev/faststats/Token.java b/core/src/main/java/dev/faststats/Token.java index 6eb09d99..a9d38f81 100644 --- a/core/src/main/java/dev/faststats/Token.java +++ b/core/src/main/java/dev/faststats/Token.java @@ -15,7 +15,7 @@ /** * An annotation to mark a token. * - * @since 0.23.0 + * @since 0.24.0 */ @NonNls @Pattern(Token.PATTERN) diff --git a/core/src/main/java/dev/faststats/data/Metric.java b/core/src/main/java/dev/faststats/data/Metric.java index 6ed98116..3f59ed40 100644 --- a/core/src/main/java/dev/faststats/data/Metric.java +++ b/core/src/main/java/dev/faststats/data/Metric.java @@ -12,14 +12,14 @@ * A metric. * * @param the metric data type - * @since 0.23.0 + * @since 0.24.0 */ public interface Metric { /** * Get the source id. * * @return the source id - * @since 0.23.0 + * @since 0.24.0 */ @SourceId @Contract(pure = true) @@ -31,7 +31,7 @@ public interface Metric { * @return an optional containing the metric data * @throws Exception if unable to compute the metric data * @implSpec The implementation must be thread-safe and pure (i.e. not modify any shared state). - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Optional compute() throws Exception; @@ -44,7 +44,7 @@ public interface Metric { * @implSpec The implementation must call {@link #compute()} to get the metric data * and follow the same thread-safety and pureness requirements. * @see #compute() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(pure = true) Optional getData() throws Exception; @@ -58,7 +58,7 @@ public interface Metric { * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric stringArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -74,7 +74,7 @@ static Metric stringArray(@SourceId final String id, final Callable booleanArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -90,7 +90,7 @@ static Metric booleanArray(@SourceId final String id, final Callable< * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric numberArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -106,7 +106,7 @@ static Metric numberArray(@SourceId final String id, final Callable> stringMap(@SourceId final String id, final Callable> callable) throws IllegalArgumentException { @@ -122,7 +122,7 @@ static Metric numberArray(@SourceId final String id, final Callable> booleanMap(@SourceId final String id, final Callable> callable) throws IllegalArgumentException { @@ -138,7 +138,7 @@ static Metric numberArray(@SourceId final String id, final Callable> numberMap(@SourceId final String id, final Callable> callable) throws IllegalArgumentException { @@ -154,7 +154,7 @@ static Metric numberArray(@SourceId final String id, final Callable bool(@SourceId final String id, final Callable<@Nullable Boolean> callable) throws IllegalArgumentException { @@ -170,7 +170,7 @@ static Metric bool(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric string(@SourceId final String id, final Callable<@Nullable String> callable) throws IllegalArgumentException { @@ -186,7 +186,7 @@ static Metric string(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.23.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric number(@SourceId final String id, final Callable<@Nullable Number> callable) throws IllegalArgumentException { diff --git a/core/src/main/java/dev/faststats/data/SourceId.java b/core/src/main/java/dev/faststats/data/SourceId.java index f702b76f..6fc30d6b 100644 --- a/core/src/main/java/dev/faststats/data/SourceId.java +++ b/core/src/main/java/dev/faststats/data/SourceId.java @@ -15,7 +15,7 @@ /** * An annotation to mark a source id. * - * @since 0.23.0 + * @since 0.24.0 */ @NonNls @Pattern(SourceId.PATTERN) diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 08e59941..86b583f2 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -12,7 +12,7 @@ /** * Fabric FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class FabricContext extends SimpleContext { final ModContainer mod; diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 1b58d304..5b1537e9 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -11,7 +11,7 @@ /** * Hytale FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class HytaleContext extends SimpleContext { public HytaleContext(final JavaPlugin plugin, @Token final String token) { diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 545c5afd..ad4d2459 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -10,7 +10,7 @@ /** * Minestom FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class MinestomContext extends SimpleContext { MinestomContext(@Token final String token) { diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index e0f15d48..b4beeabe 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -13,7 +13,7 @@ /** * Nukkit FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class NukkitContext extends SimpleContext { final PluginBase plugin; diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 0dbe6f13..035a2486 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -14,7 +14,7 @@ /** * Sponge FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class SpongeContext extends SimpleContext { final PluginContainer plugin; @@ -42,7 +42,7 @@ public Metrics create() throws IllegalStateException { /** * Injectable Sponge context builder. * - * @since 0.23.0 + * @since 0.24.0 */ public static final class Builder { private final PluginContainer plugin; @@ -54,7 +54,7 @@ public static final class Builder { * @param plugin the plugin container * @param dataDirectory the shared Sponge config directory * @apiNote This instance can be injected into your plugin. - * @since 0.23.0 + * @since 0.24.0 */ @Inject public Builder( @@ -71,7 +71,7 @@ public Builder( * @param token the FastStats project token * @return the Sponge context * @throws IllegalArgumentException if the token is invalid - * @since 0.23.0 + * @since 0.24.0 */ public SpongeContext build(@Token final String token) throws IllegalArgumentException { return new SpongeContext(plugin, dataDirectory, token); diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 3cc2e747..9b15199f 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -16,7 +16,7 @@ /** * Velocity FastStats context. * - * @since 0.23.0 + * @since 0.24.0 */ public final class VelocityContext extends SimpleContext { final PluginContainer plugin; @@ -47,7 +47,7 @@ public Metrics create() throws IllegalStateException { /** * Injectable Velocity context builder. * - * @since 0.23.0 + * @since 0.24.0 */ public static final class Builder { private final PluginContainer plugin; @@ -60,7 +60,7 @@ public static final class Builder { * @param server the velocity server * @param dataDirectory the plugin data directory * @apiNote This instance can be injected into your plugin. - * @since 0.23.0 + * @since 0.24.0 */ @Inject public Builder( @@ -79,7 +79,7 @@ public Builder( * @param token the FastStats project token * @return the Velocity context * @throws IllegalArgumentException if the token is invalid - * @since 0.23.0 + * @since 0.24.0 */ public VelocityContext build(@Token final String token) throws IllegalArgumentException { return new VelocityContext(plugin, server, dataDirectory, token); From fc600318d1a305466898a1759755e6946c17e6ac Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:51:03 +0200 Subject: [PATCH 085/140] Move error tracker to platform context --- .../main/java/com/example/ExamplePlugin.java | 4 +- .../dev/faststats/bukkit/BukkitMetrics.java | 4 - .../faststats/bukkit/BukkitMetricsImpl.java | 11 +- .../faststats/bukkit/PaperEventListener.java | 8 +- .../main/java/com/example/ExamplePlugin.java | 3 - .../example/ErrorTrackerExample.java | 9 +- .../faststats/example/FeatureFlagExample.java | 10 +- core/src/main/java/dev/faststats/Metrics.java | 23 ---- .../java/dev/faststats/SimpleContext.java | 6 +- .../dev/faststats/SimpleErrorTracker.java | 34 ++--- .../java/dev/faststats/SimpleMetrics.java | 41 +++--- .../java/dev/faststats/AnonymizationTest.java | 117 ++++++------------ .../java/dev/faststats/ErrorTrackerTest.java | 25 ++-- .../test/java/dev/faststats/MockContext.java | 4 +- .../resources/META-INF/faststats.properties | 1 + .../src/main/java/com/example/ExampleMod.java | 4 - .../main/java/com/example/ExamplePlugin.java | 5 - .../faststats/minestom/MinestomMetrics.java | 4 - .../minestom/MinestomMetricsImpl.java | 7 +- .../main/java/com/example/ExamplePlugin.java | 5 - .../main/java/com/example/ExamplePlugin.java | 4 - 21 files changed, 107 insertions(+), 222 deletions(-) create mode 100644 core/src/test/resources/META-INF/faststats.properties 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 4c22fc28..4e88ba58 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -11,15 +11,13 @@ public final class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE"); + public final ErrorTracker errorTracker = context.awareErrorTracker(); private final BukkitMetrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) .addMetric(Metric.string("server_version", () -> "1.0.0")) - // Error tracking must be enabled in the project settings - .errorTracker(ErrorTracker.contextAware()) - // #onFlush is invoked after successful metrics submission // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java index 2d566d8d..4559879b 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java @@ -1,6 +1,5 @@ package dev.faststats.bukkit; -import dev.faststats.ErrorTracker; import dev.faststats.Metrics; import dev.faststats.data.Metric; import org.bukkit.plugin.IllegalPluginAccessException; @@ -29,9 +28,6 @@ sealed interface Factory extends Metrics.Factory permits BukkitMetricsImpl.Facto @Override Factory onFlush(Runnable flush); - @Override - Factory errorTracker(ErrorTracker tracker); - @Override BukkitMetrics create() throws IllegalStateException; } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index dd8b5266..3de24d81 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -1,7 +1,6 @@ package dev.faststats.bukkit; import com.google.gson.JsonObject; -import dev.faststats.ErrorTracker; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import dev.faststats.data.Metric; @@ -82,15 +81,16 @@ private int getPlayerCount() { return plugin.getServer().getOnlinePlayers().size(); } catch (final Throwable t) { logger.error("Failed to get player count", t); + // todo: track error? return 0; } } @Override public void ready() { - if (getErrorTracker().isPresent()) try { + try { Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); - plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(this), plugin); + plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, context), plugin); } catch (final ClassNotFoundException ignored) { } } @@ -118,11 +118,6 @@ public Factory onFlush(final Runnable flush) { return (Factory) super.onFlush(flush); } - @Override - public Factory errorTracker(final ErrorTracker tracker) { - return (Factory) super.errorTracker(tracker); - } - @Override public BukkitMetrics create() throws IllegalStateException { return new BukkitMetricsImpl(this, ((BukkitContext) context).plugin); diff --git a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java index 2f2448ed..1797b6e4 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java @@ -2,16 +2,18 @@ import com.destroystokyo.paper.event.server.ServerExceptionEvent; import com.destroystokyo.paper.exception.ServerPluginException; +import dev.faststats.SimpleContext; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; -record PaperEventListener(BukkitMetricsImpl metrics) implements Listener { +record PaperEventListener(Plugin plugin, SimpleContext context) implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onServerException(final ServerExceptionEvent event) { if (!(event.getException() instanceof final ServerPluginException exception)) return; - if (!exception.getResponsiblePlugin().equals(metrics.plugin())) return; + if (!exception.getResponsiblePlugin().equals(plugin)) return; final var report = exception.getCause() != null ? exception.getCause() : exception; - metrics.getErrorTracker().ifPresent(tracker -> tracker.trackError(report, false)); + context.errorTrackers().forEach(tracker -> tracker.trackError(report, false)); } } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 20f69080..fb599e25 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -17,9 +17,6 @@ public class ExamplePlugin extends Plugin { .addMetric(Metric.number("game_count", gameCount::get)) .addMetric(Metric.string("server_version", () -> "1.0.0")) - // Error tracking must be enabled in the project settings - .errorTracker(ErrorTracker.contextAware()) - // #onFlush is invoked after successful metrics submission // This is useful for cleaning up cached data .onFlush(() -> gameCount.set(0)) // reset game count on flush diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index b2a37856..f90027cf 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -1,18 +1,19 @@ package dev.faststats.example; import dev.faststats.ErrorTracker; +import dev.faststats.FastStatsContext; import java.lang.reflect.InvocationTargetException; import java.nio.file.AccessDeniedException; public final class ErrorTrackerExample { // Context-aware: automatically tracks uncaught errors from the same class loader - public static final ErrorTracker CONTEXT_AWARE = ErrorTracker.contextAware() + public static final ErrorTracker CONTEXT_AWARE = getContext().awareErrorTracker() .ignoreError(InvocationTargetException.class, "Expected .* but got .*") .ignoreError(AccessDeniedException.class); // Context-unaware: only tracks errors passed to trackError() manually - public static final ErrorTracker CONTEXT_UNAWARE = ErrorTracker.contextUnaware() + public static final ErrorTracker CONTEXT_UNAWARE = getContext().unawareErrorTracker() .anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") @@ -26,4 +27,8 @@ public static void manualTracking() { CONTEXT_UNAWARE.trackError(e); } } + + private static FastStatsContext getContext() { + return null; + } } diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index af9eb492..bb122ca7 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -8,13 +8,13 @@ import java.time.Duration; public final class FeatureFlagExample { - public static final FeatureFlagService SERVICE = getContext().featureFlags( - Attributes.create() // Define global attributes + public static final FeatureFlagService SERVICE = getContext().featureFlagServiceFactory() + .attributes(Attributes.create() // Define global attributes .put("version", "1.2.3") .put("java_version", System.getProperty("java.version")) - .put("java_vendor", System.getProperty("java.vendor")), - Duration.ofMinutes(10) // Custom cache TTL for resolved flag values - ); + .put("java_vendor", System.getProperty("java.vendor"))) + .ttl(Duration.ofMinutes(10)) // Custom cache TTL for resolved flag values + .create(); // Define flags with default values public static final FeatureFlag NEW_COMMANDS = SERVICE.define("new_commands", false); diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java index 5e9d7f8b..b1131d94 100644 --- a/core/src/main/java/dev/faststats/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -4,23 +4,12 @@ import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.util.Optional; - /** * Metrics interface. * * @since 0.24.0 */ public interface Metrics { - /** - * Get the error tracker for this metrics instance. - * - * @return the error tracker - * @since 0.23.0 - */ - @Contract(pure = true) - Optional getErrorTracker(); - /** * Performs additional post-startup tasks. *

@@ -75,18 +64,6 @@ interface Factory { @Contract(mutates = "this") Factory onFlush(Runnable flush); - /** - * Sets the error tracker for this metrics instance. - *

- * If {@link Config#errorTracking()} is disabled, no errors will be submitted. - * - * @param tracker the error tracker - * @return the metrics factory - * @since 0.23.0 - */ - @Contract(mutates = "this") - Factory errorTracker(ErrorTracker tracker); - /** * Creates a new metrics instance. *

diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 6ec81106..0f62889c 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -41,7 +41,7 @@ protected SimpleContext(final Config config, final String name, @Token final Str } private SdkInfo constructSdkInfo(final String name) throws UncheckedIOException, IllegalStateException, IllegalArgumentException { - try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { + try (final var stream = getClass().getResourceAsStream("/META-INF/faststats.properties")) { if (stream == null) throw new IllegalStateException("Resource '/META-INF/faststats.properties' not found"); final var properties = new Properties(); @@ -116,4 +116,8 @@ public final ErrorTracker unawareErrorTracker() { public SdkInfo getSdkInfo() { return sdkInfo; } + + public Set errorTrackers() { + return errorTrackers; + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index dc58494f..ca1a158c 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -14,13 +14,12 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.regex.Matcher; import java.util.regex.Pattern; final class SimpleErrorTracker implements ErrorTracker { - private final Map reports = new ConcurrentHashMap<>(); + private final Map reports = new ConcurrentHashMap<>(); private final Map, Set> ignoredTypedPatterns = new ConcurrentHashMap<>(); private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); @@ -63,11 +62,8 @@ public void trackError(final Throwable error, final boolean handled) { try { if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - final var identity = error.getClass().getName() + "@" + error.hashCode() + ":" + (handled ? 1 : 0); - reports.compute(identity, (s, report) -> { - if (report == null) return new Report(compiled); - report.counter.incrementAndGet(); - return report; + reports.compute(compiled, (key, report) -> { + return report != null ? report + 1 : 1; }); } catch (final NoClassDefFoundError ignored) { } @@ -114,20 +110,15 @@ public ErrorTracker anonymize(final Pattern pattern, final String replacement) { public JsonArray getData() { final var report = new JsonArray(reports.size()); - - reports.forEach((hash, object) -> { - report.add(fillEntry(object.json, object.counter.get())); + reports.forEach((entry, count) -> { + final var copy = entry.deepCopy(); + context.getSdkInfo().getBuildId().ifPresent(id -> copy.addProperty("buildId", id)); + if (count > 1) copy.addProperty("count", count); + report.add(copy); }); - return report; } - private JsonObject fillEntry(final JsonObject entry, final int count) { - context.getSdkInfo().getBuildId().ifPresent(id -> entry.addProperty("buildId", id)); - if (count > 1) entry.addProperty("count", count); - return entry; - } - public void clear() { reports.clear(); } @@ -171,13 +162,4 @@ public synchronized void setContextErrorHandler(@Nullable final BiConsumer<@Null public synchronized Optional> getContextErrorHandler() { return Optional.ofNullable(errorEvent); } - - private static class Report { - private final JsonObject json; - private final AtomicInteger counter = new AtomicInteger(1); - - private Report(final JsonObject json) { - this.json = json; - } - } } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 6129c66c..5c91261e 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -1,5 +1,6 @@ package dev.faststats; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; import dev.faststats.data.Metric; import dev.faststats.internal.Logger; @@ -22,7 +23,6 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.HashSet; -import java.util.Optional; import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -42,12 +42,11 @@ public abstract class SimpleMetrics implements Metrics { .build(); private @Nullable ScheduledExecutorService executor = null; - private final @Nullable ErrorTracker tracker; private final @Nullable Runnable flush; private final Set> metrics; private final URI url; - protected final FastStatsContext context; + protected final SimpleContext context; @Contract(mutates = "io") protected SimpleMetrics(final Factory factory) throws IllegalStateException { @@ -74,7 +73,6 @@ protected SimpleMetrics( this.metrics = context.getConfig().additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); final var debug = context.getConfig().debug() || Boolean.getBoolean("faststats.debug"); this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); - this.tracker = context.getConfig().errorTracking() ? factory.tracker : null; this.flush = factory.flush; this.url = url; } @@ -168,7 +166,7 @@ private boolean submitNow() throws IOException { if (statusCode >= 200 && statusCode < 300) { logger.info("Metrics submitted with status code: %s (%s)", statusCode, body); - getErrorTracker().map(SimpleErrorTracker.class::cast).ifPresent(SimpleErrorTracker::clear); + context.errorTrackers().stream().map(SimpleErrorTracker.class::cast).forEach(SimpleErrorTracker::clear); if (flush != null) flush.run(); return true; } else if (statusCode >= 300 && statusCode < 400) { @@ -213,7 +211,7 @@ protected JsonObject createData() { appendDefaultData(metrics); } catch (final Throwable t) { logger.error("Failed to append default data", t); - getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); + // getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); // todo: fixme – report directly to faststats? } this.metrics.forEach(metric -> { @@ -221,31 +219,33 @@ protected JsonObject createData() { metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { logger.error("Failed to build metric data: %s", t, metric.getId()); - getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); + // getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); // todo: fixme – report directly to faststats? } }); data.addProperty("identifier", context.getConfig().serverId().toString()); data.add("data", metrics); - getErrorTracker().map(SimpleErrorTracker.class::cast) + // todo: remove with dedicated error tracking route + if (context.getConfig().errorTracking()) context.errorTrackers().stream() + .map(SimpleErrorTracker.class::cast) .map(SimpleErrorTracker::getData) .filter(errors -> !errors.isEmpty()) - .ifPresent(errors -> data.add("errors", errors)); + .reduce((first, second) -> { + final var errors = new JsonArray(first.size() + second.size()); + errors.addAll(first); + errors.addAll(second); + return first; + }).ifPresent(errors -> data.add("errors", errors)); return data; } - @Override - public Optional getErrorTracker() { - return Optional.ofNullable(tracker); - } - @Contract(mutates = "param1") protected abstract void appendDefaultData(JsonObject metrics); @Override public void shutdown() { - getErrorTracker().ifPresent(ErrorTracker::detachErrorContext); + context.errorTrackers().forEach(ErrorTracker::detachErrorContext); if (executor != null) try { logger.info("Shutting down metrics submission"); executor.shutdown(); @@ -259,11 +259,10 @@ public void shutdown() { public abstract static class Factory implements Metrics.Factory { private final Set> metrics = new HashSet<>(0); - protected final FastStatsContext context; - private @Nullable ErrorTracker tracker; + protected final SimpleContext context; private @Nullable Runnable flush; - protected Factory(final FastStatsContext context) { + protected Factory(final SimpleContext context) { this.context = context; } @@ -278,11 +277,5 @@ public Factory onFlush(final Runnable flush) { this.flush = flush; return this; } - - @Override - public Factory errorTracker(final ErrorTracker tracker) { - this.tracker = tracker; - return this; - } } } diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index c8109bf5..dc96ce3d 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -13,159 +13,126 @@ @NullMarked public final class AnonymizationTest { - private static MockMetrics createMetrics(final ErrorTracker tracker) { - final var context = new MockContext(UUID.randomUUID(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false); - return (MockMetrics) context.metricsFactory().errorTracker(tracker).create(); - } + private final MockContext context = new MockContext(UUID.randomUUID(), false); + private final MockMetrics metrics = (MockMetrics) context.metricsFactory().create(); + private final ErrorTracker tracker = context.unawareErrorTracker(); - private static JsonObject getError(final MockMetrics metrics) { + private JsonObject getError() { final var data = metrics.createData(); return data.getAsJsonArray("errors").get(0).getAsJsonObject(); } - private static String getErrorMessage(final MockMetrics metrics) { - return getError(metrics).get("message").getAsString(); + private String getErrorMessage() { + return getError().get("message").getAsString(); } @Test public void ipv4Anonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Connection refused at 192.168.1.100"); - assertEquals("Connection refused at [IP hidden]", getErrorMessage(metrics)); + assertEquals("Connection refused at [IP hidden]", getErrorMessage()); } @Test public void ipv6Anonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed to connect to f833:be65:65da:975b:4896:88f7:6964:44c0"); - assertEquals("Failed to connect to [IP hidden]", getErrorMessage(metrics)); + assertEquals("Failed to connect to [IP hidden]", getErrorMessage()); } @Test public void userHomePathAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("File not found: /home/" + username + "/config.yml"); - assertEquals("File not found: /home/[username hidden]/config.yml", getErrorMessage(metrics)); + assertEquals("File not found: /home/[username hidden]/config.yml", getErrorMessage()); } @Test public void windowsUserPathAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("File not found: C:\\Users\\" + username + "\\config.yml"); - assertEquals("File not found: C:\\Users\\[username hidden]\\config.yml", getErrorMessage(metrics)); + assertEquals("File not found: C:\\Users\\[username hidden]\\config.yml", getErrorMessage()); } @Test public void macUserPathAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("File not found: /Users/" + username + "/config.yml"); - assertEquals("File not found: /Users/[username hidden]/config.yml", getErrorMessage(metrics)); + assertEquals("File not found: /Users/[username hidden]/config.yml", getErrorMessage()); } @Test public void usernameAnonymizationIsCaseInsensitive() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("Error for " + swapCase(username)); - assertEquals("Error for [username hidden]", getErrorMessage(metrics)); + assertEquals("Error for [username hidden]", getErrorMessage()); } @Test public void discordWebhookAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/aAaAaAaa0AAaAAaaaAAAAa_0AAAAAAAaaaAaaAaaAAAA0aA00AAA0AAA0aAAaA0a0a0A"); - assertEquals("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/[token hidden]", getErrorMessage(metrics)); + assertEquals("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/[token hidden]", getErrorMessage()); } @Test public void jdbcUrlAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed: jdbc:mysql://localhost:3306:secretpass@mydb"); - assertEquals("Failed: jdbc:mysql://localhost:3306:[password hidden]@mydb", getErrorMessage(metrics)); + assertEquals("Failed: jdbc:mysql://localhost:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlNoPortAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed: jdbc:mysql://mydb.com:secretpass@mydb"); - assertEquals("Failed: jdbc:mysql://mydb.com:[password hidden]@mydb", getErrorMessage(metrics)); + assertEquals("Failed: jdbc:mysql://mydb.com:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlIpAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed: jdbc:mysql://127.0.0.1:3306:secretpass@mydb"); - assertEquals("Failed: jdbc:mysql://[IP hidden]:3306:[password hidden]@mydb", getErrorMessage(metrics)); + assertEquals("Failed: jdbc:mysql://[IP hidden]:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void customPatternAnonymizesMessage() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("token=[^&]+", "token=[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("token=[^&]+", "token=[redacted]"); tracker.trackError("Request failed with token=abc123secret&user=test"); - assertEquals("Request failed with token=[redacted]&user=test", getErrorMessage(metrics)); + assertEquals("Request failed with token=[redacted]&user=test", getErrorMessage()); } @Test public void customPatternWithCompiledPattern() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); tracker.trackError("Auth failed: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"); - assertEquals("Auth failed: Bearer [redacted]", getErrorMessage(metrics)); + assertEquals("Auth failed: Bearer [redacted]", getErrorMessage()); } @Test public void customPatternWithCaptureGroupReplacement() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); tracker.trackError("GET /data?api_key=sk_live_12345&format=json failed"); - assertEquals("GET /data?api_key=[redacted]&format=json failed", getErrorMessage(metrics)); + assertEquals("GET /data?api_key=[redacted]&format=json failed", getErrorMessage()); } @Test public void multipleCustomPatterns() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]") - .anonymize("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[email hidden]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]"); + tracker.anonymize("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[email hidden]"); tracker.trackError("Auth failed for user@example.com with Bearer eyJ0eXAi"); - assertEquals("Auth failed for [email hidden] with Bearer [redacted]", getErrorMessage(metrics)); + assertEquals("Auth failed for [email hidden] with Bearer [redacted]", getErrorMessage()); } @Test public void customPatternChaining() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("secret-[a-z]+", "[secret hidden]") - .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("secret-[a-z]+", "[secret hidden]"); + tracker.anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]"); tracker.trackError("Credentials: secret-abcdef / AKIA1234567890ABCDEF"); - assertEquals("Credentials: [secret hidden] / [aws-key hidden]", getErrorMessage(metrics)); + assertEquals("Credentials: [secret hidden] / [aws-key hidden]", getErrorMessage()); } @Test public void customPatternAppliedToCauseChain() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); final var cause = new IllegalArgumentException("Validation failed for ssn=123-45-6789"); tracker.trackError(new RuntimeException("Processing error", cause)); - final var error = getError(metrics); + final var error = getError(); final var stack = error.getAsJsonArray("stack"); var causeAnonymized = false; for (final var element : stack) { @@ -178,39 +145,31 @@ public void customPatternAppliedToCauseChain() { @Test public void nullMessageNotAffected() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("anything", "[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("anything", "[redacted]"); tracker.trackError(new RuntimeException((String) null)); - assertFalse(getError(metrics).has("message")); + assertFalse(getError().has("message")); } @Test public void customAndBuiltInPatternsCombined() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("session=[a-f0-9]+", "session=[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("session=[a-f0-9]+", "session=[redacted]"); final var username = System.getProperty("user.name", "user"); tracker.trackError("Error for 192.168.1.1 with session=deadbeef01 at /home/" + username + "/app"); - assertEquals("Error for [IP hidden] with session=[redacted] at /home/[username hidden]/app", getErrorMessage(metrics)); + assertEquals("Error for [IP hidden] with session=[redacted] at /home/[username hidden]/app", getErrorMessage()); } @Test public void emptyReplacementRemovesMatch() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("\\(internal ref: [^)]+\\)", ""); - final var metrics = createMetrics(tracker); + tracker.anonymize("\\(internal ref: [^)]+\\)", ""); tracker.trackError("Request failed (internal ref: REF-98765)"); - assertEquals("Request failed ", getErrorMessage(metrics)); + assertEquals("Request failed ", getErrorMessage()); } @Test public void patternDoesNotMatchLeavesMessageUnchanged() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("SECRET_[A-Z]+", "[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("SECRET_[A-Z]+", "[redacted]"); tracker.trackError("just a normal error"); - assertEquals("just a normal error", getErrorMessage(metrics)); + assertEquals("just a normal error", getErrorMessage()); } private static String swapCase(final String value) { diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 7390aee8..8b54f949 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -4,6 +4,7 @@ import java.net.URL; import java.net.URLClassLoader; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -13,6 +14,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { + private final MockContext context = new MockContext(UUID.randomUUID(), true); + @Test public void sameClassLoader() { final var loader = getClass().getClassLoader(); @@ -126,7 +129,7 @@ private IllegalArgumentException createExceptionWithStack() { @Test public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); @@ -140,7 +143,7 @@ public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { @Test public void appliesCustomRedactionAfterBuiltInRedaction() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); tracker.anonymize("session=[^ ]+", "session=[hidden]"); tracker.trackError("failed with session=abc123 from 10.0.0.1"); @@ -156,7 +159,7 @@ public void appliesCustomRedactionAfterBuiltInRedaction() { @Test public void nullMessagesAreNotSerializedAsMessageProperty() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); tracker.trackError(new RuntimeException((String) null)); @@ -167,7 +170,7 @@ public void nullMessagesAreNotSerializedAsMessageProperty() { @Test public void nestedCausesAreSerializedInOrder() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var root = new IllegalArgumentException("root secret 172.16.0.9"); root.setStackTrace(new StackTraceElement[]{ new StackTraceElement("example.Root", "fail", "Root.java", 10) @@ -202,7 +205,7 @@ public void nestedCausesAreSerializedInOrder() { @Test public void cyclicCauseChainStopsAfterFirstVisit() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var first = new RuntimeException("first"); final var second = new IllegalStateException("second", first); first.initCause(second); @@ -224,7 +227,7 @@ public void cyclicCauseChainStopsAfterFirstVisit() { @Test public void duplicateErrorsAreAggregatedWithCount() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var first = createStableError(); final var second = createStableError(); @@ -241,7 +244,7 @@ public void duplicateErrorsAreAggregatedWithCount() { @Test public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); tracker.trackError(createStableError()); tracker.trackError(createStableError()); @@ -258,7 +261,7 @@ public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { @Test public void ignoredNestedCauseSuppressesWholeReport() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); tracker.ignoreError(IllegalArgumentException.class, "ignore me"); tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); @@ -268,7 +271,7 @@ public void ignoredNestedCauseSuppressesWholeReport() { @Test public void repeatingStackFramesAreCollapsed() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var error = new StackOverflowError("recursive"); error.setStackTrace(new StackTraceElement[]{ new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), @@ -291,7 +294,7 @@ public void repeatingStackFramesAreCollapsed() { @Test public void longMessagesAreTruncatedBeforeSerialization() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var message = "a".repeat(600); tracker.trackError(message); @@ -305,7 +308,7 @@ public void longMessagesAreTruncatedBeforeSerialization() { @Test public void attachedContextTracksUnhandledThreadError() throws InterruptedException { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var handled = new CountDownLatch(1); final var thrown = new RuntimeException("async failure"); thrown.setStackTrace(new StackTraceElement[]{ diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 538709f0..fb0ace66 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -6,8 +6,8 @@ @NullMarked public final class MockContext extends SimpleContext { - public MockContext(final UUID serverId, @Token final String token, final boolean debug) throws IllegalArgumentException { - super(new MockConfig(serverId, debug), "core:test", token); + public MockContext(final UUID serverId, final boolean debug) throws IllegalArgumentException { + super(new MockConfig(serverId, debug), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); } @Override diff --git a/core/src/test/resources/META-INF/faststats.properties b/core/src/test/resources/META-INF/faststats.properties new file mode 100644 index 00000000..b507e3fc --- /dev/null +++ b/core/src/test/resources/META-INF/faststats.properties @@ -0,0 +1 @@ +version=test \ No newline at end of file diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 465e15ed..8826db8c 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -14,10 +14,6 @@ public class ExampleMod implements ModInitializer { private final Metrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - - // Error tracking must be enabled in the project settings - .errorTracker(ErrorTracker.contextAware()) - .create(); @Override diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 28dde103..2732564d 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,7 +2,6 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; -import dev.faststats.ErrorTracker; import dev.faststats.Metrics; import dev.faststats.data.Metric; import dev.faststats.hytale.HytaleContext; @@ -12,10 +11,6 @@ public class ExamplePlugin extends JavaPlugin { private final Metrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - - // Error tracking must be enabled in the project settings - .errorTracker(ErrorTracker.contextAware()) - .create(); public ExamplePlugin(final JavaPluginInit init) { diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java index 6037197c..6f057ac9 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java @@ -1,6 +1,5 @@ package dev.faststats.minestom; -import dev.faststats.ErrorTracker; import dev.faststats.Metrics; import dev.faststats.data.Metric; import net.minestom.server.Auth; @@ -28,9 +27,6 @@ sealed interface Factory extends Metrics.Factory permits MinestomMetricsImpl.Fac @Override Factory onFlush(Runnable flush); - @Override - Factory errorTracker(ErrorTracker tracker); - @Override MinestomMetrics create() throws IllegalStateException; } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index b4f9636a..9871d2f8 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -34,7 +34,7 @@ protected void appendDefaultData(final JsonObject metrics) { @Override public void ready() { - getErrorTracker().ifPresent(this::registerExceptionHandler); + context.errorTrackers().forEach(this::registerExceptionHandler); } private void registerExceptionHandler(final ErrorTracker errorTracker) { @@ -61,11 +61,6 @@ public Factory onFlush(final Runnable flush) { return (Factory) super.onFlush(flush); } - @Override - public Factory errorTracker(final ErrorTracker tracker) { - return (Factory) super.errorTracker(tracker); - } - @Override public MinestomMetrics create() throws IllegalStateException { return new MinestomMetricsImpl(this); diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 7ff88033..e7249e3f 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,7 +1,6 @@ package com.example; import com.google.inject.Inject; -import dev.faststats.ErrorTracker; import dev.faststats.Metrics; import dev.faststats.data.Metric; import dev.faststats.sponge.SpongeContext; @@ -24,10 +23,6 @@ public void onServerStart(final StartedEngineEvent event) { this.metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - - // Error tracking must be enabled in the project settings - .errorTracker(ErrorTracker.contextAware()) - .create(); } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index a70b8789..0b21341f 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -27,10 +27,6 @@ public void onProxyInitialize(final ProxyInitializeEvent event) { this.metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - - // Error tracking must be enabled in the project settings - .errorTracker(ErrorTracker.contextAware()) - .create(); } From 0d33a39452cc7150df7ac99b810a0be0ed2a9d06 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:51:11 +0200 Subject: [PATCH 086/140] Add package info --- .../src/main/java/dev/faststats/example/package-info.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 core/example/src/main/java/dev/faststats/example/package-info.java diff --git a/core/example/src/main/java/dev/faststats/example/package-info.java b/core/example/src/main/java/dev/faststats/example/package-info.java new file mode 100644 index 00000000..6e5bd0b2 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.faststats.example; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file From 96c99156a14b582e3f4ccb33091f8b007b3f5e75 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:51:17 +0200 Subject: [PATCH 087/140] Delete unused test --- .../src/test/java/dev/faststats/MetricsTest.java | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 core/src/test/java/dev/faststats/MetricsTest.java diff --git a/core/src/test/java/dev/faststats/MetricsTest.java b/core/src/test/java/dev/faststats/MetricsTest.java deleted file mode 100644 index cd590afc..00000000 --- a/core/src/test/java/dev/faststats/MetricsTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.faststats; - -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -public class MetricsTest { - @Test - public void testCreateData() { - final var context = new MockContext(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", true); - final var metrics = (SimpleMetrics) context.metricsFactory().create(); - assumeTrue(metrics.submit(), "For this test to run, the server must be running"); - } -} From d7693258ada5eb3c29a41d63eb892bb48da21da5 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:51:34 +0200 Subject: [PATCH 088/140] Remove murmur hash algorythm --- .../main/java/dev/faststats/MurmurHash3.java | 187 ------------------ 1 file changed, 187 deletions(-) delete mode 100644 core/src/main/java/dev/faststats/MurmurHash3.java diff --git a/core/src/main/java/dev/faststats/MurmurHash3.java b/core/src/main/java/dev/faststats/MurmurHash3.java deleted file mode 100644 index 021e7c37..00000000 --- a/core/src/main/java/dev/faststats/MurmurHash3.java +++ /dev/null @@ -1,187 +0,0 @@ -package dev.faststats; - -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 { - public static String hash(final String data) { - final var hash = MurmurHash3.hash128(data); - return Long.toHexString(hash[0]) + Long.toHexString(hash[1]); - } - - /** - * 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) - private static long[] hash128(final String data) { - final var bytes = data.getBytes(StandardCharsets.UTF_8); - var h1 = 0L; - var h2 = 0L; - final var c1 = 0x87c37b91114253d5L; - final var c2 = 0x4cf5ad432745937fL; - final var length = bytes.length; - final 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); - final var k3 = getInt(bytes, i * 16 + 8); - final 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; - final 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(final byte[] bytes, final int offset) { - return (bytes[offset] & 0xff) | - ((bytes[offset + 1] & 0xff) << 8) | - ((bytes[offset + 2] & 0xff) << 16) | - ((bytes[offset + 3] & 0xff) << 24); - } -} From d40cd519e8a984e4c4902f7d80ec1549fd426b44 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:56:35 +0200 Subject: [PATCH 089/140] Add feature flag service factory --- .../java/dev/faststats/FastStatsContext.java | 39 +---------------- .../dev/faststats/FeatureFlagService.java | 43 ++++++++++++++++++- .../java/dev/faststats/SimpleContext.java | 24 +---------- .../faststats/SimpleFeatureFlagService.java | 31 +++++++++++++ 4 files changed, 77 insertions(+), 60 deletions(-) diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index c8910809..663dc9a8 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -2,8 +2,6 @@ import org.jetbrains.annotations.Contract; -import java.time.Duration; - /** * Shared FastStats context. *

@@ -42,46 +40,13 @@ public sealed interface FastStatsContext permits SimpleContext { Metrics.Factory metricsFactory(); /** - * Creates a new feature flag service backed by the context's token. + * Creates a new feature flag service factory bound to this context. * * @return a new feature flag service factory * @since 0.24.0 */ @Contract(value = "-> new", pure = true) - FeatureFlagService featureFlags(); - - /** - * Creates a new feature flag service backed by the context's token and attributes. - * - * @param attributes the global targeting attributes - * @return the feature flag service - * @since 0.23.0 - */ - @Contract(value = "_ -> new", pure = true) - FeatureFlagService featureFlags(final Attributes attributes); - - /** - * Creates a new feature flag service backed by the context's token, and TTL. - * - * @param ttl the cache time-to-live for resolved flag values - * @return the feature flag service - * @throws IllegalArgumentException if the TTL is negative - * @since 0.23.0 - */ - @Contract(value = "_ -> new", pure = true) - FeatureFlagService featureFlags(final Duration ttl); - - /** - * Creates a new feature flag service backed by the context's token, attributes, and TTL. - * - * @param attributes the global targeting attributes - * @param ttl the cache time-to-live for resolved flag values - * @return the feature flag service - * @throws IllegalArgumentException if the TTL is negative - * @since 0.23.0 - */ - @Contract(value = "_, _ -> new", pure = true) - FeatureFlagService featureFlags(final Attributes attributes, final Duration ttl); + FeatureFlagService.Factory featureFlagServiceFactory(); /** * Create and attach a new context-aware error tracker. diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java index 1239319d..40893594 100644 --- a/core/src/main/java/dev/faststats/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -8,7 +8,7 @@ /** * A service for managing feature flags. *

- * Use one of the static {@code create} methods to construct a service instance. + * Use {@link FastStatsContext#featureFlagServiceFactory()} to construct a service instance. * * @since 0.24.0 */ @@ -109,4 +109,45 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { */ @Contract(mutates = "this") void shutdown(); + + /** + * A feature flag service factory. + * + * @since 0.24.0 + */ + sealed interface Factory permits SimpleFeatureFlagService.Factory { + /** + * Sets the global targeting attributes for services created by this factory. + *

+ * These attributes apply to every flag defined by the service and are + * merged with any per-flag attributes supplied during definition. + * + * @param attributes the global targeting attributes + * @return the feature flag service factory + * @since 0.24.0 + */ + @Contract(mutates = "this") + Factory attributes(Attributes attributes); + + /** + * Sets the cache time-to-live for resolved flag values. + * + * @param ttl the cache time-to-live for resolved flag values + * @return the feature flag service factory + * @throws IllegalArgumentException if the TTL is negative + * @since 0.24.0 + */ + @Contract(mutates = "this") + Factory ttl(Duration ttl) throws IllegalArgumentException; + + /** + * Creates a new feature flag service. + * + * @return the feature flag service + * @throws IllegalArgumentException if the TTL is negative + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + FeatureFlagService create() throws IllegalArgumentException; + } } diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 0f62889c..3cfd2c3b 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -2,11 +2,9 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; -import java.time.Duration; import java.util.Collections; import java.util.Properties; import java.util.Set; @@ -72,26 +70,8 @@ public final Config getConfig() { @Override @Contract(value = " -> new", pure = true) - public final FeatureFlagService featureFlags() { - return new SimpleFeatureFlagService(config, token, null, Duration.ofMinutes(5)); - } - - @Override - @Contract(value = "_ -> new", pure = true) - public final FeatureFlagService featureFlags(final Attributes attributes) { - return new SimpleFeatureFlagService(config, token, attributes, Duration.ofMinutes(5)); - } - - @Override - @Contract(value = "_ -> new", pure = true) - public final FeatureFlagService featureFlags(final Duration ttl) { - return new SimpleFeatureFlagService(config, token, null, ttl); - } - - @Override - @Contract(value = "_, _ -> new", pure = true) - public final FeatureFlagService featureFlags(@Nullable final Attributes attributes, final Duration ttl) { - return new SimpleFeatureFlagService(config, token, attributes, ttl); + public final FeatureFlagService.Factory featureFlagServiceFactory() { + return new SimpleFeatureFlagService.Factory(config, token); } @Override diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 93faa187..98d45cfe 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -25,6 +25,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private static final Logger logger = LoggerFactory.factory().getLogger(SimpleFeatureFlagService.class); private static final URI url = getFlagsServerUrl(); + private static final Duration DEFAULT_TTL = Duration.ofMinutes(5); private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) @@ -221,4 +222,34 @@ public void shutdown() { fetchesInProgress.values().forEach(fetch -> fetch.cancel(true)); fetchesInProgress.clear(); } + + static final class Factory implements FeatureFlagService.Factory { + private final Config config; + private final @Token String token; + private @Nullable Attributes attributes; + private Duration ttl = DEFAULT_TTL; + + Factory(final Config config, final @Token String token) { + this.config = config; + this.token = token; + } + + @Override + public FeatureFlagService.Factory attributes(final Attributes attributes) { + this.attributes = attributes; + return this; + } + + @Override + public FeatureFlagService.Factory ttl(final Duration ttl) throws IllegalArgumentException { + if (ttl.isNegative()) throw new IllegalArgumentException("TTL cannot be negative"); + this.ttl = ttl; + return this; + } + + @Override + public FeatureFlagService create() throws IllegalArgumentException { + return new SimpleFeatureFlagService(config, token, attributes, ttl); + } + } } From 5c98885de6e104e86c5630745dc46604e04eed93 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 25 May 2026 16:56:43 +0200 Subject: [PATCH 090/140] Update package --- core/src/main/java/dev/faststats/data/MapMetric.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/data/MapMetric.java b/core/src/main/java/dev/faststats/data/MapMetric.java index c9a5e80e..d95943d6 100644 --- a/core/src/main/java/dev/faststats/data/MapMetric.java +++ b/core/src/main/java/dev/faststats/data/MapMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import com.google.gson.JsonObject; From 12595c3c0a8c7ba4b40fb4a4bbafabafe3384303 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 26 May 2026 21:23:35 +0200 Subject: [PATCH 091/140] Add fork protection by supplying the project name --- .../src/main/java/dev/faststats/bukkit/BukkitContext.java | 5 +++++ .../src/main/java/dev/faststats/bungee/BungeeContext.java | 5 +++++ core/src/main/java/dev/faststats/SimpleContext.java | 3 +++ core/src/main/java/dev/faststats/SimpleMetrics.java | 2 ++ core/src/test/java/dev/faststats/MockContext.java | 5 +++++ core/src/test/java/dev/faststats/MockMetrics.java | 2 -- .../src/main/java/dev/faststats/fabric/FabricContext.java | 5 +++++ .../src/main/java/dev/faststats/hytale/HytaleContext.java | 8 ++++++++ .../main/java/dev/faststats/minestom/MinestomContext.java | 6 ++++++ .../src/main/java/dev/faststats/nukkit/NukkitContext.java | 5 +++++ .../src/main/java/dev/faststats/sponge/SpongeContext.java | 5 +++++ .../main/java/dev/faststats/velocity/VelocityContext.java | 5 +++++ 12 files changed, 54 insertions(+), 2 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 46b860a1..e4f7fa62 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -38,4 +38,9 @@ private static Path getPluginsFolder(final Plugin plugin) { return plugin.getDataFolder().getParentFile().toPath(); } } + + @Override + public String getProjectName() { + return plugin.getName(); + } } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index dab0dbcb..a163a5ff 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -31,4 +31,9 @@ public Metrics create() throws IllegalStateException { } }; } + + @Override + public String getProjectName() { + return plugin.getDescription().getName(); + } } diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 3cfd2c3b..c55a3571 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -56,6 +56,9 @@ private SdkInfo constructSdkInfo(final String name) throws UncheckedIOException, } } + @Contract(pure = true) + public abstract String getProjectName(); + @Override @Contract(pure = true) public final Config getConfig() { diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 5c91261e..83650b93 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -223,6 +223,8 @@ protected JsonObject createData() { } }); + + data.addProperty("project_name", context.getProjectName()); data.addProperty("identifier", context.getConfig().serverId().toString()); data.add("data", metrics); diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index fb0ace66..08b11433 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -20,6 +20,11 @@ public Metrics create() throws IllegalStateException { }; } + @Override + public String getProjectName() { + return "Mock"; + } + private record MockConfig(UUID serverId, boolean debug) implements dev.faststats.Config { @Override public boolean enabled() { diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index dca9cfd1..15ebefd5 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -1,11 +1,9 @@ package dev.faststats; import com.google.gson.JsonObject; -import org.jspecify.annotations.NullMarked; import java.net.URI; -@NullMarked final class MockMetrics extends SimpleMetrics { MockMetrics(final Factory factory) { super(factory, URI.create("http://localhost:5000/v1/collect")); diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 86b583f2..b78d04f6 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -38,4 +38,9 @@ public Metrics create() throws IllegalStateException { } }; } + + @Override + public String getProjectName() { + return mod.getMetadata().getId(); + } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 5b1537e9..d471e645 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -14,8 +14,11 @@ * @since 0.24.0 */ public final class HytaleContext extends SimpleContext { + private final String pluginName; + public HytaleContext(final JavaPlugin plugin, @Token final String token) { super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); + this.pluginName = plugin.getName(); } @Override @@ -29,4 +32,9 @@ public Metrics create() throws IllegalStateException { } }; } + + @Override + public String getProjectName() { + return pluginName; + } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index ad4d2459..9e973332 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -3,6 +3,7 @@ import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; +import net.minestom.server.MinecraftServer; import org.jetbrains.annotations.Contract; import java.nio.file.Path; @@ -22,4 +23,9 @@ public final class MinestomContext extends SimpleContext { public MinestomMetrics.Factory metricsFactory() { return new MinestomMetricsImpl.Factory(this); } + + @Override + public String getProjectName() { + return MinecraftServer.getBrandName(); + } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index b4beeabe..9bfcf397 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -33,4 +33,9 @@ public Metrics create() throws IllegalStateException { } }; } + + @Override + public String getProjectName() { + return plugin.getName(); + } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 035a2486..190f0455 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -39,6 +39,11 @@ public Metrics create() throws IllegalStateException { }; } + @Override + public String getProjectName() { + return plugin.metadata().id(); + } + /** * Injectable Sponge context builder. * diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 9b15199f..ce88eadd 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -44,6 +44,11 @@ public Metrics create() throws IllegalStateException { }; } + @Override + public String getProjectName() { + return plugin.getDescription().getId(); + } + /** * Injectable Velocity context builder. * From 40dc51e26b9d817017ed155629baabec96de76b1 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 26 May 2026 22:15:17 +0200 Subject: [PATCH 092/140] Add package info for package wide NullMarked --- core/src/test/java/dev/faststats/AnonymizationTest.java | 2 -- core/src/test/java/dev/faststats/FeatureFlagTest.java | 1 - core/src/test/java/dev/faststats/MockContext.java | 3 --- core/src/test/java/dev/faststats/package-info.java | 4 ++++ 4 files changed, 4 insertions(+), 6 deletions(-) create mode 100644 core/src/test/java/dev/faststats/package-info.java diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index dc96ce3d..d0e32453 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -1,7 +1,6 @@ package dev.faststats; import com.google.gson.JsonObject; -import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; import java.util.UUID; @@ -11,7 +10,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -@NullMarked public final class AnonymizationTest { private final MockContext context = new MockContext(UUID.randomUUID(), false); private final MockMetrics metrics = (MockMetrics) context.metricsFactory().create(); diff --git a/core/src/test/java/dev/faststats/FeatureFlagTest.java b/core/src/test/java/dev/faststats/FeatureFlagTest.java index b9f35a43..21d5ab21 100644 --- a/core/src/test/java/dev/faststats/FeatureFlagTest.java +++ b/core/src/test/java/dev/faststats/FeatureFlagTest.java @@ -34,7 +34,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -@NullMarked public final class FeatureFlagTest { private static final UUID SERVER_ID = UUID.fromString("76a88a60-1329-4913-9525-fb16b588d07e"); private static FlagServer server; diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 08b11433..fa3682df 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -1,10 +1,7 @@ package dev.faststats; -import org.jspecify.annotations.NullMarked; - import java.util.UUID; -@NullMarked public final class MockContext extends SimpleContext { public MockContext(final UUID serverId, final boolean debug) throws IllegalArgumentException { super(new MockConfig(serverId, debug), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); diff --git a/core/src/test/java/dev/faststats/package-info.java b/core/src/test/java/dev/faststats/package-info.java new file mode 100644 index 00000000..6eba45ca --- /dev/null +++ b/core/src/test/java/dev/faststats/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.faststats; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file From 44695b8d5c3fcda7e78a918b60296e43a238ad78 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 26 May 2026 22:15:42 +0200 Subject: [PATCH 093/140] pure method --- .../main/java/dev/faststats/FastStatsContext.java | 2 +- core/src/main/java/dev/faststats/SimpleContext.java | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 663dc9a8..2814ed5a 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -78,7 +78,7 @@ public sealed interface FastStatsContext permits SimpleContext { * @see ErrorTracker#trackError(Throwable) * @since 0.24.0 */ - @Contract(value = " -> new") + @Contract(value = " -> new", pure = true) ErrorTracker unawareErrorTracker(); // todo: add docs diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index c55a3571..c761f98b 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -12,7 +12,6 @@ @ApiStatus.Internal public non-sealed abstract class SimpleContext implements FastStatsContext { - private final Set errorTrackers = Collections.newSetFromMap(new WeakHashMap<>()); // todo: a set of weak references to error trackers; must also be thread safe private final Config config; private final @Token String token; private final SdkInfo sdkInfo; @@ -82,16 +81,13 @@ public final FeatureFlagService.Factory featureFlagServiceFactory() { public final ErrorTracker awareErrorTracker() { final var tracker = new SimpleErrorTracker(this); tracker.attachErrorContext(ErrorTracker.class.getClassLoader()); - errorTrackers.add(tracker); return tracker; } @Override - @Contract(value = " -> new") + @Contract(value = " -> new", pure = true) public final ErrorTracker unawareErrorTracker() { - final var tracker = new SimpleErrorTracker(this); - errorTrackers.add(tracker); - return tracker; + return new SimpleErrorTracker(this); } @Override @@ -99,8 +95,4 @@ public final ErrorTracker unawareErrorTracker() { public SdkInfo getSdkInfo() { return sdkInfo; } - - public Set errorTrackers() { - return errorTrackers; - } } From e246a57ea8d4c46f8488af864da1aa77aad01180 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 26 May 2026 22:17:07 +0200 Subject: [PATCH 094/140] Simplify submit method --- .../java/dev/faststats/SimpleMetrics.java | 67 +++++++++---------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 83650b93..efe2d62e 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -13,7 +13,6 @@ import org.jspecify.annotations.Nullable; import java.io.ByteArrayOutputStream; -import java.io.IOException; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; @@ -122,17 +121,13 @@ protected boolean isSubmitting() { return executor != null && !executor.isShutdown(); } - @VisibleForTesting - public boolean submit() { - try { - return submitNow(); - } catch (final Throwable t) { - logger.error("Failed to submit metrics", t); - return false; - } + protected final void trackError(final Throwable error, final boolean handled) { + context.errorTrackingSink().track(error, handled); } - private boolean submitNow() throws IOException { + // todo: improve logging to be less cluttered + @VisibleForTesting + public boolean submit() { final var data = createData().toString(); final var bytes = data.getBytes(UTF_8); @@ -148,45 +143,43 @@ private boolean submitNow() throws IOException { logger.info("Compressed size: %s bytes", compressed.length); final var sdk = context.getSdkInfo(); + final var agent = "FastStats Metrics " + sdk.getName() + "/" + sdk.getVersion(); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") .header("Authorization", "Bearer " + context.getToken()) - .header("User-Agent", "FastStats Metrics " + sdk.getName() + "/" + sdk.getVersion()) + .header("User-Agent", agent) .timeout(Duration.ofSeconds(3)) .uri(url) .build(); logger.info("Sending metrics to: %s", url); - try { - final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); - final var statusCode = response.statusCode(); - final var body = response.body(); - - if (statusCode >= 200 && statusCode < 300) { - logger.info("Metrics submitted with status code: %s (%s)", statusCode, body); - context.errorTrackers().stream().map(SimpleErrorTracker.class::cast).forEach(SimpleErrorTracker::clear); - if (flush != null) flush.run(); - return true; - } else if (statusCode >= 300 && statusCode < 400) { - logger.warn("Received redirect response from metrics server: %s (%s)", statusCode, body); - } else if (statusCode >= 400 && statusCode < 500) { - logger.error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); - } else if (statusCode >= 500 && statusCode < 600) { - logger.error("Received server error response from metrics server: %s (%s)", null, statusCode, body); - } else { - logger.warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); - } - } catch (final HttpConnectTimeoutException t) { - logger.error("Metrics submission timed out after 3 seconds: %s", null, url); - } catch (final ConnectException t) { - logger.error("Failed to connect to metrics server: %s", null, url); - } catch (final Throwable t) { - logger.error("Failed to submit metrics", t); + final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); + final var statusCode = response.statusCode(); + final var body = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + logger.info("Metrics submitted with status code: %s (%s)", statusCode, body); + if (flush != null) flush.run(); + return true; + } else if (statusCode >= 300 && statusCode < 400) { + logger.warn("Received redirect response from metrics server: %s (%s)", statusCode, body); + } else if (statusCode >= 400 && statusCode < 500) { + logger.error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); + } else if (statusCode >= 500 && statusCode < 600) { + logger.error("Received server error response from metrics server: %s (%s)", null, statusCode, body); + } else { + logger.warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); } - return false; + } catch (final HttpConnectTimeoutException t) { + logger.error("Metrics submission timed out after 3 seconds: %s", null, url); + } catch (final ConnectException t) { + logger.error("Failed to connect to metrics server: %s", null, url); + } catch (final Throwable t) { + logger.error("Failed to submit metrics", t); } + return false; } private static final String javaVendor = System.getProperty("java.vendor"); From 06cf386e027bd10043648e4fa9263de14d3aa452 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 26 May 2026 22:17:54 +0200 Subject: [PATCH 095/140] Get error data directly from tracker --- core/src/test/java/dev/faststats/AnonymizationTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index d0e32453..cdc89048 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -16,8 +16,7 @@ public final class AnonymizationTest { private final ErrorTracker tracker = context.unawareErrorTracker(); private JsonObject getError() { - final var data = metrics.createData(); - return data.getAsJsonArray("errors").get(0).getAsJsonObject(); + return ((SimpleErrorTracker) tracker).getData().get(0).getAsJsonObject(); } private String getErrorMessage() { From 5f7f3e1b35fefa489502928d60e2933b6a3ada8e Mon Sep 17 00:00:00 2001 From: david Date: Thu, 28 May 2026 20:54:16 +0200 Subject: [PATCH 096/140] Document SdkInfo --- .../java/dev/faststats/FastStatsContext.java | 7 +++- core/src/main/java/dev/faststats/SdkInfo.java | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 2814ed5a..c9543f83 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -81,7 +81,12 @@ public sealed interface FastStatsContext permits SimpleContext { @Contract(value = " -> new", pure = true) ErrorTracker unawareErrorTracker(); - // todo: add docs + /** + * Get the SDK information shared by services created from this context. + * + * @return the shared SDK information + * @since 0.24.0 + */ @Contract(pure = true) SdkInfo getSdkInfo(); } diff --git a/core/src/main/java/dev/faststats/SdkInfo.java b/core/src/main/java/dev/faststats/SdkInfo.java index 6676866f..6f00f195 100644 --- a/core/src/main/java/dev/faststats/SdkInfo.java +++ b/core/src/main/java/dev/faststats/SdkInfo.java @@ -2,10 +2,51 @@ import java.util.Optional; +/** + * Information that identifies the SDK implementation using FastStats. + * + * @since 0.24.0 + */ public sealed interface SdkInfo permits SimpleSdkInfo { + /** + * Get the build identifier of the project that implements this SDK. + *

+ * This identifier is used to associate uploaded errors with the correct + * obfuscation mappings, such as ProGuard or R8 mapping files. + * It does not identify the FastStats SDK build itself. + * + * @return the implementing project's build identifier, if available + * @since 0.24.0 + */ Optional getBuildId(); + /** + * Get the SDK implementation name. + * + * @return the SDK name + * @since 0.24.0 + */ String getName(); + /** + * Get the SDK implementation version. + * + * @return the SDK version + * @since 0.24.0 + */ String getVersion(); + + /** + * Get the user agent sent with FastStats HTTP requests. + *

+ * The user agent should include enough information to identify the client + * implementation, including the vendor name, SDK name, and SDK version. + * It may also include contact information, such as an email address, + * repository URL, Discord server, or website, so FastStats can reach the + * implementation owner in case of abuse or operational problems. + * + * @return the HTTP user agent + * @since 0.24.0 + */ + String getUserAgent(); } From f59df5a48c559d84cfa3ab60b69e3a64e6dec9cf Mon Sep 17 00:00:00 2001 From: david Date: Thu, 28 May 2026 20:57:56 +0200 Subject: [PATCH 097/140] Move useragent to SdkInfo --- core/src/main/java/dev/faststats/SimpleMetrics.java | 4 +--- core/src/main/java/dev/faststats/SimpleSdkInfo.java | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index efe2d62e..a88225c3 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -142,14 +142,12 @@ public boolean submit() { final var compressed = byteOutput.toByteArray(); logger.info("Compressed size: %s bytes", compressed.length); - final var sdk = context.getSdkInfo(); - final var agent = "FastStats Metrics " + sdk.getName() + "/" + sdk.getVersion(); final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) .header("Content-Encoding", "gzip") .header("Content-Type", "application/octet-stream") .header("Authorization", "Bearer " + context.getToken()) - .header("User-Agent", agent) + .header("User-Agent", context.getSdkInfo().getUserAgent()) .timeout(Duration.ofSeconds(3)) .uri(url) .build(); diff --git a/core/src/main/java/dev/faststats/SimpleSdkInfo.java b/core/src/main/java/dev/faststats/SimpleSdkInfo.java index 53fe15ac..8c4e998a 100644 --- a/core/src/main/java/dev/faststats/SimpleSdkInfo.java +++ b/core/src/main/java/dev/faststats/SimpleSdkInfo.java @@ -31,4 +31,9 @@ public String getName() { public String getVersion() { return version; } + + @Override + public String getUserAgent() { + return "FastStats Metrics " + name + "/" + version; + } } From e552226d32761f506818d0cb1dc85a7ff0230834 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 29 May 2026 20:20:57 +0200 Subject: [PATCH 098/140] W.I.P --- .../main/java/com/example/ExamplePlugin.java | 5 +- .../faststats/bukkit/BukkitMetricsImpl.java | 6 +- .../faststats/bukkit/PaperEventListener.java | 8 +- .../main/java/dev/faststats/ErrorHelper.java | 34 +++- .../main/java/dev/faststats/ErrorTracker.java | 26 +++ .../java/dev/faststats/ErrorTrackingSink.java | 183 ++++++++++++++++++ .../java/dev/faststats/FastStatsContext.java | 36 +--- .../java/dev/faststats/SimpleContext.java | 41 ++-- .../dev/faststats/SimpleErrorTracker.java | 56 ++---- .../java/dev/faststats/SimpleMetrics.java | 41 ++-- .../java/dev/faststats/AnonymizationTest.java | 69 ++++--- .../java/dev/faststats/ErrorTrackerTest.java | 82 ++++---- .../test/java/dev/faststats/MockContext.java | 17 +- .../test/java/dev/faststats/MockMetrics.java | 4 + .../faststats/hytale/logger/HytaleLogger.java | 3 +- .../hytale/logger/HytaleLoggerFactory.java | 7 +- .../minestom/MinestomMetricsImpl.java | 2 +- 17 files changed, 412 insertions(+), 208 deletions(-) create mode 100644 core/src/main/java/dev/faststats/ErrorTrackingSink.java 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 4e88ba58..4c7a51a1 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -9,9 +9,10 @@ import java.util.concurrent.atomic.AtomicInteger; public final class ExamplePlugin extends JavaPlugin { + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.aware(); private final AtomicInteger gameCount = new AtomicInteger(); - private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE"); - public final ErrorTracker errorTracker = context.awareErrorTracker(); + private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE") + .globalErrorTracker(ERROR_TRACKER); private final BukkitMetrics metrics = context.metricsFactory() // Custom metrics require a corresponding data source in your project settings diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 3de24d81..b3cd6357 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -81,7 +81,7 @@ private int getPlayerCount() { return plugin.getServer().getOnlinePlayers().size(); } catch (final Throwable t) { logger.error("Failed to get player count", t); - // todo: track error? + trackError(t, true); return 0; } } @@ -90,7 +90,9 @@ private int getPlayerCount() { public void ready() { try { Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); - plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, context), plugin); + plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, error -> { + trackError(error, false); + }), plugin); } catch (final ClassNotFoundException ignored) { } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java index 1797b6e4..031ae47e 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java @@ -2,18 +2,20 @@ import com.destroystokyo.paper.event.server.ServerExceptionEvent; import com.destroystokyo.paper.exception.ServerPluginException; -import dev.faststats.SimpleContext; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.plugin.Plugin; -record PaperEventListener(Plugin plugin, SimpleContext context) implements Listener { +import java.util.function.Consumer; + +// todo: replace consumer with context tracker +record PaperEventListener(Plugin plugin, Consumer errorTracker) implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onServerException(final ServerExceptionEvent event) { if (!(event.getException() instanceof final ServerPluginException exception)) return; if (!exception.getResponsiblePlugin().equals(plugin)) return; final var report = exception.getCause() != null ? exception.getCause() : exception; - context.errorTrackers().forEach(tracker -> tracker.trackError(report, false)); + errorTracker.accept(report); } } diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index e4b3363f..fbbd9c7b 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -20,8 +20,17 @@ final class ErrorHelper { private static final int STACK_TRACE_LENGTH = Math.min(500, Integer.getInteger("faststats.stack-trace-length", 300)); private static final int STACK_TRACE_LIMIT = Math.min(50, Integer.getInteger("faststats.stack-trace-limit", 15)); + private static final List> defaultAnonymizationEntries = defaultAnonymizationEntries(); + public static JsonObject compile(final Throwable error, @Nullable final List suppress, final boolean handled, final List> customPatterns) { + final var patterns = new ArrayList<>(customPatterns); + patterns.addAll(defaultAnonymizationEntries); + return compileAll(error, suppress, handled, patterns); + } + + private static JsonObject compileAll(final Throwable error, @Nullable final List suppress, final boolean handled, + final List> customPatterns) { final var report = new JsonObject(); final var message = getAnonymizedMessage(error, customPatterns); @@ -49,6 +58,7 @@ public static JsonObject compile(final Throwable error, @Nullable final List parentStack, @Nullable final List suppress, final JsonArray stacktrace, final List> customPatterns) { @@ -206,15 +216,27 @@ private static boolean isSameClassLoader(final ClassLoader classLoader, final Cl return truncated; } - public static Pattern discordWebhookPattern() { + private static List> defaultAnonymizationEntries() { + final var entries = new ArrayList<>(List.of( + Map.entry(ipv4Pattern(), "[IP hidden]"), + Map.entry(ipv6Pattern(), "[IP hidden]"), + Map.entry(userHomePathPattern(), "$1$2$3[username hidden]"), + Map.entry(discordWebhookPattern(), "$1[token hidden]"), + Map.entry(jdbcUrlPattern(), "$1[password hidden]$2") + )); + usernamePattern().ifPresent(pattern -> entries.add(Map.entry(pattern, "[username hidden]"))); + return entries; + } + + private static Pattern discordWebhookPattern() { return Pattern.compile("(https://discord\\.com/api/webhooks/\\d+/)[\\w-]+"); } - public static Pattern ipv4Pattern() { + private static Pattern ipv4Pattern() { return Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b"); } - public static Pattern ipv6Pattern() { + private static Pattern ipv6Pattern() { return Pattern.compile("(?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) @@ -228,11 +250,11 @@ public static Pattern ipv6Pattern() { "(?i)\\b::\\b"); // Just :: } - public static Pattern jdbcUrlPattern() { + private static Pattern jdbcUrlPattern() { return Pattern.compile("(jdbc:[^:]+://[^:]+:(?:\\d+:)?)[^@]+(@)"); } - public static Pattern userHomePathPattern() { + private static Pattern userHomePathPattern() { return Pattern.compile("(/home/)[^/\\s]+" + // Linux: /home/username "|(/Users/)[^/\\s]+" + // macOS: /Users/username "|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"); // Windows: A-Z:\\Users\\username @@ -240,7 +262,7 @@ public static Pattern userHomePathPattern() { private static final Set allowedNames = Set.of("minecraft", "server", "root", "ubuntu"); - public static Optional usernamePattern() { + private static Optional usernamePattern() { return Optional.ofNullable(System.getProperty("user.name")) .filter(s -> s.trim().length() > 2) .filter(s -> !allowedNames.contains(s.toLowerCase(Locale.ROOT))) diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 03951e6c..cbf35fea 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -14,6 +14,31 @@ * @since 0.24.0 */ public sealed interface ErrorTracker permits SimpleErrorTracker { + /** + * Creates a context-aware error tracker policy. + * + * @return the error tracker policy + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + static ErrorTracker aware() { + final var tracker = new SimpleErrorTracker(); + tracker.attachErrorContext(ErrorTracker.class.getClassLoader()); + return tracker; + } + + /** + * Creates a context-unaware error tracker policy. + * + * @return the error tracker policy + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + static ErrorTracker unaware() { + return new SimpleErrorTracker(); + } + + // todo: return tracker object to supply additional information /** * Tracks a handled error. * @@ -35,6 +60,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { @Contract(mutates = "this") void trackError(Throwable error); + // todo: remove handled overloads /** * Tracks an error. *

diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java new file mode 100644 index 00000000..f015134c --- /dev/null +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -0,0 +1,183 @@ +package dev.faststats; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.net.ConnectException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPOutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; + +final class ErrorTrackingSink { + private final Logger logger = LoggerFactory.factory().getLogger(getClass()); + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + private final SimpleContext context; + private final URI url = getErrorTrackerServerUrl(); + + final Set errorTrackers = new CopyOnWriteArraySet<>(); + final Set> submissionJobs = new CopyOnWriteArraySet<>(); + + private final @Nullable SimpleErrorTracker internalErrorTracker; + private volatile @Nullable ScheduledExecutorService submissionScheduler; + private volatile @Nullable ScheduledFuture errorSubmissionJob; + + private static final Object DISPATCHER_LOCK = new Object(); + private static final Set DISPATCHER_CONTEXTS = new CopyOnWriteArraySet<>(); + private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); + private static @Nullable Thread.UncaughtExceptionHandler originalHandler; + + ErrorTrackingSink(final SimpleContext context) { + this.context = context; + } + + private static URI getErrorTrackerServerUrl() { + final var property = System.getProperty("faststats.error-tracker-server"); + if (property != null) try { + return new URI(property); + } catch (final URISyntaxException e) { + final var logger = LoggerFactory.factory().getLogger(SimpleMetrics.class); + logger.error("Failed to parse error tracker server url: %s", e, property); + } + return URI.create("https://metrics.faststats.dev/v1/error"); + } + + JsonObject getData() { + final var data = new JsonObject(); + final var reports = new JsonArray(this.reports.size()); + this.reports.forEach((entry, count) -> { + final var copy = entry.deepCopy(); + if (count > 1) copy.addProperty("count", count); + reports.add(copy); + }); + context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); + // todo: add global context + data.addProperty("sdk_name", context.getSdkInfo().getName()); + data.addProperty("sdk_version", context.getSdkInfo().getVersion()); + return data; + } + + // todo: improve logging to be less cluttered; dedupe code + void submit() { + if (!context.getConfig().errorTracking()) return; + + final var errors = getData(); + if (errors.isEmpty()) return; + + final var data = new JsonObject(); + data.addProperty("project_name", context.getProjectName()); + data.addProperty("identifier", context.getConfig().serverId().toString()); + data.add("errors", errors); + + try (final var byteOutput = new ByteArrayOutputStream(); + final var output = new GZIPOutputStream(byteOutput)) { + output.write(data.toString().getBytes(UTF_8)); + output.finish(); + + final var compressed = byteOutput.toByteArray(); + logger.info("Sending errors to: %s", url); + // todo: dedupe this + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) + .header("Content-Encoding", "gzip") + .header("Content-Type", "application/octet-stream") + .header("Authorization", "Bearer " + context.getToken()) + .header("User-Agent", context.getSdkInfo().getUserAgent()) + .timeout(Duration.ofSeconds(3)) + .uri(url) + .build(); + + final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); + final var statusCode = response.statusCode(); + final var body = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + logger.info("Errors submitted with status code: %s (%s)", statusCode, body); + clear(); + } else if (statusCode >= 300 && statusCode < 400) { + logger.warn("Received redirect response from error server: %s (%s)", statusCode, body); + } else if (statusCode >= 400 && statusCode < 500) { + logger.error("Submitted invalid request to error server: %s (%s)", null, statusCode, body); + } else if (statusCode >= 500 && statusCode < 600) { + logger.error("Received server error response from error server: %s (%s)", null, statusCode, body); + } else { + logger.warn("Received unexpected response from error server: %s (%s)", statusCode, body); + } + } catch (final HttpConnectTimeoutException t) { + logger.error("Error submission timed out after 3 seconds: %s", null, url); + } catch (final ConnectException t) { + logger.error("Failed to connect to error server: %s", null, url); + } catch (final Throwable t) { + logger.error("Failed to submit errors", t); + } + } + + void clear() { + } + + ScheduledFuture scheduleSubmission( + final Runnable task, + final long initialDelay, + final long period, + final TimeUnit unit + ) { + final var scheduler = submissionScheduler(); + final var future = scheduler.scheduleAtFixedRate(task, Math.max(0, initialDelay), Math.max(1000, period), unit); + submissionJobs.add(future); + return future; + } + + void unregisterSubmission(final ScheduledFuture future) { + future.cancel(false); + submissionJobs.remove(future); + } + + boolean isSubmissionSchedulerRunning() { + final var scheduler = submissionScheduler; + return scheduler != null && !scheduler.isShutdown(); + } + + private ScheduledExecutorService submissionScheduler() { + var scheduler = submissionScheduler; + if (scheduler != null && !scheduler.isShutdown()) return scheduler; + synchronized (this) { + scheduler = submissionScheduler; + if (scheduler != null && !scheduler.isShutdown()) return scheduler; + submissionScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + final var thread = new Thread(runnable, "faststats-submitter"); + thread.setDaemon(true); + return thread; + }); + return submissionScheduler; + } + } + + void startErrorSubmission() { + if (!context.getConfig().errorTracking() || context.errorSubmissionJob != null) return; + errorSubmissionJob = scheduleSubmission( + errorTrackingSink::submit, + TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)), + TimeUnit.MINUTES.toMillis(30), + TimeUnit.MILLISECONDS + ); + } +} diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index c9543f83..9dd003a5 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -2,6 +2,8 @@ import org.jetbrains.annotations.Contract; +import java.util.Optional; + /** * Shared FastStats context. *

@@ -49,37 +51,17 @@ public sealed interface FastStatsContext permits SimpleContext { FeatureFlagService.Factory featureFlagServiceFactory(); /** - * 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}. + * Get the registered internal/global error tracker, if one was configured. * - * @return the error tracker - * @see #unawareErrorTracker() - * @see ErrorTracker#attachErrorContext(ClassLoader) - * @see ErrorTracker#trackError(String, boolean) - * @see ErrorTracker#trackError(Throwable, boolean) + * @return the internal/global error tracker * @since 0.24.0 */ - @Contract(value = " -> new") - ErrorTracker awareErrorTracker(); + @Contract(pure = true) + Optional errorTracker(); - /** - * 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 #awareErrorTracker() - * @see ErrorTracker#trackError(String) - * @see ErrorTracker#trackError(Throwable) - * @since 0.24.0 - */ - @Contract(value = " -> new", pure = true) - ErrorTracker unawareErrorTracker(); + FastStatsContext globalErrorTracker(ErrorTracker errorTracker); + + FastStatsContext registerErrorTracker(ErrorTracker errorTracker); /** * Get the SDK information shared by services created from this context. diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index c761f98b..e84efc62 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -2,16 +2,27 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; -import java.util.Collections; +import java.lang.Thread.UncaughtExceptionHandler; +import java.util.Optional; import java.util.Properties; import java.util.Set; -import java.util.WeakHashMap; - +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +// fixme: thread safety +// todo: cleanup +// todo: introduce a factory pattern to shorten the constructor? @ApiStatus.Internal public non-sealed abstract class SimpleContext implements FastStatsContext { + private final ErrorTrackingSink errorTrackingSink = new ErrorTrackingSink(this); + private final Config config; private final @Token String token; private final SdkInfo sdkInfo; @@ -77,17 +88,16 @@ public final FeatureFlagService.Factory featureFlagServiceFactory() { } @Override - @Contract(value = " -> new") - public final ErrorTracker awareErrorTracker() { - final var tracker = new SimpleErrorTracker(this); - tracker.attachErrorContext(ErrorTracker.class.getClassLoader()); - return tracker; + @Contract(pure = true) + public final Optional errorTracker() { + return Optional.ofNullable(internalErrorTracker); } @Override - @Contract(value = " -> new", pure = true) - public final ErrorTracker unawareErrorTracker() { - return new SimpleErrorTracker(this); + public final SimpleContext registerErrorTracker(final ErrorTracker tracker) { + errorTrackingSink.errorTrackers.add((SimpleErrorTracker) tracker); + errorTrackingSink.startErrorSubmission(); + return this; } @Override @@ -95,4 +105,13 @@ public final ErrorTracker unawareErrorTracker() { public SdkInfo getSdkInfo() { return sdkInfo; } + + ErrorTrackingSink errorTrackingSink() { + return errorTrackingSink; + } + + void trackInternalError(final Throwable error, final boolean handled) { + if (internalErrorTracker == null) return; + internalErrorTracker.trackError(error, handled); + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index ca1a158c..7f6529d8 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -2,9 +2,9 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import org.jetbrains.annotations.VisibleForTesting; import org.jspecify.annotations.Nullable; -import java.lang.Thread.UncaughtExceptionHandler; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; @@ -19,28 +19,17 @@ import java.util.regex.Pattern; final class SimpleErrorTracker implements ErrorTracker { + // todo: track unchanged exceptions and counts? private final Map reports = new ConcurrentHashMap<>(); private final Map, Set> ignoredTypedPatterns = new ConcurrentHashMap<>(); private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); private final Set ignoredPatterns = new CopyOnWriteArraySet<>(); - private final List> anonymizationEntries = new CopyOnWriteArrayList<>(List.of( - Map.entry(ErrorHelper.ipv4Pattern(), "[IP hidden]"), - Map.entry(ErrorHelper.ipv6Pattern(), "[IP hidden]"), - Map.entry(ErrorHelper.userHomePathPattern(), "$1$2$3[username hidden]"), - Map.entry(ErrorHelper.discordWebhookPattern(), "$1[token hidden]"), - Map.entry(ErrorHelper.jdbcUrlPattern(), "$1[password hidden]$2") - )); + private final List> anonymizationEntries = new CopyOnWriteArrayList<>(); - private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent = null; - private volatile @Nullable UncaughtExceptionHandler originalHandler = null; - - private final FastStatsContext context; - - public SimpleErrorTracker(final FastStatsContext context) { - this.context = context; - ErrorHelper.usernamePattern().ifPresent(pattern -> anonymizationEntries.add(Map.entry(pattern, "[username hidden]"))); - } + private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent; + private volatile @Nullable ClassLoader attachedLoader; + private volatile boolean contextAttached; @Override public void trackError(final String message) { @@ -108,49 +97,42 @@ public ErrorTracker anonymize(final Pattern pattern, final String replacement) { return this; } + @VisibleForTesting public JsonArray getData() { final var report = new JsonArray(reports.size()); reports.forEach((entry, count) -> { final var copy = entry.deepCopy(); - context.getSdkInfo().getBuildId().ifPresent(id -> copy.addProperty("buildId", id)); if (count > 1) copy.addProperty("count", count); report.add(copy); }); return report; } + @VisibleForTesting public void clear() { reports.clear(); } @Override public synchronized void attachErrorContext(@Nullable final ClassLoader loader) throws IllegalStateException { - if (originalHandler != null) throw new IllegalStateException("Error context already attached"); - originalHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { - final var handler = originalHandler; - if (handler != null) handler.uncaughtException(thread, error); - try { - if (loader != null && !ErrorTracker.isSameLoader(loader, error)) return; - final var event = errorEvent; - if (event != null) event.accept(loader, error); - trackError(error, false); - } catch (final Throwable t) { - trackError(t, false); - } - }); + if (contextAttached) throw new IllegalStateException("Error context already attached"); + contextAttached = true; + attachedLoader = loader; + // fixme: single source of truth? + // SimpleContext.attachErrorTracker(this); } @Override public synchronized void detachErrorContext() { - if (originalHandler == null) return; - Thread.setDefaultUncaughtExceptionHandler(originalHandler); - originalHandler = null; + if (!contextAttached) return; + contextAttached = false; + // fixme: single source of truth? + // SimpleContext.detachErrorTracker(this); } @Override - public synchronized boolean isContextAttached() { - return originalHandler != null; + public boolean isContextAttached() { + return contextAttached; } @Override diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index a88225c3..9a30b0de 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -1,6 +1,5 @@ package dev.faststats; -import com.google.gson.JsonArray; import com.google.gson.JsonObject; import dev.faststats.data.Metric; import dev.faststats.internal.Logger; @@ -23,8 +22,7 @@ import java.time.Duration; import java.util.HashSet; import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.zip.GZIPOutputStream; @@ -39,7 +37,7 @@ public abstract class SimpleMetrics implements Metrics { .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); - private @Nullable ScheduledExecutorService executor = null; + private @Nullable ScheduledFuture submissionJob = null; private final @Nullable Runnable flush; private final Set> metrics; @@ -107,22 +105,16 @@ private void startSubmitting(final long initialDelay, final long period, final T return; } - this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> { // todo: SINGLE THREAD??? what was i smoking? - final var thread = new Thread(runnable, "metrics-submitter"); - thread.setDaemon(true); - return thread; - }); - logger.info("Starting metrics submission"); - executor.scheduleAtFixedRate(this::submit, Math.max(0, initialDelay), Math.max(1000, period), unit); + submissionJob = context.scheduleSubmission(this::submit, initialDelay, period, unit); } protected boolean isSubmitting() { - return executor != null && !executor.isShutdown(); + return submissionJob != null && !submissionJob.isCancelled(); } protected final void trackError(final Throwable error, final boolean handled) { - context.errorTrackingSink().track(error, handled); + context.trackInternalError(error, handled); } // todo: improve logging to be less cluttered @@ -202,7 +194,7 @@ protected JsonObject createData() { appendDefaultData(metrics); } catch (final Throwable t) { logger.error("Failed to append default data", t); - // getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); // todo: fixme – report directly to faststats? + context.trackInternalError(t, true); } this.metrics.forEach(metric -> { @@ -210,7 +202,7 @@ protected JsonObject createData() { metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { logger.error("Failed to build metric data: %s", t, metric.getId()); - // getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); // todo: fixme – report directly to faststats? + context.trackInternalError(t, true); } }); @@ -219,17 +211,6 @@ protected JsonObject createData() { data.addProperty("identifier", context.getConfig().serverId().toString()); data.add("data", metrics); - // todo: remove with dedicated error tracking route - if (context.getConfig().errorTracking()) context.errorTrackers().stream() - .map(SimpleErrorTracker.class::cast) - .map(SimpleErrorTracker::getData) - .filter(errors -> !errors.isEmpty()) - .reduce((first, second) -> { - final var errors = new JsonArray(first.size() + second.size()); - errors.addAll(first); - errors.addAll(second); - return first; - }).ifPresent(errors -> data.add("errors", errors)); return data; } @@ -238,15 +219,15 @@ protected JsonObject createData() { @Override public void shutdown() { - context.errorTrackers().forEach(ErrorTracker::detachErrorContext); - if (executor != null) try { + // context.errorTrackers().forEach(ErrorTracker::detachErrorContext); // todo: detach all error contexts on shutdown? + if (submissionJob != null) try { logger.info("Shutting down metrics submission"); - executor.shutdown(); + context.unregisterSubmission(submissionJob); submit(); } catch (final Throwable t) { logger.error("Failed to submit metrics on shutdown", t); } finally { - executor = null; + submissionJob = null; } } diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index cdc89048..55d98442 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -3,7 +3,6 @@ import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; -import java.util.UUID; import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -11,12 +10,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public final class AnonymizationTest { - private final MockContext context = new MockContext(UUID.randomUUID(), false); + private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.unaware(); + private final FastStatsContext context = new MockContext(TRACKER); private final MockMetrics metrics = (MockMetrics) context.metricsFactory().create(); - private final ErrorTracker tracker = context.unawareErrorTracker(); private JsonObject getError() { - return ((SimpleErrorTracker) tracker).getData().get(0).getAsJsonObject(); + return TRACKER.getData().get(0).getAsJsonObject(); } private String getErrorMessage() { @@ -25,108 +24,108 @@ private String getErrorMessage() { @Test public void ipv4Anonymization() { - tracker.trackError("Connection refused at 192.168.1.100"); + TRACKER.trackError("Connection refused at 192.168.1.100"); assertEquals("Connection refused at [IP hidden]", getErrorMessage()); } @Test public void ipv6Anonymization() { - tracker.trackError("Failed to connect to f833:be65:65da:975b:4896:88f7:6964:44c0"); + TRACKER.trackError("Failed to connect to f833:be65:65da:975b:4896:88f7:6964:44c0"); assertEquals("Failed to connect to [IP hidden]", getErrorMessage()); } @Test public void userHomePathAnonymization() { final var username = System.getProperty("user.name", "user"); - tracker.trackError("File not found: /home/" + username + "/config.yml"); + TRACKER.trackError("File not found: /home/" + username + "/config.yml"); assertEquals("File not found: /home/[username hidden]/config.yml", getErrorMessage()); } @Test public void windowsUserPathAnonymization() { final var username = System.getProperty("user.name", "user"); - tracker.trackError("File not found: C:\\Users\\" + username + "\\config.yml"); + TRACKER.trackError("File not found: C:\\Users\\" + username + "\\config.yml"); assertEquals("File not found: C:\\Users\\[username hidden]\\config.yml", getErrorMessage()); } @Test public void macUserPathAnonymization() { final var username = System.getProperty("user.name", "user"); - tracker.trackError("File not found: /Users/" + username + "/config.yml"); + TRACKER.trackError("File not found: /Users/" + username + "/config.yml"); assertEquals("File not found: /Users/[username hidden]/config.yml", getErrorMessage()); } @Test public void usernameAnonymizationIsCaseInsensitive() { final var username = System.getProperty("user.name", "user"); - tracker.trackError("Error for " + swapCase(username)); + TRACKER.trackError("Error for " + swapCase(username)); assertEquals("Error for [username hidden]", getErrorMessage()); } @Test public void discordWebhookAnonymization() { - tracker.trackError("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/aAaAaAaa0AAaAAaaaAAAAa_0AAAAAAAaaaAaaAaaAAAA0aA00AAA0AAA0aAAaA0a0a0A"); + TRACKER.trackError("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/aAaAaAaa0AAaAAaaaAAAAa_0AAAAAAAaaaAaaAaaAAAA0aA00AAA0AAA0aAAaA0a0a0A"); assertEquals("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/[token hidden]", getErrorMessage()); } @Test public void jdbcUrlAnonymization() { - tracker.trackError("Failed: jdbc:mysql://localhost:3306:secretpass@mydb"); + TRACKER.trackError("Failed: jdbc:mysql://localhost:3306:secretpass@mydb"); assertEquals("Failed: jdbc:mysql://localhost:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlNoPortAnonymization() { - tracker.trackError("Failed: jdbc:mysql://mydb.com:secretpass@mydb"); + TRACKER.trackError("Failed: jdbc:mysql://mydb.com:secretpass@mydb"); assertEquals("Failed: jdbc:mysql://mydb.com:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlIpAnonymization() { - tracker.trackError("Failed: jdbc:mysql://127.0.0.1:3306:secretpass@mydb"); + TRACKER.trackError("Failed: jdbc:mysql://127.0.0.1:3306:secretpass@mydb"); assertEquals("Failed: jdbc:mysql://[IP hidden]:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void customPatternAnonymizesMessage() { - tracker.anonymize("token=[^&]+", "token=[redacted]"); - tracker.trackError("Request failed with token=abc123secret&user=test"); + TRACKER.anonymize("token=[^&]+", "token=[redacted]"); + TRACKER.trackError("Request failed with token=abc123secret&user=test"); assertEquals("Request failed with token=[redacted]&user=test", getErrorMessage()); } @Test public void customPatternWithCompiledPattern() { - tracker.anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); - tracker.trackError("Auth failed: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"); + TRACKER.anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); + TRACKER.trackError("Auth failed: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"); assertEquals("Auth failed: Bearer [redacted]", getErrorMessage()); } @Test public void customPatternWithCaptureGroupReplacement() { - tracker.anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); - tracker.trackError("GET /data?api_key=sk_live_12345&format=json failed"); + TRACKER.anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); + TRACKER.trackError("GET /data?api_key=sk_live_12345&format=json failed"); assertEquals("GET /data?api_key=[redacted]&format=json failed", getErrorMessage()); } @Test public void multipleCustomPatterns() { - tracker.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]"); - tracker.anonymize("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[email hidden]"); - tracker.trackError("Auth failed for user@example.com with Bearer eyJ0eXAi"); + TRACKER.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]") + .anonymize("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[email hidden]"); + TRACKER.trackError("Auth failed for user@example.com with Bearer eyJ0eXAi"); assertEquals("Auth failed for [email hidden] with Bearer [redacted]", getErrorMessage()); } @Test public void customPatternChaining() { - tracker.anonymize("secret-[a-z]+", "[secret hidden]"); - tracker.anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]"); - tracker.trackError("Credentials: secret-abcdef / AKIA1234567890ABCDEF"); + TRACKER.anonymize("secret-[a-z]+", "[secret hidden]") + .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]"); + TRACKER.trackError("Credentials: secret-abcdef / AKIA1234567890ABCDEF"); assertEquals("Credentials: [secret hidden] / [aws-key hidden]", getErrorMessage()); } @Test public void customPatternAppliedToCauseChain() { - tracker.anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); + final var tracker = TRACKER.anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); final var cause = new IllegalArgumentException("Validation failed for ssn=123-45-6789"); tracker.trackError(new RuntimeException("Processing error", cause)); final var error = getError(); @@ -142,30 +141,30 @@ public void customPatternAppliedToCauseChain() { @Test public void nullMessageNotAffected() { - tracker.anonymize("anything", "[redacted]"); - tracker.trackError(new RuntimeException((String) null)); + TRACKER.anonymize("anything", "[redacted]"); + TRACKER.trackError(new RuntimeException((String) null)); assertFalse(getError().has("message")); } @Test public void customAndBuiltInPatternsCombined() { - tracker.anonymize("session=[a-f0-9]+", "session=[redacted]"); + TRACKER.anonymize("session=[a-f0-9]+", "session=[redacted]"); final var username = System.getProperty("user.name", "user"); - tracker.trackError("Error for 192.168.1.1 with session=deadbeef01 at /home/" + username + "/app"); + TRACKER.trackError("Error for 192.168.1.1 with session=deadbeef01 at /home/" + username + "/app"); assertEquals("Error for [IP hidden] with session=[redacted] at /home/[username hidden]/app", getErrorMessage()); } @Test public void emptyReplacementRemovesMatch() { - tracker.anonymize("\\(internal ref: [^)]+\\)", ""); - tracker.trackError("Request failed (internal ref: REF-98765)"); + TRACKER.anonymize("\\(internal ref: [^)]+\\)", ""); + TRACKER.trackError("Request failed (internal ref: REF-98765)"); assertEquals("Request failed ", getErrorMessage()); } @Test public void patternDoesNotMatchLeavesMessageUnchanged() { - tracker.anonymize("SECRET_[A-Z]+", "[redacted]"); - tracker.trackError("just a normal error"); + TRACKER.anonymize("SECRET_[A-Z]+", "[redacted]"); + TRACKER.trackError("just a normal error"); assertEquals("just a normal error", getErrorMessage()); } diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 8b54f949..d43487c2 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -4,7 +4,6 @@ import java.net.URL; import java.net.URLClassLoader; -import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -14,8 +13,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { - private final MockContext context = new MockContext(UUID.randomUUID(), true); - + private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.unaware(); + private final MockContext context = new MockContext(TRACKER); + @Test public void sameClassLoader() { final var loader = getClass().getClassLoader(); @@ -129,11 +129,9 @@ private IllegalArgumentException createExceptionWithStack() { @Test public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); - - tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); + TRACKER.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = TRACKER.getData().get(0).getAsJsonObject(); final var message = report.get("message").getAsString(); final var header = report.getAsJsonArray("stack").get(0).getAsString(); @@ -143,12 +141,10 @@ public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { @Test public void appliesCustomRedactionAfterBuiltInRedaction() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); - tracker.anonymize("session=[^ ]+", "session=[hidden]"); + TRACKER.anonymize("session=[^ ]+", "session=[hidden]"); + TRACKER.trackError("failed with session=abc123 from 10.0.0.1"); - tracker.trackError("failed with session=abc123 from 10.0.0.1"); - - final var message = tracker.getData() + final var message = TRACKER.getData() .get(0) .getAsJsonObject() .get("message") @@ -159,18 +155,15 @@ public void appliesCustomRedactionAfterBuiltInRedaction() { @Test public void nullMessagesAreNotSerializedAsMessageProperty() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); - - tracker.trackError(new RuntimeException((String) null)); + TRACKER.trackError(new RuntimeException((String) null)); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = TRACKER.getData().get(0).getAsJsonObject(); assertFalse(report.has("message")); assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); } @Test public void nestedCausesAreSerializedInOrder() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var root = new IllegalArgumentException("root secret 172.16.0.9"); root.setStackTrace(new StackTraceElement[]{ new StackTraceElement("example.Root", "fail", "Root.java", 10) @@ -187,9 +180,9 @@ public void nestedCausesAreSerializedInOrder() { new StackTraceElement("example.Root", "fail", "Root.java", 10) }); - tracker.trackError(top, false); + TRACKER.trackError(top, false); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = TRACKER.getData().get(0).getAsJsonObject(); final var stack = report.getAsJsonArray("stack"); assertEquals(RuntimeException.class.getName(), report.get("error").getAsString()); @@ -205,14 +198,13 @@ public void nestedCausesAreSerializedInOrder() { @Test public void cyclicCauseChainStopsAfterFirstVisit() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var first = new RuntimeException("first"); final var second = new IllegalStateException("second", first); first.initCause(second); - tracker.trackError(first); + TRACKER.trackError(first); - final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = TRACKER.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); var firstCauseCount = 0; var secondCauseCount = 0; for (final var element : stack) { @@ -227,14 +219,13 @@ public void cyclicCauseChainStopsAfterFirstVisit() { @Test public void duplicateErrorsAreAggregatedWithCount() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var first = createStableError(); final var second = createStableError(); - tracker.trackError(first); - tracker.trackError(second); + TRACKER.trackError(first); + TRACKER.trackError(second); - final var reports = tracker.getData(); + final var reports = TRACKER.getData(); final var report = reports.get(0).getAsJsonObject(); assertEquals(1, reports.size()); @@ -244,34 +235,31 @@ public void duplicateErrorsAreAggregatedWithCount() { @Test public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); - tracker.trackError(createStableError()); - tracker.trackError(createStableError()); + TRACKER.trackError(createStableError()); + TRACKER.trackError(createStableError()); - tracker.clear(); + TRACKER.clear(); - assertEquals(0, tracker.getData().size()); + assertEquals(0, TRACKER.getData().size()); - tracker.trackError(createStableError()); + TRACKER.trackError(createStableError()); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = TRACKER.getData().get(0).getAsJsonObject(); assertEquals("duplicate", report.get("message").getAsString()); assertNull(report.get("count")); } @Test public void ignoredNestedCauseSuppressesWholeReport() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); - tracker.ignoreError(IllegalArgumentException.class, "ignore me"); + TRACKER.ignoreError(IllegalArgumentException.class, "ignore me"); - tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); + TRACKER.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); - assertEquals(0, tracker.getData().size()); + assertEquals(0, TRACKER.getData().size()); } @Test public void repeatingStackFramesAreCollapsed() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var error = new StackOverflowError("recursive"); error.setStackTrace(new StackTraceElement[]{ new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), @@ -282,9 +270,9 @@ public void repeatingStackFramesAreCollapsed() { new StackTraceElement("example.Recursive", "b", "Recursive.java", 2) }); - tracker.trackError(error); + TRACKER.trackError(error); - final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = TRACKER.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); @@ -294,12 +282,11 @@ public void repeatingStackFramesAreCollapsed() { @Test public void longMessagesAreTruncatedBeforeSerialization() { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var message = "a".repeat(600); - tracker.trackError(message); + TRACKER.trackError(message); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = TRACKER.getData().get(0).getAsJsonObject(); final var serialized = report.get("message").getAsString(); assertEquals(503, serialized.length()); assertTrue(serialized.endsWith("...")); @@ -308,15 +295,14 @@ public void longMessagesAreTruncatedBeforeSerialization() { @Test public void attachedContextTracksUnhandledThreadError() throws InterruptedException { - final var tracker = (SimpleErrorTracker) context.unawareErrorTracker(); final var handled = new CountDownLatch(1); final var thrown = new RuntimeException("async failure"); thrown.setStackTrace(new StackTraceElement[]{ new StackTraceElement("example.Async", "run", "Async.java", 7) }); - tracker.setContextErrorHandler((loader, error) -> handled.countDown()); - tracker.attachErrorContext(null); + TRACKER.setContextErrorHandler((loader, error) -> handled.countDown()); + TRACKER.attachErrorContext(null); try { final var thread = new Thread(() -> { throw thrown; @@ -325,11 +311,11 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept thread.join(1000); assertTrue(handled.await(1, TimeUnit.SECONDS)); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = TRACKER.getData().get(0).getAsJsonObject(); assertEquals("async failure", report.get("message").getAsString()); assertFalse(report.get("handled").getAsBoolean()); } finally { - tracker.detachErrorContext(); + TRACKER.detachErrorContext(); } } diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index fa3682df..5b823d79 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -1,10 +1,16 @@ package dev.faststats; +import org.jspecify.annotations.Nullable; + import java.util.UUID; public final class MockContext extends SimpleContext { - public MockContext(final UUID serverId, final boolean debug) throws IllegalArgumentException { - super(new MockConfig(serverId, debug), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + public MockContext() throws IllegalArgumentException { + this(null); + } + + public MockContext(@Nullable final ErrorTracker internalErrorTracker) throws IllegalArgumentException { + super(new MockConfig(UUID.randomUUID()), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", internalErrorTracker); } @Override @@ -22,7 +28,7 @@ public String getProjectName() { return "Mock"; } - private record MockConfig(UUID serverId, boolean debug) implements dev.faststats.Config { + private record MockConfig(UUID serverId) implements Config { @Override public boolean enabled() { return true; @@ -37,5 +43,10 @@ public boolean errorTracking() { public boolean additionalMetrics() { return true; } + + @Override + public boolean debug() { + return true; + } } } diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 15ebefd5..c029d744 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -14,6 +14,10 @@ protected boolean preSubmissionStart() { return true; } + void startTestSubmitting() { + startSubmitting(); + } + @Override public JsonObject createData() { return super.createData(); diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java index 7f276d16..6213d4e6 100644 --- a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java @@ -1,12 +1,13 @@ package dev.faststats.hytale.logger; +import dev.faststats.internal.Logger; import org.intellij.lang.annotations.PrintFormat; import org.jspecify.annotations.Nullable; import java.util.function.Predicate; import java.util.logging.Level; -final class HytaleLogger implements dev.faststats.internal.Logger { +final class HytaleLogger implements Logger { private final com.hypixel.hytale.logger.HytaleLogger logger; private volatile @Nullable Predicate filter; diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java index 2a7c420f..5af5e688 100644 --- a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java @@ -1,8 +1,11 @@ package dev.faststats.hytale.logger; -public final class HytaleLoggerFactory implements dev.faststats.internal.LoggerFactory { +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; + +public final class HytaleLoggerFactory implements LoggerFactory { @Override - public dev.faststats.internal.Logger getLogger(final String name) { + public Logger getLogger(final String name) { return new HytaleLogger(name); } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 9871d2f8..2dba1551 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -34,7 +34,7 @@ protected void appendDefaultData(final JsonObject metrics) { @Override public void ready() { - context.errorTrackers().forEach(this::registerExceptionHandler); + // context.errorTrackers().forEach(this::registerExceptionHandler); // fixme } private void registerExceptionHandler(final ErrorTracker errorTracker) { From 376f325adff5fb41a2be65db48269af6585ca61f Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 13:17:04 +0200 Subject: [PATCH 099/140] W.I.P --- .../dev/faststats/bukkit/BukkitContext.java | 7 ++++ .../example/ErrorTrackerExample.java | 4 +- .../java/dev/faststats/ErrorTrackingSink.java | 39 ++++++++++++++----- .../java/dev/faststats/FastStatsContext.java | 3 ++ .../java/dev/faststats/SimpleContext.java | 21 +++++----- .../java/dev/faststats/SimpleMetrics.java | 4 +- 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index e4f7fa62..890d251d 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -1,5 +1,6 @@ package dev.faststats.bukkit; +import dev.faststats.ErrorTracker; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -27,6 +28,12 @@ public BukkitMetrics.Factory metricsFactory() { return new BukkitMetricsImpl.Factory(this); } + @Override + public BukkitContext globalErrorTracker(final ErrorTracker errorTracker) { + super.globalErrorTracker(errorTracker); + return this; + } + private static Path getConfigPath(final Plugin plugin) { return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties"); } diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index f90027cf..85f5c7ab 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -8,12 +8,12 @@ public final class ErrorTrackerExample { // Context-aware: automatically tracks uncaught errors from the same class loader - public static final ErrorTracker CONTEXT_AWARE = getContext().awareErrorTracker() + public static final ErrorTracker CONTEXT_AWARE = ErrorTracker.aware() .ignoreError(InvocationTargetException.class, "Expected .* but got .*") .ignoreError(AccessDeniedException.class); // Context-unaware: only tracks errors passed to trackError() manually - public static final ErrorTracker CONTEXT_UNAWARE = getContext().unawareErrorTracker() + public static final ErrorTracker CONTEXT_UNAWARE = ErrorTracker.unaware() .anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index f015134c..f8708793 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -15,6 +15,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executors; @@ -37,14 +38,14 @@ final class ErrorTrackingSink { final Set errorTrackers = new CopyOnWriteArraySet<>(); final Set> submissionJobs = new CopyOnWriteArraySet<>(); - private final @Nullable SimpleErrorTracker internalErrorTracker; + private @Nullable SimpleErrorTracker internalErrorTracker; private volatile @Nullable ScheduledExecutorService submissionScheduler; private volatile @Nullable ScheduledFuture errorSubmissionJob; private static final Object DISPATCHER_LOCK = new Object(); private static final Set DISPATCHER_CONTEXTS = new CopyOnWriteArraySet<>(); private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); - private static @Nullable Thread.UncaughtExceptionHandler originalHandler; + private static Thread.@Nullable UncaughtExceptionHandler originalHandler; ErrorTrackingSink(final SimpleContext context) { this.context = context; @@ -63,16 +64,14 @@ private static URI getErrorTrackerServerUrl() { JsonObject getData() { final var data = new JsonObject(); - final var reports = new JsonArray(this.reports.size()); - this.reports.forEach((entry, count) -> { - final var copy = entry.deepCopy(); - if (count > 1) copy.addProperty("count", count); - reports.add(copy); - }); + final var reports = new JsonArray(); + if (internalErrorTracker != null) reports.addAll(internalErrorTracker.getData()); + errorTrackers.forEach(tracker -> reports.addAll(tracker.getData())); context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); // todo: add global context data.addProperty("sdk_name", context.getSdkInfo().getName()); data.addProperty("sdk_version", context.getSdkInfo().getVersion()); + data.add("reports", reports); return data; } @@ -132,6 +131,8 @@ void submit() { } void clear() { + if (internalErrorTracker != null) internalErrorTracker.clear(); + errorTrackers.forEach(SimpleErrorTracker::clear); } ScheduledFuture scheduleSubmission( @@ -151,6 +152,24 @@ void unregisterSubmission(final ScheduledFuture future) { submissionJobs.remove(future); } + Optional internalErrorTracker() { + return Optional.ofNullable(internalErrorTracker); + } + + void setInternalErrorTracker(final ErrorTracker errorTracker) { + if (!(errorTracker instanceof SimpleErrorTracker tracker)) { + throw new IllegalArgumentException("Unsupported error tracker implementation: " + errorTracker.getClass().getName()); + } + internalErrorTracker = tracker; + startErrorSubmission(); + } + + void trackInternalError(final Throwable error, final boolean handled) { + final var tracker = internalErrorTracker; + if (tracker == null) return; + tracker.trackError(error, handled); + } + boolean isSubmissionSchedulerRunning() { final var scheduler = submissionScheduler; return scheduler != null && !scheduler.isShutdown(); @@ -172,9 +191,9 @@ private ScheduledExecutorService submissionScheduler() { } void startErrorSubmission() { - if (!context.getConfig().errorTracking() || context.errorSubmissionJob != null) return; + if (!context.getConfig().errorTracking() || errorSubmissionJob != null) return; errorSubmissionJob = scheduleSubmission( - errorTrackingSink::submit, + this::submit, TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)), TimeUnit.MINUTES.toMillis(30), TimeUnit.MILLISECONDS diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 9dd003a5..dc5aa4fe 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -38,6 +38,7 @@ public sealed interface FastStatsContext permits SimpleContext { * @return a new platform metrics factory * @since 0.24.0 */ + // todo: if the context is replaced with a factory pattern make the metrics instance context wide so only one can exist and querying the metrics instance is done ON the context @Contract(value = "-> new", pure = true) Metrics.Factory metricsFactory(); @@ -47,6 +48,7 @@ public sealed interface FastStatsContext permits SimpleContext { * @return a new feature flag service factory * @since 0.24.0 */ + // todo: if the context is replaced with a factory pattern make the feature flag service instance context wide so only one can exist and querying the service instance is done ON the context @Contract(value = "-> new", pure = true) FeatureFlagService.Factory featureFlagServiceFactory(); @@ -59,6 +61,7 @@ public sealed interface FastStatsContext permits SimpleContext { @Contract(pure = true) Optional errorTracker(); + // todo: only one global error tracker can exist, let it be defined on the context factory FastStatsContext globalErrorTracker(ErrorTracker errorTracker); FastStatsContext registerErrorTracker(ErrorTracker errorTracker); diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index e84efc62..199ad71b 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -2,19 +2,11 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; -import java.lang.Thread.UncaughtExceptionHandler; import java.util.Optional; import java.util.Properties; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; // fixme: thread safety // todo: cleanup @@ -90,7 +82,15 @@ public final FeatureFlagService.Factory featureFlagServiceFactory() { @Override @Contract(pure = true) public final Optional errorTracker() { - return Optional.ofNullable(internalErrorTracker); + // todo: do we even want the error tracker to be option? or just throw if not configured? + return errorTrackingSink.internalErrorTracker(); + } + + @Override + public FastStatsContext globalErrorTracker(final ErrorTracker errorTracker) { + // todo: do we want to allow reinitialization? maybe a factory pattern for the context is the better go to here + errorTrackingSink.setInternalErrorTracker(errorTracker); + return this; } @Override @@ -111,7 +111,6 @@ ErrorTrackingSink errorTrackingSink() { } void trackInternalError(final Throwable error, final boolean handled) { - if (internalErrorTracker == null) return; - internalErrorTracker.trackError(error, handled); + errorTrackingSink.trackInternalError(error, handled); } } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 9a30b0de..90e7734d 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -106,7 +106,7 @@ private void startSubmitting(final long initialDelay, final long period, final T } logger.info("Starting metrics submission"); - submissionJob = context.scheduleSubmission(this::submit, initialDelay, period, unit); + submissionJob = context.errorTrackingSink().scheduleSubmission(this::submit, initialDelay, period, unit); } protected boolean isSubmitting() { @@ -222,7 +222,7 @@ public void shutdown() { // context.errorTrackers().forEach(ErrorTracker::detachErrorContext); // todo: detach all error contexts on shutdown? if (submissionJob != null) try { logger.info("Shutting down metrics submission"); - context.unregisterSubmission(submissionJob); + context.errorTrackingSink().unregisterSubmission(submissionJob); submit(); } catch (final Throwable t) { logger.error("Failed to submit metrics on shutdown", t); From 38aa070d8e09b6642eaeed6ef9176ded3bad6f23 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 15:46:01 +0200 Subject: [PATCH 100/140] Intorduce context factory --- .../main/java/com/example/ExamplePlugin.java | 27 +++--- .../dev/faststats/bukkit/BukkitContext.java | 28 ++++-- .../main/java/com/example/ExamplePlugin.java | 23 +++-- .../dev/faststats/bungee/BungeeContext.java | 20 +++- .../example/ErrorTrackerExample.java | 12 ++- .../faststats/example/FeatureFlagExample.java | 20 ++-- .../main/java/dev/faststats/ErrorTracker.java | 29 +++++- .../java/dev/faststats/FastStatsContext.java | 39 +++++--- .../faststats/FastStatsContextFactory.java | 94 +++++++++++++++++++ .../dev/faststats/FeatureFlagService.java | 2 +- .../java/dev/faststats/SimpleContext.java | 48 ++++++++-- .../java/dev/faststats/AnonymizationTest.java | 7 +- .../java/dev/faststats/ErrorTrackerTest.java | 4 +- .../test/java/dev/faststats/MockContext.java | 26 ++++- .../src/main/java/com/example/ExampleMod.java | 30 ++++-- .../dev/faststats/fabric/FabricContext.java | 20 +++- .../main/java/com/example/ExamplePlugin.java | 31 ++++-- .../dev/faststats/hytale/HytaleContext.java | 20 +++- .../faststats/minestom/MinestomContext.java | 16 +++- .../dev/faststats/nukkit/NukkitContext.java | 20 +++- .../main/java/com/example/ExamplePlugin.java | 33 +++++-- .../dev/faststats/sponge/SpongeContext.java | 43 ++++++--- .../main/java/com/example/ExamplePlugin.java | 35 +++++-- .../faststats/velocity/VelocityContext.java | 44 ++++++--- 24 files changed, 529 insertions(+), 142 deletions(-) create mode 100644 core/src/main/java/dev/faststats/FastStatsContextFactory.java 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 4c7a51a1..f2d2f424 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,37 +2,38 @@ import dev.faststats.ErrorTracker; import dev.faststats.bukkit.BukkitContext; -import dev.faststats.bukkit.BukkitMetrics; import dev.faststats.data.Metric; import org.bukkit.plugin.java.JavaPlugin; import java.util.concurrent.atomic.AtomicInteger; public final class ExamplePlugin extends JavaPlugin { - public static final ErrorTracker ERROR_TRACKER = ErrorTracker.aware(); + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); private final AtomicInteger gameCount = new AtomicInteger(); - private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE") - .globalErrorTracker(ERROR_TRACKER); - private final BukkitMetrics metrics = context.metricsFactory() - // Custom metrics require a corresponding data source in your project settings - .addMetric(Metric.number("game_count", gameCount::get)) - .addMetric(Metric.string("server_version", () -> "1.0.0")) + private final BukkitContext context = new BukkitContext.Factory(this, "YOUR_TOKEN_HERE") + .errorTracker(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - // #onFlush is invoked after successful metrics submission - // This is useful for cleaning up cached data - .onFlush(() -> gameCount.set(0)) // reset game count on flush + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + .create()) .create(); @Override public void onEnable() { - metrics.ready(); // register additional error handlers + context.ready(); // register additional error handlers } @Override public void onDisable() { - metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services } public void startGame() { diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 890d251d..eeebb5ff 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -1,6 +1,6 @@ package dev.faststats.bukkit; -import dev.faststats.ErrorTracker; +import dev.faststats.FastStatsContextFactory; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -17,23 +17,17 @@ public final class BukkitContext extends SimpleContext { final Plugin plugin; - public BukkitContext(final Plugin plugin, @Token final String token) { + private BukkitContext(final Plugin plugin, @Token final String token) { super(SimpleConfig.read(getConfigPath(plugin)), "bukkit", token); this.plugin = plugin; } @Override @Contract(value = " -> new", pure = true) - public BukkitMetrics.Factory metricsFactory() { + protected BukkitMetrics.Factory metricsFactory() { return new BukkitMetricsImpl.Factory(this); } - @Override - public BukkitContext globalErrorTracker(final ErrorTracker errorTracker) { - super.globalErrorTracker(errorTracker); - return this; - } - private static Path getConfigPath(final Plugin plugin) { return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties"); } @@ -50,4 +44,20 @@ private static Path getPluginsFolder(final Plugin plugin) { public String getProjectName() { return plugin.getName(); } + + public static final class Factory extends FastStatsContextFactory { + private final Plugin plugin; + private final @Token String token; + + public Factory(final Plugin plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + @Contract(value = " -> new", mutates = "io") + protected BukkitContext createContext() { + return new BukkitContext(plugin, token); + } + } } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index fb599e25..ea9f329b 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,7 +1,6 @@ package com.example; import dev.faststats.ErrorTracker; -import dev.faststats.Metrics; import dev.faststats.bungee.BungeeContext; import dev.faststats.data.Metric; import net.md_5.bungee.api.plugin.Plugin; @@ -9,23 +8,27 @@ import java.util.concurrent.atomic.AtomicInteger; public class ExamplePlugin extends Plugin { + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); private final AtomicInteger gameCount = new AtomicInteger(); - private final BungeeContext context = new BungeeContext(this, "YOUR_TOKEN_HERE"); - private final Metrics metrics = context.metricsFactory() - // Custom metrics require a corresponding data source in your project settings - .addMetric(Metric.number("game_count", gameCount::get)) - .addMetric(Metric.string("server_version", () -> "1.0.0")) + private final BungeeContext context = new BungeeContext.Factory(this, "YOUR_TOKEN_HERE") + .errorTracker(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - // #onFlush is invoked after successful metrics submission - // This is useful for cleaning up cached data - .onFlush(() -> gameCount.set(0)) // reset game count on flush + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + .create()) .create(); @Override public void onDisable() { - metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services } public void startGame() { diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index a163a5ff..47fdb524 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -1,5 +1,6 @@ package dev.faststats.bungee; +import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -16,14 +17,14 @@ public final class BungeeContext extends SimpleContext { final Plugin plugin; - public BungeeContext(final Plugin plugin, @Token final String token) { + private BungeeContext(final Plugin plugin, @Token final String token) { super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), "bungeecord", token); this.plugin = plugin; } @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -36,4 +37,19 @@ public Metrics create() throws IllegalStateException { public String getProjectName() { return plugin.getDescription().getName(); } + + public static final class Factory extends FastStatsContextFactory { + private final Plugin plugin; + private final @Token String token; + + public Factory(final Plugin plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + protected BungeeContext createContext() { + return new BungeeContext(plugin, token); + } + } } diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index 85f5c7ab..1b745cc4 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -2,24 +2,30 @@ import dev.faststats.ErrorTracker; import dev.faststats.FastStatsContext; +import dev.faststats.FastStatsContextFactory; import java.lang.reflect.InvocationTargetException; import java.nio.file.AccessDeniedException; public final class ErrorTrackerExample { // Context-aware: automatically tracks uncaught errors from the same class loader - public static final ErrorTracker CONTEXT_AWARE = ErrorTracker.aware() + public static final ErrorTracker CONTEXT_AWARE = ErrorTracker.contextAware() .ignoreError(InvocationTargetException.class, "Expected .* but got .*") .ignoreError(AccessDeniedException.class); // Context-unaware: only tracks errors passed to trackError() manually - public static final ErrorTracker CONTEXT_UNAWARE = ErrorTracker.unaware() + public static final ErrorTracker CONTEXT_UNAWARE = ErrorTracker.contextUnaware() .anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") .anonymize("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "[uuid hidden]") .anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); + public static final FastStatsContext CONTEXT = getContextFactory() + .errorTracker(CONTEXT_AWARE) // Set the global/internal error tracker + .create() + .registerErrorTracker(CONTEXT_UNAWARE); // Register an additional tracker for submission + public static void manualTracking() { try { throw new RuntimeException("Something went wrong!"); @@ -28,7 +34,7 @@ public static void manualTracking() { } } - private static FastStatsContext getContext() { + private static FastStatsContextFactory getContextFactory() { return null; } } diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index bb122ca7..f5bb8edd 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -2,20 +2,26 @@ import dev.faststats.Attributes; import dev.faststats.FastStatsContext; +import dev.faststats.FastStatsContextFactory; import dev.faststats.FeatureFlag; import dev.faststats.FeatureFlagService; import java.time.Duration; public final class FeatureFlagExample { - public static final FeatureFlagService SERVICE = getContext().featureFlagServiceFactory() - .attributes(Attributes.create() // Define global attributes - .put("version", "1.2.3") - .put("java_version", System.getProperty("java.version")) - .put("java_vendor", System.getProperty("java.vendor"))) - .ttl(Duration.ofMinutes(10)) // Custom cache TTL for resolved flag values + public static final FastStatsContext CONTEXT = getContextFactory() + // .featureFlagService(FeatureFlagService.Factory::create) // Define a feature flag service with default settings + .featureFlagService(factory -> factory + .attributes(Attributes.create() // Define global attributes + .put("version", "1.2.3") + .put("java_version", System.getProperty("java.version")) + .put("java_vendor", System.getProperty("java.vendor"))) + .ttl(Duration.ofMinutes(10)) // Custom cache TTL for resolved flag values + .create()) .create(); + public static final FeatureFlagService SERVICE = CONTEXT.featureFlagService().orElseThrow(); + // Define flags with default values public static final FeatureFlag NEW_COMMANDS = SERVICE.define("new_commands", false); public static final FeatureFlag COMPRESSION = SERVICE.define("compression", "zstd"); @@ -62,7 +68,7 @@ public static void usage() { }); } - private static FastStatsContext getContext() { + private static FastStatsContextFactory getContextFactory() { return null; } } diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index cbf35fea..c9e9af6e 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -21,9 +21,26 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * @since 0.24.0 */ @Contract(value = " -> new", pure = true) - static ErrorTracker aware() { + static ErrorTracker contextAware() { + return contextAware(ErrorTracker.class.getClassLoader()); + } + + /** + * Creates a context-aware error tracker policy for the given class loader. + *

+ * The returned tracker has its error context attached immediately. If the class + * loader is {@code null}, the tracker will track all errors. + * + * @param classLoader the class loader whose errors should be tracked, or {@code null} to track all errors + * @return the error tracker policy + * @throws IllegalStateException if the error context is already attached + * @see #attachErrorContext(ClassLoader) + * @since 0.24.0 + */ + @Contract(value = "_ -> new", pure = true) + static ErrorTracker contextAware(@Nullable final ClassLoader classLoader) { final var tracker = new SimpleErrorTracker(); - tracker.attachErrorContext(ErrorTracker.class.getClassLoader()); + tracker.attachErrorContext(classLoader); return tracker; } @@ -34,11 +51,12 @@ static ErrorTracker aware() { * @since 0.24.0 */ @Contract(value = " -> new", pure = true) - static ErrorTracker unaware() { + static ErrorTracker contextUnaware() { return new SimpleErrorTracker(); } - + // todo: return tracker object to supply additional information + /** * Tracks a handled error. * @@ -60,7 +78,8 @@ static ErrorTracker unaware() { @Contract(mutates = "this") void trackError(Throwable error); - // todo: remove handled overloads + // todo: remove handled overloads and move into the tracker object + /** * Tracks an error. *

diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index dc5aa4fe..40b03e99 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -8,7 +8,7 @@ * Shared FastStats context. *

* Platform-specific contexts should extend this class to provide a shared - * configuration, token, and metrics factory for their environment. + * configuration, token, metrics, and feature flag service for their environment. * * @since 0.24.0 */ @@ -33,24 +33,22 @@ public sealed interface FastStatsContext permits SimpleContext { String getToken(); /** - * Creates a new platform metrics factory bound to this context. + * Gets the metrics instance bound to this context. * - * @return a new platform metrics factory + * @return the context metrics instance, if one was configured * @since 0.24.0 */ - // todo: if the context is replaced with a factory pattern make the metrics instance context wide so only one can exist and querying the metrics instance is done ON the context - @Contract(value = "-> new", pure = true) - Metrics.Factory metricsFactory(); + @Contract(pure = true) + Optional metrics(); /** - * Creates a new feature flag service factory bound to this context. + * Gets the feature flag service bound to this context. * - * @return a new feature flag service factory + * @return the context feature flag service, if one was configured * @since 0.24.0 */ - // todo: if the context is replaced with a factory pattern make the feature flag service instance context wide so only one can exist and querying the service instance is done ON the context - @Contract(value = "-> new", pure = true) - FeatureFlagService.Factory featureFlagServiceFactory(); + @Contract(pure = true) + Optional featureFlagService(); /** * Get the registered internal/global error tracker, if one was configured. @@ -61,11 +59,24 @@ public sealed interface FastStatsContext permits SimpleContext { @Contract(pure = true) Optional errorTracker(); - // todo: only one global error tracker can exist, let it be defined on the context factory - FastStatsContext globalErrorTracker(ErrorTracker errorTracker); - + // todo: document FastStatsContext registerErrorTracker(ErrorTracker errorTracker); + /** + * Performs additional post-startup tasks for configured context services. + * + * @since 0.24.0 + */ + void ready(); + + /** + * Safely shuts down configured context services. + * + * @since 0.24.0 + */ + @Contract(mutates = "this") + void shutdown(); + /** * Get the SDK information shared by services created from this context. * diff --git a/core/src/main/java/dev/faststats/FastStatsContextFactory.java b/core/src/main/java/dev/faststats/FastStatsContextFactory.java new file mode 100644 index 00000000..67cf719f --- /dev/null +++ b/core/src/main/java/dev/faststats/FastStatsContextFactory.java @@ -0,0 +1,94 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; + +/** + * Factory for creating a configured FastStats context. + *

+ * Platform implementations may extend this class with constructors that accept + * platform-specific objects before creating the context. + * + * @param the context type created by this factory + * @since 0.24.0 + */ +public abstract class FastStatsContextFactory> { + private @Nullable ErrorTracker errorTrecker; + private @Nullable Function metrics = null; + private @Nullable Function featureFlagService; + + /** + * Sets the single global/internal error tracker for the context created by this factory. + * + * @param errorTracker the global/internal error tracker + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F errorTracker(final ErrorTracker errorTracker) { + this.errorTrecker = errorTracker; + return self(); + } + + /** + * Configures and creates the single metrics instance for the context. + * + * @param metrics a function that receives a new metrics factory and returns the built metrics instance + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F metrics(final Function metrics) { + this.metrics = metrics; + return self(); + } + + /** + * Configures and creates the single feature flag service instance for the context. + * + * @param featureFlagService a function that receives a new service factory and returns the built service instance + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F featureFlagService( + final Function featureFlagService + ) { + this.featureFlagService = featureFlagService; + return self(); + } + + /** + * Creates the configured context. + * + * @return the configured context + * @since 0.24.0 + */ + @Contract(value = " -> new", mutates = "io") + public final C create() { + final var context = createContext(); + if (errorTrecker != null) + context.setErrorTracker(errorTrecker); + if (metrics != null) + context.setMetrics(metrics.apply(context.metricsFactory())); + if (featureFlagService != null) + context.setFeatureFlagService(featureFlagService.apply(context.featureFlagServiceFactory())); + return context; + } + + @SuppressWarnings("unchecked") + private F self() { + return (F) this; + } + + /** + * Creates the platform-specific context instance. + * + * @return the platform-specific context + * @since 0.24.0 + */ + @Contract(value = " -> new", mutates = "io") + protected abstract C createContext(); +} diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java index 40893594..20a4ec8d 100644 --- a/core/src/main/java/dev/faststats/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -8,7 +8,7 @@ /** * A service for managing feature flags. *

- * Use {@link FastStatsContext#featureFlagServiceFactory()} to construct a service instance. + * Use {@link FastStatsContext#featureFlagService()} to access the context service instance. * * @since 0.24.0 */ diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 199ad71b..213227e8 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -1,7 +1,7 @@ package dev.faststats; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; @@ -9,15 +9,14 @@ import java.util.Properties; // fixme: thread safety -// todo: cleanup -// todo: introduce a factory pattern to shorten the constructor? -@ApiStatus.Internal public non-sealed abstract class SimpleContext implements FastStatsContext { private final ErrorTrackingSink errorTrackingSink = new ErrorTrackingSink(this); private final Config config; private final @Token String token; private final SdkInfo sdkInfo; + private @Nullable Metrics metrics; + private @Nullable FeatureFlagService featureFlagService; /** * Creates a new context that stores the shared configuration and token for all FastStats services. @@ -74,23 +73,41 @@ public final Config getConfig() { } @Override + @Contract(pure = true) + public final Optional metrics() { + return Optional.ofNullable(metrics); + } + + @Override + @Contract(pure = true) + public final Optional featureFlagService() { + return Optional.ofNullable(featureFlagService); + } + + @Contract(value = " -> new", pure = true) + protected abstract Metrics.Factory metricsFactory(); + @Contract(value = " -> new", pure = true) - public final FeatureFlagService.Factory featureFlagServiceFactory() { + protected FeatureFlagService.Factory featureFlagServiceFactory() { return new SimpleFeatureFlagService.Factory(config, token); } + final void setMetrics(final Metrics metrics) { + this.metrics = metrics; + } + + final void setFeatureFlagService(final FeatureFlagService featureFlagService) { + this.featureFlagService = featureFlagService; + } + @Override @Contract(pure = true) public final Optional errorTracker() { - // todo: do we even want the error tracker to be option? or just throw if not configured? return errorTrackingSink.internalErrorTracker(); } - @Override - public FastStatsContext globalErrorTracker(final ErrorTracker errorTracker) { - // todo: do we want to allow reinitialization? maybe a factory pattern for the context is the better go to here + final void setErrorTracker(final ErrorTracker errorTracker) { errorTrackingSink.setInternalErrorTracker(errorTracker); - return this; } @Override @@ -100,6 +117,17 @@ public final SimpleContext registerErrorTracker(final ErrorTracker tracker) { return this; } + @Override + public final void ready() { + if (metrics != null) metrics.ready(); + } + + @Override + public final void shutdown() { + if (metrics != null) metrics.shutdown(); + if (featureFlagService != null) featureFlagService.shutdown(); + } + @Override @Contract(pure = true) public SdkInfo getSdkInfo() { diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index 55d98442..b5c8b0b8 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -10,9 +10,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public final class AnonymizationTest { - private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.unaware(); - private final FastStatsContext context = new MockContext(TRACKER); - private final MockMetrics metrics = (MockMetrics) context.metricsFactory().create(); + private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + private final FastStatsContext context = new MockContext.Factory(TRACKER) + .metrics(Metrics.Factory::create) + .create(); private JsonObject getError() { return TRACKER.getData().get(0).getAsJsonObject(); diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index d43487c2..2dd83369 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -13,8 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { - private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.unaware(); - private final MockContext context = new MockContext(TRACKER); + private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + private final MockContext context = new MockContext.Factory(TRACKER).create(); @Test public void sameClassLoader() { diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 5b823d79..5913d332 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -5,16 +5,17 @@ import java.util.UUID; public final class MockContext extends SimpleContext { - public MockContext() throws IllegalArgumentException { + private MockContext() throws IllegalArgumentException { this(null); } - public MockContext(@Nullable final ErrorTracker internalErrorTracker) throws IllegalArgumentException { - super(new MockConfig(UUID.randomUUID()), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", internalErrorTracker); + private MockContext(@Nullable final ErrorTracker internalErrorTracker) throws IllegalArgumentException { + super(new MockConfig(UUID.randomUUID()), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + if (internalErrorTracker != null) setErrorTracker(internalErrorTracker); } @Override - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -49,4 +50,21 @@ public boolean debug() { return true; } } + + public static final class Factory extends FastStatsContextFactory { + private final @Nullable ErrorTracker internalErrorTracker; + + public Factory() { + this(null); + } + + public Factory(@Nullable final ErrorTracker internalErrorTracker) { + this.internalErrorTracker = internalErrorTracker; + } + + @Override + protected MockContext createContext() { + return new MockContext(internalErrorTracker); + } + } } diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 8826db8c..1e0a48cc 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -1,22 +1,40 @@ package com.example; import dev.faststats.ErrorTracker; -import dev.faststats.Metrics; import dev.faststats.data.Metric; import dev.faststats.fabric.FabricContext; import net.fabricmc.api.ModInitializer; +import java.util.concurrent.atomic.AtomicInteger; + public class ExampleMod implements ModInitializer { - private final FabricContext context = new FabricContext( + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); + private final AtomicInteger gameCount = new AtomicInteger(); + + private final FabricContext context = new FabricContext.Factory( "example-mod", // your mod id as defined in fabric.mod.json "YOUR_TOKEN_HERE" - ); - private final Metrics metrics = context.metricsFactory() - // Custom metrics require a corresponding data source in your project settings - .addMetric(Metric.number("example_metric", () -> 42)) + ) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) + .errorTracker(ERROR_TRACKER) .create(); @Override public void onInitialize() { + context.ready(); // register additional error handlers + } + + public void startGame() { + gameCount.incrementAndGet(); } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index b78d04f6..4b855114 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -1,5 +1,6 @@ package dev.faststats.fabric; +import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -17,7 +18,7 @@ public final class FabricContext extends SimpleContext { final ModContainer mod; - public FabricContext(final String modId, @Token final String token) { + private FabricContext(final String modId, @Token final String token) { super(SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); @@ -26,7 +27,7 @@ public FabricContext(final String modId, @Token final String token) { @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -43,4 +44,19 @@ public Metrics create() throws IllegalStateException { public String getProjectName() { return mod.getMetadata().getId(); } + + public static final class Factory extends FastStatsContextFactory { + private final String modId; + private final @Token String token; + + public Factory(final String modId, @Token final String token) { + this.modId = modId; + this.token = token; + } + + @Override + protected FabricContext createContext() { + return new FabricContext(modId, token); + } + } } diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 2732564d..65e8a480 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,23 +2,42 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; -import dev.faststats.Metrics; +import dev.faststats.ErrorTracker; import dev.faststats.data.Metric; import dev.faststats.hytale.HytaleContext; +import java.util.concurrent.atomic.AtomicInteger; + public class ExamplePlugin extends JavaPlugin { - private final HytaleContext context = new HytaleContext(this, "YOUR_TOKEN_HERE"); - private final Metrics metrics = context.metricsFactory() - // Custom metrics require a corresponding data source in your project settings - .addMetric(Metric.number("example_metric", () -> 42)) + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); + private final AtomicInteger gameCount = new AtomicInteger(); + + private final HytaleContext context = new HytaleContext.Factory(this, "YOUR_TOKEN_HERE") + .errorTracker(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) .create(); public ExamplePlugin(final JavaPluginInit init) { super(init); + context.ready(); // register additional error handlers } @Override protected void shutdown() { - metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services + } + + public void startGame() { + gameCount.incrementAndGet(); } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index d471e645..0a1e86b5 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -1,6 +1,7 @@ package dev.faststats.hytale; import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -16,14 +17,14 @@ public final class HytaleContext extends SimpleContext { private final String pluginName; - public HytaleContext(final JavaPlugin plugin, @Token final String token) { + private HytaleContext(final JavaPlugin plugin, @Token final String token) { super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); this.pluginName = plugin.getName(); } @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -37,4 +38,19 @@ public Metrics create() throws IllegalStateException { public String getProjectName() { return pluginName; } + + public static final class Factory extends FastStatsContextFactory { + private final JavaPlugin plugin; + private final @Token String token; + + public Factory(final JavaPlugin plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + protected HytaleContext createContext() { + return new HytaleContext(plugin, token); + } + } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 9e973332..b8c1e09c 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -1,5 +1,6 @@ package dev.faststats.minestom; +import dev.faststats.FastStatsContextFactory; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -20,7 +21,7 @@ public final class MinestomContext extends SimpleContext { @Override @Contract(value = " -> new", pure = true) - public MinestomMetrics.Factory metricsFactory() { + protected MinestomMetrics.Factory metricsFactory() { return new MinestomMetricsImpl.Factory(this); } @@ -28,4 +29,17 @@ public MinestomMetrics.Factory metricsFactory() { public String getProjectName() { return MinecraftServer.getBrandName(); } + + public static final class Factory extends FastStatsContextFactory { + private final @Token String token; + + public Factory(@Token final String token) { + this.token = token; + } + + @Override + protected MinestomContext createContext() { + return new MinestomContext(token); + } + } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 9bfcf397..2fec1666 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -1,6 +1,7 @@ package dev.faststats.nukkit; import cn.nukkit.plugin.PluginBase; +import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -18,14 +19,14 @@ public final class NukkitContext extends SimpleContext { final PluginBase plugin; - public NukkitContext(final PluginBase plugin, @Token final String token) { + private NukkitContext(final PluginBase plugin, @Token final String token) { super(SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), "nukkit", token); this.plugin = plugin; } @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -38,4 +39,19 @@ public Metrics create() throws IllegalStateException { public String getProjectName() { return plugin.getName(); } + + public static final class Factory extends FastStatsContextFactory { + private final PluginBase plugin; + private final @Token String token; + + public Factory(final PluginBase plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + protected NukkitContext createContext() { + return new NukkitContext(plugin, token); + } + } } diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index e7249e3f..619c0365 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,7 +1,7 @@ package com.example; import com.google.inject.Inject; -import dev.faststats.Metrics; +import dev.faststats.ErrorTracker; import dev.faststats.data.Metric; import dev.faststats.sponge.SpongeContext; import org.jspecify.annotations.Nullable; @@ -11,23 +11,42 @@ import org.spongepowered.api.event.lifecycle.StoppingEngineEvent; import org.spongepowered.plugin.builtin.jvm.Plugin; +import java.util.concurrent.atomic.AtomicInteger; + @Plugin("example") public class ExamplePlugin { + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); private @Inject SpongeContext.Builder contextBuilder; - private @Nullable Metrics metrics = null; + private final AtomicInteger gameCount = new AtomicInteger(); + private @Nullable SpongeContext context = null; @Listener public void onServerStart(final StartedEngineEvent event) { - final var context = contextBuilder.build("YOUR_TOKEN_HERE"); - this.metrics = context.metricsFactory() - // Custom metrics require a corresponding data source in your project settings - .addMetric(Metric.number("example_metric", () -> 42)) + this.context = contextBuilder + .token("YOUR_TOKEN_HERE") + .errorTracker(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) .create(); + context.ready(); // register additional error handlers } @Listener public void onServerStop(final StoppingEngineEvent event) { - if (metrics != null) metrics.shutdown(); // safely shut down metrics submission + if (context != null) context.shutdown(); // safely shut down configured services + } + + public void startGame() { + gameCount.incrementAndGet(); } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 190f0455..2c9a8935 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -1,11 +1,13 @@ package dev.faststats.sponge; import com.google.inject.Inject; +import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import org.spongepowered.api.config.ConfigDir; import org.spongepowered.plugin.PluginContainer; @@ -30,7 +32,7 @@ private SpongeContext( @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -49,9 +51,11 @@ public String getProjectName() { * * @since 0.24.0 */ - public static final class Builder { + public static class Factory extends FastStatsContextFactory { private final PluginContainer plugin; private final Path dataDirectory; + private @Token + @Nullable String token; /** * Creates a new Sponge context builder. @@ -62,7 +66,7 @@ public static final class Builder { * @since 0.24.0 */ @Inject - public Builder( + public Factory( final PluginContainer plugin, @ConfigDir(sharedRoot = true) final Path dataDirectory ) { @@ -70,16 +74,31 @@ public Builder( this.dataDirectory = dataDirectory; } - /** - * Builds the finalized Sponge context. - * - * @param token the FastStats project token - * @return the Sponge context - * @throws IllegalArgumentException if the token is invalid - * @since 0.24.0 - */ - public SpongeContext build(@Token final String token) throws IllegalArgumentException { + // todo: document + public SpongeContext.Factory token(@Token final String token) throws IllegalArgumentException { + this.token = token; + return this; + } + + @Override + protected SpongeContext createContext() { + if (token == null) throw new IllegalStateException("Token not configured"); return new SpongeContext(plugin, dataDirectory, token); } } + + /** + * Injectable Sponge context builder. + * + * @since 0.24.0 + */ + public static final class Builder extends Factory { + @Inject + public Builder( + final PluginContainer plugin, + @ConfigDir(sharedRoot = true) final Path dataDirectory + ) { + super(plugin, dataDirectory); + } + } } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 0b21341f..1e753a7f 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -6,32 +6,49 @@ import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.plugin.Plugin; import dev.faststats.ErrorTracker; -import dev.faststats.Metrics; import dev.faststats.data.Metric; import dev.faststats.velocity.VelocityContext; -import org.jspecify.annotations.Nullable; + +import java.util.concurrent.atomic.AtomicInteger; @Plugin(id = "example", name = "Example Plugin", version = "1.0.0", url = "https://example.com", authors = {"Your Name"}) public class ExamplePlugin { + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); + private final AtomicInteger gameCount = new AtomicInteger(); + private final VelocityContext context; - private @Nullable Metrics metrics = null; @Inject public ExamplePlugin(final VelocityContext.Builder contextBuilder) { - this.context = contextBuilder.build("YOUR_TOKEN_HERE"); + this.context = contextBuilder + .token("YOUR_TOKEN_HERE") + .errorTracker(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) + .create(); } @Subscribe public void onProxyInitialize(final ProxyInitializeEvent event) { - this.metrics = context.metricsFactory() - // Custom metrics require a corresponding data source in your project settings - .addMetric(Metric.number("example_metric", () -> 42)) - .create(); + context.ready(); // register additional error handlers } @Subscribe public void onProxyStop(final ProxyShutdownEvent event) { - if (metrics != null) metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services + } + + public void startGame() { + gameCount.incrementAndGet(); } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index ce88eadd..913bc225 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -4,12 +4,14 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import java.nio.file.Path; @@ -35,7 +37,7 @@ private VelocityContext( @Override @Contract(value = " -> new", pure = true) - public Metrics.Factory metricsFactory() { + protected Metrics.Factory metricsFactory() { return new SimpleMetrics.Factory(this) { @Override public Metrics create() throws IllegalStateException { @@ -54,10 +56,12 @@ public String getProjectName() { * * @since 0.24.0 */ - public static final class Builder { + public static class Factory extends FastStatsContextFactory { private final PluginContainer plugin; private final ProxyServer server; private final Path dataDirectory; + private @Token + @Nullable String token; /** * Creates a new Velocity context builder. @@ -68,7 +72,7 @@ public static final class Builder { * @since 0.24.0 */ @Inject - public Builder( + public Factory( final PluginContainer plugin, final ProxyServer server, @DataDirectory final Path dataDirectory @@ -78,16 +82,32 @@ public Builder( this.dataDirectory = dataDirectory; } - /** - * Builds the finalized Velocity context. - * - * @param token the FastStats project token - * @return the Velocity context - * @throws IllegalArgumentException if the token is invalid - * @since 0.24.0 - */ - public VelocityContext build(@Token final String token) throws IllegalArgumentException { + // todo: document + public Factory token(@Token final String token) { + this.token = token; + return this; + } + + @Override + protected VelocityContext createContext() { + if (token == null) throw new IllegalStateException("Token not configured"); return new VelocityContext(plugin, server, dataDirectory, token); } } + + /** + * Injectable Velocity context builder. + * + * @since 0.24.0 + */ + public static final class Builder extends Factory { + @Inject + public Builder( + final PluginContainer plugin, + final ProxyServer server, + @DataDirectory final Path dataDirectory + ) { + super(plugin, server, dataDirectory); + } + } } From bbd664a61626bd05a25444f49c34847dfa7ae97b Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 15:51:43 +0200 Subject: [PATCH 101/140] Document Sponge/Velocity Context#token --- .../main/java/dev/faststats/sponge/SpongeContext.java | 9 ++++++++- .../java/dev/faststats/velocity/VelocityContext.java | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 2c9a8935..01bb8ddb 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -74,7 +74,14 @@ public Factory( this.dataDirectory = dataDirectory; } - // todo: document + /** + * Sets the FastStats project token used by the context created from this factory. + * + * @param token the FastStats project token + * @return this factory + * @throws IllegalArgumentException if the token is invalid + * @since 0.24.0 + */ public SpongeContext.Factory token(@Token final String token) throws IllegalArgumentException { this.token = token; return this; diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 913bc225..b90e6ffd 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -82,7 +82,14 @@ public Factory( this.dataDirectory = dataDirectory; } - // todo: document + /** + * Sets the FastStats project token used by the context created from this factory. + * + * @param token the FastStats project token + * @return this factory + * @throws IllegalArgumentException if the token is invalid + * @since 0.24.0 + */ public Factory token(@Token final String token) { this.token = token; return this; From 4f428a8d8f05e5691047f159edb1a55c19af2156 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 15:52:00 +0200 Subject: [PATCH 102/140] Document FastStatsContext#registerErrorTracker --- .../main/java/dev/faststats/FastStatsContext.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 40b03e99..99424127 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -59,7 +59,17 @@ public sealed interface FastStatsContext permits SimpleContext { @Contract(pure = true) Optional errorTracker(); - // todo: document + /** + * Registers an additional error tracker for submission with this context. + *

+ * The global/internal tracker returned by {@link #errorTracker()} is configured + * by the context factory. Additional trackers registered here are submitted by + * the same context, but are not used for internal FastStats errors. + * + * @param errorTracker the additional error tracker + * @return this context + * @since 0.24.0 + */ FastStatsContext registerErrorTracker(ErrorTracker errorTracker); /** From 3902e2ca2747f7b8f554e99b944fb9765d68a47f Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 15:52:07 +0200 Subject: [PATCH 103/140] Fix typo --- .../main/java/dev/faststats/FastStatsContextFactory.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/faststats/FastStatsContextFactory.java b/core/src/main/java/dev/faststats/FastStatsContextFactory.java index 67cf719f..5046ba13 100644 --- a/core/src/main/java/dev/faststats/FastStatsContextFactory.java +++ b/core/src/main/java/dev/faststats/FastStatsContextFactory.java @@ -15,7 +15,7 @@ * @since 0.24.0 */ public abstract class FastStatsContextFactory> { - private @Nullable ErrorTracker errorTrecker; + private @Nullable ErrorTracker errorTracker; private @Nullable Function metrics = null; private @Nullable Function featureFlagService; @@ -28,7 +28,7 @@ public abstract class FastStatsContextFactory Date: Sat, 30 May 2026 15:52:24 +0200 Subject: [PATCH 104/140] Document new generic parameter --- core/src/main/java/dev/faststats/FastStatsContextFactory.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/dev/faststats/FastStatsContextFactory.java b/core/src/main/java/dev/faststats/FastStatsContextFactory.java index 5046ba13..dae55cbe 100644 --- a/core/src/main/java/dev/faststats/FastStatsContextFactory.java +++ b/core/src/main/java/dev/faststats/FastStatsContextFactory.java @@ -12,6 +12,7 @@ * platform-specific objects before creating the context. * * @param the context type created by this factory + * @param the concrete factory type * @since 0.24.0 */ public abstract class FastStatsContextFactory> { From ce7eb103f507bc6edf5dd7bdd606d58c3792e146 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 15:52:40 +0200 Subject: [PATCH 105/140] Add missing parameters --- .../src/main/java/dev/faststats/minestom/MinestomContext.java | 2 +- nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index b8c1e09c..a26319d4 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -30,7 +30,7 @@ public String getProjectName() { return MinecraftServer.getBrandName(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends FastStatsContextFactory { private final @Token String token; public Factory(@Token final String token) { diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 2fec1666..4c9c08a2 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -40,7 +40,7 @@ public String getProjectName() { return plugin.getName(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends FastStatsContextFactory { private final PluginBase plugin; private final @Token String token; From ebbe0a203ba9da8162b4313184d5609c048fbbc0 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 17:21:12 +0200 Subject: [PATCH 106/140] Improve internal error tracking --- .../java/dev/faststats/bukkit/BukkitMetricsImpl.java | 10 ++-------- .../java/dev/faststats/bukkit/PaperEventListener.java | 8 +++----- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index b3cd6357..c5bb6057 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -37,10 +37,6 @@ private BukkitMetricsImpl(final Factory factory, final Plugin plugin) throws Ill startSubmitting(); } - Plugin plugin() { - return plugin; - } - private boolean checkOnlineMode() { final var server = plugin.getServer(); return tryOrEmpty(() -> server.getServerConfig().isProxyOnlineMode()) @@ -81,7 +77,7 @@ private int getPlayerCount() { return plugin.getServer().getOnlinePlayers().size(); } catch (final Throwable t) { logger.error("Failed to get player count", t); - trackError(t, true); + trackError(t); return 0; } } @@ -90,9 +86,7 @@ private int getPlayerCount() { public void ready() { try { Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); - plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, error -> { - trackError(error, false); - }), plugin); + plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, context), plugin); } catch (final ClassNotFoundException ignored) { } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java index 031ae47e..405341ad 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java @@ -2,20 +2,18 @@ import com.destroystokyo.paper.event.server.ServerExceptionEvent; import com.destroystokyo.paper.exception.ServerPluginException; +import dev.faststats.SimpleContext; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.plugin.Plugin; -import java.util.function.Consumer; - -// todo: replace consumer with context tracker -record PaperEventListener(Plugin plugin, Consumer errorTracker) implements Listener { +record PaperEventListener(Plugin plugin, SimpleContext context) implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onServerException(final ServerExceptionEvent event) { if (!(event.getException() instanceof final ServerPluginException exception)) return; if (!exception.getResponsiblePlugin().equals(plugin)) return; final var report = exception.getCause() != null ? exception.getCause() : exception; - errorTracker.accept(report); + context.errorTracker().ifPresent(tracker -> tracker.trackError(report)); } } From fd9ba568c2dfa0249f015c9730f38d078193b9fb Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 17:21:48 +0200 Subject: [PATCH 107/140] Add Attributes#containsKey --- core/src/main/java/dev/faststats/Attributes.java | 10 ++++++++++ core/src/main/java/dev/faststats/SimpleAttributes.java | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/Attributes.java b/core/src/main/java/dev/faststats/Attributes.java index c470209d..1638fb07 100644 --- a/core/src/main/java/dev/faststats/Attributes.java +++ b/core/src/main/java/dev/faststats/Attributes.java @@ -84,6 +84,16 @@ static Attributes copyOf(final Attributes attributes) { @Contract(value = "_ -> this", mutates = "this") Attributes remove(String key); + /** + * Returns whether a value is set for the given key. + * + * @param key the key + * @return whether a value is set + * @since 0.24.0 + */ + @Contract(pure = true) + boolean containsKey(String key); + /** * Visit each stored attribute as its underlying JSON primitive value. * diff --git a/core/src/main/java/dev/faststats/SimpleAttributes.java b/core/src/main/java/dev/faststats/SimpleAttributes.java index 8413a4e2..584e27cf 100644 --- a/core/src/main/java/dev/faststats/SimpleAttributes.java +++ b/core/src/main/java/dev/faststats/SimpleAttributes.java @@ -2,10 +2,10 @@ import com.google.gson.JsonPrimitive; -import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; -record SimpleAttributes(Map attributes) implements Attributes { +record SimpleAttributes(ConcurrentHashMap attributes) implements Attributes { @Override public Attributes put(final String key, final String value) { attributes.put(key, new JsonPrimitive(value)); @@ -31,6 +31,11 @@ public Attributes remove(final String key) { return this; } + @Override + public boolean containsKey(final String key) { + return attributes.containsKey(key); + } + @Override public void forEachPrimitive(final BiConsumer action) { attributes.forEach(action); From 3be2803110bfe102d63b84fb67457cf0a72328a0 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 18:15:31 +0200 Subject: [PATCH 108/140] Add TrackedError --- .../main/java/dev/faststats/ErrorHelper.java | 16 ++++-- .../main/java/dev/faststats/ErrorTracker.java | 37 ++---------- .../java/dev/faststats/ErrorTrackingSink.java | 6 +- .../java/dev/faststats/SimpleContext.java | 4 +- .../dev/faststats/SimpleErrorTracker.java | 38 +++++-------- .../java/dev/faststats/SimpleMetrics.java | 8 +-- .../dev/faststats/SimpleTrackedError.java | 56 ++++++++++++++++++ .../main/java/dev/faststats/TrackedError.java | 57 +++++++++++++++++++ 8 files changed, 150 insertions(+), 72 deletions(-) create mode 100644 core/src/main/java/dev/faststats/SimpleTrackedError.java create mode 100644 core/src/main/java/dev/faststats/TrackedError.java diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index fbbd9c7b..d29e3820 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -20,17 +20,19 @@ final class ErrorHelper { private static final int STACK_TRACE_LENGTH = Math.min(500, Integer.getInteger("faststats.stack-trace-length", 300)); private static final int STACK_TRACE_LIMIT = Math.min(50, Integer.getInteger("faststats.stack-trace-limit", 15)); + private static final Set allowedNames = Set.of("minecraft", "server", "root", "ubuntu"); private static final List> defaultAnonymizationEntries = defaultAnonymizationEntries(); - public static JsonObject compile(final Throwable error, @Nullable final List suppress, final boolean handled, + public static JsonObject compile(final TrackedError error, @Nullable final List suppress, final List> customPatterns) { final var patterns = new ArrayList<>(customPatterns); patterns.addAll(defaultAnonymizationEntries); - return compileAll(error, suppress, handled, patterns); + return compileAll(error, suppress, patterns); } - private static JsonObject compileAll(final Throwable error, @Nullable final List suppress, final boolean handled, + private static JsonObject compileAll(final TrackedError trackedError, @Nullable final List suppress, final List> customPatterns) { + final var error = trackedError.error(); final var report = new JsonObject(); final var message = getAnonymizedMessage(error, customPatterns); @@ -53,7 +55,11 @@ private static JsonObject compileAll(final Throwable error, @Nullable final List if (message != null) report.addProperty("message", message); report.add("stack", stacktrace); - report.addProperty("handled", handled); + report.addProperty("handled", trackedError.handled()); + + final var attributes = new JsonObject(); + trackedError.attributes().forEachPrimitive(attributes::add); + if (!attributes.isEmpty()) report.add("attributes", attributes); return report; } @@ -260,8 +266,6 @@ private static Pattern userHomePathPattern() { "|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"); // Windows: A-Z:\\Users\\username } - private static final Set allowedNames = Set.of("minecraft", "server", "root", "ubuntu"); - private static Optional usernamePattern() { return Optional.ofNullable(System.getProperty("user.name")) .filter(s -> s.trim().length() > 2) diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index c9e9af6e..5086f6df 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -55,55 +55,26 @@ static ErrorTracker contextUnaware() { return new SimpleErrorTracker(); } - // todo: return tracker object to supply additional information - /** * Tracks a handled error. * * @param message the error message + * @return a new mutable tracked error * @see #trackError(Throwable) - * @see #trackError(String, boolean) * @since 0.24.0 */ @Contract(mutates = "this") - void trackError(String message); + TrackedError trackError(String message); /** * Tracks a handled error. * * @param error the error - * @see #trackError(Throwable, boolean) - * @since 0.24.0 - */ - @Contract(mutates = "this") - void trackError(Throwable error); - - // todo: remove handled overloads and move into the tracker object - - /** - * Tracks an error. - *

- * A {@code handled=true} error is expected and properly handled. - * - * @param message the error message - * @param handled whether the error was handled - * @see #trackError(Throwable, boolean) - * @since 0.24.0 - */ - @Contract(mutates = "this") - void trackError(String message, boolean handled); - - /** - * Tracks an error. - *

- * A {@code handled=true} error is expected and properly handled. - * - * @param error the error - * @param handled whether the error was handled + * @return a new mutable tracked error * @since 0.24.0 */ @Contract(mutates = "this") - void trackError(Throwable error, boolean handled); + TrackedError trackError(Throwable error); /** * Adds an error type that will not be reported to FastStats. diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index f8708793..f5137454 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -164,10 +164,10 @@ void setInternalErrorTracker(final ErrorTracker errorTracker) { startErrorSubmission(); } - void trackInternalError(final Throwable error, final boolean handled) { + TrackedError trackInternalError(final Throwable error) { final var tracker = internalErrorTracker; - if (tracker == null) return; - tracker.trackError(error, handled); + if (tracker == null) return new SimpleTrackedError(error); + return tracker.trackError(error); } boolean isSubmissionSchedulerRunning() { diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 213227e8..86ddaef1 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -138,7 +138,7 @@ ErrorTrackingSink errorTrackingSink() { return errorTrackingSink; } - void trackInternalError(final Throwable error, final boolean handled) { - errorTrackingSink.trackInternalError(error, handled); + TrackedError trackInternalError(final Throwable error) { + return errorTrackingSink.trackInternalError(error); } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 7f6529d8..66f048bc 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -1,7 +1,6 @@ package dev.faststats; import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import org.jetbrains.annotations.VisibleForTesting; import org.jspecify.annotations.Nullable; @@ -19,8 +18,7 @@ import java.util.regex.Pattern; final class SimpleErrorTracker implements ErrorTracker { - // todo: track unchanged exceptions and counts? - private final Map reports = new ConcurrentHashMap<>(); + private final Map reports = new ConcurrentHashMap<>(); private final Map, Set> ignoredTypedPatterns = new ConcurrentHashMap<>(); private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); @@ -32,30 +30,22 @@ final class SimpleErrorTracker implements ErrorTracker { private volatile boolean contextAttached; @Override - public void trackError(final String message) { - trackError(message, true); + public TrackedError trackError(final String message) { + return trackError(new RuntimeException(message)); } @Override - public void trackError(final Throwable error) { - trackError(error, true); - } - - @Override - public void trackError(final String message, final boolean handled) { - trackError(new RuntimeException(message), handled); - } - - @Override - public void trackError(final Throwable error, final boolean handled) { + public TrackedError trackError(final Throwable error) { + final var trackedError = new SimpleTrackedError(error); try { - if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; - final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - reports.compute(compiled, (key, report) -> { - return report != null ? report + 1 : 1; + if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return trackedError; + reports.compute(trackedError, (key, reports) -> { + return reports != null ? reports + 1 : 1; }); } catch (final NoClassDefFoundError ignored) { + // todo: add logging } + return trackedError; } private boolean isIgnored(@Nullable final Throwable error, final Set visited) { @@ -100,10 +90,10 @@ public ErrorTracker anonymize(final Pattern pattern, final String replacement) { @VisibleForTesting public JsonArray getData() { final var report = new JsonArray(reports.size()); - reports.forEach((entry, count) -> { - final var copy = entry.deepCopy(); - if (count > 1) copy.addProperty("count", count); - report.add(copy); + reports.forEach((error, count) -> { + final var compiled = ErrorHelper.compile(error, null, anonymizationEntries); + if (count > 1) compiled.addProperty("count", count); + report.add(compiled); }); return report; } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 90e7734d..0f2c9e93 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -113,8 +113,8 @@ protected boolean isSubmitting() { return submissionJob != null && !submissionJob.isCancelled(); } - protected final void trackError(final Throwable error, final boolean handled) { - context.trackInternalError(error, handled); + protected final TrackedError trackError(final Throwable error) { + return context.trackInternalError(error); } // todo: improve logging to be less cluttered @@ -194,7 +194,7 @@ protected JsonObject createData() { appendDefaultData(metrics); } catch (final Throwable t) { logger.error("Failed to append default data", t); - context.trackInternalError(t, true); + context.trackInternalError(t); } this.metrics.forEach(metric -> { @@ -202,7 +202,7 @@ protected JsonObject createData() { metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { logger.error("Failed to build metric data: %s", t, metric.getId()); - context.trackInternalError(t, true); + context.trackInternalError(t); } }); diff --git a/core/src/main/java/dev/faststats/SimpleTrackedError.java b/core/src/main/java/dev/faststats/SimpleTrackedError.java new file mode 100644 index 00000000..018ae7b0 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleTrackedError.java @@ -0,0 +1,56 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +final class SimpleTrackedError implements TrackedError { + private Attributes attributes = Attributes.create(); + private boolean handled = true; + private final Throwable error; + + SimpleTrackedError(final Throwable error) { + this.error = error; + } + + @Override + public Throwable error() { + return error; + } + + @Override + public boolean handled() { + return handled; + } + + @Override + public TrackedError handled(final boolean handled) { + this.handled = handled; + return this; + } + + @Override + public Attributes attributes() { + return attributes; + } + + @Override + public TrackedError attributes(final Attributes attributes) { + this.attributes = Attributes.copyOf(attributes); + return this; + } + + @Override + public boolean equals(@Nullable final Object o) { + if (o == null || getClass() != o.getClass()) return false; + final SimpleTrackedError that = (SimpleTrackedError) o; + return handled == that.handled + && Objects.equals(attributes, that.attributes) + && Objects.equals(error, that.error); + } + + @Override + public int hashCode() { + return Objects.hash(attributes, handled, error); + } +} diff --git a/core/src/main/java/dev/faststats/TrackedError.java b/core/src/main/java/dev/faststats/TrackedError.java new file mode 100644 index 00000000..66ee4ff7 --- /dev/null +++ b/core/src/main/java/dev/faststats/TrackedError.java @@ -0,0 +1,57 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +/** + * An error report with tracking metadata. + * + * @since 0.24.0 + */ +public sealed interface TrackedError permits SimpleTrackedError { + /** + * Returns the tracked error. + * + * @return the tracked error + * @since 0.24.0 + */ + @Contract(pure = true) + Throwable error(); + + /** + * Returns whether the error was handled. + * + * @return whether the error was handled + * @since 0.24.0 + */ + @Contract(pure = true) + boolean handled(); + + /** + * Sets whether the error was handled. + * + * @param handled whether the error was handled + * @return this tracked error + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + TrackedError handled(boolean handled); + + /** + * Returns the additional error attributes. + * + * @return the additional error attributes + * @since 0.24.0 + */ + @Contract(pure = true) + Attributes attributes(); + + /** + * Sets the additional error attributes. + * + * @param attributes the additional error attributes + * @return this tracked error + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + TrackedError attributes(Attributes attributes); +} From a6d118789c82c6e19ee0c820f17899d98a5a832c Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 18:15:35 +0200 Subject: [PATCH 109/140] Fix tests --- .../java/dev/faststats/AnonymizationTest.java | 64 +++++++------- .../java/dev/faststats/ErrorTrackerTest.java | 84 +++++++++++-------- 2 files changed, 83 insertions(+), 65 deletions(-) diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index b5c8b0b8..67edc0ef 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -10,13 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public final class AnonymizationTest { - private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - private final FastStatsContext context = new MockContext.Factory(TRACKER) + private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + private final FastStatsContext context = new MockContext.Factory(tracker) .metrics(Metrics.Factory::create) .create(); private JsonObject getError() { - return TRACKER.getData().get(0).getAsJsonObject(); + return tracker.getData().get(0).getAsJsonObject(); } private String getErrorMessage() { @@ -25,108 +25,108 @@ private String getErrorMessage() { @Test public void ipv4Anonymization() { - TRACKER.trackError("Connection refused at 192.168.1.100"); + tracker.trackError("Connection refused at 192.168.1.100"); assertEquals("Connection refused at [IP hidden]", getErrorMessage()); } @Test public void ipv6Anonymization() { - TRACKER.trackError("Failed to connect to f833:be65:65da:975b:4896:88f7:6964:44c0"); + tracker.trackError("Failed to connect to f833:be65:65da:975b:4896:88f7:6964:44c0"); assertEquals("Failed to connect to [IP hidden]", getErrorMessage()); } @Test public void userHomePathAnonymization() { final var username = System.getProperty("user.name", "user"); - TRACKER.trackError("File not found: /home/" + username + "/config.yml"); + tracker.trackError("File not found: /home/" + username + "/config.yml"); assertEquals("File not found: /home/[username hidden]/config.yml", getErrorMessage()); } @Test public void windowsUserPathAnonymization() { final var username = System.getProperty("user.name", "user"); - TRACKER.trackError("File not found: C:\\Users\\" + username + "\\config.yml"); + tracker.trackError("File not found: C:\\Users\\" + username + "\\config.yml"); assertEquals("File not found: C:\\Users\\[username hidden]\\config.yml", getErrorMessage()); } @Test public void macUserPathAnonymization() { final var username = System.getProperty("user.name", "user"); - TRACKER.trackError("File not found: /Users/" + username + "/config.yml"); + tracker.trackError("File not found: /Users/" + username + "/config.yml"); assertEquals("File not found: /Users/[username hidden]/config.yml", getErrorMessage()); } @Test public void usernameAnonymizationIsCaseInsensitive() { final var username = System.getProperty("user.name", "user"); - TRACKER.trackError("Error for " + swapCase(username)); + tracker.trackError("Error for " + swapCase(username)); assertEquals("Error for [username hidden]", getErrorMessage()); } @Test public void discordWebhookAnonymization() { - TRACKER.trackError("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/aAaAaAaa0AAaAAaaaAAAAa_0AAAAAAAaaaAaaAaaAAAA0aA00AAA0AAA0aAAaA0a0a0A"); + tracker.trackError("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/aAaAaAaa0AAaAAaaaAAAAa_0AAAAAAAaaaAaaAaaAAAA0aA00AAA0AAA0aAAaA0a0a0A"); assertEquals("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/[token hidden]", getErrorMessage()); } @Test public void jdbcUrlAnonymization() { - TRACKER.trackError("Failed: jdbc:mysql://localhost:3306:secretpass@mydb"); + tracker.trackError("Failed: jdbc:mysql://localhost:3306:secretpass@mydb"); assertEquals("Failed: jdbc:mysql://localhost:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlNoPortAnonymization() { - TRACKER.trackError("Failed: jdbc:mysql://mydb.com:secretpass@mydb"); + tracker.trackError("Failed: jdbc:mysql://mydb.com:secretpass@mydb"); assertEquals("Failed: jdbc:mysql://mydb.com:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlIpAnonymization() { - TRACKER.trackError("Failed: jdbc:mysql://127.0.0.1:3306:secretpass@mydb"); + tracker.trackError("Failed: jdbc:mysql://127.0.0.1:3306:secretpass@mydb"); assertEquals("Failed: jdbc:mysql://[IP hidden]:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void customPatternAnonymizesMessage() { - TRACKER.anonymize("token=[^&]+", "token=[redacted]"); - TRACKER.trackError("Request failed with token=abc123secret&user=test"); + tracker.anonymize("token=[^&]+", "token=[redacted]"); + tracker.trackError("Request failed with token=abc123secret&user=test"); assertEquals("Request failed with token=[redacted]&user=test", getErrorMessage()); } @Test public void customPatternWithCompiledPattern() { - TRACKER.anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); - TRACKER.trackError("Auth failed: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"); + tracker.anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); + tracker.trackError("Auth failed: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"); assertEquals("Auth failed: Bearer [redacted]", getErrorMessage()); } @Test public void customPatternWithCaptureGroupReplacement() { - TRACKER.anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); - TRACKER.trackError("GET /data?api_key=sk_live_12345&format=json failed"); + tracker.anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); + tracker.trackError("GET /data?api_key=sk_live_12345&format=json failed"); assertEquals("GET /data?api_key=[redacted]&format=json failed", getErrorMessage()); } @Test public void multipleCustomPatterns() { - TRACKER.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]") + tracker.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]") .anonymize("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[email hidden]"); - TRACKER.trackError("Auth failed for user@example.com with Bearer eyJ0eXAi"); + tracker.trackError("Auth failed for user@example.com with Bearer eyJ0eXAi"); assertEquals("Auth failed for [email hidden] with Bearer [redacted]", getErrorMessage()); } @Test public void customPatternChaining() { - TRACKER.anonymize("secret-[a-z]+", "[secret hidden]") + tracker.anonymize("secret-[a-z]+", "[secret hidden]") .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]"); - TRACKER.trackError("Credentials: secret-abcdef / AKIA1234567890ABCDEF"); + tracker.trackError("Credentials: secret-abcdef / AKIA1234567890ABCDEF"); assertEquals("Credentials: [secret hidden] / [aws-key hidden]", getErrorMessage()); } @Test public void customPatternAppliedToCauseChain() { - final var tracker = TRACKER.anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); + final var tracker = this.tracker.anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); final var cause = new IllegalArgumentException("Validation failed for ssn=123-45-6789"); tracker.trackError(new RuntimeException("Processing error", cause)); final var error = getError(); @@ -142,30 +142,30 @@ public void customPatternAppliedToCauseChain() { @Test public void nullMessageNotAffected() { - TRACKER.anonymize("anything", "[redacted]"); - TRACKER.trackError(new RuntimeException((String) null)); + tracker.anonymize("anything", "[redacted]"); + tracker.trackError(new RuntimeException((String) null)); assertFalse(getError().has("message")); } @Test public void customAndBuiltInPatternsCombined() { - TRACKER.anonymize("session=[a-f0-9]+", "session=[redacted]"); + tracker.anonymize("session=[a-f0-9]+", "session=[redacted]"); final var username = System.getProperty("user.name", "user"); - TRACKER.trackError("Error for 192.168.1.1 with session=deadbeef01 at /home/" + username + "/app"); + tracker.trackError("Error for 192.168.1.1 with session=deadbeef01 at /home/" + username + "/app"); assertEquals("Error for [IP hidden] with session=[redacted] at /home/[username hidden]/app", getErrorMessage()); } @Test public void emptyReplacementRemovesMatch() { - TRACKER.anonymize("\\(internal ref: [^)]+\\)", ""); - TRACKER.trackError("Request failed (internal ref: REF-98765)"); + tracker.anonymize("\\(internal ref: [^)]+\\)", ""); + tracker.trackError("Request failed (internal ref: REF-98765)"); assertEquals("Request failed ", getErrorMessage()); } @Test public void patternDoesNotMatchLeavesMessageUnchanged() { - TRACKER.anonymize("SECRET_[A-Z]+", "[redacted]"); - TRACKER.trackError("just a normal error"); + tracker.anonymize("SECRET_[A-Z]+", "[redacted]"); + tracker.trackError("just a normal error"); assertEquals("just a normal error", getErrorMessage()); } diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 2dd83369..7a4db278 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -13,8 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { - private static final SimpleErrorTracker TRACKER = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - private final MockContext context = new MockContext.Factory(TRACKER).create(); + private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + private final MockContext context = new MockContext.Factory(tracker).create(); @Test public void sameClassLoader() { @@ -129,9 +129,9 @@ private IllegalArgumentException createExceptionWithStack() { @Test public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { - TRACKER.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); + tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - final var report = TRACKER.getData().get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); final var message = report.get("message").getAsString(); final var header = report.getAsJsonArray("stack").get(0).getAsString(); @@ -141,10 +141,10 @@ public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { @Test public void appliesCustomRedactionAfterBuiltInRedaction() { - TRACKER.anonymize("session=[^ ]+", "session=[hidden]"); - TRACKER.trackError("failed with session=abc123 from 10.0.0.1"); + tracker.anonymize("session=[^ ]+", "session=[hidden]"); + tracker.trackError("failed with session=abc123 from 10.0.0.1"); - final var message = TRACKER.getData() + final var message = tracker.getData() .get(0) .getAsJsonObject() .get("message") @@ -155,9 +155,9 @@ public void appliesCustomRedactionAfterBuiltInRedaction() { @Test public void nullMessagesAreNotSerializedAsMessageProperty() { - TRACKER.trackError(new RuntimeException((String) null)); + tracker.trackError(new RuntimeException((String) null)); - final var report = TRACKER.getData().get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); assertFalse(report.has("message")); assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); } @@ -180,9 +180,9 @@ public void nestedCausesAreSerializedInOrder() { new StackTraceElement("example.Root", "fail", "Root.java", 10) }); - TRACKER.trackError(top, false); + tracker.trackError(top).handled(false); - final var report = TRACKER.getData().get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); final var stack = report.getAsJsonArray("stack"); assertEquals(RuntimeException.class.getName(), report.get("error").getAsString()); @@ -202,9 +202,9 @@ public void cyclicCauseChainStopsAfterFirstVisit() { final var second = new IllegalStateException("second", first); first.initCause(second); - TRACKER.trackError(first); + tracker.trackError(first); - final var stack = TRACKER.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); var firstCauseCount = 0; var secondCauseCount = 0; for (final var element : stack) { @@ -222,10 +222,10 @@ public void duplicateErrorsAreAggregatedWithCount() { final var first = createStableError(); final var second = createStableError(); - TRACKER.trackError(first); - TRACKER.trackError(second); + tracker.trackError(first); + tracker.trackError(second); - final var reports = TRACKER.getData(); + final var reports = tracker.getData(); final var report = reports.get(0).getAsJsonObject(); assertEquals(1, reports.size()); @@ -235,27 +235,27 @@ public void duplicateErrorsAreAggregatedWithCount() { @Test public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { - TRACKER.trackError(createStableError()); - TRACKER.trackError(createStableError()); + tracker.trackError(createStableError()); + tracker.trackError(createStableError()); - TRACKER.clear(); + tracker.clear(); - assertEquals(0, TRACKER.getData().size()); + assertEquals(0, tracker.getData().size()); - TRACKER.trackError(createStableError()); + tracker.trackError(createStableError()); - final var report = TRACKER.getData().get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); assertEquals("duplicate", report.get("message").getAsString()); assertNull(report.get("count")); } @Test public void ignoredNestedCauseSuppressesWholeReport() { - TRACKER.ignoreError(IllegalArgumentException.class, "ignore me"); + tracker.ignoreError(IllegalArgumentException.class, "ignore me"); - TRACKER.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); + tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); - assertEquals(0, TRACKER.getData().size()); + assertEquals(0, tracker.getData().size()); } @Test @@ -270,9 +270,9 @@ public void repeatingStackFramesAreCollapsed() { new StackTraceElement("example.Recursive", "b", "Recursive.java", 2) }); - TRACKER.trackError(error); + tracker.trackError(error); - final var stack = TRACKER.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); @@ -284,9 +284,9 @@ public void repeatingStackFramesAreCollapsed() { public void longMessagesAreTruncatedBeforeSerialization() { final var message = "a".repeat(600); - TRACKER.trackError(message); + tracker.trackError(message); - final var report = TRACKER.getData().get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); final var serialized = report.get("message").getAsString(); assertEquals(503, serialized.length()); assertTrue(serialized.endsWith("...")); @@ -301,8 +301,8 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept new StackTraceElement("example.Async", "run", "Async.java", 7) }); - TRACKER.setContextErrorHandler((loader, error) -> handled.countDown()); - TRACKER.attachErrorContext(null); + tracker.setContextErrorHandler((loader, error) -> handled.countDown()); + tracker.attachErrorContext(null); try { final var thread = new Thread(() -> { throw thrown; @@ -311,14 +311,32 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept thread.join(1000); assertTrue(handled.await(1, TimeUnit.SECONDS)); - final var report = TRACKER.getData().get(0).getAsJsonObject(); + final var report = tracker.getData().get(0).getAsJsonObject(); assertEquals("async failure", report.get("message").getAsString()); assertFalse(report.get("handled").getAsBoolean()); } finally { - TRACKER.detachErrorContext(); + tracker.detachErrorContext(); } } + @Test + public void trackedErrorSerializesProperties() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var error = tracker.trackError("with properties"); + error.attributes(Attributes.create() + .put("stage", "startup") + .put("attempt", 2) + .put("retrying", true)); + + final var report = tracker.getData().get(0).getAsJsonObject(); + final var attributes = report.getAsJsonObject("attributes"); + + assertTrue(report.get("handled").getAsBoolean()); + assertEquals("startup", attributes.get("stage").getAsString()); + assertEquals(2, attributes.get("attempt").getAsInt()); + assertTrue(attributes.get("retrying").getAsBoolean()); + } + private RuntimeException createStableError() { final var error = new RuntimeException("duplicate"); error.setStackTrace(new StackTraceElement[]{ From f6b5e1ce48530bc41fc1eee97133c12d5f7ae442 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 18:23:19 +0200 Subject: [PATCH 110/140] It only has to compile and pass tests for now; sorry future me and anyone having to read this --- .../java/dev/faststats/ErrorTrackingSink.java | 48 +++++++++++++++++-- .../dev/faststats/SimpleErrorTracker.java | 10 ++-- .../dev/faststats/SimpleTrackedError.java | 33 ++++++++++++- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index f5137454..a35c87fe 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -37,13 +37,13 @@ final class ErrorTrackingSink { final Set errorTrackers = new CopyOnWriteArraySet<>(); final Set> submissionJobs = new CopyOnWriteArraySet<>(); - + private @Nullable SimpleErrorTracker internalErrorTracker; private volatile @Nullable ScheduledExecutorService submissionScheduler; private volatile @Nullable ScheduledFuture errorSubmissionJob; private static final Object DISPATCHER_LOCK = new Object(); - private static final Set DISPATCHER_CONTEXTS = new CopyOnWriteArraySet<>(); + private static final Set DISPATCHER_TRACKERS = new CopyOnWriteArraySet<>(); private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); private static Thread.@Nullable UncaughtExceptionHandler originalHandler; @@ -51,6 +51,48 @@ final class ErrorTrackingSink { this.context = context; } + // fixme: hacky shit; it only has to compile and pass tests for now + static void attachErrorTracker(final SimpleErrorTracker tracker) { + synchronized (DISPATCHER_LOCK) { + if (DISPATCHER_TRACKERS.isEmpty()) { + originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(ErrorTrackingSink::handleUncaughtException); + } + DISPATCHER_TRACKERS.add(tracker); + } + } + + // fixme: hacky shit; it only has to compile and pass tests for now + static void detachErrorTracker(final SimpleErrorTracker tracker) { + synchronized (DISPATCHER_LOCK) { + DISPATCHER_TRACKERS.remove(tracker); + if (DISPATCHER_TRACKERS.isEmpty()) { + Thread.setDefaultUncaughtExceptionHandler(originalHandler); + originalHandler = null; + } + } + } + + // fixme: hacky shit; it only has to compile and pass tests for now + private static void handleUncaughtException(final Thread thread, final Throwable error) { + if (!DISPATCHING.get()) { + DISPATCHING.set(true); + try { + for (final var tracker : DISPATCHER_TRACKERS) { + final var loader = tracker.attachedLoader(); + if (loader != null && !ErrorHelper.isSameLoader(loader, error)) continue; + tracker.trackError(error).handled(false); + tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); + } + } finally { + DISPATCHING.set(false); + } + } + + final var handler = originalHandler; + if (handler != null) handler.uncaughtException(thread, error); + } + private static URI getErrorTrackerServerUrl() { final var property = System.getProperty("faststats.error-tracker-server"); if (property != null) try { @@ -157,7 +199,7 @@ Optional internalErrorTracker() { } void setInternalErrorTracker(final ErrorTracker errorTracker) { - if (!(errorTracker instanceof SimpleErrorTracker tracker)) { + if (!(errorTracker instanceof final SimpleErrorTracker tracker)) { throw new IllegalArgumentException("Unsupported error tracker implementation: " + errorTracker.getClass().getName()); } internalErrorTracker = tracker; diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 66f048bc..d3da36c7 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -108,16 +108,14 @@ public synchronized void attachErrorContext(@Nullable final ClassLoader loader) if (contextAttached) throw new IllegalStateException("Error context already attached"); contextAttached = true; attachedLoader = loader; - // fixme: single source of truth? - // SimpleContext.attachErrorTracker(this); + ErrorTrackingSink.attachErrorTracker(this); } @Override public synchronized void detachErrorContext() { if (!contextAttached) return; contextAttached = false; - // fixme: single source of truth? - // SimpleContext.detachErrorTracker(this); + ErrorTrackingSink.detachErrorTracker(this); } @Override @@ -134,4 +132,8 @@ public synchronized void setContextErrorHandler(@Nullable final BiConsumer<@Null public synchronized Optional> getContextErrorHandler() { return Optional.ofNullable(errorEvent); } + + @Nullable ClassLoader attachedLoader() { + return attachedLoader; + } } diff --git a/core/src/main/java/dev/faststats/SimpleTrackedError.java b/core/src/main/java/dev/faststats/SimpleTrackedError.java index 018ae7b0..cab9f563 100644 --- a/core/src/main/java/dev/faststats/SimpleTrackedError.java +++ b/core/src/main/java/dev/faststats/SimpleTrackedError.java @@ -2,7 +2,11 @@ import org.jspecify.annotations.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Objects; +import java.util.Set; final class SimpleTrackedError implements TrackedError { private Attributes attributes = Attributes.create(); @@ -46,11 +50,36 @@ public boolean equals(@Nullable final Object o) { final SimpleTrackedError that = (SimpleTrackedError) o; return handled == that.handled && Objects.equals(attributes, that.attributes) - && Objects.equals(error, that.error); + && deepEquals(error, that.error, Collections.newSetFromMap(new IdentityHashMap<>())); } @Override public int hashCode() { - return Objects.hash(attributes, handled, error); + return Objects.hash(attributes, handled, hash(error, Collections.newSetFromMap(new IdentityHashMap<>()))); + } + + // fixme: hacky shit; it only has to compile and pass tests for now + private static boolean deepEquals( + @Nullable final Throwable first, + @Nullable final Throwable second, + final Set visited + ) { + if (first == second) return true; + if (first == null || second == null) return false; + if (first.getClass() != second.getClass()) return false; + if (!Objects.equals(first.getMessage(), second.getMessage())) return false; + if (!Arrays.equals(first.getStackTrace(), second.getStackTrace())) return false; + if (!visited.add(first)) return true; + return deepEquals(first.getCause(), second.getCause(), visited); + } + + private static int hash(@Nullable final Throwable error, final Set visited) { + if (error == null || !visited.add(error)) return 0; + return Objects.hash( + error.getClass(), + error.getMessage(), + Arrays.hashCode(error.getStackTrace()), + hash(error.getCause(), visited) + ); } } From a51b0b69bdb109578c3d2f60a7be621b4bde2af2 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 18:35:53 +0200 Subject: [PATCH 111/140] Bump version to 0.24.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 4433e215..31662ead 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=0.23.0 +version=0.24.0 From 99c8facdd2de12cdffe7dd1296a5c50aa21c8420 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 18:43:13 +0200 Subject: [PATCH 112/140] Revert publishing exclusion for config module --- build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2059308a..cc3d0743 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,8 +22,8 @@ subprojects { plugin("java-library") } - val noPublish = project.name.startsWith("example") || project.name == "config" - if (noPublish) { + val example = project.name.startsWith("example") + if (example) { apply { plugin("com.gradleup.shadow") } } else { apply { plugin("maven-publish") } @@ -96,7 +96,7 @@ subprojects { } afterEvaluate { - if (noPublish) return@afterEvaluate + if (example) return@afterEvaluate extensions.configure { publications.create("maven") { artifactId = project.name From 5c6ff194cd6288d9d2fad91827d8ff4c6e99ea87 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 19:37:36 +0200 Subject: [PATCH 113/140] Fix data body --- .../java/dev/faststats/ErrorTrackingSink.java | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index a35c87fe..cc25483e 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -104,16 +104,10 @@ private static URI getErrorTrackerServerUrl() { return URI.create("https://metrics.faststats.dev/v1/error"); } - JsonObject getData() { - final var data = new JsonObject(); - final var reports = new JsonArray(); - if (internalErrorTracker != null) reports.addAll(internalErrorTracker.getData()); - errorTrackers.forEach(tracker -> reports.addAll(tracker.getData())); - context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); - // todo: add global context - data.addProperty("sdk_name", context.getSdkInfo().getName()); - data.addProperty("sdk_version", context.getSdkInfo().getVersion()); - data.add("reports", reports); + JsonArray getData() { + final var data = new JsonArray(); + if (internalErrorTracker != null) data.addAll(internalErrorTracker.getData()); + errorTrackers.forEach(tracker -> data.addAll(tracker.getData())); return data; } @@ -125,8 +119,12 @@ void submit() { if (errors.isEmpty()) return; final var data = new JsonObject(); - data.addProperty("project_name", context.getProjectName()); + context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); data.addProperty("identifier", context.getConfig().serverId().toString()); + data.addProperty("project_name", context.getProjectName()); + data.addProperty("sdk_name", context.getSdkInfo().getName()); + data.addProperty("sdk_version", context.getSdkInfo().getVersion()); + // data.add("context", ); // todo: add global attributes and default and custom metrics data.add("errors", errors); try (final var byteOutput = new ByteArrayOutputStream(); From 242175814181e9f80ac379d6daf289decf9bfb0d Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 19:37:47 +0200 Subject: [PATCH 114/140] Log uncompressed data --- core/src/main/java/dev/faststats/ErrorTrackingSink.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index cc25483e..c87e38db 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -134,6 +134,7 @@ void submit() { final var compressed = byteOutput.toByteArray(); logger.info("Sending errors to: %s", url); + logger.info("Uncompressed data: %s", data); // todo: dedupe this final var request = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) From c5bdc9f4a81c100d5bc2bde20a4bae485d213918 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 19:38:00 +0200 Subject: [PATCH 115/140] Fix context key --- core/src/main/java/dev/faststats/ErrorHelper.java | 2 +- core/src/test/java/dev/faststats/ErrorTrackerTest.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index d29e3820..66f6a067 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -59,7 +59,7 @@ private static JsonObject compileAll(final TrackedError trackedError, @Nullable final var attributes = new JsonObject(); trackedError.attributes().forEachPrimitive(attributes::add); - if (!attributes.isEmpty()) report.add("attributes", attributes); + if (!attributes.isEmpty()) report.add("context", attributes); return report; } diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 7a4db278..240127c5 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -329,12 +329,12 @@ public void trackedErrorSerializesProperties() { .put("retrying", true)); final var report = tracker.getData().get(0).getAsJsonObject(); - final var attributes = report.getAsJsonObject("attributes"); + final var context = report.getAsJsonObject("context"); assertTrue(report.get("handled").getAsBoolean()); - assertEquals("startup", attributes.get("stage").getAsString()); - assertEquals(2, attributes.get("attempt").getAsInt()); - assertTrue(attributes.get("retrying").getAsBoolean()); + assertEquals("startup", context.get("stage").getAsString()); + assertEquals(2, context.get("attempt").getAsInt()); + assertTrue(context.get("retrying").getAsBoolean()); } private RuntimeException createStableError() { From e2ec0d2da01ddbf25eea40dcae63a1bca85f7298 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 30 May 2026 22:33:15 +0200 Subject: [PATCH 116/140] Append metrics data to error context --- .../java/dev/faststats/ErrorTrackingSink.java | 50 +++++++++++-------- .../java/dev/faststats/SimpleMetrics.java | 29 ++++++----- .../test/java/dev/faststats/MockMetrics.java | 5 -- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index c87e38db..4f021095 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -38,7 +38,7 @@ final class ErrorTrackingSink { final Set errorTrackers = new CopyOnWriteArraySet<>(); final Set> submissionJobs = new CopyOnWriteArraySet<>(); - private @Nullable SimpleErrorTracker internalErrorTracker; + private volatile @Nullable SimpleErrorTracker internalErrorTracker; private volatile @Nullable ScheduledExecutorService submissionScheduler; private volatile @Nullable ScheduledFuture errorSubmissionJob; @@ -104,28 +104,12 @@ private static URI getErrorTrackerServerUrl() { return URI.create("https://metrics.faststats.dev/v1/error"); } - JsonArray getData() { - final var data = new JsonArray(); - if (internalErrorTracker != null) data.addAll(internalErrorTracker.getData()); - errorTrackers.forEach(tracker -> data.addAll(tracker.getData())); - return data; - } - // todo: improve logging to be less cluttered; dedupe code void submit() { if (!context.getConfig().errorTracking()) return; - final var errors = getData(); - if (errors.isEmpty()) return; - - final var data = new JsonObject(); - context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); - data.addProperty("identifier", context.getConfig().serverId().toString()); - data.addProperty("project_name", context.getProjectName()); - data.addProperty("sdk_name", context.getSdkInfo().getName()); - data.addProperty("sdk_version", context.getSdkInfo().getVersion()); - // data.add("context", ); // todo: add global attributes and default and custom metrics - data.add("errors", errors); + final var data = createData(); + if (data == null) return; try (final var byteOutput = new ByteArrayOutputStream(); final var output = new GZIPOutputStream(byteOutput)) { @@ -171,8 +155,34 @@ void submit() { } } + private @Nullable JsonObject createData() { + final var internal = internalErrorTracker; + if (internal == null && errorTrackers.isEmpty()) return null; + + final var data = new JsonObject(); + context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); + data.addProperty("identifier", context.getConfig().serverId().toString()); + data.addProperty("project_name", context.getProjectName()); + data.addProperty("sdk_name", context.getSdkInfo().getName()); + data.addProperty("sdk_version", context.getSdkInfo().getVersion()); + + final var defaultContext = new JsonObject(); + context.metrics().ifPresent(metrics -> { + final var simpleMetrics = (SimpleMetrics) metrics; + simpleMetrics.appendData(defaultContext); + }); + data.add("context", defaultContext); + + final var errors = new JsonArray(); + if (internal != null) errors.addAll(internal.getData()); + errorTrackers.forEach(tracker -> errors.addAll(tracker.getData())); + data.add("errors", errors); + return data; + } + void clear() { - if (internalErrorTracker != null) internalErrorTracker.clear(); + final var internal = internalErrorTracker; + if (internal != null) internal.clear(); errorTrackers.forEach(SimpleErrorTracker::clear); } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 0f2c9e93..88aa0345 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -179,33 +179,38 @@ public boolean submit() { private static final String osVersion = System.getProperty("os.version"); private static final int coreCount = Runtime.getRuntime().availableProcessors(); - protected JsonObject createData() { - final var data = new JsonObject(); - final var metrics = new JsonObject(); - + private void appendInternalData(final JsonObject metrics) { metrics.addProperty("core_count", coreCount); metrics.addProperty("java_vendor", javaVendor); metrics.addProperty("java_version", javaVersion); metrics.addProperty("os_arch", osArch); metrics.addProperty("os_name", osName); metrics.addProperty("os_version", osVersion); + } - try { - appendDefaultData(metrics); - } catch (final Throwable t) { - logger.error("Failed to append default data", t); - context.trackInternalError(t); - } - + private void appendCustomData(final JsonObject metrics) { this.metrics.forEach(metric -> { try { + // todo: prevent overriding and log warning if multiple similar metric ids are used metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { - logger.error("Failed to build metric data: %s", t, metric.getId()); + logger.error("Failed to append custom metric data: %s", t, metric.getId()); context.trackInternalError(t); } }); + } + + public final void appendData(final JsonObject metrics) { + appendInternalData(metrics); + appendDefaultData(metrics); + appendCustomData(metrics); + } + + private JsonObject createData() { + final var data = new JsonObject(); + final var metrics = new JsonObject(); + appendData(metrics); data.addProperty("project_name", context.getProjectName()); data.addProperty("identifier", context.getConfig().serverId().toString()); diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index c029d744..3a960f5a 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -18,11 +18,6 @@ void startTestSubmitting() { startSubmitting(); } - @Override - public JsonObject createData() { - return super.createData(); - } - @Override protected void appendDefaultData(final JsonObject metrics) { } From 02cff7dfd034e162a16dea47888623cad11eb9fb Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 08:45:08 +0200 Subject: [PATCH 117/140] Add error tracker service --- .../main/java/com/example/ExamplePlugin.java | 2 +- .../faststats/bukkit/BukkitMetricsImpl.java | 2 +- .../faststats/bukkit/PaperEventListener.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- .../example/ErrorTrackerExample.java | 8 +- .../dev/faststats/ErrorTrackerService.java | 85 +++++++++++++++++++ .../java/dev/faststats/ErrorTrackingSink.java | 33 ++----- .../java/dev/faststats/FastStatsContext.java | 22 ++--- .../faststats/FastStatsContextFactory.java | 16 ++-- .../dev/faststats/FeatureFlagService.java | 1 + .../java/dev/faststats/SimpleContext.java | 26 +++--- .../faststats/SimpleErrorTrackerService.java | 75 ++++++++++++++++ .../java/dev/faststats/SimpleMetrics.java | 6 +- .../java/dev/faststats/AnonymizationTest.java | 3 +- .../java/dev/faststats/ErrorTrackerTest.java | 36 +++++++- .../test/java/dev/faststats/MockContext.java | 19 +---- .../src/main/java/com/example/ExampleMod.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- 20 files changed, 246 insertions(+), 100 deletions(-) create mode 100644 core/src/main/java/dev/faststats/ErrorTrackerService.java create mode 100644 core/src/main/java/dev/faststats/SimpleErrorTrackerService.java 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 f2d2f424..afb9fb72 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -12,7 +12,7 @@ public final class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BukkitContext context = new BukkitContext.Factory(this, "YOUR_TOKEN_HERE") - .errorTracker(ERROR_TRACKER) + .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index c5bb6057..04980042 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -77,7 +77,7 @@ private int getPlayerCount() { return plugin.getServer().getOnlinePlayers().size(); } catch (final Throwable t) { logger.error("Failed to get player count", t); - trackError(t); + context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(t)); return 0; } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java index 405341ad..329b5239 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java @@ -14,6 +14,6 @@ public void onServerException(final ServerExceptionEvent event) { if (!(event.getException() instanceof final ServerPluginException exception)) return; if (!exception.getResponsiblePlugin().equals(plugin)) return; final var report = exception.getCause() != null ? exception.getCause() : exception; - context.errorTracker().ifPresent(tracker -> tracker.trackError(report)); + context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(report)); } } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index ea9f329b..d771295d 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -12,7 +12,7 @@ public class ExamplePlugin extends Plugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BungeeContext context = new BungeeContext.Factory(this, "YOUR_TOKEN_HERE") - .errorTracker(ERROR_TRACKER) + .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index 1b745cc4..fcdac243 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -22,9 +22,11 @@ public final class ErrorTrackerExample { .anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); public static final FastStatsContext CONTEXT = getContextFactory() - .errorTracker(CONTEXT_AWARE) // Set the global/internal error tracker - .create() - .registerErrorTracker(CONTEXT_UNAWARE); // Register an additional tracker for submission + .errorTrackerService(factory -> factory + .globalErrorTracker(CONTEXT_AWARE) // Set the global/internal error tracker + .create() + .registerErrorTracker(CONTEXT_UNAWARE)) // Register an additional tracker for submission + .create(); public static void manualTracking() { try { diff --git a/core/src/main/java/dev/faststats/ErrorTrackerService.java b/core/src/main/java/dev/faststats/ErrorTrackerService.java new file mode 100644 index 00000000..b9cb6de3 --- /dev/null +++ b/core/src/main/java/dev/faststats/ErrorTrackerService.java @@ -0,0 +1,85 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +import java.util.Optional; + +/** + * A service for managing error trackers. + *

+ * Use {@link FastStatsContext#errorTrackerService()} to access the context service instance. + * + * @since 0.24.0 + */ +public sealed interface ErrorTrackerService permits SimpleErrorTrackerService { + /** + * Returns the global/internal error tracker configured for this service. + * + * @return the global/internal error tracker + * @since 0.24.0 + */ + @Contract(pure = true) + ErrorTracker globalErrorTracker(); + + /** + * Returns the global error context attributes configured for this service. + * + * @return the global error context attributes, if configured + * @since 0.24.0 + */ + @Contract(pure = true) + // todo: always provide an attributes instance + Optional getAttributes(); + + /** + * Registers an additional error tracker for submission with this service. + *

+ * The global/internal tracker returned by {@link #globalErrorTracker()} is configured + * by the service factory. Additional trackers registered here are submitted by + * the same context, but are not used for internal FastStats errors. + * + * @param errorTracker the additional error tracker + * @return this service + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + ErrorTrackerService registerErrorTracker(ErrorTracker errorTracker); + + /** + * An error tracker service factory. + * + * @since 0.24.0 + */ + // todo: remove factory? there is almost no gain from it and i don't see any reason why there would be configuration required + sealed interface Factory permits SimpleErrorTrackerService.Factory { + /** + * Sets the global/internal error tracker for services created by this factory. + * + * @param errorTracker the global/internal error tracker + * @return the error tracker service factory + * @since 0.24.0 + */ + @Contract(mutates = "this") + Factory globalErrorTracker(ErrorTracker errorTracker); + + /** + * Sets the global error context attributes for services created by this factory. + * + * @param attributes the global error context attributes + * @return the error tracker service factory + * @since 0.24.0 + */ + @Contract(mutates = "this") + Factory attributes(Attributes attributes); + + /** + * Creates a new error tracker service. + * + * @return the error tracker service + * @throws IllegalStateException if no global error tracker was configured + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + ErrorTrackerService create() throws IllegalStateException; + } +} diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index 4f021095..bf77ff30 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -15,7 +15,6 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.util.Optional; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executors; @@ -38,7 +37,6 @@ final class ErrorTrackingSink { final Set errorTrackers = new CopyOnWriteArraySet<>(); final Set> submissionJobs = new CopyOnWriteArraySet<>(); - private volatile @Nullable SimpleErrorTracker internalErrorTracker; private volatile @Nullable ScheduledExecutorService submissionScheduler; private volatile @Nullable ScheduledFuture errorSubmissionJob; @@ -156,8 +154,8 @@ void submit() { } private @Nullable JsonObject createData() { - final var internal = internalErrorTracker; - if (internal == null && errorTrackers.isEmpty()) return null; + final var service = context.errorTrackerService().orElse(null); + if (service == null && errorTrackers.isEmpty()) return null; final var data = new JsonObject(); context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); @@ -171,18 +169,21 @@ void submit() { final var simpleMetrics = (SimpleMetrics) metrics; simpleMetrics.appendData(defaultContext); }); + if (service != null) service.getAttributes().ifPresent(attributes -> attributes.forEachPrimitive(defaultContext::add)); data.add("context", defaultContext); final var errors = new JsonArray(); - if (internal != null) errors.addAll(internal.getData()); + if (service != null) errors.addAll(((SimpleErrorTracker) service.globalErrorTracker()).getData()); errorTrackers.forEach(tracker -> errors.addAll(tracker.getData())); data.add("errors", errors); return data; } void clear() { - final var internal = internalErrorTracker; - if (internal != null) internal.clear(); + context.errorTrackerService() + .map(ErrorTrackerService::globalErrorTracker) + .map(SimpleErrorTracker.class::cast) + .ifPresent(SimpleErrorTracker::clear); errorTrackers.forEach(SimpleErrorTracker::clear); } @@ -203,24 +204,6 @@ void unregisterSubmission(final ScheduledFuture future) { submissionJobs.remove(future); } - Optional internalErrorTracker() { - return Optional.ofNullable(internalErrorTracker); - } - - void setInternalErrorTracker(final ErrorTracker errorTracker) { - if (!(errorTracker instanceof final SimpleErrorTracker tracker)) { - throw new IllegalArgumentException("Unsupported error tracker implementation: " + errorTracker.getClass().getName()); - } - internalErrorTracker = tracker; - startErrorSubmission(); - } - - TrackedError trackInternalError(final Throwable error) { - final var tracker = internalErrorTracker; - if (tracker == null) return new SimpleTrackedError(error); - return tracker.trackError(error); - } - boolean isSubmissionSchedulerRunning() { final var scheduler = submissionScheduler; return scheduler != null && !scheduler.isShutdown(); diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java index 99424127..6a667283 100644 --- a/core/src/main/java/dev/faststats/FastStatsContext.java +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -8,7 +8,8 @@ * Shared FastStats context. *

* Platform-specific contexts should extend this class to provide a shared - * configuration, token, metrics, and feature flag service for their environment. + * configuration, token, metrics, feature flag service, and error tracker service + * for their environment. * * @since 0.24.0 */ @@ -51,26 +52,13 @@ public sealed interface FastStatsContext permits SimpleContext { Optional featureFlagService(); /** - * Get the registered internal/global error tracker, if one was configured. + * Gets the error tracker service bound to this context. * - * @return the internal/global error tracker + * @return the context error tracker service, if one was configured * @since 0.24.0 */ @Contract(pure = true) - Optional errorTracker(); - - /** - * Registers an additional error tracker for submission with this context. - *

- * The global/internal tracker returned by {@link #errorTracker()} is configured - * by the context factory. Additional trackers registered here are submitted by - * the same context, but are not used for internal FastStats errors. - * - * @param errorTracker the additional error tracker - * @return this context - * @since 0.24.0 - */ - FastStatsContext registerErrorTracker(ErrorTracker errorTracker); + Optional errorTrackerService(); /** * Performs additional post-startup tasks for configured context services. diff --git a/core/src/main/java/dev/faststats/FastStatsContextFactory.java b/core/src/main/java/dev/faststats/FastStatsContextFactory.java index dae55cbe..d5c913cc 100644 --- a/core/src/main/java/dev/faststats/FastStatsContextFactory.java +++ b/core/src/main/java/dev/faststats/FastStatsContextFactory.java @@ -16,20 +16,22 @@ * @since 0.24.0 */ public abstract class FastStatsContextFactory> { - private @Nullable ErrorTracker errorTracker; private @Nullable Function metrics = null; private @Nullable Function featureFlagService; + private @Nullable Function errorTrackerService; /** - * Sets the single global/internal error tracker for the context created by this factory. + * Configures and creates the single error tracker service instance for the context. * - * @param errorTracker the global/internal error tracker + * @param errorTrackerService a function that receives a new service factory and returns the built service instance * @return this factory * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") - public F errorTracker(final ErrorTracker errorTracker) { - this.errorTracker = errorTracker; + public F errorTrackerService( + final Function errorTrackerService + ) { + this.errorTrackerService = errorTrackerService; return self(); } @@ -70,12 +72,12 @@ public F featureFlagService( @Contract(value = " -> new", mutates = "io") public final C create() { final var context = createContext(); - if (errorTracker != null) - context.setErrorTracker(errorTracker); if (metrics != null) context.setMetrics(metrics.apply(context.metricsFactory())); if (featureFlagService != null) context.setFeatureFlagService(featureFlagService.apply(context.featureFlagServiceFactory())); + if (errorTrackerService != null) + context.setErrorTrackerService(errorTrackerService.apply(context.errorTrackerServiceFactory())); return context; } diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java index 20a4ec8d..852565e8 100644 --- a/core/src/main/java/dev/faststats/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -92,6 +92,7 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * @since 0.24.0 */ @Contract(pure = true) + // todo: always provide an attributes instance Optional getAttributes(); /** diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 86ddaef1..9e9679a7 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -17,6 +17,7 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { private final SdkInfo sdkInfo; private @Nullable Metrics metrics; private @Nullable FeatureFlagService featureFlagService; + private @Nullable ErrorTrackerService errorTrackerService; /** * Creates a new context that stores the shared configuration and token for all FastStats services. @@ -92,6 +93,11 @@ protected FeatureFlagService.Factory featureFlagServiceFactory() { return new SimpleFeatureFlagService.Factory(config, token); } + @Contract(value = " -> new", pure = true) + protected ErrorTrackerService.Factory errorTrackerServiceFactory() { + return new SimpleErrorTrackerService.Factory(errorTrackingSink); + } + final void setMetrics(final Metrics metrics) { this.metrics = metrics; } @@ -102,19 +108,13 @@ final void setFeatureFlagService(final FeatureFlagService featureFlagService) { @Override @Contract(pure = true) - public final Optional errorTracker() { - return errorTrackingSink.internalErrorTracker(); - } - - final void setErrorTracker(final ErrorTracker errorTracker) { - errorTrackingSink.setInternalErrorTracker(errorTracker); + public final Optional errorTrackerService() { + return Optional.ofNullable(errorTrackerService); } - @Override - public final SimpleContext registerErrorTracker(final ErrorTracker tracker) { - errorTrackingSink.errorTrackers.add((SimpleErrorTracker) tracker); - errorTrackingSink.startErrorSubmission(); - return this; + // todo: mutation sucks :) + final void setErrorTrackerService(final ErrorTrackerService errorTrackerService) { + this.errorTrackerService = errorTrackerService; } @Override @@ -137,8 +137,4 @@ public SdkInfo getSdkInfo() { ErrorTrackingSink errorTrackingSink() { return errorTrackingSink; } - - TrackedError trackInternalError(final Throwable error) { - return errorTrackingSink.trackInternalError(error); - } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java new file mode 100644 index 00000000..a3b12ca5 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -0,0 +1,75 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +final class SimpleErrorTrackerService implements ErrorTrackerService { + private final ErrorTrackingSink sink; + private final SimpleErrorTracker globalErrorTracker; + private final @Nullable Attributes attributes; + + SimpleErrorTrackerService( + final ErrorTrackingSink sink, + final ErrorTracker globalErrorTracker, + final @Nullable Attributes attributes + ) { + // todo: don't even let the user provide anything else + if (!(globalErrorTracker instanceof final SimpleErrorTracker tracker)) { + throw new IllegalArgumentException("Unsupported error tracker implementation: " + globalErrorTracker.getClass().getName()); + } + this.sink = sink; + this.globalErrorTracker = tracker; + this.attributes = attributes; + sink.startErrorSubmission(); + } + + @Override + public ErrorTracker globalErrorTracker() { + return globalErrorTracker; + } + + @Override + public Optional getAttributes() { + return Optional.ofNullable(attributes); + } + + @Override + public ErrorTrackerService registerErrorTracker(final ErrorTracker errorTracker) { + // todo: the class is sealed this check will always succeed, cast directly + if (!(errorTracker instanceof final SimpleErrorTracker tracker)) { + throw new IllegalArgumentException("Unsupported error tracker implementation: " + errorTracker.getClass().getName()); + } + sink.errorTrackers.add(tracker); + sink.startErrorSubmission(); + return this; + } + + static final class Factory implements ErrorTrackerService.Factory { + private final ErrorTrackingSink sink; + private @Nullable ErrorTracker globalErrorTracker; + private @Nullable Attributes attributes; + + Factory(final ErrorTrackingSink sink) { + this.sink = sink; + } + + @Override + public ErrorTrackerService.Factory globalErrorTracker(final ErrorTracker errorTracker) { + this.globalErrorTracker = errorTracker; + return this; + } + + @Override + public ErrorTrackerService.Factory attributes(final Attributes attributes) { + this.attributes = attributes; + return this; + } + + @Override + public ErrorTrackerService create() throws IllegalStateException { + if (globalErrorTracker == null) throw new IllegalStateException("A global error tracker is required"); + return new SimpleErrorTrackerService(sink, globalErrorTracker, attributes); + } + } +} diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 88aa0345..3d570a9c 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -113,10 +113,6 @@ protected boolean isSubmitting() { return submissionJob != null && !submissionJob.isCancelled(); } - protected final TrackedError trackError(final Throwable error) { - return context.trackInternalError(error); - } - // todo: improve logging to be less cluttered @VisibleForTesting public boolean submit() { @@ -195,7 +191,7 @@ private void appendCustomData(final JsonObject metrics) { metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { logger.error("Failed to append custom metric data: %s", t, metric.getId()); - context.trackInternalError(t); + context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(t)); } }); } diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index 67edc0ef..709a7b86 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -11,7 +11,8 @@ public final class AnonymizationTest { private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - private final FastStatsContext context = new MockContext.Factory(tracker) + private final FastStatsContext context = new MockContext.Factory() + .errorTrackerService(factory -> factory.globalErrorTracker(tracker).create()) .metrics(Metrics.Factory::create) .create(); diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 240127c5..f6d2b4d9 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,5 +1,6 @@ package dev.faststats; +import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; import java.net.URL; @@ -9,12 +10,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - private final MockContext context = new MockContext.Factory(tracker).create(); + private final MockContext context = new MockContext.Factory() + .errorTrackerService(factory -> factory.globalErrorTracker(tracker).create()) + .create(); @Test public void sameClassLoader() { @@ -337,6 +341,36 @@ public void trackedErrorSerializesProperties() { assertTrue(context.get("retrying").getAsBoolean()); } + @Test + public void errorTrackerServiceRequiresGlobalTracker() { + final var factory = new SimpleErrorTrackerService.Factory(context.errorTrackingSink()); + assertThrows(IllegalStateException.class, factory::create); + } + + @Test + public void errorTrackerServiceSerializesGlobalAttributes() throws ReflectiveOperationException { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var context = new MockContext.Factory() + .errorTrackerService(factory -> factory + .globalErrorTracker(tracker) + .attributes(Attributes.create() + .put("stage", "startup") + .put("attempt", 2) + .put("retrying", true)) + .create()) + .create(); + tracker.trackError("with global attributes"); + + final var method = ErrorTrackingSink.class.getDeclaredMethod("createData"); + method.setAccessible(true); + final var data = (JsonObject) method.invoke(context.errorTrackingSink()); + final var globalContext = data.getAsJsonObject("context"); + + assertEquals("startup", globalContext.get("stage").getAsString()); + assertEquals(2, globalContext.get("attempt").getAsInt()); + assertTrue(globalContext.get("retrying").getAsBoolean()); + } + private RuntimeException createStableError() { final var error = new RuntimeException("duplicate"); error.setStackTrace(new StackTraceElement[]{ diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 5913d332..80deeef0 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -1,17 +1,10 @@ package dev.faststats; -import org.jspecify.annotations.Nullable; - import java.util.UUID; public final class MockContext extends SimpleContext { private MockContext() throws IllegalArgumentException { - this(null); - } - - private MockContext(@Nullable final ErrorTracker internalErrorTracker) throws IllegalArgumentException { super(new MockConfig(UUID.randomUUID()), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - if (internalErrorTracker != null) setErrorTracker(internalErrorTracker); } @Override @@ -52,19 +45,9 @@ public boolean debug() { } public static final class Factory extends FastStatsContextFactory { - private final @Nullable ErrorTracker internalErrorTracker; - - public Factory() { - this(null); - } - - public Factory(@Nullable final ErrorTracker internalErrorTracker) { - this.internalErrorTracker = internalErrorTracker; - } - @Override protected MockContext createContext() { - return new MockContext(internalErrorTracker); + return new MockContext(); } } } diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 1e0a48cc..11a49091 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -26,7 +26,7 @@ public class ExampleMod implements ModInitializer { .onFlush(() -> gameCount.set(0)) // reset game count on flush .create()) - .errorTracker(ERROR_TRACKER) + .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) .create(); @Override diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 65e8a480..2304cf10 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -13,7 +13,7 @@ public class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final HytaleContext context = new HytaleContext.Factory(this, "YOUR_TOKEN_HERE") - .errorTracker(ERROR_TRACKER) + .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 619c0365..972195df 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -25,7 +25,7 @@ public class ExamplePlugin { public void onServerStart(final StartedEngineEvent event) { this.context = contextBuilder .token("YOUR_TOKEN_HERE") - .errorTracker(ERROR_TRACKER) + .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 1e753a7f..0c80f399 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -23,7 +23,7 @@ public class ExamplePlugin { public ExamplePlugin(final VelocityContext.Builder contextBuilder) { this.context = contextBuilder .token("YOUR_TOKEN_HERE") - .errorTracker(ERROR_TRACKER) + .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings From 88b04fb31fba3dd8eee6e882b5aba66638317650 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 08:50:44 +0200 Subject: [PATCH 118/140] Rename Attributes#create to Attributes#empty and make global attributes required --- .../dev/faststats/example/FeatureFlagExample.java | 2 +- core/src/main/java/dev/faststats/Attributes.java | 2 +- .../main/java/dev/faststats/ErrorTrackerService.java | 7 ++----- .../main/java/dev/faststats/ErrorTrackingSink.java | 2 +- .../main/java/dev/faststats/FeatureFlagService.java | 6 ++---- .../dev/faststats/SimpleErrorTrackerService.java | 11 +++++------ .../java/dev/faststats/SimpleFeatureFlagService.java | 12 ++++++------ .../main/java/dev/faststats/SimpleTrackedError.java | 2 +- .../test/java/dev/faststats/ErrorTrackerTest.java | 4 ++-- .../src/test/java/dev/faststats/FeatureFlagTest.java | 9 ++++----- 10 files changed, 25 insertions(+), 32 deletions(-) diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index f5bb8edd..58667670 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -12,7 +12,7 @@ public final class FeatureFlagExample { public static final FastStatsContext CONTEXT = getContextFactory() // .featureFlagService(FeatureFlagService.Factory::create) // Define a feature flag service with default settings .featureFlagService(factory -> factory - .attributes(Attributes.create() // Define global attributes + .attributes(Attributes.empty() // Define global attributes .put("version", "1.2.3") .put("java_version", System.getProperty("java.version")) .put("java_vendor", System.getProperty("java.vendor"))) diff --git a/core/src/main/java/dev/faststats/Attributes.java b/core/src/main/java/dev/faststats/Attributes.java index 1638fb07..aad4a817 100644 --- a/core/src/main/java/dev/faststats/Attributes.java +++ b/core/src/main/java/dev/faststats/Attributes.java @@ -23,7 +23,7 @@ public sealed interface Attributes permits SimpleAttributes { * @since 0.24.0 */ @Contract(value = " -> new", pure = true) - static Attributes create() { + static Attributes empty() { return new SimpleAttributes(new ConcurrentHashMap<>()); } diff --git a/core/src/main/java/dev/faststats/ErrorTrackerService.java b/core/src/main/java/dev/faststats/ErrorTrackerService.java index b9cb6de3..d556ae6e 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/ErrorTrackerService.java @@ -2,8 +2,6 @@ import org.jetbrains.annotations.Contract; -import java.util.Optional; - /** * A service for managing error trackers. *

@@ -24,12 +22,11 @@ public sealed interface ErrorTrackerService permits SimpleErrorTrackerService { /** * Returns the global error context attributes configured for this service. * - * @return the global error context attributes, if configured + * @return the global error context attributes * @since 0.24.0 */ @Contract(pure = true) - // todo: always provide an attributes instance - Optional getAttributes(); + Attributes getAttributes(); /** * Registers an additional error tracker for submission with this service. diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java index bf77ff30..06fd9fde 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ b/core/src/main/java/dev/faststats/ErrorTrackingSink.java @@ -169,7 +169,7 @@ void submit() { final var simpleMetrics = (SimpleMetrics) metrics; simpleMetrics.appendData(defaultContext); }); - if (service != null) service.getAttributes().ifPresent(attributes -> attributes.forEachPrimitive(defaultContext::add)); + if (service != null) service.getAttributes().forEachPrimitive(defaultContext::add); data.add("context", defaultContext); final var errors = new JsonArray(); diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java index 852565e8..dec8f35f 100644 --- a/core/src/main/java/dev/faststats/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -3,7 +3,6 @@ import org.jetbrains.annotations.Contract; import java.time.Duration; -import java.util.Optional; /** * A service for managing feature flags. @@ -88,12 +87,11 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { * These attributes apply to every flag defined by the service and are * merged with any per-flag attributes supplied during definition. * - * @return the global targeting attributes, if configured + * @return the global targeting attributes * @since 0.24.0 */ @Contract(pure = true) - // todo: always provide an attributes instance - Optional getAttributes(); + Attributes getAttributes(); /** * Returns the cache time-to-live used for resolved flag values. diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index a3b12ca5..1dafcd36 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -2,17 +2,15 @@ import org.jspecify.annotations.Nullable; -import java.util.Optional; - final class SimpleErrorTrackerService implements ErrorTrackerService { private final ErrorTrackingSink sink; private final SimpleErrorTracker globalErrorTracker; - private final @Nullable Attributes attributes; + private final Attributes attributes; SimpleErrorTrackerService( final ErrorTrackingSink sink, final ErrorTracker globalErrorTracker, - final @Nullable Attributes attributes + final Attributes attributes ) { // todo: don't even let the user provide anything else if (!(globalErrorTracker instanceof final SimpleErrorTracker tracker)) { @@ -30,8 +28,8 @@ public ErrorTracker globalErrorTracker() { } @Override - public Optional getAttributes() { - return Optional.ofNullable(attributes); + public Attributes getAttributes() { + return attributes; } @Override @@ -69,6 +67,7 @@ public ErrorTrackerService.Factory attributes(final Attributes attributes) { @Override public ErrorTrackerService create() throws IllegalStateException { if (globalErrorTracker == null) throw new IllegalStateException("A global error tracker is required"); + final var attributes = this.attributes != null ? this.attributes : Attributes.empty(); return new SimpleErrorTrackerService(sink, globalErrorTracker, attributes); } } diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 98d45cfe..51595898 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -17,7 +17,6 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.Map; -import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -34,7 +33,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { private final UUID serverId; private final @Token String token; - private final @Nullable Attributes attributes; + private final Attributes attributes; private final Duration ttl; private final Map> fetchesInProgress = new ConcurrentHashMap<>(); @@ -42,7 +41,7 @@ final class SimpleFeatureFlagService implements FeatureFlagService { SimpleFeatureFlagService( final Config config, final @Token String token, - final @Nullable Attributes attributes, + final Attributes attributes, final Duration ttl ) throws IllegalArgumentException { if (ttl.isNegative()) throw new IllegalArgumentException("TTL cannot be negative"); @@ -103,7 +102,7 @@ private CompletableFuture createFetch(final SimpleFeatureFlag flag) { requestBody.addProperty("key", flag.getId()); final var attributes = new JsonObject(); - if (this.attributes != null) this.attributes.forEachPrimitive(attributes::add); + this.attributes.forEachPrimitive(attributes::add); if (flag.attributes() != null) flag.attributes().forEachPrimitive(attributes::add); if (!attributes.isEmpty()) requestBody.add("attributes", attributes); @@ -208,8 +207,8 @@ public FeatureFlag define(final String id, final Number defaultValue, fi } @Override - public Optional getAttributes() { - return Optional.ofNullable(attributes); + public Attributes getAttributes() { + return attributes; } @Override @@ -249,6 +248,7 @@ public FeatureFlagService.Factory ttl(final Duration ttl) throws IllegalArgument @Override public FeatureFlagService create() throws IllegalArgumentException { + final var attributes = this.attributes != null ? this.attributes : Attributes.empty(); return new SimpleFeatureFlagService(config, token, attributes, ttl); } } diff --git a/core/src/main/java/dev/faststats/SimpleTrackedError.java b/core/src/main/java/dev/faststats/SimpleTrackedError.java index cab9f563..7e3bc71c 100644 --- a/core/src/main/java/dev/faststats/SimpleTrackedError.java +++ b/core/src/main/java/dev/faststats/SimpleTrackedError.java @@ -9,7 +9,7 @@ import java.util.Set; final class SimpleTrackedError implements TrackedError { - private Attributes attributes = Attributes.create(); + private Attributes attributes = Attributes.empty(); private boolean handled = true; private final Throwable error; diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index f6d2b4d9..65fd9703 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -327,7 +327,7 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept public void trackedErrorSerializesProperties() { final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); final var error = tracker.trackError("with properties"); - error.attributes(Attributes.create() + error.attributes(Attributes.empty() .put("stage", "startup") .put("attempt", 2) .put("retrying", true)); @@ -353,7 +353,7 @@ public void errorTrackerServiceSerializesGlobalAttributes() throws ReflectiveOpe final var context = new MockContext.Factory() .errorTrackerService(factory -> factory .globalErrorTracker(tracker) - .attributes(Attributes.create() + .attributes(Attributes.empty() .put("stage", "startup") .put("attempt", 2) .put("retrying", true)) diff --git a/core/src/test/java/dev/faststats/FeatureFlagTest.java b/core/src/test/java/dev/faststats/FeatureFlagTest.java index 21d5ab21..efb460c5 100644 --- a/core/src/test/java/dev/faststats/FeatureFlagTest.java +++ b/core/src/test/java/dev/faststats/FeatureFlagTest.java @@ -2,7 +2,6 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -102,11 +101,11 @@ public void stringAndNumberFlagsUseDefaultValueTypes() throws Exception { public void serviceAndFlagAttributesAreMergedInFetchRequest() throws Exception { server.enqueue(200, "{\"value\":true}"); - final var serviceAttributes = Attributes.create() + final var serviceAttributes = Attributes.empty() .put("region", "global") .put("players", 20) .put("premium", false); - final var flagAttributes = Attributes.create() + final var flagAttributes = Attributes.empty() .put("region", "flag") .put("beta", true); final var service = service(serviceAttributes, Duration.ofMinutes(5)); @@ -184,10 +183,10 @@ public void nonSuccessfulFetchResponseFails() { } private static SimpleFeatureFlagService service(final Duration ttl) { - return service(null, ttl); + return service(Attributes.empty(), ttl); } - private static SimpleFeatureFlagService service(@Nullable final Attributes attributes, final Duration ttl) { + private static SimpleFeatureFlagService service(final Attributes attributes, final Duration ttl) { return new SimpleFeatureFlagService(new TestConfig(), "test-token", attributes, ttl); } From 22afec2db8905e7bfce812216314b294b36fb631 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 08:59:00 +0200 Subject: [PATCH 119/140] Merge ErrorTrackingSink into SimpleErrorTrackerService --- .../java/dev/faststats/ErrorTrackingSink.java | 236 ----------------- .../java/dev/faststats/SimpleContext.java | 8 +- .../dev/faststats/SimpleErrorTracker.java | 4 +- .../faststats/SimpleErrorTrackerService.java | 241 +++++++++++++++++- .../java/dev/faststats/SimpleMetrics.java | 17 +- 5 files changed, 249 insertions(+), 257 deletions(-) delete mode 100644 core/src/main/java/dev/faststats/ErrorTrackingSink.java diff --git a/core/src/main/java/dev/faststats/ErrorTrackingSink.java b/core/src/main/java/dev/faststats/ErrorTrackingSink.java deleted file mode 100644 index 06fd9fde..00000000 --- a/core/src/main/java/dev/faststats/ErrorTrackingSink.java +++ /dev/null @@ -1,236 +0,0 @@ -package dev.faststats; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import dev.faststats.internal.Logger; -import dev.faststats.internal.LoggerFactory; -import org.jspecify.annotations.Nullable; - -import java.io.ByteArrayOutputStream; -import java.net.ConnectException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.http.HttpClient; -import java.net.http.HttpConnectTimeoutException; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.zip.GZIPOutputStream; - -import static java.nio.charset.StandardCharsets.UTF_8; - -final class ErrorTrackingSink { - private final Logger logger = LoggerFactory.factory().getLogger(getClass()); - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(3)) - .version(HttpClient.Version.HTTP_1_1) - .build(); - private final SimpleContext context; - private final URI url = getErrorTrackerServerUrl(); - - final Set errorTrackers = new CopyOnWriteArraySet<>(); - final Set> submissionJobs = new CopyOnWriteArraySet<>(); - - private volatile @Nullable ScheduledExecutorService submissionScheduler; - private volatile @Nullable ScheduledFuture errorSubmissionJob; - - private static final Object DISPATCHER_LOCK = new Object(); - private static final Set DISPATCHER_TRACKERS = new CopyOnWriteArraySet<>(); - private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); - private static Thread.@Nullable UncaughtExceptionHandler originalHandler; - - ErrorTrackingSink(final SimpleContext context) { - this.context = context; - } - - // fixme: hacky shit; it only has to compile and pass tests for now - static void attachErrorTracker(final SimpleErrorTracker tracker) { - synchronized (DISPATCHER_LOCK) { - if (DISPATCHER_TRACKERS.isEmpty()) { - originalHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(ErrorTrackingSink::handleUncaughtException); - } - DISPATCHER_TRACKERS.add(tracker); - } - } - - // fixme: hacky shit; it only has to compile and pass tests for now - static void detachErrorTracker(final SimpleErrorTracker tracker) { - synchronized (DISPATCHER_LOCK) { - DISPATCHER_TRACKERS.remove(tracker); - if (DISPATCHER_TRACKERS.isEmpty()) { - Thread.setDefaultUncaughtExceptionHandler(originalHandler); - originalHandler = null; - } - } - } - - // fixme: hacky shit; it only has to compile and pass tests for now - private static void handleUncaughtException(final Thread thread, final Throwable error) { - if (!DISPATCHING.get()) { - DISPATCHING.set(true); - try { - for (final var tracker : DISPATCHER_TRACKERS) { - final var loader = tracker.attachedLoader(); - if (loader != null && !ErrorHelper.isSameLoader(loader, error)) continue; - tracker.trackError(error).handled(false); - tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); - } - } finally { - DISPATCHING.set(false); - } - } - - final var handler = originalHandler; - if (handler != null) handler.uncaughtException(thread, error); - } - - private static URI getErrorTrackerServerUrl() { - final var property = System.getProperty("faststats.error-tracker-server"); - if (property != null) try { - return new URI(property); - } catch (final URISyntaxException e) { - final var logger = LoggerFactory.factory().getLogger(SimpleMetrics.class); - logger.error("Failed to parse error tracker server url: %s", e, property); - } - return URI.create("https://metrics.faststats.dev/v1/error"); - } - - // todo: improve logging to be less cluttered; dedupe code - void submit() { - if (!context.getConfig().errorTracking()) return; - - final var data = createData(); - if (data == null) return; - - try (final var byteOutput = new ByteArrayOutputStream(); - final var output = new GZIPOutputStream(byteOutput)) { - output.write(data.toString().getBytes(UTF_8)); - output.finish(); - - final var compressed = byteOutput.toByteArray(); - logger.info("Sending errors to: %s", url); - logger.info("Uncompressed data: %s", data); - // todo: dedupe this - final var request = HttpRequest.newBuilder() - .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) - .header("Content-Encoding", "gzip") - .header("Content-Type", "application/octet-stream") - .header("Authorization", "Bearer " + context.getToken()) - .header("User-Agent", context.getSdkInfo().getUserAgent()) - .timeout(Duration.ofSeconds(3)) - .uri(url) - .build(); - - final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); - final var statusCode = response.statusCode(); - final var body = response.body(); - - if (statusCode >= 200 && statusCode < 300) { - logger.info("Errors submitted with status code: %s (%s)", statusCode, body); - clear(); - } else if (statusCode >= 300 && statusCode < 400) { - logger.warn("Received redirect response from error server: %s (%s)", statusCode, body); - } else if (statusCode >= 400 && statusCode < 500) { - logger.error("Submitted invalid request to error server: %s (%s)", null, statusCode, body); - } else if (statusCode >= 500 && statusCode < 600) { - logger.error("Received server error response from error server: %s (%s)", null, statusCode, body); - } else { - logger.warn("Received unexpected response from error server: %s (%s)", statusCode, body); - } - } catch (final HttpConnectTimeoutException t) { - logger.error("Error submission timed out after 3 seconds: %s", null, url); - } catch (final ConnectException t) { - logger.error("Failed to connect to error server: %s", null, url); - } catch (final Throwable t) { - logger.error("Failed to submit errors", t); - } - } - - private @Nullable JsonObject createData() { - final var service = context.errorTrackerService().orElse(null); - if (service == null && errorTrackers.isEmpty()) return null; - - final var data = new JsonObject(); - context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); - data.addProperty("identifier", context.getConfig().serverId().toString()); - data.addProperty("project_name", context.getProjectName()); - data.addProperty("sdk_name", context.getSdkInfo().getName()); - data.addProperty("sdk_version", context.getSdkInfo().getVersion()); - - final var defaultContext = new JsonObject(); - context.metrics().ifPresent(metrics -> { - final var simpleMetrics = (SimpleMetrics) metrics; - simpleMetrics.appendData(defaultContext); - }); - if (service != null) service.getAttributes().forEachPrimitive(defaultContext::add); - data.add("context", defaultContext); - - final var errors = new JsonArray(); - if (service != null) errors.addAll(((SimpleErrorTracker) service.globalErrorTracker()).getData()); - errorTrackers.forEach(tracker -> errors.addAll(tracker.getData())); - data.add("errors", errors); - return data; - } - - void clear() { - context.errorTrackerService() - .map(ErrorTrackerService::globalErrorTracker) - .map(SimpleErrorTracker.class::cast) - .ifPresent(SimpleErrorTracker::clear); - errorTrackers.forEach(SimpleErrorTracker::clear); - } - - ScheduledFuture scheduleSubmission( - final Runnable task, - final long initialDelay, - final long period, - final TimeUnit unit - ) { - final var scheduler = submissionScheduler(); - final var future = scheduler.scheduleAtFixedRate(task, Math.max(0, initialDelay), Math.max(1000, period), unit); - submissionJobs.add(future); - return future; - } - - void unregisterSubmission(final ScheduledFuture future) { - future.cancel(false); - submissionJobs.remove(future); - } - - boolean isSubmissionSchedulerRunning() { - final var scheduler = submissionScheduler; - return scheduler != null && !scheduler.isShutdown(); - } - - private ScheduledExecutorService submissionScheduler() { - var scheduler = submissionScheduler; - if (scheduler != null && !scheduler.isShutdown()) return scheduler; - synchronized (this) { - scheduler = submissionScheduler; - if (scheduler != null && !scheduler.isShutdown()) return scheduler; - submissionScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { - final var thread = new Thread(runnable, "faststats-submitter"); - thread.setDaemon(true); - return thread; - }); - return submissionScheduler; - } - } - - void startErrorSubmission() { - if (!context.getConfig().errorTracking() || errorSubmissionJob != null) return; - errorSubmissionJob = scheduleSubmission( - this::submit, - TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)), - TimeUnit.MINUTES.toMillis(30), - TimeUnit.MILLISECONDS - ); - } -} diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 9e9679a7..90100e1f 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -10,8 +10,6 @@ // fixme: thread safety public non-sealed abstract class SimpleContext implements FastStatsContext { - private final ErrorTrackingSink errorTrackingSink = new ErrorTrackingSink(this); - private final Config config; private final @Token String token; private final SdkInfo sdkInfo; @@ -95,7 +93,7 @@ protected FeatureFlagService.Factory featureFlagServiceFactory() { @Contract(value = " -> new", pure = true) protected ErrorTrackerService.Factory errorTrackerServiceFactory() { - return new SimpleErrorTrackerService.Factory(errorTrackingSink); + return new SimpleErrorTrackerService.Factory(this); } final void setMetrics(final Metrics metrics) { @@ -133,8 +131,4 @@ public final void shutdown() { public SdkInfo getSdkInfo() { return sdkInfo; } - - ErrorTrackingSink errorTrackingSink() { - return errorTrackingSink; - } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index d3da36c7..9c93253c 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -108,14 +108,14 @@ public synchronized void attachErrorContext(@Nullable final ClassLoader loader) if (contextAttached) throw new IllegalStateException("Error context already attached"); contextAttached = true; attachedLoader = loader; - ErrorTrackingSink.attachErrorTracker(this); + SimpleErrorTrackerService.attachErrorTracker(this); } @Override public synchronized void detachErrorContext() { if (!contextAttached) return; contextAttached = false; - ErrorTrackingSink.detachErrorTracker(this); + SimpleErrorTrackerService.detachErrorTracker(this); } @Override diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 1dafcd36..9efcef17 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -1,14 +1,54 @@ package dev.faststats; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; import org.jspecify.annotations.Nullable; +import java.io.ByteArrayOutputStream; +import java.net.ConnectException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPOutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; + final class SimpleErrorTrackerService implements ErrorTrackerService { - private final ErrorTrackingSink sink; + private final Logger logger = LoggerFactory.factory().getLogger(getClass()); + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + private final SimpleContext context; + private final URI url = getErrorTrackerServerUrl(); private final SimpleErrorTracker globalErrorTracker; private final Attributes attributes; + final Set errorTrackers = new CopyOnWriteArraySet<>(); + final Set> submissionJobs = new CopyOnWriteArraySet<>(); + + private volatile @Nullable ScheduledExecutorService submissionScheduler; + private volatile @Nullable ScheduledFuture errorSubmissionJob; + + private static final Object DISPATCHER_LOCK = new Object(); + private static final Set DISPATCHER_TRACKERS = new CopyOnWriteArraySet<>(); + private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); + private static Thread.@Nullable UncaughtExceptionHandler originalHandler; + SimpleErrorTrackerService( - final ErrorTrackingSink sink, + final SimpleContext context, final ErrorTracker globalErrorTracker, final Attributes attributes ) { @@ -16,10 +56,10 @@ final class SimpleErrorTrackerService implements ErrorTrackerService { if (!(globalErrorTracker instanceof final SimpleErrorTracker tracker)) { throw new IllegalArgumentException("Unsupported error tracker implementation: " + globalErrorTracker.getClass().getName()); } - this.sink = sink; + this.context = context; this.globalErrorTracker = tracker; this.attributes = attributes; - sink.startErrorSubmission(); + startErrorSubmission(); } @Override @@ -38,18 +78,199 @@ public ErrorTrackerService registerErrorTracker(final ErrorTracker errorTracker) if (!(errorTracker instanceof final SimpleErrorTracker tracker)) { throw new IllegalArgumentException("Unsupported error tracker implementation: " + errorTracker.getClass().getName()); } - sink.errorTrackers.add(tracker); - sink.startErrorSubmission(); + errorTrackers.add(tracker); + startErrorSubmission(); return this; } + // fixme: hacky shit; it only has to compile and pass tests for now + static void attachErrorTracker(final SimpleErrorTracker tracker) { + synchronized (DISPATCHER_LOCK) { + if (DISPATCHER_TRACKERS.isEmpty()) { + originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(SimpleErrorTrackerService::handleUncaughtException); + } + DISPATCHER_TRACKERS.add(tracker); + } + } + + // fixme: hacky shit; it only has to compile and pass tests for now + static void detachErrorTracker(final SimpleErrorTracker tracker) { + synchronized (DISPATCHER_LOCK) { + DISPATCHER_TRACKERS.remove(tracker); + if (DISPATCHER_TRACKERS.isEmpty()) { + Thread.setDefaultUncaughtExceptionHandler(originalHandler); + originalHandler = null; + } + } + } + + // fixme: hacky shit; it only has to compile and pass tests for now + private static void handleUncaughtException(final Thread thread, final Throwable error) { + if (!DISPATCHING.get()) { + DISPATCHING.set(true); + try { + for (final var tracker : DISPATCHER_TRACKERS) { + final var loader = tracker.attachedLoader(); + if (loader != null && !ErrorHelper.isSameLoader(loader, error)) continue; + tracker.trackError(error).handled(false); + tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); + } + } finally { + DISPATCHING.set(false); + } + } + + final var handler = originalHandler; + if (handler != null) handler.uncaughtException(thread, error); + } + + private static URI getErrorTrackerServerUrl() { + final var property = System.getProperty("faststats.error-tracker-server"); + if (property != null) try { + return new URI(property); + } catch (final URISyntaxException e) { + final var logger = LoggerFactory.factory().getLogger(SimpleMetrics.class); + logger.error("Failed to parse error tracker server url: %s", e, property); + } + return URI.create("https://metrics.faststats.dev/v1/error"); + } + + // todo: improve logging to be less cluttered; dedupe code + void submit() { + if (!context.getConfig().errorTracking()) return; + + final var data = createData(); + if (data == null) return; + + try (final var byteOutput = new ByteArrayOutputStream(); + final var output = new GZIPOutputStream(byteOutput)) { + output.write(data.toString().getBytes(UTF_8)); + output.finish(); + + final var compressed = byteOutput.toByteArray(); + logger.info("Sending errors to: %s", url); + logger.info("Uncompressed data: %s", data); + // todo: dedupe this + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) + .header("Content-Encoding", "gzip") + .header("Content-Type", "application/octet-stream") + .header("Authorization", "Bearer " + context.getToken()) + .header("User-Agent", context.getSdkInfo().getUserAgent()) + .timeout(Duration.ofSeconds(3)) + .uri(url) + .build(); + + final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); + final var statusCode = response.statusCode(); + final var body = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + logger.info("Errors submitted with status code: %s (%s)", statusCode, body); + clear(); + } else if (statusCode >= 300 && statusCode < 400) { + logger.warn("Received redirect response from error server: %s (%s)", statusCode, body); + } else if (statusCode >= 400 && statusCode < 500) { + logger.error("Submitted invalid request to error server: %s (%s)", null, statusCode, body); + } else if (statusCode >= 500 && statusCode < 600) { + logger.error("Received server error response from error server: %s (%s)", null, statusCode, body); + } else { + logger.warn("Received unexpected response from error server: %s (%s)", statusCode, body); + } + } catch (final HttpConnectTimeoutException t) { + logger.error("Error submission timed out after 3 seconds: %s", null, url); + } catch (final ConnectException t) { + logger.error("Failed to connect to error server: %s", null, url); + } catch (final Throwable t) { + logger.error("Failed to submit errors", t); + } + } + + private @Nullable JsonObject createData() { + if (errorTrackers.isEmpty() && globalErrorTracker.getData().isEmpty()) return null; + + final var data = new JsonObject(); + context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); + data.addProperty("identifier", context.getConfig().serverId().toString()); + data.addProperty("project_name", context.getProjectName()); + data.addProperty("sdk_name", context.getSdkInfo().getName()); + data.addProperty("sdk_version", context.getSdkInfo().getVersion()); + + final var defaultContext = new JsonObject(); + context.metrics().ifPresent(metrics -> { + final var simpleMetrics = (SimpleMetrics) metrics; + simpleMetrics.appendData(defaultContext); + }); + attributes.forEachPrimitive(defaultContext::add); + data.add("context", defaultContext); + + final var errors = new JsonArray(); + errors.addAll(globalErrorTracker.getData()); + errorTrackers.forEach(tracker -> errors.addAll(tracker.getData())); + data.add("errors", errors); + return data; + } + + void clear() { + globalErrorTracker.clear(); + errorTrackers.forEach(SimpleErrorTracker::clear); + } + + ScheduledFuture scheduleSubmission( + final Runnable task, + final long initialDelay, + final long period, + final TimeUnit unit + ) { + final var scheduler = submissionScheduler(); + final var future = scheduler.scheduleAtFixedRate(task, Math.max(0, initialDelay), Math.max(1000, period), unit); + submissionJobs.add(future); + return future; + } + + void unregisterSubmission(final ScheduledFuture future) { + future.cancel(false); + submissionJobs.remove(future); + } + + boolean isSubmissionSchedulerRunning() { + final var scheduler = submissionScheduler; + return scheduler != null && !scheduler.isShutdown(); + } + + private ScheduledExecutorService submissionScheduler() { + var scheduler = submissionScheduler; + if (scheduler != null && !scheduler.isShutdown()) return scheduler; + synchronized (this) { + scheduler = submissionScheduler; + if (scheduler != null && !scheduler.isShutdown()) return scheduler; + submissionScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + final var thread = new Thread(runnable, "faststats-submitter"); + thread.setDaemon(true); + return thread; + }); + return submissionScheduler; + } + } + + void startErrorSubmission() { + if (!context.getConfig().errorTracking() || errorSubmissionJob != null) return; + errorSubmissionJob = scheduleSubmission( + this::submit, + TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)), + TimeUnit.MINUTES.toMillis(30), + TimeUnit.MILLISECONDS + ); + } + static final class Factory implements ErrorTrackerService.Factory { - private final ErrorTrackingSink sink; + private final SimpleContext context; private @Nullable ErrorTracker globalErrorTracker; private @Nullable Attributes attributes; - Factory(final ErrorTrackingSink sink) { - this.sink = sink; + Factory(final SimpleContext context) { + this.context = context; } @Override @@ -68,7 +289,7 @@ public ErrorTrackerService.Factory attributes(final Attributes attributes) { public ErrorTrackerService create() throws IllegalStateException { if (globalErrorTracker == null) throw new IllegalStateException("A global error tracker is required"); final var attributes = this.attributes != null ? this.attributes : Attributes.empty(); - return new SimpleErrorTrackerService(sink, globalErrorTracker, attributes); + return new SimpleErrorTrackerService(context, globalErrorTracker, attributes); } } } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 3d570a9c..6f3d3b52 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -22,6 +22,8 @@ import java.time.Duration; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -37,6 +39,11 @@ public abstract class SimpleMetrics implements Metrics { .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); + private final ScheduledExecutorService submissionScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + final var thread = new Thread(runnable, "faststats-submitter"); + thread.setDaemon(true); + return thread; + }); private @Nullable ScheduledFuture submissionJob = null; private final @Nullable Runnable flush; @@ -106,7 +113,12 @@ private void startSubmitting(final long initialDelay, final long period, final T } logger.info("Starting metrics submission"); - submissionJob = context.errorTrackingSink().scheduleSubmission(this::submit, initialDelay, period, unit); + submissionJob = submissionScheduler.scheduleAtFixedRate( + this::submit, + Math.max(0, initialDelay), + Math.max(1000, period), + unit + ); } protected boolean isSubmitting() { @@ -223,12 +235,13 @@ public void shutdown() { // context.errorTrackers().forEach(ErrorTracker::detachErrorContext); // todo: detach all error contexts on shutdown? if (submissionJob != null) try { logger.info("Shutting down metrics submission"); - context.errorTrackingSink().unregisterSubmission(submissionJob); + submissionJob.cancel(false); submit(); } catch (final Throwable t) { logger.error("Failed to submit metrics on shutdown", t); } finally { submissionJob = null; + submissionScheduler.shutdown(); } } From 803cfa26ef63fa0882a849f47779c1f913c9f0fd Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 09:15:43 +0200 Subject: [PATCH 120/140] make createData visible for testing --- .../dev/faststats/SimpleErrorTrackerService.java | 4 +++- .../java/dev/faststats/ErrorTrackerTest.java | 16 +++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 9efcef17..e512cfe3 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -4,6 +4,7 @@ import com.google.gson.JsonObject; import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.VisibleForTesting; import org.jspecify.annotations.Nullable; import java.io.ByteArrayOutputStream; @@ -187,7 +188,8 @@ void submit() { } } - private @Nullable JsonObject createData() { + @VisibleForTesting + public @Nullable JsonObject createData() { if (errorTrackers.isEmpty() && globalErrorTracker.getData().isEmpty()) return null; final var data = new JsonObject(); diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 65fd9703..85df24ec 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,6 +1,5 @@ package dev.faststats; -import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; import java.net.URL; @@ -10,8 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { @@ -343,12 +343,12 @@ public void trackedErrorSerializesProperties() { @Test public void errorTrackerServiceRequiresGlobalTracker() { - final var factory = new SimpleErrorTrackerService.Factory(context.errorTrackingSink()); + final var factory = new SimpleErrorTrackerService.Factory(context); assertThrows(IllegalStateException.class, factory::create); } @Test - public void errorTrackerServiceSerializesGlobalAttributes() throws ReflectiveOperationException { + public void errorTrackerServiceSerializesGlobalAttributes() { final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); final var context = new MockContext.Factory() .errorTrackerService(factory -> factory @@ -361,9 +361,11 @@ public void errorTrackerServiceSerializesGlobalAttributes() throws ReflectiveOpe .create(); tracker.trackError("with global attributes"); - final var method = ErrorTrackingSink.class.getDeclaredMethod("createData"); - method.setAccessible(true); - final var data = (JsonObject) method.invoke(context.errorTrackingSink()); + final var service = context.errorTrackerService() + .map(SimpleErrorTrackerService.class::cast) + .orElseThrow(); + final var data = service.createData(); + assertNotNull(data); final var globalContext = data.getAsJsonObject("context"); assertEquals("startup", globalContext.get("stage").getAsString()); From 7d79000ef98f58857bf5175a2d8b3ba0f12a7e56 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 09:58:27 +0200 Subject: [PATCH 121/140] Rmove error tracker service factory --- .../main/java/com/example/ExamplePlugin.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- .../example/ErrorTrackerExample.java | 10 +++-- .../main/java/dev/faststats/ErrorTracker.java | 2 + .../dev/faststats/ErrorTrackerService.java | 45 ++----------------- .../faststats/FastStatsContextFactory.java | 17 ++++--- .../java/dev/faststats/SimpleContext.java | 31 ++++++------- .../faststats/SimpleErrorTrackerService.java | 28 ------------ .../java/dev/faststats/AnonymizationTest.java | 2 +- .../java/dev/faststats/ErrorTrackerTest.java | 21 +++------ .../src/main/java/com/example/ExampleMod.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- .../main/java/com/example/ExamplePlugin.java | 2 +- 14 files changed, 45 insertions(+), 123 deletions(-) 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 afb9fb72..710d7219 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -12,7 +12,7 @@ public final class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BukkitContext context = new BukkitContext.Factory(this, "YOUR_TOKEN_HERE") - .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) + .errorTrackerService(ERROR_TRACKER) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index d771295d..e9eeb551 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -12,7 +12,7 @@ public class ExamplePlugin extends Plugin { private final AtomicInteger gameCount = new AtomicInteger(); private final BungeeContext context = new BungeeContext.Factory(this, "YOUR_TOKEN_HERE") - .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) + .errorTrackerService(ERROR_TRACKER) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index fcdac243..4f0c5a1e 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -22,12 +22,14 @@ public final class ErrorTrackerExample { .anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); public static final FastStatsContext CONTEXT = getContextFactory() - .errorTrackerService(factory -> factory - .globalErrorTracker(CONTEXT_AWARE) // Set the global/internal error tracker - .create() - .registerErrorTracker(CONTEXT_UNAWARE)) // Register an additional tracker for submission + .errorTrackerService(CONTEXT_AWARE) // Set the global/internal error tracker .create(); + static { + // Register an additional tracker for submission + CONTEXT.errorTrackerService().orElseThrow().registerErrorTracker(CONTEXT_UNAWARE); + } + public static void manualTracking() { try { throw new RuntimeException("Something went wrong!"); diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 5086f6df..c9bca849 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -55,6 +55,8 @@ static ErrorTracker contextUnaware() { return new SimpleErrorTracker(); } + // todo: add error tracker wide attributes + /** * Tracks a handled error. * diff --git a/core/src/main/java/dev/faststats/ErrorTrackerService.java b/core/src/main/java/dev/faststats/ErrorTrackerService.java index d556ae6e..69548505 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/ErrorTrackerService.java @@ -26,14 +26,13 @@ public sealed interface ErrorTrackerService permits SimpleErrorTrackerService { * @since 0.24.0 */ @Contract(pure = true) - Attributes getAttributes(); + Attributes getAttributes(); // todo: move into error tracker itself /** * Registers an additional error tracker for submission with this service. *

- * The global/internal tracker returned by {@link #globalErrorTracker()} is configured - * by the service factory. Additional trackers registered here are submitted by - * the same context, but are not used for internal FastStats errors. + * Additional trackers registered here are submitted by the same context, but are not + * used for internal FastStats errors. * * @param errorTracker the additional error tracker * @return this service @@ -41,42 +40,4 @@ public sealed interface ErrorTrackerService permits SimpleErrorTrackerService { */ @Contract(value = "_ -> this", mutates = "this") ErrorTrackerService registerErrorTracker(ErrorTracker errorTracker); - - /** - * An error tracker service factory. - * - * @since 0.24.0 - */ - // todo: remove factory? there is almost no gain from it and i don't see any reason why there would be configuration required - sealed interface Factory permits SimpleErrorTrackerService.Factory { - /** - * Sets the global/internal error tracker for services created by this factory. - * - * @param errorTracker the global/internal error tracker - * @return the error tracker service factory - * @since 0.24.0 - */ - @Contract(mutates = "this") - Factory globalErrorTracker(ErrorTracker errorTracker); - - /** - * Sets the global error context attributes for services created by this factory. - * - * @param attributes the global error context attributes - * @return the error tracker service factory - * @since 0.24.0 - */ - @Contract(mutates = "this") - Factory attributes(Attributes attributes); - - /** - * Creates a new error tracker service. - * - * @return the error tracker service - * @throws IllegalStateException if no global error tracker was configured - * @since 0.24.0 - */ - @Contract(value = " -> new", pure = true) - ErrorTrackerService create() throws IllegalStateException; - } } diff --git a/core/src/main/java/dev/faststats/FastStatsContextFactory.java b/core/src/main/java/dev/faststats/FastStatsContextFactory.java index d5c913cc..09988ff2 100644 --- a/core/src/main/java/dev/faststats/FastStatsContextFactory.java +++ b/core/src/main/java/dev/faststats/FastStatsContextFactory.java @@ -18,20 +18,18 @@ public abstract class FastStatsContextFactory> { private @Nullable Function metrics = null; private @Nullable Function featureFlagService; - private @Nullable Function errorTrackerService; + private @Nullable ErrorTracker errorTracker; /** - * Configures and creates the single error tracker service instance for the context. + * Configures the global/internal error tracker for the context. * - * @param errorTrackerService a function that receives a new service factory and returns the built service instance + * @param errorTracker the global/internal error tracker * @return this factory * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") - public F errorTrackerService( - final Function errorTrackerService - ) { - this.errorTrackerService = errorTrackerService; + public F errorTrackerService(final ErrorTracker errorTracker) { + this.errorTracker = errorTracker; return self(); } @@ -69,6 +67,7 @@ public F featureFlagService( * @return the configured context * @since 0.24.0 */ + // todo: mutation sucks, move factory into context @Contract(value = " -> new", mutates = "io") public final C create() { final var context = createContext(); @@ -76,8 +75,8 @@ public final C create() { context.setMetrics(metrics.apply(context.metricsFactory())); if (featureFlagService != null) context.setFeatureFlagService(featureFlagService.apply(context.featureFlagServiceFactory())); - if (errorTrackerService != null) - context.setErrorTrackerService(errorTrackerService.apply(context.errorTrackerServiceFactory())); + if (errorTracker != null) + context.setErrorTrackerService(errorTracker); return context; } diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 90100e1f..031559f9 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -13,6 +13,7 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { private final Config config; private final @Token String token; private final SdkInfo sdkInfo; + private @Nullable Metrics metrics; private @Nullable FeatureFlagService featureFlagService; private @Nullable ErrorTrackerService errorTrackerService; @@ -91,30 +92,12 @@ protected FeatureFlagService.Factory featureFlagServiceFactory() { return new SimpleFeatureFlagService.Factory(config, token); } - @Contract(value = " -> new", pure = true) - protected ErrorTrackerService.Factory errorTrackerServiceFactory() { - return new SimpleErrorTrackerService.Factory(this); - } - - final void setMetrics(final Metrics metrics) { - this.metrics = metrics; - } - - final void setFeatureFlagService(final FeatureFlagService featureFlagService) { - this.featureFlagService = featureFlagService; - } - @Override @Contract(pure = true) public final Optional errorTrackerService() { return Optional.ofNullable(errorTrackerService); } - // todo: mutation sucks :) - final void setErrorTrackerService(final ErrorTrackerService errorTrackerService) { - this.errorTrackerService = errorTrackerService; - } - @Override public final void ready() { if (metrics != null) metrics.ready(); @@ -131,4 +114,16 @@ public final void shutdown() { public SdkInfo getSdkInfo() { return sdkInfo; } + + final void setMetrics(final Metrics metrics) { + this.metrics = metrics; + } + + final void setFeatureFlagService(final FeatureFlagService featureFlagService) { + this.featureFlagService = featureFlagService; + } + + final void setErrorTrackerService(final ErrorTracker errorTracker) { + this.errorTrackerService = new SimpleErrorTrackerService(this, errorTracker, Attributes.empty()); + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index e512cfe3..10695e3e 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -266,32 +266,4 @@ void startErrorSubmission() { ); } - static final class Factory implements ErrorTrackerService.Factory { - private final SimpleContext context; - private @Nullable ErrorTracker globalErrorTracker; - private @Nullable Attributes attributes; - - Factory(final SimpleContext context) { - this.context = context; - } - - @Override - public ErrorTrackerService.Factory globalErrorTracker(final ErrorTracker errorTracker) { - this.globalErrorTracker = errorTracker; - return this; - } - - @Override - public ErrorTrackerService.Factory attributes(final Attributes attributes) { - this.attributes = attributes; - return this; - } - - @Override - public ErrorTrackerService create() throws IllegalStateException { - if (globalErrorTracker == null) throw new IllegalStateException("A global error tracker is required"); - final var attributes = this.attributes != null ? this.attributes : Attributes.empty(); - return new SimpleErrorTrackerService(context, globalErrorTracker, attributes); - } - } } diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index 709a7b86..b59ca781 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -12,7 +12,7 @@ public final class AnonymizationTest { private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); private final FastStatsContext context = new MockContext.Factory() - .errorTrackerService(factory -> factory.globalErrorTracker(tracker).create()) + .errorTrackerService(tracker) .metrics(Metrics.Factory::create) .create(); diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 85df24ec..4e63ef6f 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -11,13 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); private final MockContext context = new MockContext.Factory() - .errorTrackerService(factory -> factory.globalErrorTracker(tracker).create()) + .errorTrackerService(tracker) .create(); @Test @@ -341,24 +340,16 @@ public void trackedErrorSerializesProperties() { assertTrue(context.get("retrying").getAsBoolean()); } - @Test - public void errorTrackerServiceRequiresGlobalTracker() { - final var factory = new SimpleErrorTrackerService.Factory(context); - assertThrows(IllegalStateException.class, factory::create); - } - @Test public void errorTrackerServiceSerializesGlobalAttributes() { final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); final var context = new MockContext.Factory() - .errorTrackerService(factory -> factory - .globalErrorTracker(tracker) - .attributes(Attributes.empty() - .put("stage", "startup") - .put("attempt", 2) - .put("retrying", true)) - .create()) + .errorTrackerService(tracker) .create(); + context.errorTrackerService().ifPresent(service -> service.getAttributes() + .put("stage", "startup") + .put("attempt", 2) + .put("retrying", true)); tracker.trackError("with global attributes"); final var service = context.errorTrackerService() diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 11a49091..95923819 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -26,7 +26,7 @@ public class ExampleMod implements ModInitializer { .onFlush(() -> gameCount.set(0)) // reset game count on flush .create()) - .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) + .errorTrackerService(ERROR_TRACKER) .create(); @Override diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index 2304cf10..72db9444 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -13,7 +13,7 @@ public class ExamplePlugin extends JavaPlugin { private final AtomicInteger gameCount = new AtomicInteger(); private final HytaleContext context = new HytaleContext.Factory(this, "YOUR_TOKEN_HERE") - .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) + .errorTrackerService(ERROR_TRACKER) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java index 972195df..4fc917d2 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -25,7 +25,7 @@ public class ExamplePlugin { public void onServerStart(final StartedEngineEvent event) { this.context = contextBuilder .token("YOUR_TOKEN_HERE") - .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) + .errorTrackerService(ERROR_TRACKER) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index 0c80f399..e84256e5 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -23,7 +23,7 @@ public class ExamplePlugin { public ExamplePlugin(final VelocityContext.Builder contextBuilder) { this.context = contextBuilder .token("YOUR_TOKEN_HERE") - .errorTrackerService(factory -> factory.globalErrorTracker(ERROR_TRACKER).create()) + .errorTrackerService(ERROR_TRACKER) // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics .metrics(factory -> factory // Custom metrics require a corresponding data source in your project settings From 92825e664a7a832667056148c44ad612ba7722db Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 10:07:35 +0200 Subject: [PATCH 122/140] Add attributes to error tracker --- .../main/java/dev/faststats/ErrorHelper.java | 12 +++- .../main/java/dev/faststats/ErrorTracker.java | 9 ++- .../dev/faststats/ErrorTrackerService.java | 9 --- .../java/dev/faststats/SimpleContext.java | 2 +- .../dev/faststats/SimpleErrorTracker.java | 15 ++++- .../faststats/SimpleErrorTrackerService.java | 19 ++---- .../java/dev/faststats/AnonymizationTest.java | 2 +- .../java/dev/faststats/ErrorTrackerTest.java | 63 ++++++++++++++----- 8 files changed, 86 insertions(+), 45 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 66f6a067..51f83a15 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -25,13 +25,20 @@ final class ErrorHelper { public static JsonObject compile(final TrackedError error, @Nullable final List suppress, final List> customPatterns) { + return compile(error, suppress, customPatterns, null); + } + + public static JsonObject compile(final TrackedError error, @Nullable final List suppress, + final List> customPatterns, + @Nullable final Attributes attributes) { final var patterns = new ArrayList<>(customPatterns); patterns.addAll(defaultAnonymizationEntries); - return compileAll(error, suppress, patterns); + return compileAll(error, suppress, patterns, attributes); } private static JsonObject compileAll(final TrackedError trackedError, @Nullable final List suppress, - final List> customPatterns) { + final List> customPatterns, + @Nullable final Attributes defaultAttributes) { final var error = trackedError.error(); final var report = new JsonObject(); final var message = getAnonymizedMessage(error, customPatterns); @@ -58,6 +65,7 @@ private static JsonObject compileAll(final TrackedError trackedError, @Nullable report.addProperty("handled", trackedError.handled()); final var attributes = new JsonObject(); + if (defaultAttributes != null) defaultAttributes.forEachPrimitive(attributes::add); trackedError.attributes().forEachPrimitive(attributes::add); if (!attributes.isEmpty()) report.add("context", attributes); diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index c9bca849..a316b27d 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -55,7 +55,14 @@ static ErrorTracker contextUnaware() { return new SimpleErrorTracker(); } - // todo: add error tracker wide attributes + /** + * Returns the global error context attributes configured for this tracker. + * + * @return the global error context attributes + * @since 0.24.0 + */ + @Contract(pure = true) + Attributes getAttributes(); /** * Tracks a handled error. diff --git a/core/src/main/java/dev/faststats/ErrorTrackerService.java b/core/src/main/java/dev/faststats/ErrorTrackerService.java index 69548505..6e4054db 100644 --- a/core/src/main/java/dev/faststats/ErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/ErrorTrackerService.java @@ -19,15 +19,6 @@ public sealed interface ErrorTrackerService permits SimpleErrorTrackerService { @Contract(pure = true) ErrorTracker globalErrorTracker(); - /** - * Returns the global error context attributes configured for this service. - * - * @return the global error context attributes - * @since 0.24.0 - */ - @Contract(pure = true) - Attributes getAttributes(); // todo: move into error tracker itself - /** * Registers an additional error tracker for submission with this service. *

diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 031559f9..989fe07f 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -124,6 +124,6 @@ final void setFeatureFlagService(final FeatureFlagService featureFlagService) { } final void setErrorTrackerService(final ErrorTracker errorTracker) { - this.errorTrackerService = new SimpleErrorTrackerService(this, errorTracker, Attributes.empty()); + this.errorTrackerService = new SimpleErrorTrackerService(this, errorTracker); } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 9c93253c..93b2f30d 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -19,6 +19,7 @@ final class SimpleErrorTracker implements ErrorTracker { private final Map reports = new ConcurrentHashMap<>(); + private final Attributes attributes = Attributes.empty(); private final Map, Set> ignoredTypedPatterns = new ConcurrentHashMap<>(); private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); @@ -29,6 +30,11 @@ final class SimpleErrorTracker implements ErrorTracker { private volatile @Nullable ClassLoader attachedLoader; private volatile boolean contextAttached; + @Override + public Attributes getAttributes() { + return attributes; + } + @Override public TrackedError trackError(final String message) { return trackError(new RuntimeException(message)); @@ -88,10 +94,15 @@ public ErrorTracker anonymize(final Pattern pattern, final String replacement) { } @VisibleForTesting - public JsonArray getData() { + public JsonArray getFullData() { + return getData(true); + } + + JsonArray getData(final boolean includeTrackerAttributes) { final var report = new JsonArray(reports.size()); reports.forEach((error, count) -> { - final var compiled = ErrorHelper.compile(error, null, anonymizationEntries); + final var attributes = includeTrackerAttributes ? this.attributes : null; + final var compiled = ErrorHelper.compile(error, null, anonymizationEntries, attributes); if (count > 1) compiled.addProperty("count", count); report.add(compiled); }); diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 10695e3e..ea69ed68 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -35,7 +35,6 @@ final class SimpleErrorTrackerService implements ErrorTrackerService { private final SimpleContext context; private final URI url = getErrorTrackerServerUrl(); private final SimpleErrorTracker globalErrorTracker; - private final Attributes attributes; final Set errorTrackers = new CopyOnWriteArraySet<>(); final Set> submissionJobs = new CopyOnWriteArraySet<>(); @@ -50,8 +49,7 @@ final class SimpleErrorTrackerService implements ErrorTrackerService { SimpleErrorTrackerService( final SimpleContext context, - final ErrorTracker globalErrorTracker, - final Attributes attributes + final ErrorTracker globalErrorTracker ) { // todo: don't even let the user provide anything else if (!(globalErrorTracker instanceof final SimpleErrorTracker tracker)) { @@ -59,7 +57,6 @@ final class SimpleErrorTrackerService implements ErrorTrackerService { } this.context = context; this.globalErrorTracker = tracker; - this.attributes = attributes; startErrorSubmission(); } @@ -68,11 +65,6 @@ public ErrorTracker globalErrorTracker() { return globalErrorTracker; } - @Override - public Attributes getAttributes() { - return attributes; - } - @Override public ErrorTrackerService registerErrorTracker(final ErrorTracker errorTracker) { // todo: the class is sealed this check will always succeed, cast directly @@ -190,7 +182,8 @@ void submit() { @VisibleForTesting public @Nullable JsonObject createData() { - if (errorTrackers.isEmpty() && globalErrorTracker.getData().isEmpty()) return null; + final var globalErrorTrackerData = globalErrorTracker.getData(false); + if (errorTrackers.isEmpty() && globalErrorTrackerData.isEmpty()) return null; final var data = new JsonObject(); context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); @@ -204,12 +197,12 @@ void submit() { final var simpleMetrics = (SimpleMetrics) metrics; simpleMetrics.appendData(defaultContext); }); - attributes.forEachPrimitive(defaultContext::add); + globalErrorTracker.getAttributes().forEachPrimitive(defaultContext::add); data.add("context", defaultContext); final var errors = new JsonArray(); - errors.addAll(globalErrorTracker.getData()); - errorTrackers.forEach(tracker -> errors.addAll(tracker.getData())); + errors.addAll(globalErrorTrackerData); + errorTrackers.forEach(tracker -> errors.addAll(tracker.getFullData())); data.add("errors", errors); return data; } diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index b59ca781..05b4d0e3 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -17,7 +17,7 @@ public final class AnonymizationTest { .create(); private JsonObject getError() { - return tracker.getData().get(0).getAsJsonObject(); + return tracker.getFullData().get(0).getAsJsonObject(); } private String getErrorMessage() { diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 4e63ef6f..88722cd3 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -134,7 +134,7 @@ private IllegalArgumentException createExceptionWithStack() { public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); final var message = report.get("message").getAsString(); final var header = report.getAsJsonArray("stack").get(0).getAsString(); @@ -147,7 +147,7 @@ public void appliesCustomRedactionAfterBuiltInRedaction() { tracker.anonymize("session=[^ ]+", "session=[hidden]"); tracker.trackError("failed with session=abc123 from 10.0.0.1"); - final var message = tracker.getData() + final var message = tracker.getFullData() .get(0) .getAsJsonObject() .get("message") @@ -160,7 +160,7 @@ public void appliesCustomRedactionAfterBuiltInRedaction() { public void nullMessagesAreNotSerializedAsMessageProperty() { tracker.trackError(new RuntimeException((String) null)); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); assertFalse(report.has("message")); assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); } @@ -185,7 +185,7 @@ public void nestedCausesAreSerializedInOrder() { tracker.trackError(top).handled(false); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); final var stack = report.getAsJsonArray("stack"); assertEquals(RuntimeException.class.getName(), report.get("error").getAsString()); @@ -207,7 +207,7 @@ public void cyclicCauseChainStopsAfterFirstVisit() { tracker.trackError(first); - final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = tracker.getFullData().get(0).getAsJsonObject().getAsJsonArray("stack"); var firstCauseCount = 0; var secondCauseCount = 0; for (final var element : stack) { @@ -228,7 +228,7 @@ public void duplicateErrorsAreAggregatedWithCount() { tracker.trackError(first); tracker.trackError(second); - final var reports = tracker.getData(); + final var reports = tracker.getFullData(); final var report = reports.get(0).getAsJsonObject(); assertEquals(1, reports.size()); @@ -243,11 +243,11 @@ public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { tracker.clear(); - assertEquals(0, tracker.getData().size()); + assertEquals(0, tracker.getFullData().size()); tracker.trackError(createStableError()); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); assertEquals("duplicate", report.get("message").getAsString()); assertNull(report.get("count")); } @@ -258,7 +258,7 @@ public void ignoredNestedCauseSuppressesWholeReport() { tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); - assertEquals(0, tracker.getData().size()); + assertEquals(0, tracker.getFullData().size()); } @Test @@ -275,7 +275,7 @@ public void repeatingStackFramesAreCollapsed() { tracker.trackError(error); - final var stack = tracker.getData().get(0).getAsJsonObject().getAsJsonArray("stack"); + final var stack = tracker.getFullData().get(0).getAsJsonObject().getAsJsonArray("stack"); assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); @@ -289,7 +289,7 @@ public void longMessagesAreTruncatedBeforeSerialization() { tracker.trackError(message); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); final var serialized = report.get("message").getAsString(); assertEquals(503, serialized.length()); assertTrue(serialized.endsWith("...")); @@ -314,7 +314,7 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept thread.join(1000); assertTrue(handled.await(1, TimeUnit.SECONDS)); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); assertEquals("async failure", report.get("message").getAsString()); assertFalse(report.get("handled").getAsBoolean()); } finally { @@ -331,7 +331,7 @@ public void trackedErrorSerializesProperties() { .put("attempt", 2) .put("retrying", true)); - final var report = tracker.getData().get(0).getAsJsonObject(); + final var report = tracker.getFullData().get(0).getAsJsonObject(); final var context = report.getAsJsonObject("context"); assertTrue(report.get("handled").getAsBoolean()); @@ -342,14 +342,14 @@ public void trackedErrorSerializesProperties() { @Test public void errorTrackerServiceSerializesGlobalAttributes() { - final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var tracker = ErrorTracker.contextUnaware(); final var context = new MockContext.Factory() .errorTrackerService(tracker) .create(); - context.errorTrackerService().ifPresent(service -> service.getAttributes() + tracker.getAttributes() .put("stage", "startup") .put("attempt", 2) - .put("retrying", true)); + .put("retrying", true); tracker.trackError("with global attributes"); final var service = context.errorTrackerService() @@ -364,6 +364,37 @@ public void errorTrackerServiceSerializesGlobalAttributes() { assertTrue(globalContext.get("retrying").getAsBoolean()); } + @Test + public void errorTrackerServiceSerializesRegisteredTrackerAttributesBeforeErrorAttributes() { + final var globalTracker = ErrorTracker.contextUnaware(); + final var tracker = ErrorTracker.contextUnaware(); + final var context = new MockContext.Factory() + .errorTrackerService(globalTracker) + .create(); + tracker.getAttributes() + .put("stage", "startup") + .put("attempt", 1); + tracker.trackError("with tracker attributes") + .attributes(Attributes.empty() + .put("attempt", 2) + .put("retrying", true)); + + final var service = context.errorTrackerService() + .map(SimpleErrorTrackerService.class::cast) + .orElseThrow(); + service.registerErrorTracker(tracker); + final var data = service.createData(); + assertNotNull(data); + final var errorContext = data.getAsJsonArray("errors") + .get(0) + .getAsJsonObject() + .getAsJsonObject("context"); + + assertEquals("startup", errorContext.get("stage").getAsString()); + assertEquals(2, errorContext.get("attempt").getAsInt()); + assertTrue(errorContext.get("retrying").getAsBoolean()); + } + private RuntimeException createStableError() { final var error = new RuntimeException("duplicate"); error.setStackTrace(new StackTraceElement[]{ From 2bc230aed9811b73c70b4c1b124a0e433da7a54e Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 10:39:59 +0200 Subject: [PATCH 123/140] Merge FastStatsContextFactory into SimpleContext --- .../dev/faststats/bukkit/BukkitContext.java | 3 +- .../dev/faststats/bungee/BungeeContext.java | 3 +- .../faststats/FastStatsContextFactory.java | 96 ------------------- .../java/dev/faststats/SimpleContext.java | 88 +++++++++++++++++ .../test/java/dev/faststats/MockContext.java | 2 +- .../dev/faststats/fabric/FabricContext.java | 3 +- .../dev/faststats/hytale/HytaleContext.java | 3 +- .../faststats/minestom/MinestomContext.java | 3 +- .../dev/faststats/nukkit/NukkitContext.java | 3 +- .../dev/faststats/sponge/SpongeContext.java | 3 +- .../faststats/velocity/VelocityContext.java | 3 +- 11 files changed, 97 insertions(+), 113 deletions(-) delete mode 100644 core/src/main/java/dev/faststats/FastStatsContextFactory.java diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index eeebb5ff..2cdc2b03 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -1,6 +1,5 @@ package dev.faststats.bukkit; -import dev.faststats.FastStatsContextFactory; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -45,7 +44,7 @@ public String getProjectName() { return plugin.getName(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { private final Plugin plugin; private final @Token String token; diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index 47fdb524..0c64e56a 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -1,6 +1,5 @@ package dev.faststats.bungee; -import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -38,7 +37,7 @@ public String getProjectName() { return plugin.getDescription().getName(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { private final Plugin plugin; private final @Token String token; diff --git a/core/src/main/java/dev/faststats/FastStatsContextFactory.java b/core/src/main/java/dev/faststats/FastStatsContextFactory.java deleted file mode 100644 index 09988ff2..00000000 --- a/core/src/main/java/dev/faststats/FastStatsContextFactory.java +++ /dev/null @@ -1,96 +0,0 @@ -package dev.faststats; - -import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; - -import java.util.function.Function; - -/** - * Factory for creating a configured FastStats context. - *

- * Platform implementations may extend this class with constructors that accept - * platform-specific objects before creating the context. - * - * @param the context type created by this factory - * @param the concrete factory type - * @since 0.24.0 - */ -public abstract class FastStatsContextFactory> { - private @Nullable Function metrics = null; - private @Nullable Function featureFlagService; - private @Nullable ErrorTracker errorTracker; - - /** - * Configures the global/internal error tracker for the context. - * - * @param errorTracker the global/internal error tracker - * @return this factory - * @since 0.24.0 - */ - @Contract(value = "_ -> this", mutates = "this") - public F errorTrackerService(final ErrorTracker errorTracker) { - this.errorTracker = errorTracker; - return self(); - } - - /** - * Configures and creates the single metrics instance for the context. - * - * @param metrics a function that receives a new metrics factory and returns the built metrics instance - * @return this factory - * @since 0.24.0 - */ - @Contract(value = "_ -> this", mutates = "this") - public F metrics(final Function metrics) { - this.metrics = metrics; - return self(); - } - - /** - * Configures and creates the single feature flag service instance for the context. - * - * @param featureFlagService a function that receives a new service factory and returns the built service instance - * @return this factory - * @since 0.24.0 - */ - @Contract(value = "_ -> this", mutates = "this") - public F featureFlagService( - final Function featureFlagService - ) { - this.featureFlagService = featureFlagService; - return self(); - } - - /** - * Creates the configured context. - * - * @return the configured context - * @since 0.24.0 - */ - // todo: mutation sucks, move factory into context - @Contract(value = " -> new", mutates = "io") - public final C create() { - final var context = createContext(); - if (metrics != null) - context.setMetrics(metrics.apply(context.metricsFactory())); - if (featureFlagService != null) - context.setFeatureFlagService(featureFlagService.apply(context.featureFlagServiceFactory())); - if (errorTracker != null) - context.setErrorTrackerService(errorTracker); - return context; - } - - @SuppressWarnings("unchecked") - private F self() { - return (F) this; - } - - /** - * Creates the platform-specific context instance. - * - * @return the platform-specific context - * @since 0.24.0 - */ - @Contract(value = " -> new", mutates = "io") - protected abstract C createContext(); -} diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 989fe07f..31945946 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -7,6 +7,7 @@ import java.io.UncheckedIOException; import java.util.Optional; import java.util.Properties; +import java.util.function.Function; // fixme: thread safety public non-sealed abstract class SimpleContext implements FastStatsContext { @@ -126,4 +127,91 @@ final void setFeatureFlagService(final FeatureFlagService featureFlagService) { final void setErrorTrackerService(final ErrorTracker errorTracker) { this.errorTrackerService = new SimpleErrorTrackerService(this, errorTracker); } + + /** + * Factory for creating a configured FastStats context. + *

+ * Platform implementations may extend this class with constructors that accept + * platform-specific objects before creating the context. + * + * @param the context type created by this factory + * @param the concrete factory type + * @since 0.24.0 + */ + public abstract static class Factory> { + private @Nullable Function metrics = null; + private @Nullable Function featureFlagService; + private @Nullable ErrorTracker errorTracker; + + /** + * Configures the global/internal error tracker for the context. + * + * @param errorTracker the global/internal error tracker + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F errorTrackerService(final ErrorTracker errorTracker) { + this.errorTracker = errorTracker; + return self(); + } + + /** + * Configures and creates the single metrics instance for the context. + * + * @param metrics a function that receives a new metrics factory and returns the built metrics instance + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F metrics(final Function metrics) { + this.metrics = metrics; + return self(); + } + + /** + * Configures and creates the single feature flag service instance for the context. + * + * @param featureFlagService a function that receives a new service factory and returns the built service instance + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F featureFlagService(final Function featureFlagService) { + this.featureFlagService = featureFlagService; + return self(); + } + + /** + * Creates the configured context. + * + * @return the configured context + * @since 0.24.0 + */ + @Contract(value = " -> new", mutates = "io") + public final C create() { + final var context = createContext(); + if (metrics != null) + context.setMetrics(metrics.apply(context.metricsFactory())); + if (featureFlagService != null) + context.setFeatureFlagService(featureFlagService.apply(context.featureFlagServiceFactory())); + if (errorTracker != null) + context.setErrorTrackerService(errorTracker); + return context; + } + + @SuppressWarnings("unchecked") + private F self() { + return (F) this; + } + + /** + * Creates the platform-specific context instance. + * + * @return the platform-specific context + * @since 0.24.0 + */ + @Contract(value = " -> new", mutates = "io") + protected abstract C createContext(); + } } diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 80deeef0..39cb7aef 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -44,7 +44,7 @@ public boolean debug() { } } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { @Override protected MockContext createContext() { return new MockContext(); diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 4b855114..d0ca15a3 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -1,6 +1,5 @@ package dev.faststats.fabric; -import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -45,7 +44,7 @@ public String getProjectName() { return mod.getMetadata().getId(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 0a1e86b5..b1f70238 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -1,7 +1,6 @@ package dev.faststats.hytale; import com.hypixel.hytale.server.core.plugin.JavaPlugin; -import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -39,7 +38,7 @@ public String getProjectName() { return pluginName; } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { private final JavaPlugin plugin; private final @Token String token; diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index a26319d4..6b147b9a 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -1,6 +1,5 @@ package dev.faststats.minestom; -import dev.faststats.FastStatsContextFactory; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -30,7 +29,7 @@ public String getProjectName() { return MinecraftServer.getBrandName(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { private final @Token String token; public Factory(@Token final String token) { diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 4c9c08a2..336b6f15 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -1,7 +1,6 @@ package dev.faststats.nukkit; import cn.nukkit.plugin.PluginBase; -import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -40,7 +39,7 @@ public String getProjectName() { return plugin.getName(); } - public static final class Factory extends FastStatsContextFactory { + public static final class Factory extends SimpleContext.Factory { private final PluginBase plugin; private final @Token String token; diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 01bb8ddb..edf58184 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -1,7 +1,6 @@ package dev.faststats.sponge; import com.google.inject.Inject; -import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -51,7 +50,7 @@ public String getProjectName() { * * @since 0.24.0 */ - public static class Factory extends FastStatsContextFactory { + public static class Factory extends SimpleContext.Factory { private final PluginContainer plugin; private final Path dataDirectory; private @Token diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index b90e6ffd..b4e7d211 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -4,7 +4,6 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.FastStatsContextFactory; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -56,7 +55,7 @@ public String getProjectName() { * * @since 0.24.0 */ - public static class Factory extends FastStatsContextFactory { + public static class Factory extends SimpleContext.Factory { private final PluginContainer plugin; private final ProxyServer server; private final Path dataDirectory; From f7a39775d13f4c5d4451bcb058daa6cecb538912 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 12:23:56 +0200 Subject: [PATCH 124/140] Prevent overriding existing metrics entries --- core/src/main/java/dev/faststats/SimpleMetrics.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 6f3d3b52..506b88b8 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -199,8 +199,9 @@ private void appendInternalData(final JsonObject metrics) { private void appendCustomData(final JsonObject metrics) { this.metrics.forEach(metric -> { try { - // todo: prevent overriding and log warning if multiple similar metric ids are used - metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); + if (metrics.has(metric.getId())) + logger.warn("Skipped duplicated metrics entry: %s", metric.getId()); + else metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); } catch (final Throwable t) { logger.error("Failed to append custom metric data: %s", t, metric.getId()); context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(t)); From 0218aa76a1acf6048e01fc2459b5c07e5674e2a4 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 13:34:22 +0200 Subject: [PATCH 125/140] Remove outdated todo --- core/src/main/java/dev/faststats/SimpleMetrics.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 506b88b8..9647807e 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -233,7 +233,6 @@ private JsonObject createData() { @Override public void shutdown() { - // context.errorTrackers().forEach(ErrorTracker::detachErrorContext); // todo: detach all error contexts on shutdown? if (submissionJob != null) try { logger.info("Shutting down metrics submission"); submissionJob.cancel(false); From 785b2ea84afe5f2212ba6fc80b6358835537a365 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 13:50:58 +0200 Subject: [PATCH 126/140] DI Factory into context for safety --- .../dev/faststats/bukkit/BukkitContext.java | 9 ++-- .../dev/faststats/bungee/BungeeContext.java | 8 +-- .../java/dev/faststats/SimpleContext.java | 50 +++++-------------- .../test/java/dev/faststats/MockContext.java | 8 +-- .../dev/faststats/fabric/FabricContext.java | 8 +-- .../dev/faststats/hytale/HytaleContext.java | 10 ++-- .../faststats/minestom/MinestomContext.java | 10 ++-- .../dev/faststats/nukkit/NukkitContext.java | 8 +-- .../dev/faststats/sponge/SpongeContext.java | 8 +-- .../faststats/velocity/VelocityContext.java | 8 +-- 10 files changed, 51 insertions(+), 76 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 2cdc2b03..17046716 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -16,8 +16,8 @@ public final class BukkitContext extends SimpleContext { final Plugin plugin; - private BukkitContext(final Plugin plugin, @Token final String token) { - super(SimpleConfig.read(getConfigPath(plugin)), "bukkit", token); + private BukkitContext(final Factory factory, final Plugin plugin, @Token final String token) { + super(factory, SimpleConfig.read(getConfigPath(plugin)), "bukkit", token); this.plugin = plugin; } @@ -54,9 +54,8 @@ public Factory(final Plugin plugin, @Token final String token) { } @Override - @Contract(value = " -> new", mutates = "io") - protected BukkitContext createContext() { - return new BukkitContext(plugin, token); + public BukkitContext create() { + return new BukkitContext(this, plugin, token); } } } diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index 0c64e56a..82cdb6f1 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -16,8 +16,8 @@ public final class BungeeContext extends SimpleContext { final Plugin plugin; - private BungeeContext(final Plugin plugin, @Token final String token) { - super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), "bungeecord", token); + private BungeeContext(final Factory factory, final Plugin plugin, @Token final String token) { + super(factory, SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), "bungeecord", token); this.plugin = plugin; } @@ -47,8 +47,8 @@ public Factory(final Plugin plugin, @Token final String token) { } @Override - protected BungeeContext createContext() { - return new BungeeContext(plugin, token); + public BungeeContext create() { + return new BungeeContext(this, plugin, token); } } } diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 31945946..58984efe 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -15,9 +15,9 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { private final @Token String token; private final SdkInfo sdkInfo; - private @Nullable Metrics metrics; - private @Nullable FeatureFlagService featureFlagService; - private @Nullable ErrorTrackerService errorTrackerService; + private final @Nullable Metrics metrics; + private final @Nullable FeatureFlagService featureFlagService; + private final @Nullable ErrorTrackerService errorTrackerService; /** * Creates a new context that stores the shared configuration and token for all FastStats services. @@ -31,13 +31,17 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { * @throws UncheckedIOException if an IO error occurs * @since 0.24.0 */ - protected SimpleContext(final Config config, final String name, @Token final String token) throws IllegalArgumentException { - this.sdkInfo = constructSdkInfo(name); - if (!token.matches(Token.PATTERN)) { + protected SimpleContext(final Factory factory, final Config config, final String name, @Token final String token) throws IllegalArgumentException { + if (!token.matches(Token.PATTERN)) throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } + + this.sdkInfo = constructSdkInfo(name); this.config = config; this.token = token; + + this.metrics = factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; + this.errorTrackerService = factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; + this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(config, token)) : null; } private SdkInfo constructSdkInfo(final String name) throws UncheckedIOException, IllegalStateException, IllegalArgumentException { @@ -116,18 +120,6 @@ public SdkInfo getSdkInfo() { return sdkInfo; } - final void setMetrics(final Metrics metrics) { - this.metrics = metrics; - } - - final void setFeatureFlagService(final FeatureFlagService featureFlagService) { - this.featureFlagService = featureFlagService; - } - - final void setErrorTrackerService(final ErrorTracker errorTracker) { - this.errorTrackerService = new SimpleErrorTrackerService(this, errorTracker); - } - /** * Factory for creating a configured FastStats context. *

@@ -189,29 +181,11 @@ public F featureFlagService(final Function { @Override - protected MockContext createContext() { - return new MockContext(); + public MockContext create() { + return new MockContext(this); } } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index d0ca15a3..10fbc294 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -17,8 +17,8 @@ public final class FabricContext extends SimpleContext { final ModContainer mod; - private FabricContext(final String modId, @Token final String token) { - super(SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), "fabric", token); + private FabricContext(final Factory factory, final String modId, @Token final String token) { + super(factory, SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); @@ -54,8 +54,8 @@ public Factory(final String modId, @Token final String token) { } @Override - protected FabricContext createContext() { - return new FabricContext(modId, token); + public FabricContext create() { + return new FabricContext(this, modId, token); } } } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index b1f70238..07d013b2 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -15,9 +15,9 @@ */ public final class HytaleContext extends SimpleContext { private final String pluginName; - - private HytaleContext(final JavaPlugin plugin, @Token final String token) { - super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); + + private HytaleContext(final Factory factory, final JavaPlugin plugin, @Token final String token) { + super(factory, SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); this.pluginName = plugin.getName(); } @@ -48,8 +48,8 @@ public Factory(final JavaPlugin plugin, @Token final String token) { } @Override - protected HytaleContext createContext() { - return new HytaleContext(plugin, token); + public HytaleContext create() { + return new HytaleContext(this, plugin, token); } } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 6b147b9a..e0f2b78c 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -1,5 +1,7 @@ package dev.faststats.minestom; +import dev.faststats.ErrorTracker; +import dev.faststats.ErrorTrackerService; import dev.faststats.SimpleContext; import dev.faststats.Token; import dev.faststats.config.SimpleConfig; @@ -14,8 +16,8 @@ * @since 0.24.0 */ public final class MinestomContext extends SimpleContext { - MinestomContext(@Token final String token) { - super(SimpleConfig.read(Path.of("faststats", "config.properties")), "minestom", token); + MinestomContext(final Factory factory, @Token final String token) { + super(factory, SimpleConfig.read(Path.of("faststats", "config.properties")), "minestom", token); } @Override @@ -37,8 +39,8 @@ public Factory(@Token final String token) { } @Override - protected MinestomContext createContext() { - return new MinestomContext(token); + public MinestomContext create() { + return new MinestomContext(this, token); } } } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 336b6f15..2c204ab9 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -18,8 +18,8 @@ public final class NukkitContext extends SimpleContext { final PluginBase plugin; - private NukkitContext(final PluginBase plugin, @Token final String token) { - super(SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), "nukkit", token); + private NukkitContext(final Factory factory, final PluginBase plugin, @Token final String token) { + super(factory, SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), "nukkit", token); this.plugin = plugin; } @@ -49,8 +49,8 @@ public Factory(final PluginBase plugin, @Token final String token) { } @Override - protected NukkitContext createContext() { - return new NukkitContext(plugin, token); + public NukkitContext create() { + return new NukkitContext(this, plugin, token); } } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index edf58184..2c2de539 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -21,11 +21,11 @@ public final class SpongeContext extends SimpleContext { final PluginContainer plugin; private SpongeContext( - final PluginContainer plugin, + final Factory factory, final PluginContainer plugin, @ConfigDir(sharedRoot = true) final Path dataDirectory, @Token final String token ) { - super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), "sponge", token); + super(factory, SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), "sponge", token); this.plugin = plugin; } @@ -87,9 +87,9 @@ public SpongeContext.Factory token(@Token final String token) throws IllegalArgu } @Override - protected SpongeContext createContext() { + public SpongeContext create() { if (token == null) throw new IllegalStateException("Token not configured"); - return new SpongeContext(plugin, dataDirectory, token); + return new SpongeContext(this, plugin, dataDirectory, token); } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index b4e7d211..04733cad 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -24,12 +24,12 @@ public final class VelocityContext extends SimpleContext { final ProxyServer server; private VelocityContext( - final PluginContainer plugin, + final Factory factory, final PluginContainer plugin, final ProxyServer server, @DataDirectory final Path dataDirectory, @Token final String token ) { - super(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), "velocity", token); + super(factory, SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), "velocity", token); this.plugin = plugin; this.server = server; } @@ -95,9 +95,9 @@ public Factory token(@Token final String token) { } @Override - protected VelocityContext createContext() { + public VelocityContext create() { if (token == null) throw new IllegalStateException("Token not configured"); - return new VelocityContext(plugin, server, dataDirectory, token); + return new VelocityContext(this, plugin, server, dataDirectory, token); } } From db54423feb435a9be48c1dd2271667f3c61cd220 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 13:52:29 +0200 Subject: [PATCH 127/140] Move ready and shutdown methods to context --- .../dev/faststats/bukkit/BukkitContext.java | 9 ++++++++ .../dev/faststats/bukkit/BukkitMetrics.java | 12 ---------- .../faststats/bukkit/BukkitMetricsImpl.java | 9 -------- .../dev/faststats/FeatureFlagService.java | 8 ------- core/src/main/java/dev/faststats/Metrics.java | 23 ------------------- .../java/dev/faststats/SimpleContext.java | 8 +++---- .../faststats/SimpleFeatureFlagService.java | 1 - .../java/dev/faststats/SimpleMetrics.java | 3 +-- .../faststats/minestom/MinestomContext.java | 13 +++++++++++ .../faststats/minestom/MinestomMetrics.java | 11 --------- .../minestom/MinestomMetricsImpl.java | 15 ------------ 11 files changed, 27 insertions(+), 85 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 17046716..edc2a68b 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -27,6 +27,15 @@ protected BukkitMetrics.Factory metricsFactory() { return new BukkitMetricsImpl.Factory(this); } + @Override + public void ready() { + try { + Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); + plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, this), plugin); + } catch (final ClassNotFoundException ignored) { + } + } + private static Path getConfigPath(final Plugin plugin) { return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties"); } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java index 4559879b..1be54a7b 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java @@ -2,8 +2,6 @@ import dev.faststats.Metrics; import dev.faststats.data.Metric; -import org.bukkit.plugin.IllegalPluginAccessException; -import org.bukkit.plugin.Plugin; /** * Bukkit metrics implementation. @@ -11,16 +9,6 @@ * @since 0.1.0 */ public sealed interface BukkitMetrics extends Metrics permits BukkitMetricsImpl { - /** - * Registers additional exception handlers on Paper-based implementations. - * - * @throws IllegalPluginAccessException if the plugin is not yet enabled - * @apiNote This method may only be called {@link Plugin#onEnable() onEnable()}. - * @since 0.14.0 - */ - @Override - void ready() throws IllegalPluginAccessException; - sealed interface Factory extends Metrics.Factory permits BukkitMetricsImpl.Factory { @Override Factory addMetric(Metric metric) throws IllegalArgumentException; diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 04980042..d6e372a9 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -82,15 +82,6 @@ private int getPlayerCount() { } } - @Override - public void ready() { - try { - Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); - plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, context), plugin); - } catch (final ClassNotFoundException ignored) { - } - } - private Optional tryOrEmpty(final Supplier supplier) { try { return Optional.of(supplier.get()); diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java index dec8f35f..d6914131 100644 --- a/core/src/main/java/dev/faststats/FeatureFlagService.java +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -101,14 +101,6 @@ public sealed interface FeatureFlagService permits SimpleFeatureFlagService { */ Duration getTTL(); - /** - * Shuts down the feature flag service. - * - * @since 0.24.0 - */ - @Contract(mutates = "this") - void shutdown(); - /** * A feature flag service factory. * diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java index b1131d94..b842cca2 100644 --- a/core/src/main/java/dev/faststats/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -10,29 +10,6 @@ * @since 0.24.0 */ public interface Metrics { - /** - * Performs additional post-startup tasks. - *

- * This method may only be called when the application startup is complete. - *

- * No-op in most implementations. - * - * @apiNote Refer to your {@code Metrics} provider's documentation. - * @since 0.24.0 - */ - default void ready() { - } - - /** - * Safely shuts down the metrics submission. - *

- * This method should be called when the application is shutting down. - * - * @since 0.24.0 - */ - @Contract(mutates = "this") - void shutdown(); - /** * A metrics factory. * diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 58984efe..d3ae6086 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -104,14 +104,14 @@ public final Optional errorTrackerService() { } @Override - public final void ready() { - if (metrics != null) metrics.ready(); + public void ready() { } @Override public final void shutdown() { - if (metrics != null) metrics.shutdown(); - if (featureFlagService != null) featureFlagService.shutdown(); + if (errorTrackerService instanceof final SimpleErrorTrackerService service) service.clear(); + if (featureFlagService instanceof final SimpleFeatureFlagService service) service.shutdown(); + if (metrics instanceof final SimpleMetrics simpleMetrics) simpleMetrics.shutdown(); } @Override diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java index 51595898..b6cf97a4 100644 --- a/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -216,7 +216,6 @@ public Duration getTTL() { return ttl; } - @Override public void shutdown() { fetchesInProgress.values().forEach(fetch -> fetch.cancel(true)); fetchesInProgress.clear(); diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 9647807e..8310f371 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -231,8 +231,7 @@ private JsonObject createData() { @Contract(mutates = "param1") protected abstract void appendDefaultData(JsonObject metrics); - @Override - public void shutdown() { + protected void shutdown() { if (submissionJob != null) try { logger.info("Shutting down metrics submission"); submissionJob.cancel(false); diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index e0f2b78c..3b069661 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -20,6 +20,19 @@ public final class MinestomContext extends SimpleContext { super(factory, SimpleConfig.read(Path.of("faststats", "config.properties")), "minestom", token); } + @Override + public void ready() { + super.ready(); + errorTrackerService().map(ErrorTrackerService::globalErrorTracker).ifPresent(errorTracker -> { + final var handler = MinecraftServer.getExceptionManager().getExceptionHandler(); + MinecraftServer.getExceptionManager().setExceptionHandler(error -> { + handler.handleException(error); + if (!ErrorTracker.isSameLoader(getClass().getClassLoader(), error)) return; + errorTracker.trackError(error); + }); + }); + } + @Override @Contract(value = " -> new", pure = true) protected MinestomMetrics.Factory metricsFactory() { diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java index 6f057ac9..7ae318c8 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java @@ -2,8 +2,6 @@ import dev.faststats.Metrics; import dev.faststats.data.Metric; -import net.minestom.server.Auth; -import net.minestom.server.MinecraftServer; /** * Minestom metrics implementation. @@ -11,15 +9,6 @@ * @since 0.1.0 */ public sealed interface MinestomMetrics extends Metrics permits MinestomMetricsImpl { - /** - * Registers additional exception handlers. - * - * @apiNote This method may only be called after {@link MinecraftServer#init(Auth)}. - * @since 0.14.0 - */ - @Override - void ready(); - sealed interface Factory extends Metrics.Factory permits MinestomMetricsImpl.Factory { @Override Factory addMetric(Metric metric) throws IllegalArgumentException; diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 2dba1551..d864024d 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -1,7 +1,6 @@ package dev.faststats.minestom; import com.google.gson.JsonObject; -import dev.faststats.ErrorTracker; import dev.faststats.SimpleMetrics; import dev.faststats.config.SimpleConfig; import dev.faststats.data.Metric; @@ -32,20 +31,6 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Minestom"); } - @Override - public void ready() { - // context.errorTrackers().forEach(this::registerExceptionHandler); // fixme - } - - private void registerExceptionHandler(final ErrorTracker errorTracker) { - final var handler = MinecraftServer.getExceptionManager().getExceptionHandler(); - MinecraftServer.getExceptionManager().setExceptionHandler(error -> { - handler.handleException(error); - if (!ErrorTracker.isSameLoader(getClass().getClassLoader(), error)) return; - errorTracker.trackError(error); - }); - } - public static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { Factory(final MinestomContext context) { super(context); From bd1eec9198bd1db151fdbe19dfb485e9a97df203 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 13:52:45 +0200 Subject: [PATCH 128/140] Internal errors are always unhandled --- .../main/java/dev/faststats/bukkit/PaperEventListener.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java index 329b5239..8e352614 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java @@ -14,6 +14,8 @@ public void onServerException(final ServerExceptionEvent event) { if (!(event.getException() instanceof final ServerPluginException exception)) return; if (!exception.getResponsiblePlugin().equals(plugin)) return; final var report = exception.getCause() != null ? exception.getCause() : exception; - context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(report)); + context.errorTrackerService().ifPresent(service -> { + service.globalErrorTracker().trackError(report).handled(false); + }); } } From 76602d29f15fd095e4c929a458a13e4cebf1dffc Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 13:52:52 +0200 Subject: [PATCH 129/140] Fix import --- .../main/java/dev/faststats/example/ErrorTrackerExample.java | 4 ++-- .../main/java/dev/faststats/example/FeatureFlagExample.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java index 4f0c5a1e..e874eb89 100644 --- a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -2,7 +2,7 @@ import dev.faststats.ErrorTracker; import dev.faststats.FastStatsContext; -import dev.faststats.FastStatsContextFactory; +import dev.faststats.SimpleContext; import java.lang.reflect.InvocationTargetException; import java.nio.file.AccessDeniedException; @@ -38,7 +38,7 @@ public static void manualTracking() { } } - private static FastStatsContextFactory getContextFactory() { + private static SimpleContext.Factory getContextFactory() { return null; } } diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java index 58667670..8e32e979 100644 --- a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -2,9 +2,9 @@ import dev.faststats.Attributes; import dev.faststats.FastStatsContext; -import dev.faststats.FastStatsContextFactory; import dev.faststats.FeatureFlag; import dev.faststats.FeatureFlagService; +import dev.faststats.SimpleContext; import java.time.Duration; @@ -68,7 +68,7 @@ public static void usage() { }); } - private static FastStatsContextFactory getContextFactory() { + private static SimpleContext.Factory getContextFactory() { return null; } } From d64dd6836150424e0f1aca1bb2d029b9dcff63e3 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 13:53:31 +0200 Subject: [PATCH 130/140] Smol cleanup --- .../main/java/dev/faststats/ErrorTracker.java | 2 +- .../dev/faststats/SimpleErrorTracker.java | 1 - .../faststats/SimpleErrorTrackerService.java | 18 ++----- .../java/dev/faststats/SimpleMetrics.java | 2 +- hytale/README.md | 52 ------------------- 5 files changed, 5 insertions(+), 70 deletions(-) delete mode 100644 hytale/README.md diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index a316b27d..687b1c7b 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -63,7 +63,7 @@ static ErrorTracker contextUnaware() { */ @Contract(pure = true) Attributes getAttributes(); - + /** * Tracks a handled error. * diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 93b2f30d..4872366a 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -49,7 +49,6 @@ public TrackedError trackError(final Throwable error) { return reports != null ? reports + 1 : 1; }); } catch (final NoClassDefFoundError ignored) { - // todo: add logging } return trackedError; } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index ea69ed68..0b802ca7 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -47,16 +47,9 @@ final class SimpleErrorTrackerService implements ErrorTrackerService { private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); private static Thread.@Nullable UncaughtExceptionHandler originalHandler; - SimpleErrorTrackerService( - final SimpleContext context, - final ErrorTracker globalErrorTracker - ) { - // todo: don't even let the user provide anything else - if (!(globalErrorTracker instanceof final SimpleErrorTracker tracker)) { - throw new IllegalArgumentException("Unsupported error tracker implementation: " + globalErrorTracker.getClass().getName()); - } + SimpleErrorTrackerService(final SimpleContext context, final ErrorTracker globalErrorTracker) { this.context = context; - this.globalErrorTracker = tracker; + this.globalErrorTracker = ((SimpleErrorTracker) globalErrorTracker); startErrorSubmission(); } @@ -67,11 +60,7 @@ public ErrorTracker globalErrorTracker() { @Override public ErrorTrackerService registerErrorTracker(final ErrorTracker errorTracker) { - // todo: the class is sealed this check will always succeed, cast directly - if (!(errorTracker instanceof final SimpleErrorTracker tracker)) { - throw new IllegalArgumentException("Unsupported error tracker implementation: " + errorTracker.getClass().getName()); - } - errorTrackers.add(tracker); + errorTrackers.add(((SimpleErrorTracker) errorTracker)); startErrorSubmission(); return this; } @@ -258,5 +247,4 @@ void startErrorSubmission() { TimeUnit.MILLISECONDS ); } - } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 8310f371..d40b5ff3 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -245,9 +245,9 @@ protected void shutdown() { } public abstract static class Factory implements Metrics.Factory { + private @Nullable Runnable flush; private final Set> metrics = new HashSet<>(0); protected final SimpleContext context; - private @Nullable Runnable flush; protected Factory(final SimpleContext context) { this.context = context; diff --git a/hytale/README.md b/hytale/README.md deleted file mode 100644 index b4948f3f..00000000 --- a/hytale/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Hytale Module - -Since the Hytale API is not public yet, and redistribution is not allowed, -you have to download the Hytale server yourself. - -## Initial Setup - -Before building this module, you need to authenticate with your Hytale account. You have two options: - -### Option 1: Using Environment Variable (Recommended for CI) - -Set the `HYTALE_DOWNLOADER_CREDENTIALS` environment variable with your Hytale authentication credentials: - -```bash -export HYTALE_DOWNLOADER_CREDENTIALS='{"your":"auth","json":"here"}' -./gradlew :hytale:download-server -``` - -This token can be obtained by running the download task without credentials once ( -see [Obtaining Hytale Authentication](#obtaining-hytale-authentication)). - -### Option 2: Using Credentials File (Recommended for Local Development) - -1. Create `.hytale-downloader-credentials.json` in the `hytale/` directory -2. Paste your Hytale authentication JSON credentials in the file -3. Run the download task: - -```bash -./gradlew :hytale:download-server -``` - -The credentials file is gitignored and won't be committed. - -## Obtaining Hytale Authentication - -To get your Hytale authentication credentials: - -1. Run the download task without credentials: - ```bash - ./gradlew :hytale:download-server - ``` -2. The Hytale downloader will prompt you to authenticate -3. After successful authentication, the credentials will be saved to - `.hytale-downloader-credentials.json` for future use - -## Updating the Server - -To update the Hytale server: - -```bash -./gradlew :hytale:update-server -``` \ No newline at end of file From 1f89526f16b171ebfe28d47e90076cc230e43e8a Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 14:07:35 +0200 Subject: [PATCH 131/140] provide proper test version --- core/src/test/resources/META-INF/faststats.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/test/resources/META-INF/faststats.properties b/core/src/test/resources/META-INF/faststats.properties index b507e3fc..beb72cc4 100644 --- a/core/src/test/resources/META-INF/faststats.properties +++ b/core/src/test/resources/META-INF/faststats.properties @@ -1 +1 @@ -version=test \ No newline at end of file +version=1.0.0 \ No newline at end of file From 1727751aefbdd3d5899f875e4899b95f82d60ff0 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 14:07:59 +0200 Subject: [PATCH 132/140] Provide proper test name --- core/src/test/java/dev/faststats/MockContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index 0210fcc3..c79b2895 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -4,7 +4,7 @@ public final class MockContext extends SimpleContext { private MockContext(final Factory factory) throws IllegalArgumentException { - super(factory, new MockConfig(UUID.randomUUID()), "core:test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + super(factory, new MockConfig(UUID.randomUUID()), "test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); } @Override From 11fad725002715950192bf84f1dc806ea5372f62 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 14:15:26 +0200 Subject: [PATCH 133/140] Add debug to show attached services and user agent --- .../main/java/dev/faststats/SimpleContext.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index d3ae6086..7b0bdafe 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -1,16 +1,20 @@ package dev.faststats; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; +import java.util.HashSet; import java.util.Optional; import java.util.Properties; import java.util.function.Function; -// fixme: thread safety public non-sealed abstract class SimpleContext implements FastStatsContext { + private final Logger logger = LoggerFactory.factory().getLogger(getClass()); + private final Config config; private final @Token String token; private final SdkInfo sdkInfo; @@ -42,6 +46,16 @@ protected SimpleContext(final Factory factory, final Config config, final this.metrics = factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; this.errorTrackerService = factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(config, token)) : null; + + final var features = new HashSet(3); + features.add("metrics=" + (metrics != null ? "yes" : "no")); + features.add("error-tracking=" + (errorTrackerService != null ? "yes" : "no")); + features.add("feature-flags=" + (featureFlagService != null ? "yes" : "no")); + + logger.info("Created FastStats context for %s using %s (%s)", + getProjectName(), sdkInfo.getUserAgent(), + String.join(", ", features) + ); } private SdkInfo constructSdkInfo(final String name) throws UncheckedIOException, IllegalStateException, IllegalArgumentException { From 45106fd2450321c6057bf2b31cc12a38d08d2733 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 14:15:42 +0200 Subject: [PATCH 134/140] Prevent empty context --- .../src/main/java/dev/faststats/SimpleContext.java | 3 +++ .../test/java/dev/faststats/SimpleContextTest.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 core/src/test/java/dev/faststats/SimpleContextTest.java diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 7b0bdafe..dff21134 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -47,6 +47,9 @@ protected SimpleContext(final Factory factory, final Config config, final this.errorTrackerService = factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(config, token)) : null; + if (metrics == null && errorTrackerService == null && featureFlagService == null) + throw new IllegalStateException("Context created without any service attached, was this intentional?"); + final var features = new HashSet(3); features.add("metrics=" + (metrics != null ? "yes" : "no")); features.add("error-tracking=" + (errorTrackerService != null ? "yes" : "no")); diff --git a/core/src/test/java/dev/faststats/SimpleContextTest.java b/core/src/test/java/dev/faststats/SimpleContextTest.java new file mode 100644 index 00000000..153408f0 --- /dev/null +++ b/core/src/test/java/dev/faststats/SimpleContextTest.java @@ -0,0 +1,14 @@ +package dev.faststats; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public final class SimpleContextTest { + @Test + public void contextWithoutAttachedServicesThrows() { + final var error = assertThrows(IllegalStateException.class, () -> new MockContext.Factory().create()); + assertEquals("Context created without any service attached, was this intentional?", error.getMessage()); + } +} From 894a3c944c8a16d61543dd63c30f4268200baabc Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 15:36:52 +0200 Subject: [PATCH 135/140] Update config --- .../dev/faststats/config/SimpleConfig.java | 111 +++++++++++------- core/src/main/java/dev/faststats/Config.java | 11 +- .../java/dev/faststats/SimpleMetrics.java | 2 +- .../java/dev/faststats/FeatureFlagTest.java | 5 + .../test/java/dev/faststats/MockContext.java | 5 + .../dev/faststats/sponge/SpongeConfig.java | 101 ++++++++++------ 6 files changed, 156 insertions(+), 79 deletions(-) diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index 71e0b69f..416866c2 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -4,16 +4,17 @@ import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.OutputStreamWriter; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; import java.util.Properties; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.logging.Level; import static java.nio.charset.StandardCharsets.UTF_8; @@ -21,14 +22,16 @@ @ApiStatus.Internal public record SimpleConfig( UUID serverId, + boolean enabled, boolean additionalMetrics, boolean debug, - boolean enabled, + boolean submitMetrics, boolean errorTracking, boolean firstRun ) implements Config { + private static final int CONFIG_VERSION = 1; - public static final String COMMENT = """ + private static final String COMMENT = """ FastStats (https://faststats.dev) collects anonymous usage statistics. # This helps developers understand how their projects are used in the real world. # @@ -36,9 +39,13 @@ public record SimpleConfig( # The server ID below is randomly generated and can be regenerated at any time. # # Enabling metrics has no noticeable performance impact. - # Keeping metrics enabled is recommended, but you can opt out by setting 'enabled=false'. + # Keeping FastStats enabled is recommended. + # To disable all FastStats features, set 'enabled=false'. + # To disable only metrics submission, set 'submitMetrics=false'. + # To disable only additional metrics, set 'submitAdditionalMetrics=false'. + # To disable only error tracking, set 'submitErrors=false'. # - # If you suspect a developer is collecting personal data or bypassing the "enabled" option, + # If you suspect a developer is collecting personal data or bypassing any opt-out option, # please report it at: https://faststats.dev/abuse # # For more information, visit: https://faststats.dev/info @@ -55,47 +62,37 @@ public record SimpleConfig( @Contract(mutates = "io") public static SimpleConfig read(final Path file) throws RuntimeException { final var properties = readOrEmpty(file); - final var firstRun = properties.isEmpty(); + final var firstRun = properties != null; final var saveConfig = new AtomicBoolean(firstRun); - final var serverId = properties.map(object -> object.getProperty("serverId")).map(string -> { - try { - final var trimmed = string.trim(); - final var corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed; - if (!corrected.equals(string)) saveConfig.set(true); - return UUID.fromString(corrected); - } catch (final IllegalArgumentException e) { - saveConfig.set(true); - return UUID.randomUUID(); - } - }).orElseGet(() -> { - saveConfig.set(true); - return UUID.randomUUID(); + final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { + final var corrected = value.length() > 36 ? value.substring(0, 36) : value; + final var uuid = UUID.fromString(corrected); + if (!value.equals(uuid.toString())) saveConfig.set(true); + return uuid; }); - - final BiPredicate predicate = (key, defaultValue) -> { - return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> { - saveConfig.set(true); - return defaultValue; - }); - }; - - final var enabled = predicate.test("enabled", true); - final var errorTracking = predicate.test("submitErrors", true); - final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); - final var debug = predicate.test("debug", false); - - if (saveConfig.get()) try { + final var configVersion = parse(properties, saveConfig, "configVersion", null, Integer::parseInt); + final boolean enabled = parse(properties, saveConfig, "enabled", () -> true, Boolean::parseBoolean); + final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean); + final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean); + final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean); + final boolean debug = parse(properties, saveConfig, "debug", () -> true, Boolean::parseBoolean); + + if (saveConfig.get() && (configVersion == null || configVersion <= CONFIG_VERSION)) try { Files.createDirectories(file.getParent()); try (final var out = Files.newOutputStream(file); final var writer = new OutputStreamWriter(out, UTF_8)) { final var store = new Properties(); - store.setProperty("serverId", serverId.toString()); store.setProperty("enabled", Boolean.toString(enabled)); - store.setProperty("submitErrors", Boolean.toString(errorTracking)); + store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + + store.setProperty("serverId", serverId.toString()); + store.setProperty("debug", Boolean.toString(debug)); + store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); store.store(writer, COMMENT); } @@ -103,15 +100,49 @@ public static SimpleConfig read(final Path file) throws RuntimeException { throw new RuntimeException("Failed to save metrics config", e); } - return new SimpleConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun); + return new SimpleConfig( + serverId, + enabled, + enabled && additionalMetrics, + debug, + enabled && submitMetrics, + enabled && errorTracking, + firstRun + ); + } + + // fixme: this code sucks ass + @Contract(value = "_, _, _, !null, _ -> !null") + private static @Nullable T parse( + @Nullable final Properties properties, + final AtomicBoolean saveConfig, + final String key, + @Nullable final Supplier defaultValue, + final Function parser + ) { + if (properties == null) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + final var property = properties.getProperty(key); + if (property == null) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + try { + return parser.apply(property.trim()); + } catch (final Exception e) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } } - private static Optional readOrEmpty(final Path file) throws RuntimeException { - if (!Files.isRegularFile(file)) return Optional.empty(); + private static @Nullable Properties readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return null; try (final var reader = Files.newBufferedReader(file, UTF_8)) { final var properties = new Properties(); properties.load(reader); - return Optional.of(properties); + return properties; } catch (final IOException e) { throw new RuntimeException("Failed to read metrics config", e); } diff --git a/core/src/main/java/dev/faststats/Config.java b/core/src/main/java/dev/faststats/Config.java index f7d24b5e..e2d570df 100644 --- a/core/src/main/java/dev/faststats/Config.java +++ b/core/src/main/java/dev/faststats/Config.java @@ -19,6 +19,15 @@ public interface Config { @Contract(pure = true) UUID serverId(); + /** + * Whether all FastStats features are enabled. + * + * @return {@code true} if FastStats features are enabled, {@code false} otherwise + * @since 0.24.0 + */ + @Contract(pure = true) + boolean enabled(); + /** * Whether metrics submission is enabled. *

@@ -29,7 +38,7 @@ public interface Config { * @since 0.24.0 */ @Contract(pure = true) - boolean enabled(); + boolean submitMetrics(); /** * Whether error tracking is enabled across all metrics instances. diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index d40b5ff3..0cc26d29 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -102,7 +102,7 @@ private void startSubmitting(final long initialDelay, final long period, final T final var enabled = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); - if (!context.getConfig().enabled() || !enabled) { + if (!context.getConfig().submitMetrics() || !enabled) { logger.warn("Metrics disabled, not starting submission"); return; } diff --git a/core/src/test/java/dev/faststats/FeatureFlagTest.java b/core/src/test/java/dev/faststats/FeatureFlagTest.java index efb460c5..3be1dcdf 100644 --- a/core/src/test/java/dev/faststats/FeatureFlagTest.java +++ b/core/src/test/java/dev/faststats/FeatureFlagTest.java @@ -201,6 +201,11 @@ public boolean enabled() { return true; } + @Override + public boolean submitMetrics() { + return true; + } + @Override public boolean errorTracking() { return true; diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index c79b2895..ba0dab68 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -28,6 +28,11 @@ public boolean enabled() { return true; } + @Override + public boolean submitMetrics() { + return true; + } + @Override public boolean errorTracking() { return true; diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java index 6dd3a955..1eeb5447 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -4,6 +4,7 @@ import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; import org.spongepowered.api.Sponge; import org.spongepowered.plugin.PluginContainer; @@ -11,11 +12,11 @@ import java.io.OutputStreamWriter; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; import java.util.Properties; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.logging.Level; import static java.nio.charset.StandardCharsets.UTF_8; @@ -23,12 +24,14 @@ @ApiStatus.Internal public record SpongeConfig( UUID serverId, + boolean enabled, boolean additionalMetrics, boolean debug, - boolean enabled, + boolean submitMetrics, boolean errorTracking, boolean firstRun ) implements Config { + private static final int CONFIG_VERSION = 1; private static final String COMMENT = """ FastStats (https://faststats.dev) collects anonymous usage statistics. @@ -38,8 +41,11 @@ public record SpongeConfig( # The server ID below is randomly generated and can be regenerated at any time. # # Enabling metrics has no noticeable performance impact. - # Enabling metrics is recommended, you can do so in the Sponge metrics.config, + # Enabling metrics is highly recommended, you can do so in the Sponge metrics.config, # by setting the "global-state" property to "TRUE". + # To disable only metrics submission, set 'submitMetrics=false'. + # To disable additional metrics, set 'submitAdditionalMetrics=false'. + # To disable error tracking, set 'submitErrors=false'. # # If you suspect a developer is collecting personal data or bypassing the Sponge config, # please report it at: https://faststats.dev/abuse @@ -56,45 +62,35 @@ public record SpongeConfig( @Contract(mutates = "io") public static SpongeConfig read(final PluginContainer plugin, final Path file) throws RuntimeException { final var properties = readOrEmpty(file); - final var firstRun = properties.isEmpty(); + final var firstRun = properties == null; final var saveConfig = new AtomicBoolean(firstRun); - final var serverId = properties.map(object -> object.getProperty("serverId")).map(string -> { - try { - final var trimmed = string.trim(); - final var corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed; - if (!corrected.equals(string)) saveConfig.set(true); - return UUID.fromString(corrected); - } catch (final IllegalArgumentException e) { - saveConfig.set(true); - return UUID.randomUUID(); - } - }).orElseGet(() -> { - saveConfig.set(true); - return UUID.randomUUID(); + final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { + final var corrected = value.length() > 36 ? value.substring(0, 36) : value; + final var uuid = UUID.fromString(corrected); + if (!value.equals(uuid.toString())) saveConfig.set(true); + return uuid; }); + final var configVersion = parse(properties, saveConfig, "configVersion", null, Integer::parseInt); + final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean); + final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean); + final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean); + final boolean debug = parse(properties, saveConfig, "debug", () -> false, Boolean::parseBoolean); - final BiPredicate predicate = (key, defaultValue) -> { - return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> { - saveConfig.set(true); - return defaultValue; - }); - }; - - final var errorTracking = predicate.test("submitErrors", true); - final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); - final var debug = predicate.test("debug", false); - - if (saveConfig.get()) try { + if (saveConfig.get() && (configVersion == null || configVersion <= CONFIG_VERSION)) try { Files.createDirectories(file.getParent()); try (final var out = Files.newOutputStream(file); final var writer = new OutputStreamWriter(out, UTF_8)) { final var store = new Properties(); - store.setProperty("serverId", serverId.toString()); + store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); store.setProperty("submitErrors", Boolean.toString(errorTracking)); store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + + store.setProperty("serverId", serverId.toString()); + store.setProperty("debug", Boolean.toString(debug)); + store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); store.store(writer, COMMENT); } @@ -103,15 +99,48 @@ public static SpongeConfig read(final PluginContainer plugin, final Path file) t } final var enabled = Sponge.metricsConfigManager().effectiveCollectionState(plugin).asBoolean(); - return new SpongeConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun); + return new SpongeConfig( + serverId, + enabled, + enabled && additionalMetrics, + debug, + enabled && submitMetrics, + enabled && errorTracking, + firstRun + ); } - private static Optional readOrEmpty(final Path file) throws RuntimeException { - if (!Files.isRegularFile(file)) return Optional.empty(); + @Contract(value = "_, _, _, !null, _ -> !null") + private static @Nullable T parse( + @Nullable final Properties properties, + final AtomicBoolean saveConfig, + final String key, + @Nullable final Supplier defaultValue, + final Function parser + ) { + if (properties == null) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + final var property = properties.getProperty(key); + if (property == null) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + try { + return parser.apply(property.trim()); + } catch (final Exception e) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + } + + private static @Nullable Properties readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return null; try (final var reader = Files.newBufferedReader(file, UTF_8)) { final var properties = new Properties(); properties.load(reader); - return Optional.of(properties); + return properties; } catch (final IOException e) { throw new RuntimeException("Failed to read metrics config", e); } @@ -137,5 +166,3 @@ public boolean preSubmissionStart() { return true; } } - - From ea42205a2af52976aa1ea5af92da917947116a76 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 15:56:58 +0200 Subject: [PATCH 136/140] Move context initialization out of constructor --- .../main/java/dev/faststats/bukkit/BukkitContext.java | 1 + .../main/java/dev/faststats/bungee/BungeeContext.java | 1 + core/src/main/java/dev/faststats/SimpleContext.java | 10 +++++++--- core/src/test/java/dev/faststats/MockContext.java | 1 + .../main/java/dev/faststats/fabric/FabricContext.java | 1 + .../main/java/dev/faststats/hytale/HytaleContext.java | 1 + .../java/dev/faststats/minestom/MinestomContext.java | 1 + .../main/java/dev/faststats/nukkit/NukkitContext.java | 1 + .../main/java/dev/faststats/sponge/SpongeContext.java | 1 + .../java/dev/faststats/velocity/VelocityContext.java | 1 + 10 files changed, 16 insertions(+), 3 deletions(-) diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index edc2a68b..8939fed7 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -19,6 +19,7 @@ public final class BukkitContext extends SimpleContext { private BukkitContext(final Factory factory, final Plugin plugin, @Token final String token) { super(factory, SimpleConfig.read(getConfigPath(plugin)), "bukkit", token); this.plugin = plugin; + initializeServices(factory); } @Override diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index 82cdb6f1..ef7ff6c1 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -19,6 +19,7 @@ public final class BungeeContext extends SimpleContext { private BungeeContext(final Factory factory, final Plugin plugin, @Token final String token) { super(factory, SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), "bungeecord", token); this.plugin = plugin; + initializeServices(factory); } @Override diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index dff21134..ef17a68a 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -3,6 +3,7 @@ import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.MustBeInvokedByOverriders; import org.jspecify.annotations.Nullable; import java.io.IOException; @@ -19,9 +20,9 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { private final @Token String token; private final SdkInfo sdkInfo; - private final @Nullable Metrics metrics; - private final @Nullable FeatureFlagService featureFlagService; - private final @Nullable ErrorTrackerService errorTrackerService; + private @Nullable Metrics metrics; + private @Nullable FeatureFlagService featureFlagService; + private @Nullable ErrorTrackerService errorTrackerService; /** * Creates a new context that stores the shared configuration and token for all FastStats services. @@ -42,7 +43,10 @@ protected SimpleContext(final Factory factory, final Config config, final this.sdkInfo = constructSdkInfo(name); this.config = config; this.token = token; + } + @MustBeInvokedByOverriders + protected final void initializeServices(final Factory factory) throws IllegalStateException { this.metrics = factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; this.errorTrackerService = factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(config, token)) : null; diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java index ba0dab68..0a1bff96 100644 --- a/core/src/test/java/dev/faststats/MockContext.java +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -5,6 +5,7 @@ public final class MockContext extends SimpleContext { private MockContext(final Factory factory) throws IllegalArgumentException { super(factory, new MockConfig(UUID.randomUUID()), "test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + initializeServices(factory); } @Override diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 10fbc294..f8a8bdca 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -22,6 +22,7 @@ private FabricContext(final Factory factory, final String modId, @Token final St this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); + initializeServices(factory); } @Override diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index 07d013b2..60dd8827 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -19,6 +19,7 @@ public final class HytaleContext extends SimpleContext { private HytaleContext(final Factory factory, final JavaPlugin plugin, @Token final String token) { super(factory, SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); this.pluginName = plugin.getName(); + initializeServices(factory); } @Override diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index 3b069661..e7239014 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -18,6 +18,7 @@ public final class MinestomContext extends SimpleContext { MinestomContext(final Factory factory, @Token final String token) { super(factory, SimpleConfig.read(Path.of("faststats", "config.properties")), "minestom", token); + initializeServices(factory); } @Override diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 2c204ab9..031007bd 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -21,6 +21,7 @@ public final class NukkitContext extends SimpleContext { private NukkitContext(final Factory factory, final PluginBase plugin, @Token final String token) { super(factory, SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), "nukkit", token); this.plugin = plugin; + initializeServices(factory); } @Override diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 2c2de539..38bedda5 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -27,6 +27,7 @@ private SpongeContext( ) { super(factory, SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), "sponge", token); this.plugin = plugin; + initializeServices(factory); } @Override diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index 04733cad..2531d012 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -32,6 +32,7 @@ private VelocityContext( super(factory, SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), "velocity", token); this.plugin = plugin; this.server = server; + initializeServices(factory); } @Override From f55684273b486bc38c83f560530c316c0e97851a Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 16:39:05 +0200 Subject: [PATCH 137/140] Improve config saving --- .../dev/faststats/config/SimpleConfig.java | 17 +++++++++++++---- .../dev/faststats/sponge/SpongeConfig.java | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index 416866c2..8308bb00 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -1,6 +1,7 @@ package dev.faststats.config; import dev.faststats.Config; +import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; @@ -29,6 +30,7 @@ public record SimpleConfig( boolean errorTracking, boolean firstRun ) implements Config { + private static final Logger logger = LoggerFactory.factory().getLogger(SimpleConfig.class); private static final int CONFIG_VERSION = 1; private static final String COMMENT = """ @@ -62,7 +64,7 @@ public record SimpleConfig( @Contract(mutates = "io") public static SimpleConfig read(final Path file) throws RuntimeException { final var properties = readOrEmpty(file); - final var firstRun = properties != null; + final var firstRun = properties == null; final var saveConfig = new AtomicBoolean(firstRun); final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { @@ -76,9 +78,14 @@ public static SimpleConfig read(final Path file) throws RuntimeException { final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean); final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean); final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean); - final boolean debug = parse(properties, saveConfig, "debug", () -> true, Boolean::parseBoolean); + final boolean debug = parse(properties, saveConfig, "debug", () -> false, Boolean::parseBoolean); - if (saveConfig.get() && (configVersion == null || configVersion <= CONFIG_VERSION)) try { + if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); + else if (configVersion > CONFIG_VERSION) saveConfig.set(false); + + if (saveConfig.get()) try { + if (configVersion == null || configVersion < CONFIG_VERSION) + logger.info("Updating config version to %s", CONFIG_VERSION); Files.createDirectories(file.getParent()); try (final var out = Files.newOutputStream(file); final var writer = new OutputStreamWriter(out, UTF_8)) { @@ -101,7 +108,7 @@ public static SimpleConfig read(final Path file) throws RuntimeException { } return new SimpleConfig( - serverId, + serverId, enabled, enabled && additionalMetrics, debug, @@ -126,12 +133,14 @@ public static SimpleConfig read(final Path file) throws RuntimeException { } final var property = properties.getProperty(key); if (property == null) { + logger.warn("Missing configuration property: %s", key); saveConfig.set(true); return defaultValue != null ? defaultValue.get() : null; } try { return parser.apply(property.trim()); } catch (final Exception e) { + logger.error("Failed to read property '%s' from config", e, key); saveConfig.set(true); return defaultValue != null ? defaultValue.get() : null; } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java index 1eeb5447..1934befe 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -1,6 +1,7 @@ package dev.faststats.sponge; import dev.faststats.Config; +import dev.faststats.internal.Logger; import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; @@ -31,6 +32,7 @@ public record SpongeConfig( boolean errorTracking, boolean firstRun ) implements Config { + private static final Logger logger = LoggerFactory.factory().getLogger(SpongeConfig.class); private static final int CONFIG_VERSION = 1; private static final String COMMENT = """ @@ -57,6 +59,9 @@ public record SpongeConfig( No personal or identifying information is ever collected. It is recommended to enable metrics by setting 'global-state=TRUE' in the sponge metrics config. Learn more at: https://faststats.dev/info + + Since this is your first start with FastStats, metrics submission will not start + until you restart the server to allow you to opt out if you prefer. """; @Contract(mutates = "io") @@ -75,17 +80,22 @@ public static SpongeConfig read(final PluginContainer plugin, final Path file) t final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean); final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean); final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean); - final boolean debug = parse(properties, saveConfig, "debug", () -> false, Boolean::parseBoolean); + final boolean debug = parse(properties, saveConfig, "debug", () -> true, Boolean::parseBoolean); - if (saveConfig.get() && (configVersion == null || configVersion <= CONFIG_VERSION)) try { + if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); + else if (configVersion > CONFIG_VERSION) saveConfig.set(false); + + if (saveConfig.get()) try { + if (configVersion == null || configVersion < CONFIG_VERSION) + logger.info("Updating config version to %s", CONFIG_VERSION); Files.createDirectories(file.getParent()); try (final var out = Files.newOutputStream(file); final var writer = new OutputStreamWriter(out, UTF_8)) { final var store = new Properties(); store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); - store.setProperty("submitErrors", Boolean.toString(errorTracking)); store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); store.setProperty("serverId", serverId.toString()); @@ -124,12 +134,14 @@ public static SpongeConfig read(final PluginContainer plugin, final Path file) t } final var property = properties.getProperty(key); if (property == null) { + logger.warn("Missing configuration property: %s", key); saveConfig.set(true); return defaultValue != null ? defaultValue.get() : null; } try { return parser.apply(property.trim()); } catch (final Exception e) { + logger.error("Failed to read property '%s' from config", e, key); saveConfig.set(true); return defaultValue != null ? defaultValue.get() : null; } From 7477bfb76248f18b500d7356f0c36e87a9f6be47 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 17:29:40 +0200 Subject: [PATCH 138/140] Remove unused method --- core/src/main/java/dev/faststats/ErrorHelper.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 51f83a15..3649dcd5 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -23,11 +23,6 @@ final class ErrorHelper { private static final Set allowedNames = Set.of("minecraft", "server", "root", "ubuntu"); private static final List> defaultAnonymizationEntries = defaultAnonymizationEntries(); - public static JsonObject compile(final TrackedError error, @Nullable final List suppress, - final List> customPatterns) { - return compile(error, suppress, customPatterns, null); - } - public static JsonObject compile(final TrackedError error, @Nullable final List suppress, final List> customPatterns, @Nullable final Attributes attributes) { From fc9a9298d4e6373fde16e7a6c2211c778f952b40 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 17:30:58 +0200 Subject: [PATCH 139/140] Add language to error data --- core/src/main/java/dev/faststats/SimpleErrorTrackerService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 0b802ca7..743ee237 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -177,6 +177,7 @@ void submit() { final var data = new JsonObject(); context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); data.addProperty("identifier", context.getConfig().serverId().toString()); + data.addProperty("language", "java"); data.addProperty("project_name", context.getProjectName()); data.addProperty("sdk_name", context.getSdkInfo().getName()); data.addProperty("sdk_version", context.getSdkInfo().getVersion()); From 15831e79eddf1bc4ba3f6969402243d6f9e71958 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 31 May 2026 22:27:49 +0200 Subject: [PATCH 140/140] Return a copy of the attributes --- core/src/main/java/dev/faststats/SimpleTrackedError.java | 2 +- core/src/main/java/dev/faststats/TrackedError.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/faststats/SimpleTrackedError.java b/core/src/main/java/dev/faststats/SimpleTrackedError.java index 7e3bc71c..7d85aef8 100644 --- a/core/src/main/java/dev/faststats/SimpleTrackedError.java +++ b/core/src/main/java/dev/faststats/SimpleTrackedError.java @@ -35,7 +35,7 @@ public TrackedError handled(final boolean handled) { @Override public Attributes attributes() { - return attributes; + return Attributes.copyOf(attributes); } @Override diff --git a/core/src/main/java/dev/faststats/TrackedError.java b/core/src/main/java/dev/faststats/TrackedError.java index 66ee4ff7..02e282cb 100644 --- a/core/src/main/java/dev/faststats/TrackedError.java +++ b/core/src/main/java/dev/faststats/TrackedError.java @@ -37,12 +37,12 @@ public sealed interface TrackedError permits SimpleTrackedError { TrackedError handled(boolean handled); /** - * Returns the additional error attributes. + * Returns a copy of the additional error attributes. * - * @return the additional error attributes + * @return a copy of the additional error attributes * @since 0.24.0 */ - @Contract(pure = true) + @Contract(value = " -> new", pure = true) Attributes attributes(); /**