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 2b1d9cf..90884f3 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -5,49 +5,22 @@ 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 - - .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 .create(this); @@ -62,15 +35,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/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 58e30e5..4222d8f 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; @@ -76,26 +74,11 @@ 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; } } - @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/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 37d245a..223a64d 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -6,32 +6,22 @@ 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 .create(this); @@ -41,12 +31,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/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index 34fd29f..ba70bc0 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/example/build.gradle.kts b/core/example/build.gradle.kts new file mode 100644 index 0000000..eca1ab1 --- /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 0000000..aa8d643 --- /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 0000000..1461d99 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -0,0 +1,63 @@ +package dev.faststats.example; + +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 { + public static final FeatureFlagService SERVICE = FeatureFlagService.create( + "YOUR_TOKEN_HERE", // token can be found in the settings of your project + 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 + ); + + // 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 0000000..066b5b4 --- /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/src/main/java/dev/faststats/core/Config.java b/core/src/main/java/dev/faststats/core/Config.java new file mode 100644 index 0000000..f1e39f5 --- /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 interface Config { + /** + * 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/ErrorTracker.java b/core/src/main/java/dev/faststats/core/ErrorTracker.java index 1fe010d..a02b928 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 7a60ede..d931cc2 100644 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ b/core/src/main/java/dev/faststats/core/Metrics.java @@ -4,9 +4,7 @@ import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import java.net.URI; import java.util.Optional; -import java.util.UUID; /** * Metrics interface. @@ -49,6 +47,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() { @@ -107,21 +106,6 @@ interface Factory> { @Contract(mutates = "this") 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. - * - * @param enabled whether debug mode is enabled - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(mutates = "this") - F debug(boolean enabled); - /** * Sets the token used to authenticate with the metrics server and identify the project. *

@@ -135,18 +119,6 @@ interface Factory> { @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 - */ - @Contract(mutates = "this") - F url(URI url); - /** * Creates a new metrics instance. *

@@ -163,58 +135,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 0000000..b515c03 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/SimpleConfig.java @@ -0,0 +1,118 @@ +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. + # 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'. + # + # 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/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java index 0b2b757..3a72d7d 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/core/src/main/java/dev/faststats/core/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java index 7dbfd5a..1354034 100644 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/core/SimpleMetrics.java @@ -2,6 +2,9 @@ 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 org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.MustBeInvokedByOverriders; @@ -10,59 +13,41 @@ import java.io.ByteArrayOutputStream; 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; 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; import static java.nio.charset.StandardCharsets.UTF_8; public abstract class SimpleMetrics implements Metrics { + protected 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 @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 URI url; - private final boolean debug; - - private final String SDK_NAME; - private final String SDK_VERSION; - private final String BUILD_ID; - - { - final var properties = new Properties(); - try (final var stream = getClass().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"); - } @Contract(mutates = "io") @SuppressWarnings("PatternValidation") @@ -70,17 +55,28 @@ protected SimpleMetrics(final Factory factory, final Config config) throws if (factory.token == null) throw new IllegalStateException("Token must be specified"); this.config = config; - 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.tracker = config.errorTracking ? factory.tracker : null; + 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 = factory.url; + this.url = getMetricsServerUrl(); + } + + private URI getMetricsServerUrl() { + final var property = System.getProperty("faststats.metrics-server"); + if (property != null) try { + return new URI(property); + } catch (final URISyntaxException e) { + logger.error("Failed to parse metrics server url: %s", e, property); + } + return URI.create("https://metrics.faststats.dev/v1/collect"); } @Contract(mutates = "io") protected SimpleMetrics(final Factory factory, final Path config) throws IllegalStateException { - this(factory, Config.read(config)); + this(factory, SimpleConfig.read(config)); } @VisibleForTesting @@ -93,13 +89,9 @@ protected SimpleMetrics( final URI url, final boolean debug ) { - 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.metrics = config.additionalMetrics() ? Set.copyOf(metrics) : Set.of(); this.config = config; - this.debug = debug; + this.logger.setLevel(debug ? Level.ALL : Level.OFF); this.token = token; this.tracker = tracker; this.flush = flush; @@ -131,45 +123,52 @@ protected void startSubmitting() { startSubmitting(getInitialDelay(), getPeriod(), TimeUnit.MILLISECONDS); } - private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { + @SuppressWarnings("PatternValidation") + protected boolean preSubmissionStart() { + /* if (Boolean.getBoolean("faststats.first-run")) { - info("Skipping metrics submission due to first-run flag"); - return; + logger.info("Skipping metrics submission due to first-run flag"); + return false; } - 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(); - printInfo("-".repeat(separatorLength)); - for (final var s : split) printInfo(s); - printInfo("-".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; + 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")); 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); } @@ -181,7 +180,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; } } @@ -190,7 +189,7 @@ private boolean submitNow() throws IOException { final var data = createData().toString(); final var bytes = data.getBytes(UTF_8); - info("Uncompressed data: " + data); + logger.info("Uncompressed data: %s", data); try (final var byteOutput = new ByteArrayOutputStream(); final var output = new GZIPOutputStream(byteOutput)) { @@ -199,55 +198,55 @@ private boolean submitNow() throws IOException { output.finish(); final var compressed = byteOutput.toByteArray(); - info("Compressed size: " + compressed.length + " bytes"); + 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 " + getToken()) - .header("User-Agent", "FastStats Metrics " + SDK_NAME + "/" + SDK_VERSION) + .header("Authorization", "Bearer " + token) + .header("User-Agent", "FastStats Metrics " + Constants.SDK_NAME + "/" + Constants.SDK_VERSION) .timeout(Duration.ofSeconds(3)) .uri(url) .build(); - info("Sending metrics to: " + 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: " + 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: " + 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: " + statusCode + " (" + body + ")", null); + 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: " + statusCode + " (" + body + ")", null); + logger.error("Received server error response from metrics server: %s (%s)", null, statusCode, body); } else { - warn("Received unexpected response from metrics server: " + 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: " + url, null); + logger.error("Metrics submission timed out after 3 seconds: %s", null, url); } catch (final ConnectException t) { - error("Failed to connect to metrics server: " + url, null); + 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(); @@ -263,7 +262,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)); } @@ -271,7 +270,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); + logger.error("Failed to build metric data: %s", t, metric.getId()); getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); } }); @@ -280,7 +279,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; @@ -297,42 +296,24 @@ public Optional getErrorTracker() { } @Override - public Metrics.Config getConfig() { + public dev.faststats.core.Config getConfig() { return config; } @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 warn(final String message) { - if (debug) printWarning("[" + getClass().getName() + "]: " + message); - } - - protected void info(final String message) { - if (debug) printInfo("[" + getClass().getName() + "]: " + message); - } - - protected abstract void printError(String message, @Nullable Throwable throwable); - - protected abstract void printInfo(String message); - - protected abstract void printWarning(String message); - @Override public void shutdown() { getErrorTracker().ifPresent(ErrorTracker::detachErrorContext); if (executor != null) try { - info("Shutting down metrics submission"); + logger.info("Shutting down metrics submission"); executor.shutdown(); getErrorTracker().map(SimpleErrorTracker.class::cast) .filter(SimpleErrorTracker::needsFlushing) .ifPresent(ignored -> 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; } @@ -340,11 +321,9 @@ 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 Runnable flush; private @Nullable String token; - private boolean debug = false; @Override @SuppressWarnings("unchecked") @@ -367,13 +346,6 @@ public F errorTracker(final ErrorTracker tracker) { return (F) this; } - @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 { @@ -383,114 +355,6 @@ public F token(@Token final String token) throws IllegalArgumentException { this.token = token; return (F) this; } - - @Override - @SuppressWarnings("unchecked") - public F url(final URI url) { - this.url = url; - return (F) this; - } } - 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 plugin 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 plugins/faststats/config.properties. - # - # If you suspect a plugin 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/main/java/dev/faststats/core/flags/Attributes.java b/core/src/main/java/dev/faststats/core/flags/Attributes.java new file mode 100644 index 0000000..ad08cb8 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/Attributes.java @@ -0,0 +1,112 @@ +package dev.faststats.core.flags; + +import com.google.gson.JsonPrimitive; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +/** + * 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 + * @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) throws IllegalArgumentException; + + /** + * 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); + + /** + * 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); + + /** + * 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 0000000..77532a1 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlag.java @@ -0,0 +1,181 @@ +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 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. + *

+ * 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 + */ + @Contract(pure = true) + Class getTypeClass(); + + /** + * 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(); + + /** + * Supported value types for feature flags. + * + * @since 0.23.0 + */ + enum Type { + /** + * 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 + } +} 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 0000000..d576a1b --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/FeatureFlagService.java @@ -0,0 +1,159 @@ +package dev.faststats.core.flags; + +import dev.faststats.core.Token; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.Optional; + +/** + * A service for managing feature flags. + *

+ * Use one of the static {@code create} methods to construct a service instance. + * + * @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. + * + * @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); + + /** + * 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. + * + * @since 0.23.0 + */ + @Contract(mutates = "this") + void shutdown(); +} 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 0000000..a137801 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/SimpleAttributes.java @@ -0,0 +1,38 @@ +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 { + @Override + public Attributes put(final String key, final String value) { + attributes.put(key, new JsonPrimitive(value)); + return this; + } + + @Override + public Attributes put(final String key, final Number value) { + if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Value must be finite"); + attributes.put(key, new JsonPrimitive(value)); + return this; + } + + @Override + public Attributes put(final String key, final boolean value) { + attributes.put(key, new JsonPrimitive(value)); + return this; + } + + @Override + public Attributes remove(final String key) { + attributes.remove(key); + return this; + } + + @Override + public void forEachPrimitive(final BiConsumer action) { + attributes.forEach(action); + } +} 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 0000000..b7f4670 --- /dev/null +++ b/core/src/main/java/dev/faststats/core/flags/SimpleFeatureFlag.java @@ -0,0 +1,120 @@ +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; + private final Type type; + + 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; + 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); + } + + @Override + public String getId() { + return id; + } + + @Override + public Type getType() { + return type; + } + + @Override + @SuppressWarnings("unchecked") + public Class getTypeClass() { + return (Class) switch (type) { + case STRING -> String.class; + case NUMBER -> Number.class; + case BOOLEAN -> Boolean.class; + }; + } + + @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 0000000..f361279 --- /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.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.Config; +import dev.faststats.core.SimpleConfig; +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; +import java.net.URISyntaxException; +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.concurrent.CompletableFuture; +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() + .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; + private final Duration ttl; + + private final Map cache = new ConcurrentHashMap<>(); + private final Map fetchTimes = new ConcurrentHashMap<>(); + private final Map> fetchesInProgress = new ConcurrentHashMap<>(); + + SimpleFeatureFlagService( + 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; + } + + private static URI getFlagsServerUrl() { + final var property = System.getProperty("faststats.flags-server"); + if (property != null) try { + return new URI(property); + } catch (final URISyntaxException e) { + logger.error("Failed to parse flags server url: %s", e, property); + } + 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)); + } + + CompletableFuture optIn(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/opt-in"); + } + + CompletableFuture optOut(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/opt-out"); + } + + private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { + final var requestBody = new JsonObject(); + requestBody.addProperty("serverId", config.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 CompletableFuture.failedFuture(new IllegalStateException( + "Feature flag opt request failed with status " + response.statusCode() + )); + } + return fetch(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", config.serverId().toString()); + requestBody.addProperty("key", flag.getId()); + + 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(requestBody.toString())) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .timeout(Duration.ofSeconds(3)) + .uri(url.resolve("/check")) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { + 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); + } + }).whenComplete((ignored, throwable) -> fetchesInProgress.remove(flag.getId())); + } + + @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 + 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 Optional getAttributes() { + return Optional.ofNullable(attributes); + } + + @Override + public Duration getTTL() { + return ttl; + } + + @Override + public void shutdown() { + fetchesInProgress.values().forEach(fetch -> fetch.cancel(true)); + fetchesInProgress.clear(); + fetchTimes.clear(); + cache.clear(); + } +} 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 0000000..b379046 --- /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"); + } +} 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 0000000..d5fd1d9 --- /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.function.Predicate; +import java.util.logging.Level; + +public interface Logger { + void setLevel(Level level); + + boolean isLoggable(Level level); + + void setFilter(@Nullable Predicate 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 0000000..5a4a1af --- /dev/null +++ b/core/src/main/java/dev/faststats/core/internal/LoggerFactory.java @@ -0,0 +1,20 @@ +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; + } + + default Logger getLogger(final Class clazz) { + return getLogger(clazz.getName()); + } + + 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 0000000..37d96ed --- /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.function.Predicate; +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 Predicate filter) { + logger.setFilter(filter != null ? record -> filter.test(record.getLevel()) : null); + } + + @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 0000000..aab3ef8 --- /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 0000000..93362ed --- /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 612834e..e665b64 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -3,11 +3,16 @@ @NullMarked 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; + requires java.logging; requires java.net.http; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file + + uses dev.faststats.core.internal.LoggerFactory; +} diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 3d9df9e..87494bd 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,23 +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); - } - - @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); + 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/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 47d8eae..779163a 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -6,45 +6,17 @@ 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) - - .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 .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/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index ba48e45..35b443b 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; @@ -30,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(); }); @@ -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/gradle.properties b/gradle.properties index edf5dab..4433e21 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=0.22.0 +version=0.23.0 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 a215487..98a7958 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -7,32 +7,13 @@ 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) - - .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 .create(this); @@ -45,13 +26,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/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index d837202..8a6cba2 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,18 +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; 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(); } @@ -32,27 +27,12 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_type", "Hytale"); } - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.atSevere().log(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.atInfo().log(message); - } - - @Override - protected void printWarning(final String message) { - logger.atWarning().log(message); - } - 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 0000000..3b9ae5c --- /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 0000000..81d2cce --- /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 a091bad..ce31562 100644 --- a/hytale/src/main/java/module-info.java +++ b/hytale/src/main/java/module-info.java @@ -6,7 +6,10 @@ requires com.google.gson; requires dev.faststats.core; + requires java.logging; 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 0000000..9affb6b --- /dev/null +++ b/hytale/src/main/resources/META-INF/services/dev.faststats.core.internal.LoggerFactory @@ -0,0 +1 @@ +dev.faststats.hytale.logger.HytaleLoggerFactory diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 217f194..91bc04e 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 1316d89..80bec0d 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -2,20 +2,17 @@ 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; 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 +21,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 +36,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/settings.gradle.kts b/settings.gradle.kts index e67c2ef..d944992 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 3c43e7d..bb6caab 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -13,17 +13,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,22 +23,11 @@ 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 .create(pluginContainer); @@ -57,13 +37,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/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index b029e84..c47e4bf 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -2,11 +2,11 @@ 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; 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; @@ -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,13 +25,12 @@ 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 """; - private final Logger logger; private final PluginContainer plugin; @Async.Schedule @@ -42,12 +41,9 @@ 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.logger = logger; this.plugin = plugin; - startSubmitting(); } @@ -70,21 +66,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/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index b852d12..29d136d 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -11,18 +11,9 @@ 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,22 +25,11 @@ 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 .create(this); @@ -59,13 +39,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 0c64cf8..dbfb60b 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -8,13 +8,11 @@ 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; @@ -29,7 +27,6 @@ private VelocityMetricsImpl( ) throws IllegalStateException { super(factory, config); - this.logger = logger; this.server = server; this.plugin = plugin; @@ -46,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;