diff --git a/build.gradle.kts b/build.gradle.kts index 668852d..8949609 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,14 +17,16 @@ val javaVersionsOverride = mapOf( val defaultJavaVersion = 17 subprojects { - apply(plugin = "java") - apply(plugin = "java-library") + apply { + plugin("java") + plugin("java-library") + } - val example = project.name.startsWith("example") - if (example) { - apply(plugin = "com.gradleup.shadow") + val noPublish = project.name.startsWith("example") || project.name != "config" + if (noPublish) { + apply { plugin("com.gradleup.shadow") } } else { - apply(plugin = "maven-publish") + apply { plugin("maven-publish") } } group = "dev.faststats.metrics" @@ -94,7 +96,7 @@ subprojects { } afterEvaluate { - if (example) return@afterEvaluate + if (noPublish) return@afterEvaluate extensions.configure { publications.create("maven") { artifactId = project.name diff --git a/bukkit/build.gradle.kts b/bukkit/build.gradle.kts index 10ff4af..dce2e74 100644 --- a/bukkit/build.gradle.kts +++ b/bukkit/build.gradle.kts @@ -14,5 +14,6 @@ configurations.compileClasspath { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") } diff --git a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java index 2b1d9cf..299e0ce 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,56 +1,30 @@ package com.example; +import dev.faststats.ErrorTracker; +import dev.faststats.bukkit.BukkitContext; import dev.faststats.bukkit.BukkitMetrics; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.data.Metric; +import dev.faststats.data.Metric; import org.bukkit.plugin.java.JavaPlugin; -import java.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 BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE"); - 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)) + private final BukkitMetrics metrics = context.metrics() + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("game_count", gameCount::get)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .onFlush(() -> gameCount.set(0)) // Reset game count on flush + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(this); + .create(); @Override public void onEnable() { @@ -62,15 +36,6 @@ public void onDisable() { metrics.shutdown(); // safely shut down metrics submission } - public void doSomethingWrong() { - try { - // Do something that might throw an error - throw new RuntimeException("Something went wrong!"); - } catch (final Exception e) { - CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e); - } - } - public void startGame() { gameCount.incrementAndGet(); } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java new file mode 100644 index 0000000..9fa127b --- /dev/null +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -0,0 +1,39 @@ +package dev.faststats.bukkit; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.bukkit.plugin.Plugin; + +import java.nio.file.Path; + +/** + * Bukkit FastStats context. + * + * @since 0.23.0 + */ +public final class BukkitContext extends SimpleContext { + final Plugin plugin; + + public BukkitContext(final Plugin plugin, @Token final String token) { + super(SimpleConfig.read(getConfigPath(plugin)), token); + this.plugin = plugin; + } + + @Override + public BukkitMetrics.Factory metrics() { + return new BukkitMetricsImpl.Factory(this); + } + + private static Path getConfigPath(final Plugin plugin) { + return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties"); + } + + private static Path getPluginsFolder(final Plugin plugin) { + try { + return plugin.getServer().getPluginsFolder().toPath(); + } catch (final NoSuchMethodError e) { + return plugin.getDataFolder().getParentFile().toPath(); + } + } +} diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java index 810cca6..2d566d8 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java @@ -1,9 +1,10 @@ package dev.faststats.bukkit; -import dev.faststats.core.Metrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.Contract; /** * Bukkit metrics implementation. @@ -11,17 +12,6 @@ * @since 0.1.0 */ public sealed interface BukkitMetrics extends Metrics permits BukkitMetricsImpl { - /** - * Creates a new metrics factory for Bukkit. - * - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(pure = true) - static Factory factory() { - return new BukkitMetricsImpl.Factory(); - } - /** * Registers additional exception handlers on Paper-based implementations. * @@ -32,8 +22,17 @@ static Factory factory() { @Override void ready() throws IllegalPluginAccessException; - interface Factory extends Metrics.Factory { + sealed interface Factory extends Metrics.Factory permits BukkitMetricsImpl.Factory { + @Override + Factory addMetric(Metric metric) throws IllegalArgumentException; + + @Override + Factory onFlush(Runnable flush); + + @Override + Factory errorTracker(ErrorTracker tracker); + @Override - BukkitMetrics create(Plugin object) throws IllegalStateException; + BukkitMetrics create() throws IllegalStateException; } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 58e30e5..7ea95a5 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -1,16 +1,17 @@ package dev.faststats.bukkit; import com.google.gson.JsonObject; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.FastStatsContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; +import dev.faststats.data.Metric; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -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; @@ -22,8 +23,8 @@ final class BukkitMetricsImpl extends SimpleMetrics implements BukkitMetrics { @Async.Schedule @Contract(mutates = "io") @SuppressWarnings({"deprecation", "Convert2MethodRef"}) - private BukkitMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { - super(factory, config); + private BukkitMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException { + super(factory); this.plugin = plugin; final var server = plugin.getServer(); @@ -63,6 +64,11 @@ private boolean isProxyOnlineMode() { return settings.getBoolean("bungeecord") && proxies.getBoolean("bungee-cord.online-mode"); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", minecraftVersion); @@ -76,26 +82,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 { @@ -113,20 +104,33 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { + public static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory { + public Factory(final BukkitContext context) { + super(context); + } + + Factory(final FastStatsContext context) { + super(context); + } + @Override - public BukkitMetrics create(final Plugin plugin) throws IllegalStateException { - final var dataFolder = getPluginsFolder(plugin).resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); - return new BukkitMetricsImpl(this, plugin, config); + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + return (Factory) super.addMetric(metric); } - private static Path getPluginsFolder(final Plugin plugin) { - try { - return plugin.getServer().getPluginsFolder().toPath(); - } catch (final NoSuchMethodError e) { - return plugin.getDataFolder().getParentFile().toPath(); - } + @Override + public Factory onFlush(final Runnable flush) { + return (Factory) super.onFlush(flush); + } + + @Override + public Factory errorTracker(final ErrorTracker tracker) { + return (Factory) super.errorTracker(tracker); + } + + @Override + public BukkitMetrics create() throws IllegalStateException { + return new BukkitMetricsImpl(this, ((BukkitContext) context).plugin); } } } diff --git a/bukkit/src/main/java/module-info.java b/bukkit/src/main/java/module-info.java index afc285b..f74287e 100644 --- a/bukkit/src/main/java/module-info.java +++ b/bukkit/src/main/java/module-info.java @@ -5,10 +5,11 @@ exports dev.faststats.bukkit; requires com.google.gson; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; requires java.logging; requires org.bukkit; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/bungeecord/build.gradle.kts b/bungeecord/build.gradle.kts index ed20e02..7bf56fa 100644 --- a/bungeecord/build.gradle.kts +++ b/bungeecord/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("net.md-5:bungeecord-api:26.1-R0.1-SNAPSHOT") } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 37d245a..6c87185 100644 --- a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,52 +1,37 @@ package com.example; -import dev.faststats.bungee.BungeeMetrics; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.bungee.BungeeContext; +import dev.faststats.data.Metric; import net.md_5.bungee.api.plugin.Plugin; -import java.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(); + private final AtomicInteger gameCount = new AtomicInteger(); + private final BungeeContext context = new BungeeContext(this, "YOUR_TOKEN_HERE"); - // context-unaware error tracker, does not automatically track errors - public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware(); + private final Metrics metrics = context.metrics() + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - private final Metrics metrics = BungeeMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - // 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})) + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) - - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(this); + .create(); @Override 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/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java new file mode 100644 index 0000000..93e39ce --- /dev/null +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -0,0 +1,26 @@ +package dev.faststats.bungee; + +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.md_5.bungee.api.plugin.Plugin; + +/** + * BungeeCord FastStats context. + * + * @since 0.23.0 + */ +public final class BungeeContext extends SimpleContext { + final Plugin plugin; + + public BungeeContext(final Plugin plugin, @Token final String token) { + super(SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), token); + this.plugin = plugin; + } + + @Override + public Metrics.Factory metrics() { + return new BungeeMetricsImpl.Factory(this); + } +} diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java deleted file mode 100644 index 434f655..0000000 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetrics.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.faststats.bungee; - -import dev.faststats.core.Metrics; -import net.md_5.bungee.api.plugin.Plugin; -import org.jetbrains.annotations.Contract; - -/** - * BungeeCord metrics implementation. - * - * @since 0.1.0 - */ -public sealed interface BungeeMetrics extends Metrics permits BungeeMetricsImpl { - /** - * Creates a new metrics factory for BungeeCord. - * - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(pure = true) - static Factory factory() { - return new BungeeMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { - } -} diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java index 34fd29f..87542d3 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -1,35 +1,34 @@ package dev.faststats.bungee; import com.google.gson.JsonObject; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.plugin.Plugin; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import 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; +final class BungeeMetricsImpl extends SimpleMetrics { private final ProxyServer server; private final Plugin plugin; @Async.Schedule @Contract(mutates = "io") - private BungeeMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException { - super(factory, config); + private BungeeMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException { + super(factory); - this.logger = plugin.getLogger(); this.server = plugin.getProxy(); this.plugin = plugin; startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("online_mode", server.getConfig().isOnlineMode()); @@ -39,27 +38,14 @@ 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 { + public Factory(final BungeeContext context) { + super(context); + } - static final class Factory extends SimpleMetrics.Factory implements BungeeMetrics.Factory { @Override - public Metrics create(final Plugin plugin) throws IllegalStateException { - final var dataFolder = plugin.getProxy().getPluginsFolder().toPath().resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); - return new BungeeMetricsImpl(this, plugin, config); + public Metrics create() throws IllegalStateException { + return new BungeeMetricsImpl(this, ((BungeeContext) context).plugin); } } } diff --git a/bungeecord/src/main/java/module-info.java b/bungeecord/src/main/java/module-info.java index 5764d13..1f8b5d5 100644 --- a/bungeecord/src/main/java/module-info.java +++ b/bungeecord/src/main/java/module-info.java @@ -5,9 +5,10 @@ exports dev.faststats.bungee; requires com.google.gson; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/config/build.gradle.kts b/config/build.gradle.kts new file mode 100644 index 0000000..e762f00 --- /dev/null +++ b/config/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + compileOnly(project(":core")) +} diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java new file mode 100644 index 0000000..71e0b69 --- /dev/null +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -0,0 +1,139 @@ +package dev.faststats.config; + +import dev.faststats.Config; +import dev.faststats.internal.LoggerFactory; +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 java.util.logging.Level; + +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 +) implements Config { + + public static final String COMMENT = """ + FastStats (https://faststats.dev) collects anonymous usage statistics. + # This helps developers understand how their projects are used in the real world. + # + # No IP addresses, player data, or personal information is collected. + # The server ID below is randomly generated and can be regenerated at any time. + # + # Enabling metrics has no noticeable performance impact. + # 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 + """; + private static final String ONBOARDING_MESSAGE = """ + This plugin uses FastStats to collect anonymous usage statistics. + No personal or identifying information is ever collected. + To opt out, set 'enabled=false' in the metrics configuration file. + Learn more at: https://faststats.dev/info + + Since this is your first start with FastStats, metrics submission will not start + until you restart the server to allow you to opt out if you prefer."""; + + @Contract(mutates = "io") + public static SimpleConfig read(final Path file) throws RuntimeException { + 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 = 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 { + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var store = new Properties(); + + store.setProperty("serverId", serverId.toString()); + store.setProperty("enabled", Boolean.toString(enabled)); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("debug", Boolean.toString(debug)); + + store.store(writer, COMMENT); + } + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + + return new SimpleConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun); + } + + private static Optional readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return Optional.empty(); + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return Optional.of(properties); + } catch (final IOException e) { + throw new RuntimeException("Failed to read metrics config", e); + } + } + + @SuppressWarnings("PatternValidation") + public boolean preSubmissionStart() { + if (Boolean.getBoolean("faststats.first-run")) return false; + + if (firstRun()) { + var separatorLength = 0; + final var split = ONBOARDING_MESSAGE.split("\n"); + for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); + + final var logger = LoggerFactory.factory().getLogger(getClass()); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + for (final var s : split) logger.log(Level.CONFIG, s); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + + System.setProperty("faststats.first-run", "true"); + return false; + } + return true; + } +} diff --git a/config/src/main/java/module-info.java b/config/src/main/java/module-info.java new file mode 100644 index 0000000..251f400 --- /dev/null +++ b/config/src/main/java/module-info.java @@ -0,0 +1,12 @@ +import org.jspecify.annotations.NullMarked; + +@NullMarked +module dev.faststats.config { + exports dev.faststats.config; + + requires dev.faststats; + requires java.logging; + + requires static org.jetbrains.annotations; + requires static org.jspecify; +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c87a367..92b7c0a 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { compileOnlyApi("org.jetbrains:annotations:26.1.0") compileOnlyApi("org.jspecify:jspecify:1.0.0") + testImplementation(project(":config")) testImplementation("com.google.code.gson:gson:2.13.2") testImplementation("org.junit.jupiter:junit-jupiter") testImplementation(platform("org.junit:junit-bom:6.1.0-M1")) 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..b2a3785 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -0,0 +1,29 @@ +package dev.faststats.example; + +import dev.faststats.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..97bda5d --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -0,0 +1,67 @@ +package dev.faststats.example; + +import dev.faststats.Attributes; +import dev.faststats.FastStatsContext; +import dev.faststats.FeatureFlag; +import dev.faststats.FeatureFlagService; + +import java.time.Duration; +import java.time.Instant; + +public final class FeatureFlagExample { + public static final FeatureFlagService SERVICE = getContext().featureFlags( + 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 + } + }); + } + + private static FastStatsContext getContext() { + return null; + } +} 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..5d7ac3e --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/MetricTypesExample.java @@ -0,0 +1,14 @@ +package dev.faststats.example; + +import dev.faststats.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/Attributes.java b/core/src/main/java/dev/faststats/Attributes.java new file mode 100644 index 0000000..83fddca --- /dev/null +++ b/core/src/main/java/dev/faststats/Attributes.java @@ -0,0 +1,112 @@ +package dev.faststats; + +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/Config.java b/core/src/main/java/dev/faststats/Config.java new file mode 100644 index 0000000..0c9cb5d --- /dev/null +++ b/core/src/main/java/dev/faststats/Config.java @@ -0,0 +1,60 @@ +package dev.faststats; + +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/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java similarity index 95% rename from core/src/main/java/dev/faststats/core/ErrorHelper.java rename to core/src/main/java/dev/faststats/ErrorHelper.java index fe427c5..67010df 100644 --- a/core/src/main/java/dev/faststats/core/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -153,7 +153,7 @@ private static boolean isSameLoader(final ClassLoader loader, @Nullable final Th for (var i = 0; i < framesToCheck; i++) { final var frame = stackTrace[firstNonLibraryIndex + i]; - if (isLibraryClass(frame.getClassName())) continue; + if (isLibraryFrame(frame.getClassName())) continue; if (!isFromLoader(frame, loader)) return isSameLoader(loader, error.getCause(), visited); } @@ -162,17 +162,17 @@ private static boolean isSameLoader(final ClassLoader loader, @Nullable final Th private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { for (var i = 0; i < stackTrace.length; i++) { - if (!isLibraryClass(stackTrace[i].getClassName())) return i; + if (!isLibraryFrame(stackTrace[i].getClassName())) return i; } return -1; } - private static boolean isLibraryClass(final String className) { - return className.startsWith("java.") - || className.startsWith("javax.") - || className.startsWith("sun.") - || className.startsWith("com.sun.") - || className.startsWith("jdk."); + static boolean isLibraryFrame(final String frame) { + return frame.startsWith("java.") + || frame.startsWith("javax.") + || frame.startsWith("sun.") + || frame.startsWith("com.sun.") + || frame.startsWith("jdk."); } private static boolean isFromLoader(final StackTraceElement frame, final ClassLoader loader) { diff --git a/core/src/main/java/dev/faststats/core/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java similarity index 94% rename from core/src/main/java/dev/faststats/core/ErrorTracker.java rename to core/src/main/java/dev/faststats/ErrorTracker.java index 1fe010d..0873177 100644 --- a/core/src/main/java/dev/faststats/core/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import org.intellij.lang.annotations.RegExp; import org.jetbrains.annotations.Contract; @@ -11,7 +11,7 @@ /** * An error tracker. * - * @since 0.10.0 + * @since 0.23.0 */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** @@ -25,7 +25,7 @@ public sealed interface ErrorTracker permits SimpleErrorTracker { * @see #contextUnaware() * @see #trackError(String, boolean) * @see #trackError(Throwable, boolean) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(value = " -> new") static ErrorTracker contextAware() { @@ -45,9 +45,9 @@ static ErrorTracker contextAware() { * @see #contextAware() * @see #trackError(String) * @see #trackError(Throwable) - * @since 0.10.0 + * @since 0.23.0 */ - @Contract(value = " -> new") + @Contract(value = " -> new", pure = true) static ErrorTracker contextUnaware() { return new SimpleErrorTracker(); } @@ -58,7 +58,7 @@ static ErrorTracker contextUnaware() { * @param message the error message * @see #trackError(Throwable) * @see #trackError(String, boolean) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(String message); @@ -68,7 +68,7 @@ static ErrorTracker contextUnaware() { * * @param error the error * @see #trackError(Throwable, boolean) - * @since 0.10.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(Throwable error); @@ -81,7 +81,7 @@ static ErrorTracker contextUnaware() { * @param message the error message * @param handled whether the error was handled * @see #trackError(Throwable, boolean) - * @since 0.20.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(String message, boolean handled); @@ -93,7 +93,7 @@ static ErrorTracker contextUnaware() { * * @param error the error * @param handled whether the error was handled - * @since 0.20.0 + * @since 0.23.0 */ @Contract(mutates = "this") void trackError(Throwable error, boolean handled); @@ -106,7 +106,7 @@ static ErrorTracker contextUnaware() { * * @param type the error type * @return the error tracker - * @since 0.22.0 + * @since 0.23.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Class type); @@ -125,7 +125,7 @@ static ErrorTracker contextUnaware() { * * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Pattern pattern); @@ -138,7 +138,7 @@ static ErrorTracker contextUnaware() { * @param pattern the regex pattern string to match against error messages * @return the error tracker * @see #ignoreError(Pattern) - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_ -> this", mutates = "this") default ErrorTracker ignoreError(@RegExp final String pattern) { @@ -156,7 +156,7 @@ default ErrorTracker ignoreError(@RegExp final String pattern) { * @param type the error type * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker ignoreError(Class type, Pattern pattern); @@ -170,7 +170,7 @@ default ErrorTracker ignoreError(@RegExp final String pattern) { * @param pattern the regex pattern string to match against error messages * @return the error tracker * @see #ignoreError(Class, Pattern) - * @since 0.21.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker ignoreError(final Class type, @RegExp final String pattern) { @@ -187,7 +187,7 @@ default ErrorTracker ignoreError(final Class type, @RegExp * @param replacement the replacement string * @return the error tracker * @see java.util.regex.Matcher#replaceAll(String) - * @since 0.22.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker anonymize(Pattern pattern, String replacement); @@ -200,7 +200,7 @@ default ErrorTracker ignoreError(final Class type, @RegExp * @return the error tracker * @see #anonymize(Pattern, String) * @see java.util.regex.Matcher#replaceAll(String) - * @since 0.22.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker anonymize(@RegExp final String pattern, final String replacement) { @@ -214,7 +214,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * * @param loader the class loader * @throws IllegalStateException if the error context is already attached - * @since 0.10.0 + * @since 0.23.0 */ void attachErrorContext(@Nullable ClassLoader loader) throws IllegalStateException; @@ -227,7 +227,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * This should be called during shutdown to prevent {@link BootstrapMethodError} * when the provider's JAR file is closed. * - * @since 0.13.0 + * @since 0.23.0 */ void detachErrorContext(); @@ -235,7 +235,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * Returns whether an error context is attached. * * @return whether an error context is attached - * @since 0.13.0 + * @since 0.23.0 */ boolean isContextAttached(); @@ -245,7 +245,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * The purpose of this handler is to allow custom error handling like logging. * * @param errorEvent the error event handler - * @since 0.11.0 + * @since 0.23.0 */ @Contract(mutates = "this") void setContextErrorHandler(@Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent); @@ -254,7 +254,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * Returns the error event handler which will be called when an error is tracked automatically. * * @return the error event handler - * @since 0.11.0 + * @since 0.23.0 */ @Contract(pure = true) Optional> getContextErrorHandler(); @@ -265,7 +265,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac * @param loader the class loader * @param error the error * @return whether the error occurred in the same class loader - * @since 0.14.0 + * @since 0.23.0 */ @Contract(pure = true) static boolean isSameLoader(final ClassLoader loader, final Throwable error) { diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java new file mode 100644 index 0000000..5fadbba --- /dev/null +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -0,0 +1,85 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +import java.time.Duration; + +/** + * Shared FastStats context. + *

+ * Platform-specific contexts should extend this class to provide a shared + * configuration, token, and metrics factory for their environment. + * + * @since 0.23.0 + */ +public interface FastStatsContext { + /** + * Get the metrics configuration shared by services created from this context. + * + * @return the shared configuration + * @since 0.23.0 + */ + @Contract(pure = true) + Config getConfig(); + + /** + * Get the token shared by services created from this context. + * + * @return the shared token + * @since 0.23.0 + */ + @Token + @Contract(pure = true) + String getToken(); + + /** + * Creates a new platform metrics factory bound to this context. + * + * @return a new platform metrics factory + * @since 0.23.0 + */ + @Contract(value = "-> new", pure = true) + Metrics.Factory metrics(); + + /** + * Creates a new feature flag service backed by this context token. + * + * @return the feature flag service + * @since 0.23.0 + */ + @Contract(value = "-> new", pure = true) + FeatureFlagService featureFlags(); + + /** + * Creates a new feature flag service backed by this context token and attributes. + * + * @param attributes the global targeting attributes + * @return the feature flag service + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + FeatureFlagService featureFlags(final Attributes attributes); + + /** + * Creates a new feature flag service backed by this context token, and TTL. + * + * @param ttl the cache time-to-live for resolved flag values + * @return the feature flag service + * @throws IllegalArgumentException if the TTL is negative + * @since 0.23.0 + */ + @Contract(value = "_ -> new", pure = true) + FeatureFlagService featureFlags(final Duration ttl); + + /** + * Creates a new feature flag service backed by this context token, attributes, and TTL. + * + * @param attributes the global targeting attributes + * @param ttl the cache time-to-live for resolved flag values + * @return the feature flag service + * @throws IllegalArgumentException if the TTL is negative + * @since 0.23.0 + */ + @Contract(value = "_, _ -> new", pure = true) + FeatureFlagService featureFlags(final Attributes attributes, final Duration ttl); +} diff --git a/core/src/main/java/dev/faststats/FeatureFlag.java b/core/src/main/java/dev/faststats/FeatureFlag.java new file mode 100644 index 0000000..bc2e703 --- /dev/null +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -0,0 +1,181 @@ +package dev.faststats; + +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/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java new file mode 100644 index 0000000..73cb2fc --- /dev/null +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -0,0 +1,112 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +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 { + /** + * 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/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java new file mode 100644 index 0000000..f1372e3 --- /dev/null +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -0,0 +1,123 @@ +package dev.faststats; + +import dev.faststats.data.Metric; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; + +import java.util.Optional; + +/** + * Metrics interface. + * + * @since 0.23.0 + */ +public interface Metrics { + /** + * Get the token used to authenticate with the metrics server and identify the project. + * + * @return the metrics token + * @since 0.23.0 + */ + @Token + @Contract(pure = true) + String getToken(); + + /** + * Get the error tracker for this metrics instance. + * + * @return the error tracker + * @since 0.23.0 + */ + @Contract(pure = true) + Optional getErrorTracker(); + + /** + * Get the metrics configuration. + * + * @return the metrics configuration + * @since 0.23.0 + */ + @Contract(pure = true) + Config getConfig(); + + /** + * Performs additional post-startup tasks. + *

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

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

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

+ * If {@link Config#additionalMetrics()} is disabled, the metric will not be submitted. + * + * @param metric the metric to add + * @return the metrics factory + * @throws IllegalArgumentException if the metric is already added + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory addMetric(Metric metric) throws IllegalArgumentException; + + /** + * Sets the flush callback for this metrics instance. + *

+ * This callback will be invoked when the metrics have been submitted to, and accepted by, the metrics server. + * + * @param flush the flush callback + * @return the metrics factory + * @since 0.23.0 + */ + @Contract(mutates = "this") + Factory onFlush(Runnable flush); + + /** + * Sets the error tracker for this metrics instance. + *

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

+ * Metrics submission will start automatically. + * + * @return the metrics instance + * @throws IllegalStateException if the token is not specified + * @since 0.23.0 + */ + @Async.Schedule + @Contract(value = " -> new", mutates = "io") + Metrics create() throws IllegalStateException; + } + +} diff --git a/core/src/main/java/dev/faststats/core/MurmurHash3.java b/core/src/main/java/dev/faststats/MurmurHash3.java similarity index 96% rename from core/src/main/java/dev/faststats/core/MurmurHash3.java rename to core/src/main/java/dev/faststats/MurmurHash3.java index 157b765..a78c26b 100644 --- a/core/src/main/java/dev/faststats/core/MurmurHash3.java +++ b/core/src/main/java/dev/faststats/MurmurHash3.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonObject; import org.jetbrains.annotations.Contract; @@ -21,8 +21,8 @@ *

*/ final class MurmurHash3 { - public static String hash(final JsonObject object) { - final var hash = MurmurHash3.hash(object.toString()); + public static String hash(final String data) { + final var hash = MurmurHash3.hash128(data); return Long.toHexString(hash[0]) + Long.toHexString(hash[1]); } @@ -38,7 +38,7 @@ public static String hash(final JsonObject object) { * @see MurmurHash on Wikipedia */ @Contract(value = "_ -> new", pure = true) - private static long[] hash(final String data) { + private static long[] hash128(final String data) { final var bytes = data.getBytes(StandardCharsets.UTF_8); var h1 = 0L; var h2 = 0L; diff --git a/core/src/main/java/dev/faststats/SimpleAttributes.java b/core/src/main/java/dev/faststats/SimpleAttributes.java new file mode 100644 index 0000000..8413a4e --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleAttributes.java @@ -0,0 +1,38 @@ +package dev.faststats; + +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/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java new file mode 100644 index 0000000..7aa5476 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -0,0 +1,58 @@ +package dev.faststats; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; + +@ApiStatus.Internal +public abstract class SimpleContext implements FastStatsContext { + private final Config config; + private final @Token String token; + + /** + * Creates a new context that stores the shared configuration and token for all FastStats services. + * + * @param config the shared configuration + * @param token the FastStats project token + * @throws IllegalArgumentException if the token is invalid + * @since 0.23.0 + */ + protected SimpleContext(final Config config, @Token final String token) throws IllegalArgumentException { + if (!token.matches(Token.PATTERN)) { + throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); + } + this.config = config; + this.token = token; + } + + @Override + public final Config getConfig() { + return config; + } + + @Override + public final @Token String getToken() { + return token; + } + + @Override + public final FeatureFlagService featureFlags() { + return new SimpleFeatureFlagService(config, token, null, Duration.ofMinutes(5)); + } + + @Override + public final FeatureFlagService featureFlags(final Attributes attributes) { + return new SimpleFeatureFlagService(config, token, attributes, Duration.ofMinutes(5)); + } + + @Override + public final FeatureFlagService featureFlags(final Duration ttl) { + return new SimpleFeatureFlagService(config, token, null, ttl); + } + + @Override + public final FeatureFlagService featureFlags(@Nullable final Attributes attributes, final Duration ttl) { + return new SimpleFeatureFlagService(config, token, attributes, ttl); + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java similarity index 97% rename from core/src/main/java/dev/faststats/core/SimpleErrorTracker.java rename to core/src/main/java/dev/faststats/SimpleErrorTracker.java index 0b2b757..66b24b3 100644 --- a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -59,11 +59,11 @@ public void trackError(final String message, final boolean handled) { public void trackError(final Throwable error, final boolean handled) { try { if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; - final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - final var hashed = MurmurHash3.hash(compiled); + final var hashed = StackTraceFingerprint.hash(error); // todo: report duplicate errors with different messages if (collected.compute(hashed, (k, v) -> { return v == null ? 1 : v + 1; }) > 1) return; + final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); reports.put(hashed, compiled); } catch (final NoClassDefFoundError ignored) { } diff --git a/core/src/main/java/dev/faststats/SimpleFeatureFlag.java b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java new file mode 100644 index 0000000..1106769 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -0,0 +1,120 @@ +package dev.faststats; + +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/SimpleFeatureFlagService.java b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java new file mode 100644 index 0000000..5e311d0 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -0,0 +1,226 @@ +package dev.faststats; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import dev.faststats.internal.Logger; +import dev.faststats.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.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +final class SimpleFeatureFlagService implements FeatureFlagService { + private static final 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 UUID serverId; + + 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 Config config, + 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; + this.serverId = config.serverId(); + } + + 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", 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", 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/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java new file mode 100644 index 0000000..f979558 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -0,0 +1,316 @@ +package dev.faststats; + +import com.google.gson.JsonObject; +import dev.faststats.data.Metric; +import dev.faststats.internal.Constants; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.MustBeInvokedByOverriders; +import org.jetbrains.annotations.VisibleForTesting; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.ConnectException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.zip.GZIPOutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApiStatus.Internal +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; + + @Contract(mutates = "io") + @SuppressWarnings("PatternValidation") + protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { + this.config = config; + this.token = factory.context.getToken(); + this.metrics = config.additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); + final var debug = config.debug() || Boolean.getBoolean("faststats.debug"); + this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); + this.tracker = config.errorTracking() ? factory.tracker : null; + this.flush = factory.flush; + this.url = getMetricsServerUrl(); + } + + @Contract(mutates = "io") + protected SimpleMetrics(final Factory factory) throws IllegalStateException { + this(factory, factory.context.getConfig()); + } + + private URI getMetricsServerUrl() { + final var property = System.getProperty("faststats.metrics-server"); + if (property != null) try { + 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"); + } + + @VisibleForTesting + protected SimpleMetrics( + final Config config, + final Set> metrics, + @Token final String token, + @Nullable final ErrorTracker tracker, + @Nullable final Runnable flush, + final URI url, + final boolean debug + ) { + this.metrics = config.additionalMetrics() ? Set.copyOf(metrics) : Set.of(); + this.config = config; + this.logger.setLevel(debug ? Level.ALL : Level.OFF); + this.token = token; + this.tracker = tracker; + this.flush = flush; + this.url = url; + } + + protected long getInitialDelay() { + return TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)); + } + + protected long getPeriod() { + return TimeUnit.MINUTES.toMillis(30); + } + + @Async.Schedule + @MustBeInvokedByOverriders + protected void startSubmitting() { + startSubmitting(getInitialDelay(), getPeriod(), TimeUnit.MILLISECONDS); + } + + protected abstract boolean preSubmissionStart(); + + 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) { + logger.warn("Metrics disabled, not starting submission"); + return; + } + + if (isSubmitting()) { + logger.warn("Metrics already submitting, not starting again"); + return; + } + + this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> { // todo: SINGLE THREAD??? what was i smoking? + final var thread = new Thread(runnable, "metrics-submitter"); + thread.setDaemon(true); + return thread; + }); + + logger.info("Starting metrics submission"); + executor.scheduleAtFixedRate(this::submit, Math.max(0, initialDelay), Math.max(1000, period), unit); + } + + protected boolean isSubmitting() { + return executor != null && !executor.isShutdown(); + } + + public boolean submit() { + try { + return submitNow(); + } catch (final Throwable t) { + logger.error("Failed to submit metrics", t); + return false; + } + } + + private boolean submitNow() throws IOException { + final var data = createData().toString(); + final var bytes = data.getBytes(UTF_8); + + logger.info("Uncompressed data: %s", data); + + try (final var byteOutput = new ByteArrayOutputStream(); + final var output = new GZIPOutputStream(byteOutput)) { + + output.write(bytes); + output.finish(); + + final var compressed = byteOutput.toByteArray(); + logger.info("Compressed size: %s bytes", compressed.length); + + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) + .header("Content-Encoding", "gzip") + .header("Content-Type", "application/octet-stream") + .header("Authorization", "Bearer " + token) + .header("User-Agent", "FastStats Metrics " + Constants.SDK_NAME + "/" + Constants.SDK_VERSION) + .timeout(Duration.ofSeconds(3)) + .uri(url) + .build(); + + logger.info("Sending metrics to: %s", url); + try { + final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); + final var statusCode = response.statusCode(); + final var body = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + logger.info("Metrics submitted with status code: %s (%s)", statusCode, body); + getErrorTracker().map(SimpleErrorTracker.class::cast).ifPresent(SimpleErrorTracker::clear); + if (flush != null) flush.run(); + return true; + } else if (statusCode >= 300 && statusCode < 400) { + logger.warn("Received redirect response from metrics server: %s (%s)", statusCode, body); + } else if (statusCode >= 400 && statusCode < 500) { + logger.error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); + } else if (statusCode >= 500 && statusCode < 600) { + logger.error("Received server error response from metrics server: %s (%s)", null, statusCode, body); + } else { + logger.warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); + } + } catch (final HttpConnectTimeoutException t) { + logger.error("Metrics submission timed out after 3 seconds: %s", null, url); + } catch (final ConnectException t) { + logger.error("Failed to connect to metrics server: %s", null, url); + } catch (final Throwable t) { + logger.error("Failed to submit metrics", t); + } + return false; + } + } + + 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(); + final var metrics = new JsonObject(); + + metrics.addProperty("core_count", coreCount); + metrics.addProperty("java_vendor", javaVendor); + metrics.addProperty("java_version", javaVersion); + metrics.addProperty("os_arch", osArch); + metrics.addProperty("os_name", osName); + metrics.addProperty("os_version", osVersion); + + try { + appendDefaultData(metrics); + } catch (final Throwable t) { + logger.error("Failed to append default data", t); + getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); + } + + this.metrics.forEach(metric -> { + try { + metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); + } catch (final Throwable t) { + logger.error("Failed to build metric data: %s", t, metric.getId()); + getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); + } + }); + + data.addProperty("identifier", config.serverId().toString()); + data.add("data", metrics); + + getErrorTracker().map(SimpleErrorTracker.class::cast) + .map(tracker -> tracker.getData(Constants.BUILD_ID)) + .filter(errors -> !errors.isEmpty()) + .ifPresent(errors -> data.add("errors", errors)); + return data; + } + + @Override + public @Token String getToken() { + return token; + } + + @Override + public Optional getErrorTracker() { + return Optional.ofNullable(tracker); + } + + @Override + public dev.faststats.Config getConfig() { + return config; + } + + @Contract(mutates = "param1") + protected abstract void appendDefaultData(JsonObject metrics); + + @Override + public void shutdown() { + getErrorTracker().ifPresent(ErrorTracker::detachErrorContext); + if (executor != null) try { + logger.info("Shutting down metrics submission"); + executor.shutdown(); + getErrorTracker().map(SimpleErrorTracker.class::cast) + .filter(SimpleErrorTracker::needsFlushing) + .ifPresent(ignored -> submit()); + } catch (final Throwable t) { + logger.error("Failed to submit metrics on shutdown", t); + } finally { + executor = null; + } + } + + public abstract static class Factory implements Metrics.Factory { + private final Set> metrics = new HashSet<>(0); + protected final FastStatsContext context; + private @Nullable ErrorTracker tracker; + private @Nullable Runnable flush; + + protected Factory(final FastStatsContext context) { + this.context = context; + } + + @Override + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + if (!metrics.add(metric)) throw new IllegalArgumentException("Metric already added: " + metric.getId()); + return this; + } + + @Override + public Factory onFlush(final Runnable flush) { + this.flush = flush; + return this; + } + + @Override + public Factory errorTracker(final ErrorTracker tracker) { + this.tracker = tracker; + return this; + } + } +} diff --git a/core/src/main/java/dev/faststats/StackTraceFingerprint.java b/core/src/main/java/dev/faststats/StackTraceFingerprint.java new file mode 100644 index 0000000..d86258e --- /dev/null +++ b/core/src/main/java/dev/faststats/StackTraceFingerprint.java @@ -0,0 +1,41 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +final class StackTraceFingerprint { + private static final int STACK_TRACE_LIMIT = 5; + + private StackTraceFingerprint() { + } + + public static String hash(final Throwable error) { + return MurmurHash3.hash(normalize(error)); + } + + public static String normalize(final Throwable error) { + final var visited = Collections.newSetFromMap(new IdentityHashMap<>()); + final var builder = new StringBuilder(); + append(error, builder, visited); + return builder.toString(); + } + + private static void append(@Nullable final Throwable error, final StringBuilder builder, final Set visited) { + if (error == null || !visited.add(error)) return; + + if (!builder.isEmpty()) builder.append('\n'); + builder.append("e").append(error.getClass().getName()); + + var frames = 0; + for (final var element : error.getStackTrace()) { + if (ErrorHelper.isLibraryFrame(element.getClassName())) continue; + builder.append("\nf").append(element.getClassName()).append('.').append(element.getMethodName()); + if (++frames >= STACK_TRACE_LIMIT) break; + } + + append(error.getCause(), builder, visited); + } +} diff --git a/core/src/main/java/dev/faststats/core/Token.java b/core/src/main/java/dev/faststats/Token.java similarity index 93% rename from core/src/main/java/dev/faststats/core/Token.java rename to core/src/main/java/dev/faststats/Token.java index 35ed3d3..6eb09d9 100644 --- a/core/src/main/java/dev/faststats/core/Token.java +++ b/core/src/main/java/dev/faststats/Token.java @@ -1,4 +1,4 @@ -package dev.faststats.core; +package dev.faststats; import org.intellij.lang.annotations.Pattern; import org.jetbrains.annotations.NonNls; @@ -15,7 +15,7 @@ /** * An annotation to mark a token. * - * @since 0.1.0 + * @since 0.23.0 */ @NonNls @Pattern(Token.PATTERN) diff --git a/core/src/main/java/dev/faststats/core/Metrics.java b/core/src/main/java/dev/faststats/core/Metrics.java deleted file mode 100644 index 7a60ede..0000000 --- a/core/src/main/java/dev/faststats/core/Metrics.java +++ /dev/null @@ -1,220 +0,0 @@ -package dev.faststats.core; - -import dev.faststats.core.data.Metric; -import org.jetbrains.annotations.Async; -import org.jetbrains.annotations.Contract; - -import java.net.URI; -import java.util.Optional; -import java.util.UUID; - -/** - * Metrics interface. - * - * @since 0.1.0 - */ -public interface Metrics { - /** - * Get the token used to authenticate with the metrics server and identify the project. - * - * @return the metrics token - * @since 0.1.0 - */ - @Token - @Contract(pure = true) - String getToken(); - - /** - * Get the error tracker for this metrics instance. - * - * @return the error tracker - * @since 0.10.0 - */ - @Contract(pure = true) - Optional getErrorTracker(); - - /** - * Get the metrics configuration. - * - * @return the metrics configuration - * @since 0.1.0 - */ - @Contract(pure = true) - Config getConfig(); - - /** - * Performs additional post-startup tasks. - *

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

- * No-op in most implementations. - * - * @since 0.14.0 - */ - default void ready() { - } - - /** - * Safely shuts down the metrics submission. - *

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

- * If {@link Config#additionalMetrics()} is disabled, the metric will not be submitted. - * - * @param metric the metric to add - * @return the metrics factory - * @throws IllegalArgumentException if the metric is already added - * @since 0.16.0 - */ - @Contract(mutates = "this") - F addMetric(Metric metric) throws IllegalArgumentException; - - /** - * Sets the flush callback for this metrics instance. - *

- * This callback will be invoked when the metrics have been submitted to, and accepted by, the metrics server. - * - * @param flush the flush callback - * @return the metrics factory - * @since 0.15.0 - */ - @Contract(mutates = "this") - F onFlush(Runnable flush); - - /** - * Sets the error tracker for this metrics instance. - *

- * If {@link Config#errorTracking()} is disabled, no errors will be submitted. - * - * @param tracker the error tracker - * @return the metrics factory - * @since 0.10.0 - */ - @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. - *

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

- * Metrics submission will start automatically. - * - * @param object a required object as defined by the implementation - * @return the metrics instance - * @throws IllegalStateException if the token is not specified - * @see #token(String) - * @since 0.1.0 - */ - @Async.Schedule - @Contract(value = "_ -> new", mutates = "io") - 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/SimpleMetrics.java b/core/src/main/java/dev/faststats/core/SimpleMetrics.java deleted file mode 100644 index 7dbfd5a..0000000 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ /dev/null @@ -1,496 +0,0 @@ -package dev.faststats.core; - -import com.google.gson.JsonObject; -import dev.faststats.core.data.Metric; -import org.jetbrains.annotations.Async; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.MustBeInvokedByOverriders; -import org.jetbrains.annotations.VisibleForTesting; -import org.jspecify.annotations.Nullable; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.net.ConnectException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpConnectTimeoutException; -import java.net.http.HttpRequest; -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.zip.GZIPOutputStream; - -import static java.nio.charset.StandardCharsets.UTF_8; - -public abstract class SimpleMetrics implements Metrics { - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(3)) - .version(HttpClient.Version.HTTP_1_1) - .build(); - private @Nullable ScheduledExecutorService executor = null; - - 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") - protected SimpleMetrics(final Factory factory, final Config config) throws IllegalStateException { - if (factory.token == null) throw new IllegalStateException("Token must be specified"); - - this.config = config; - this.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.flush = factory.flush; - this.url = factory.url; - } - - @Contract(mutates = "io") - protected SimpleMetrics(final Factory factory, final Path config) throws IllegalStateException { - this(factory, Config.read(config)); - } - - @VisibleForTesting - protected SimpleMetrics( - final Config config, - final Set> metrics, - @Token final String token, - @Nullable final ErrorTracker tracker, - @Nullable final Runnable flush, - final URI url, - final boolean debug - ) { - if (!token.matches(Token.PATTERN)) { - throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } - - this.metrics = config.additionalMetrics ? Set.copyOf(metrics) : Set.of(); - this.config = config; - this.debug = debug; - this.token = token; - this.tracker = tracker; - this.flush = flush; - this.url = url; - } - - protected String getOnboardingMessage() { - return """ - This plugin uses FastStats to collect anonymous usage statistics. - No personal or identifying information is ever collected. - To opt out, set 'enabled=false' in the metrics configuration file. - Learn more at: https://faststats.dev/info - - Since this is your first start with FastStats, metrics submission will not start - until you restart the server to allow you to opt out if you prefer."""; - } - - protected long getInitialDelay() { - return TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)); - } - - protected long getPeriod() { - return TimeUnit.MINUTES.toMillis(30); - } - - @Async.Schedule - @MustBeInvokedByOverriders - protected void startSubmitting() { - startSubmitting(getInitialDelay(), getPeriod(), TimeUnit.MILLISECONDS); - } - - private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) { - if (Boolean.getBoolean("faststats.first-run")) { - info("Skipping metrics submission due to first-run flag"); - return; - } - - 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)); - - System.setProperty("faststats.first-run", "true"); - if (!config.externallyManaged()) return; - } - - final var enabled = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); - - if (!config.enabled() || !enabled) { - warn("Metrics disabled, not starting submission"); - return; - } - - if (isSubmitting()) { - warn("Metrics already submitting, not starting again"); - return; - } - - this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> { - final var thread = new Thread(runnable, "metrics-submitter"); - thread.setDaemon(true); - return thread; - }); - - info("Starting metrics submission"); - executor.scheduleAtFixedRate(this::submit, Math.max(0, initialDelay), Math.max(1000, period), unit); - } - - protected boolean isSubmitting() { - return executor != null && !executor.isShutdown(); - } - - public boolean submit() { - try { - return submitNow(); - } catch (final Throwable t) { - error("Failed to submit metrics", t); - return false; - } - } - - private boolean submitNow() throws IOException { - final var data = createData().toString(); - final var bytes = data.getBytes(UTF_8); - - info("Uncompressed data: " + data); - - try (final var byteOutput = new ByteArrayOutputStream(); - final var output = new GZIPOutputStream(byteOutput)) { - - output.write(bytes); - output.finish(); - - final var compressed = byteOutput.toByteArray(); - info("Compressed size: " + compressed.length + " bytes"); - - 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) - .timeout(Duration.ofSeconds(3)) - .uri(url) - .build(); - - info("Sending metrics to: " + 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 + ")"); - 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 + ")"); - } else if (statusCode >= 400 && statusCode < 500) { - error("Submitted invalid request to metrics server: " + statusCode + " (" + body + ")", null); - } else if (statusCode >= 500 && statusCode < 600) { - error("Received server error response from metrics server: " + statusCode + " (" + body + ")", null); - } else { - warn("Received unexpected response from metrics server: " + statusCode + " (" + body + ")"); - } - } catch (final HttpConnectTimeoutException t) { - error("Metrics submission timed out after 3 seconds: " + url, null); - } catch (final ConnectException t) { - error("Failed to connect to metrics server: " + url, null); - } catch (final Throwable t) { - 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(); - - protected JsonObject createData() { - final var data = new JsonObject(); - final var metrics = new JsonObject(); - - metrics.addProperty("core_count", coreCount); - metrics.addProperty("java_vendor", javaVendor); - metrics.addProperty("java_version", javaVersion); - metrics.addProperty("os_arch", osArch); - metrics.addProperty("os_name", osName); - metrics.addProperty("os_version", osVersion); - - try { - appendDefaultData(metrics); - } catch (final Throwable t) { - error("Failed to append default data", t); - getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); - } - - this.metrics.forEach(metric -> { - try { - metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); - } catch (final Throwable t) { - error("Failed to build metric data: " + metric.getId(), t); - getErrorTracker().ifPresent(tracker -> tracker.trackError(t)); - } - }); - - data.addProperty("identifier", config.serverId().toString()); - data.add("data", metrics); - - getErrorTracker().map(SimpleErrorTracker.class::cast) - .map(tracker -> tracker.getData(BUILD_ID)) - .filter(errors -> !errors.isEmpty()) - .ifPresent(errors -> data.add("errors", errors)); - return data; - } - - @Override - public @Token String getToken() { - return token; - } - - @Override - public Optional getErrorTracker() { - return Optional.ofNullable(tracker); - } - - @Override - public Metrics.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"); - 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); - } finally { - executor = null; - } - } - - 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") - public F addMetric(final Metric metric) throws IllegalArgumentException { - if (!metrics.add(metric)) throw new IllegalArgumentException("Metric already added: " + metric.getId()); - return (F) this; - } - - @Override - @SuppressWarnings("unchecked") - public F onFlush(final Runnable flush) { - this.flush = flush; - return (F) this; - } - - @Override - @SuppressWarnings("unchecked") - public F errorTracker(final ErrorTracker tracker) { - this.tracker = 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 { - if (!token.matches(Token.PATTERN)) { - throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); - } - 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/data/ArrayMetric.java b/core/src/main/java/dev/faststats/data/ArrayMetric.java similarity index 96% rename from core/src/main/java/dev/faststats/core/data/ArrayMetric.java rename to core/src/main/java/dev/faststats/data/ArrayMetric.java index bcba91a..376fff9 100644 --- a/core/src/main/java/dev/faststats/core/data/ArrayMetric.java +++ b/core/src/main/java/dev/faststats/data/ArrayMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonArray; import com.google.gson.JsonElement; diff --git a/core/src/main/java/dev/faststats/core/data/Metric.java b/core/src/main/java/dev/faststats/data/Metric.java similarity index 95% rename from core/src/main/java/dev/faststats/core/data/Metric.java rename to core/src/main/java/dev/faststats/data/Metric.java index 7fb753c..5b18918 100644 --- a/core/src/main/java/dev/faststats/core/data/Metric.java +++ b/core/src/main/java/dev/faststats/data/Metric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import org.jetbrains.annotations.Contract; @@ -11,14 +11,14 @@ * A metric. * * @param the metric data type - * @since 0.16.0 + * @since 0.23.0 */ public interface Metric { /** * Get the source id. * * @return the source id - * @since 0.16.0 + * @since 0.23.0 */ @SourceId @Contract(pure = true) @@ -30,7 +30,7 @@ public interface Metric { * @return an optional containing the metric data * @throws Exception if unable to compute the metric data * @implSpec The implementation must be thread-safe and pure (i.e. not modify any shared state). - * @since 0.16.0 + * @since 0.23.0 */ @Contract(pure = true) Optional compute() throws Exception; @@ -43,7 +43,7 @@ public interface Metric { * @implSpec The implementation must call {@link #compute()} to get the metric data * and follow the same thread-safety and pureness requirements. * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(pure = true) Optional getData() throws Exception; @@ -57,7 +57,7 @@ public interface Metric { * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric stringArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -73,7 +73,7 @@ static Metric stringArray(@SourceId final String id, final Callable booleanArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -89,7 +89,7 @@ static Metric booleanArray(@SourceId final String id, final Callable< * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric numberArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -105,7 +105,7 @@ static Metric numberArray(@SourceId final String id, final Callable bool(@SourceId final String id, final Callable<@Nullable Boolean> callable) throws IllegalArgumentException { @@ -121,7 +121,7 @@ static Metric bool(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric string(@SourceId final String id, final Callable<@Nullable String> callable) throws IllegalArgumentException { @@ -137,7 +137,7 @@ static Metric string(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.23.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric number(@SourceId final String id, final Callable<@Nullable Number> callable) throws IllegalArgumentException { diff --git a/core/src/main/java/dev/faststats/core/data/SimpleMetric.java b/core/src/main/java/dev/faststats/data/SimpleMetric.java similarity index 97% rename from core/src/main/java/dev/faststats/core/data/SimpleMetric.java rename to core/src/main/java/dev/faststats/data/SimpleMetric.java index 8d635e4..0012354 100644 --- a/core/src/main/java/dev/faststats/core/data/SimpleMetric.java +++ b/core/src/main/java/dev/faststats/data/SimpleMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/dev/faststats/core/data/SingleValueMetric.java b/core/src/main/java/dev/faststats/data/SingleValueMetric.java similarity index 95% rename from core/src/main/java/dev/faststats/core/data/SingleValueMetric.java rename to core/src/main/java/dev/faststats/data/SingleValueMetric.java index 458679c..8cf335a 100644 --- a/core/src/main/java/dev/faststats/core/data/SingleValueMetric.java +++ b/core/src/main/java/dev/faststats/data/SingleValueMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; diff --git a/core/src/main/java/dev/faststats/core/data/SourceId.java b/core/src/main/java/dev/faststats/data/SourceId.java similarity index 93% rename from core/src/main/java/dev/faststats/core/data/SourceId.java rename to core/src/main/java/dev/faststats/data/SourceId.java index c7295ec..f702b76 100644 --- a/core/src/main/java/dev/faststats/core/data/SourceId.java +++ b/core/src/main/java/dev/faststats/data/SourceId.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import org.intellij.lang.annotations.Pattern; import org.jetbrains.annotations.NonNls; @@ -15,7 +15,7 @@ /** * An annotation to mark a source id. * - * @since 0.16.0 + * @since 0.23.0 */ @NonNls @Pattern(SourceId.PATTERN) diff --git a/core/src/main/java/dev/faststats/internal/Constants.java b/core/src/main/java/dev/faststats/internal/Constants.java new file mode 100644 index 0000000..065c307 --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/Constants.java @@ -0,0 +1,23 @@ +package dev.faststats.internal; + +import dev.faststats.SimpleMetrics; + +import java.io.IOException; +import java.util.Properties; + +public final class Constants { + public static final String SDK_NAME; + public static final String SDK_VERSION; + public static final String BUILD_ID; + + static { + final var properties = new Properties(); + try (final var stream = SimpleMetrics.class.getClassLoader().getResourceAsStream("/META-INF/faststats.properties")) { + if (stream != null) properties.load(stream); + } catch (final IOException ignored) { + } + SDK_NAME = properties.getProperty("name", "unknown"); + SDK_VERSION = properties.getProperty("version", "unknown"); + BUILD_ID = properties.getProperty("build-id", "unknown"); + } +} diff --git a/core/src/main/java/dev/faststats/internal/Logger.java b/core/src/main/java/dev/faststats/internal/Logger.java new file mode 100644 index 0000000..3f5f232 --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/Logger.java @@ -0,0 +1,27 @@ +package dev.faststats.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/internal/LoggerFactory.java b/core/src/main/java/dev/faststats/internal/LoggerFactory.java new file mode 100644 index 0000000..567bd5d --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/LoggerFactory.java @@ -0,0 +1,20 @@ +package dev.faststats.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/internal/SimpleLogger.java b/core/src/main/java/dev/faststats/internal/SimpleLogger.java new file mode 100644 index 0000000..d16cc69 --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/SimpleLogger.java @@ -0,0 +1,45 @@ +package dev.faststats.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/internal/SimpleLoggerFactory.java b/core/src/main/java/dev/faststats/internal/SimpleLoggerFactory.java new file mode 100644 index 0000000..dcb8b9c --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/SimpleLoggerFactory.java @@ -0,0 +1,8 @@ +package dev.faststats.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/internal/package-info.java b/core/src/main/java/dev/faststats/internal/package-info.java new file mode 100644 index 0000000..dfe3b56 --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/package-info.java @@ -0,0 +1,4 @@ +@ApiStatus.Internal +package dev.faststats.internal; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 612834e..0f76310 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -1,13 +1,17 @@ import org.jspecify.annotations.NullMarked; @NullMarked -module dev.faststats.core { - exports dev.faststats.core.data; - exports dev.faststats.core; +module dev.faststats { + exports dev.faststats.data; + exports dev.faststats.internal; + exports dev.faststats; requires com.google.gson; + requires java.logging; requires java.net.http; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file + + uses dev.faststats.internal.LoggerFactory; +} diff --git a/core/src/test/java/dev/faststats/AnonymizationTest.java b/core/src/test/java/dev/faststats/AnonymizationTest.java index 512a955..aa7be74 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -1,7 +1,6 @@ package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.core.ErrorTracker; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index f500cd4..bc82940 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,20 +1,18 @@ package dev.faststats; -import dev.faststats.core.ErrorTracker; import org.junit.jupiter.api.Test; import java.net.URL; import java.net.URLClassLoader; -import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class ErrorTrackerTest { - // todo: add redaction tests - // todo: add nesting tests - // todo: add duplicate tests - @Test public void sameClassLoader() { final var loader = getClass().getClassLoader(); @@ -127,73 +125,219 @@ private IllegalArgumentException createExceptionWithStack() { } @Test - // todo: fix this mess - public void testCompile() throws InterruptedException { - final var tracker = ErrorTracker.contextUnaware(); - tracker.attachErrorContext(null); + public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - try { - roundAndRound(10); - } catch (final Throwable t) { - tracker.trackError(t); - } - try { - recursiveError(); - } catch (final Throwable t) { - tracker.trackError("↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ↓→ħſðđſ→ðđ””ſ→ʒðđ↓ʒ”ſðđʒ"); - tracker.trackError(t); - } - try { - aroundAndAround(); - } catch (final Throwable t) { - tracker.trackError(t); - return; - } + tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - tracker.trackError("Test error"); - final var nestedError = new RuntimeException("Nested error"); - final var error = new RuntimeException(null, nestedError); - tracker.trackError(error); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var message = report.get("message").getAsString(); + final var header = report.getAsJsonArray("stack").get(0).getAsString(); + + assertEquals("connect jdbc:postgresql://localhost:[password hidden]@db from [IP hidden]", message); + assertEquals("java.lang.RuntimeException: " + message, header); + } + + @Test + public void appliesCustomRedactionAfterBuiltInRedaction() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + tracker.anonymize("session=[^ ]+", "session=[hidden]"); + + tracker.trackError("failed with session=abc123 from 10.0.0.1"); + + final var message = tracker.getData("build") + .get(0) + .getAsJsonObject() + .get("message") + .getAsString(); + + assertEquals("failed with session=[hidden] from [IP hidden]", message); + } + + @Test + public void nullMessagesAreNotSerializedAsMessageProperty() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); - tracker.trackError("hello my name is david"); - tracker.trackError("/home/MyName/Documents/MyFile.txt"); - tracker.trackError("C:\\Users\\MyName\\AppData\\Local\\Temp"); - tracker.trackError("/Users/MyName/AppData/Local/Temp"); - tracker.trackError("my ipv4 address is 215.223.110.131"); - tracker.trackError("my ipv6 address is f833:be65:65da:975b:4896:88f7:6964:44c0"); + tracker.trackError(new RuntimeException((String) null)); - final var deepAsyncError = new RuntimeException("deep async error"); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + assertFalse(report.has("message")); + assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); + } - final var thisIsANiceError = new Thread(() -> { - final var nestedAsyncError = new RuntimeException("nested async error", deepAsyncError); - throw new CompletionException("async error", nestedAsyncError); + @Test + public void nestedCausesAreSerializedInOrder() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var root = new IllegalArgumentException("root secret 172.16.0.9"); + root.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Root", "fail", "Root.java", 10) + }); + final var middle = new IllegalStateException("middle", root); + middle.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Middle", "call", "Middle.java", 20), + new StackTraceElement("example.Root", "fail", "Root.java", 10) }); - thisIsANiceError.start(); - thisIsANiceError.join(1000); + final var top = new RuntimeException("top", middle); + top.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Top", "run", "Top.java", 30), + new StackTraceElement("example.Middle", "call", "Middle.java", 20), + new StackTraceElement("example.Root", "fail", "Root.java", 10) + }); + + tracker.trackError(top, false); - Thread.sleep(1000); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var stack = report.getAsJsonArray("stack"); - tracker.trackError("Test error"); + assertEquals(RuntimeException.class.getName(), report.get("error").getAsString()); + assertFalse(report.get("handled").getAsBoolean()); + assertEquals("java.lang.RuntimeException: top", stack.get(0).getAsString()); + assertEquals(" at example.Top.run(Top.java:30)", stack.get(1).getAsString()); + assertEquals(" at example.Middle.call(Middle.java:20)", stack.get(2).getAsString()); + assertEquals(" at example.Root.fail(Root.java:10)", stack.get(3).getAsString()); + assertEquals("Caused by: java.lang.IllegalStateException: middle", stack.get(4).getAsString()); + assertEquals(" ... 2 more", stack.get(5).getAsString()); + assertEquals("Caused by: java.lang.IllegalArgumentException: root secret [IP hidden]", stack.get(6).getAsString()); } - public void recursiveError() throws StackOverflowError { - goRoundAndRound(); + @Test + public void cyclicCauseChainStopsAfterFirstVisit() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var first = new RuntimeException("first"); + final var second = new IllegalStateException("second", first); + first.initCause(second); + + tracker.trackError(first); + + final var stack = tracker.getData("build").get(0).getAsJsonObject().getAsJsonArray("stack"); + var firstCauseCount = 0; + var secondCauseCount = 0; + for (final var element : stack) { + final var line = element.getAsString(); + if (line.equals("Caused by: java.lang.RuntimeException: first")) firstCauseCount++; + if (line.equals("Caused by: java.lang.IllegalStateException: second")) secondCauseCount++; + } + + assertEquals(1, firstCauseCount); + assertEquals(1, secondCauseCount); } - public void goRoundAndRound() throws StackOverflowError { - andRoundAndRound(); + @Test + public void duplicateErrorsAreAggregatedWithCount() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var first = createStableError(); + final var second = createStableError(); + + tracker.trackError(first); + tracker.trackError(second); + + final var reports = tracker.getData("build"); + final var report = reports.get(0).getAsJsonObject(); + + assertEquals(1, reports.size()); + assertEquals(2, report.get("count").getAsInt()); + assertEquals("build", report.get("buildId").getAsString()); + assertEquals("duplicate", report.get("message").getAsString()); } - public void andRoundAndRound() throws StackOverflowError { - goRoundAndRound(); + @Test + public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + tracker.trackError(createStableError()); + tracker.trackError(createStableError()); + + tracker.clear(); + + assertFalse(tracker.needsFlushing()); + assertEquals(0, tracker.getData("build").size()); + + tracker.trackError(createStableError()); + + final var report = tracker.getData("build").get(0).getAsJsonObject(); + assertEquals("duplicate", report.get("message").getAsString()); + assertNull(report.get("count")); + } + + @Test + public void ignoredNestedCauseSuppressesWholeReport() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + tracker.ignoreError(IllegalArgumentException.class, "ignore me"); + + tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); + + assertEquals(0, tracker.getData("build").size()); + assertFalse(tracker.needsFlushing()); } - public void aroundAndAround() throws StackOverflowError { - aroundAndAround(); + @Test + public void repeatingStackFramesAreCollapsed() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var error = new StackOverflowError("recursive"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), + new StackTraceElement("example.Recursive", "b", "Recursive.java", 2), + new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), + new StackTraceElement("example.Recursive", "b", "Recursive.java", 2), + new StackTraceElement("example.Recursive", "a", "Recursive.java", 1), + new StackTraceElement("example.Recursive", "b", "Recursive.java", 2) + }); + + tracker.trackError(error); + + final var stack = tracker.getData("build").get(0).getAsJsonObject().getAsJsonArray("stack"); + assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); + assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); + assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); + assertEquals(" ... 4 more", stack.get(3).getAsString()); + assertEquals(4, stack.size()); } - public void roundAndRound(final int i) throws RuntimeException { - if (i <= 0) throw new RuntimeException("out of stack"); - roundAndRound(i - 1); + @Test + public void longMessagesAreTruncatedBeforeSerialization() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var message = "a".repeat(600); + + tracker.trackError(message); + + final var report = tracker.getData("build").get(0).getAsJsonObject(); + final var serialized = report.get("message").getAsString(); + assertEquals(503, serialized.length()); + assertTrue(serialized.endsWith("...")); + assertEquals("java.lang.RuntimeException: " + serialized, report.getAsJsonArray("stack").get(0).getAsString()); + } + + @Test + public void attachedContextTracksUnhandledThreadError() throws InterruptedException { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var handled = new CountDownLatch(1); + final var thrown = new RuntimeException("async failure"); + thrown.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Async", "run", "Async.java", 7) + }); + + tracker.setContextErrorHandler((loader, error) -> handled.countDown()); + tracker.attachErrorContext(null); + try { + final var thread = new Thread(() -> { + throw thrown; + }); + thread.start(); + thread.join(1000); + + assertTrue(handled.await(1, TimeUnit.SECONDS)); + final var report = tracker.getData("build").get(0).getAsJsonObject(); + assertEquals("async failure", report.get("message").getAsString()); + assertFalse(report.get("handled").getAsBoolean()); + } finally { + tracker.detachErrorContext(); + } + } + + private RuntimeException createStableError() { + final var error = new RuntimeException("duplicate"); + error.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("example.Plugin", "run", "Plugin.java", 42) + }); + return error; } } diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 3d9df9e..00bdc21 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -1,9 +1,7 @@ package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.SimpleMetrics; -import dev.faststats.core.Token; +import dev.faststats.config.SimpleConfig; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -14,23 +12,12 @@ @NullMarked public final class MockMetrics extends SimpleMetrics { public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new Config(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); + super(new SimpleConfig(serverId, true, debug, true, true, false), 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); + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); } @Override diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index d9caf07..4b1a38f 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { api(project(":core")) + implementation(project(":config")) mappings(loom.officialMojangMappings()) minecraft("com.mojang:minecraft:1.21.11") modCompileOnly("net.fabricmc.fabric-api:fabric-api:0.139.4+1.21.11") diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 47d8eae..e4dd883 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -1,49 +1,24 @@ package com.example; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.fabric.FabricMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.fabric.FabricContext; import net.fabricmc.api.ModInitializer; -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 + private final FabricContext context = new FabricContext( + "example-mod", // your mod id as defined in fabric.mod.json + "YOUR_TOKEN_HERE" + ); + private final Metrics metrics = context.metrics() + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .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); - } - } + .create(); @Override public void onInitialize() { diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java new file mode 100644 index 0000000..9cbe36d --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -0,0 +1,29 @@ +package dev.faststats.fabric; + +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; + +/** + * Fabric FastStats context. + * + * @since 0.23.0 + */ +public final class FabricContext extends SimpleContext { + final ModContainer mod; + + public FabricContext(final String modId, @Token final String token) { + super(SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), token); + this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { + return new IllegalArgumentException("Mod not found: " + modId); + }); + } + + @Override + public Metrics.Factory metrics() { + return new FabricMetricsImpl.Factory(this); + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java deleted file mode 100644 index f6ce2df..0000000 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetrics.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.faststats.fabric; - -import dev.faststats.core.Metrics; -import org.jetbrains.annotations.Async; -import org.jetbrains.annotations.Contract; - -/** - * Fabric metrics implementation. - * - * @since 0.12.0 - */ -public sealed interface FabricMetrics extends Metrics permits FabricMetricsImpl { - /** - * Creates a new metrics factory for Fabric. - * - * @return the metrics factory - * @since 0.12.0 - */ - @Contract(pure = true) - static Factory factory() { - return new FabricMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { - /** - * Creates a new metrics instance. - *

- * Metrics submission will start automatically. - * - * @param modId the mod id - * @return the metrics instance - * @throws IllegalStateException if the token is not specified - * @throws IllegalArgumentException if the mod is not found - * @see #token(String) - * @since 0.12.0 - */ - @Override - @Async.Schedule - @Contract(value = "_ -> new", mutates = "io") - Metrics create(String modId) throws IllegalStateException, IllegalArgumentException; - } -} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java new file mode 100644 index 0000000..b5f427b --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java @@ -0,0 +1,46 @@ +package dev.faststats.fabric; + +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.SharedConstants; +import net.minecraft.client.Minecraft; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +final class FabricMetricsClientImpl extends FabricMetricsImpl { + private @Nullable Minecraft client; + + @Async.Schedule + @Contract(mutates = "io") + FabricMetricsClientImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory, mod); + + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + this.client = client; + startSubmitting(); + }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> shutdown()); + } + + @Override + protected void appendDefaultData(final JsonObject metrics) { + assert client != null : "Client not initialized"; + metrics.addProperty("minecraft_version", SharedConstants.getCurrentVersion().name()); // todo: doublecheck + metrics.addProperty("online_mode", client.getUser().getXuid().isPresent() && !client.isOfflineDeveloperMode()); // todo: doublecheck + metrics.addProperty("player_count", getPlayerCount()); + appendFabricData(metrics, "Fabric Client"); + } + + private int getPlayerCount() { + assert client != null : "Client not initialized"; + final var connection = client.getConnection(); + if (connection != null) return connection.getOnlinePlayers().size(); + + final var server = client.getSingleplayerServer(); + if (server != null) return server.getPlayerCount(); + + return client.player == null ? 0 : 1; + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index ba48e45..5b227f6 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -1,87 +1,47 @@ package dev.faststats.fabric; import com.google.gson.JsonObject; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; -import net.minecraft.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; -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; +abstract class FabricMetricsImpl extends SimpleMetrics { + protected final ModContainer mod; @Async.Schedule @Contract(mutates = "io") - private FabricMetricsImpl(final Factory factory, final ModContainer mod, final Path config) throws IllegalStateException { - super(factory, config); + FabricMetricsImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory); this.mod = mod; - - ServerLifecycleEvents.SERVER_STARTED.register(server -> { - this.server = server; - startSubmitting(); - }); - ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); - } - - @Override - protected void appendDefaultData(final JsonObject metrics) { - assert server != null : "Server not initialized"; - metrics.addProperty("minecraft_version", server.getServerVersion()); - metrics.addProperty("online_mode", server.usesAuthentication()); - metrics.addProperty("player_count", server.getPlayerCount()); - metrics.addProperty("plugin_version", mod.getMetadata().getVersion().getFriendlyString()); - metrics.addProperty("server_type", "Fabric"); } @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); } - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warn(message); + protected void appendFabricData(final JsonObject metrics, final String serverType) { + metrics.addProperty("plugin_version", mod.getMetadata().getVersion().getFriendlyString()); + metrics.addProperty("server_type", serverType); } - private Optional tryOrEmpty(final Supplier supplier) { - try { - return Optional.of(supplier.get()); - } catch (final NoSuchMethodError | Exception e) { - return Optional.empty(); + static final class Factory extends SimpleMetrics.Factory { + public Factory(final FabricContext context) { + super(context); } - } - static final class Factory extends SimpleMetrics.Factory implements FabricMetrics.Factory { @Override - public Metrics create(final String modId) throws IllegalStateException, IllegalArgumentException { - final var fabric = FabricLoader.getInstance(); - final var mod = fabric.getModContainer(modId).orElseThrow(() -> { - return new IllegalArgumentException("Mod not found: " + modId); - }); - - final var dataFolder = fabric.getConfigDir().resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); - - return new FabricMetricsImpl(this, mod, config); + public Metrics create() throws IllegalStateException, IllegalArgumentException { + final var mod = ((FabricContext) context).mod; + return switch (FabricLoader.getInstance().getEnvironmentType()) { + case CLIENT -> new FabricMetricsClientImpl(this, mod); + case SERVER -> new FabricMetricsServerImpl(this, mod); + }; } } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java new file mode 100644 index 0000000..ebc9e44 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java @@ -0,0 +1,34 @@ +package dev.faststats.fabric; + +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +final class FabricMetricsServerImpl extends FabricMetricsImpl { + private @Nullable MinecraftServer server; + + @Async.Schedule + @Contract(mutates = "io") + FabricMetricsServerImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory, mod); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + this.server = server; + startSubmitting(); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); + } + + @Override + protected void appendDefaultData(final JsonObject metrics) { + assert server != null : "Server not initialized"; + metrics.addProperty("minecraft_version", server.getServerVersion()); + metrics.addProperty("online_mode", server.usesAuthentication()); + metrics.addProperty("player_count", server.getPlayerCount()); + appendFabricData(metrics, "Fabric"); + } +} diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index c6601e4..2ca3373 100644 --- a/fabric/src/main/java/module-info.java +++ b/fabric/src/main/java/module-info.java @@ -5,10 +5,11 @@ exports dev.faststats.fabric; requires com.google.gson; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; requires net.fabricmc.loader; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/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/build.gradle.kts b/hytale/build.gradle.kts index fdada47..f7c947f 100644 --- a/hytale/build.gradle.kts +++ b/hytale/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("com.hypixel.hytale:Server:2026.04.17-c2d518cc9") } 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..868bea6 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,40 +2,21 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.hytale.HytaleMetrics; - -import java.net.URI; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.hytale.HytaleContext; 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 + private final HytaleContext context = new HytaleContext(this, "YOUR_TOKEN_HERE"); + private final Metrics metrics = context.metrics() + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .addMetric(Metric.string("example_string", () -> "Hello, World!")) - .addMetric(Metric.bool("example_boolean", () -> true)) - .addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"})) - .addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3})) - .addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false})) - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + // Error tracking must be enabled in the project settings + .errorTracker(ErrorTracker.contextAware()) - .debug(true) // Enable debug mode for development and testing - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(this); + .create(); public ExamplePlugin(final JavaPluginInit init) { super(init); @@ -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/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java new file mode 100644 index 0000000..f268d78 --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -0,0 +1,23 @@ +package dev.faststats.hytale; + +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; + +/** + * Hytale FastStats context. + * + * @since 0.23.0 + */ +public final class HytaleContext extends SimpleContext { + public HytaleContext(final JavaPlugin plugin, @Token final String token) { + super(SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), token); + } + + @Override + public Metrics.Factory metrics() { + return new HytaleMetricsImpl.Factory(this); + } +} diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java deleted file mode 100644 index 96748f7..0000000 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetrics.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.faststats.hytale; - -import com.hypixel.hytale.server.core.plugin.JavaPlugin; -import dev.faststats.core.Metrics; -import org.jetbrains.annotations.Contract; - -/** - * Hytale metrics implementation. - * - * @since 0.9.0 - */ -public sealed interface HytaleMetrics extends Metrics permits HytaleMetricsImpl { - /** - * Creates a new metrics factory for Hytale. - * - * @return the metrics factory - * @since 0.9.0 - */ - @Contract(pure = true) - static Factory factory() { - return new HytaleMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { - } -} diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index d837202..29797e5 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -1,30 +1,29 @@ 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; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.Config; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; - -import java.nio.file.Path; - -final class HytaleMetricsImpl extends SimpleMetrics implements HytaleMetrics { - private final HytaleLogger logger; +final class HytaleMetricsImpl extends SimpleMetrics { @Async.Schedule @Contract(mutates = "io") - private HytaleMetricsImpl(final Factory factory, final HytaleLogger logger, final Path config) throws IllegalStateException { - super(factory, config); - this.logger = logger; + private HytaleMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_version", HytaleServer.get().getServerName()); @@ -32,27 +31,14 @@ 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 { + public Factory(final HytaleContext context) { + super(context); + } - 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); + public Metrics create() throws IllegalStateException { + return new HytaleMetricsImpl(this); } } } diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java new file mode 100644 index 0000000..7f276d1 --- /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.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..2a7c420 --- /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.internal.LoggerFactory { + @Override + public dev.faststats.internal.Logger getLogger(final String name) { + return new HytaleLogger(name); + } +} diff --git a/hytale/src/main/java/module-info.java b/hytale/src/main/java/module-info.java index a091bad..a937216 100644 --- a/hytale/src/main/java/module-info.java +++ b/hytale/src/main/java/module-info.java @@ -5,8 +5,12 @@ exports dev.faststats.hytale; requires com.google.gson; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; + requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file + + provides dev.faststats.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/build.gradle.kts b/minestom/build.gradle.kts index dead07b..caebdea 100644 --- a/minestom/build.gradle.kts +++ b/minestom/build.gradle.kts @@ -1,4 +1,5 @@ dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("net.minestom:minestom:2026.04.13-1.21.11") -} \ No newline at end of file +} diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java new file mode 100644 index 0000000..2433f73 --- /dev/null +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -0,0 +1,23 @@ +package dev.faststats.minestom; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; + +import java.nio.file.Path; + +/** + * Minestom FastStats context. + * + * @since 0.23.0 + */ +public final class MinestomContext extends SimpleContext { + public MinestomContext(@Token final String token) { + super(SimpleConfig.read(Path.of("faststats", "config.properties")), token); + } + + @Override + public MinestomMetrics.Factory metrics() { + return new MinestomMetricsImpl.Factory(this); + } +} diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java index f4df6b9..6037197 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java @@ -1,9 +1,10 @@ package dev.faststats.minestom; -import dev.faststats.core.Metrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; -import org.jetbrains.annotations.Contract; /** * Minestom metrics implementation. @@ -11,17 +12,6 @@ * @since 0.1.0 */ public sealed interface MinestomMetrics extends Metrics permits MinestomMetricsImpl { - /** - * Creates a new metrics factory forMinestom. - * - * @return the metrics factory - * @since 0.1.0 - */ - @Contract(pure = true) - static Factory factory() { - return new MinestomMetricsImpl.Factory(); - } - /** * Registers additional exception handlers. * @@ -31,6 +21,17 @@ static Factory factory() { @Override void ready(); - interface Factory extends Metrics.Factory { + sealed interface Factory extends Metrics.Factory permits MinestomMetricsImpl.Factory { + @Override + Factory addMetric(Metric metric) throws IllegalArgumentException; + + @Override + Factory onFlush(Runnable flush); + + @Override + Factory errorTracker(ErrorTracker tracker); + + @Override + MinestomMetrics create() throws IllegalStateException; } } diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java index 217f194..6aff56d 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -1,30 +1,29 @@ package dev.faststats.minestom; import com.google.gson.JsonObject; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; +import dev.faststats.data.Metric; import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; import org.jetbrains.annotations.Async; 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 { - super(factory, config); + private MinestomMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", MinecraftServer.VERSION_NAME); @@ -33,21 +32,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); @@ -62,11 +46,29 @@ private void registerExceptionHandler(final ErrorTracker errorTracker) { }); } - static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + public Factory(final MinestomContext context) { + super(context); + } + + @Override + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + return (Factory) super.addMetric(metric); + } + + @Override + public Factory onFlush(final Runnable flush) { + return (Factory) super.onFlush(flush); + } + + @Override + public Factory errorTracker(final ErrorTracker tracker) { + return (Factory) super.errorTracker(tracker); + } + @Override - public Metrics create(final MinecraftServer server) throws IllegalStateException { - final var config = Path.of("faststats", "config.properties"); - return new MinestomMetricsImpl(this, config); + public MinestomMetrics create() throws IllegalStateException { + return new MinestomMetricsImpl(this); } } } diff --git a/minestom/src/main/java/module-info.java b/minestom/src/main/java/module-info.java index 629f4d5..e0c4b0c 100644 --- a/minestom/src/main/java/module-info.java +++ b/minestom/src/main/java/module-info.java @@ -5,10 +5,11 @@ exports dev.faststats.minestom; requires com.google.gson; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; requires net.minestom.server; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/nukkit/build.gradle.kts b/nukkit/build.gradle.kts index af632e8..d4a090a 100644 --- a/nukkit/build.gradle.kts +++ b/nukkit/build.gradle.kts @@ -7,5 +7,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("cn.nukkit:nukkit:1.0-SNAPSHOT") } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java new file mode 100644 index 0000000..29efb2b --- /dev/null +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -0,0 +1,28 @@ +package dev.faststats.nukkit; + +import cn.nukkit.plugin.PluginBase; +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; + +import java.nio.file.Path; + +/** + * Nukkit FastStats context. + * + * @since 0.23.0 + */ +public final class NukkitContext extends SimpleContext { + final PluginBase plugin; + + public NukkitContext(final PluginBase plugin, @Token final String token) { + super(SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), token); + this.plugin = plugin; + } + + @Override + public Metrics.Factory metrics() { + return new NukkitMetricsImpl.Factory(this); + } +} diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java deleted file mode 100644 index 2420e2a..0000000 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetrics.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.faststats.nukkit; - -import cn.nukkit.plugin.PluginBase; -import dev.faststats.core.Metrics; -import org.jetbrains.annotations.Contract; - -/** - * Nukkit metrics implementation. - * - * @since 0.8.0 - */ -public sealed interface NukkitMetrics extends Metrics permits NukkitMetricsImpl { - /** - * Creates a new metrics factory for Nukkit. - * - * @return the metrics factory - * @since 0.8.0 - */ - @Contract(pure = true) - static Factory factory() { - return new NukkitMetricsImpl.Factory(); - } - - interface Factory extends Metrics.Factory { - } -} diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java index 1316d89..7f3ff1b 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -2,35 +2,36 @@ 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 dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; 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; +final class NukkitMetricsImpl extends SimpleMetrics { private final Server server; private final PluginBase plugin; @Async.Schedule @Contract(mutates = "io") - private NukkitMetricsImpl(final Factory factory, final PluginBase plugin, final Path config) throws IllegalStateException { - super(factory, config); + private NukkitMetricsImpl(final Factory factory, final PluginBase plugin) throws IllegalStateException { + super(factory); - this.logger = plugin.getLogger(); this.server = plugin.getServer(); this.plugin = plugin; startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", server.getVersion()); @@ -40,21 +41,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()); @@ -63,12 +49,14 @@ private Optional tryOrEmpty(final Supplier supplier) { } } - static final class Factory extends SimpleMetrics.Factory implements NukkitMetrics.Factory { + static final class Factory extends SimpleMetrics.Factory { + Factory(final NukkitContext context) { + super(context); + } + @Override - public Metrics create(final PluginBase plugin) throws IllegalStateException { - final var dataFolder = Path.of(plugin.getServer().getPluginPath(), "faststats"); - final var config = dataFolder.resolve("config.properties"); - return new NukkitMetricsImpl(this, plugin, config); + public Metrics create() throws IllegalStateException { + return new NukkitMetricsImpl(this, ((NukkitContext) context).plugin); } } } diff --git a/nukkit/src/main/java/module-info.java b/nukkit/src/main/java/module-info.java index b7b0b2b..c8722c4 100644 --- a/nukkit/src/main/java/module-info.java +++ b/nukkit/src/main/java/module-info.java @@ -5,8 +5,9 @@ exports dev.faststats.nukkit; requires com.google.gson; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e67c2ef..402a4cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,7 +12,9 @@ include("bukkit") include("bukkit:example-plugin") include("bungeecord") include("bungeecord:example-plugin") +include("config") 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..0738852 100644 --- a/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/sponge/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,69 +1,38 @@ package com.example; import com.google.inject.Inject; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.sponge.SpongeMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.sponge.SpongeContext; import org.jspecify.annotations.Nullable; import org.spongepowered.api.Server; import org.spongepowered.api.event.Listener; import org.spongepowered.api.event.lifecycle.StartedEngineEvent; import org.spongepowered.api.event.lifecycle.StoppingEngineEvent; -import org.spongepowered.plugin.PluginContainer; import org.spongepowered.plugin.builtin.jvm.Plugin; -import java.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; + private @Inject SpongeContext.Builder contextBuilder; private @Nullable Metrics metrics = null; @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 + final var context = contextBuilder.build("YOUR_TOKEN_HERE"); + this.metrics = context.metrics() + // Custom metrics require a corresponding data source in your project settings .addMetric(Metric.number("example_metric", () -> 42)) - .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); + .create(); } @Listener 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/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java new file mode 100644 index 0000000..6dd3a95 --- /dev/null +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -0,0 +1,141 @@ +package dev.faststats.sponge; + +import dev.faststats.Config; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.spongepowered.api.Sponge; +import org.spongepowered.plugin.PluginContainer; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiPredicate; +import java.util.logging.Level; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApiStatus.Internal +public record SpongeConfig( + UUID serverId, + boolean additionalMetrics, + boolean debug, + boolean enabled, + boolean errorTracking, + boolean firstRun +) implements Config { + + private static final String COMMENT = """ + FastStats (https://faststats.dev) collects anonymous usage statistics. + # This helps developers understand how their projects are used in the real world. + # + # No IP addresses, player data, or personal information is collected. + # The server ID below is randomly generated and can be regenerated at any time. + # + # Enabling metrics has no noticeable performance impact. + # Enabling metrics is recommended, you can do so in the Sponge metrics.config, + # by setting the "global-state" property to "TRUE". + # + # If you suspect a developer is collecting personal data or bypassing the Sponge config, + # please report it at: https://faststats.dev/abuse + # + # For more information, visit: https://faststats.dev/info + """; + private static final String ONBOARDING_MESSAGE = """ + This plugin uses FastStats to collect anonymous usage statistics. + No personal or identifying information is ever collected. + It is recommended to enable metrics by setting 'global-state=TRUE' in the sponge metrics config. + Learn more at: https://faststats.dev/info + """; + + @Contract(mutates = "io") + public static SpongeConfig read(final PluginContainer plugin, final Path file) throws RuntimeException { + final var properties = readOrEmpty(file); + final var firstRun = properties.isEmpty(); + final var saveConfig = new AtomicBoolean(firstRun); + + final var serverId = properties.map(object -> object.getProperty("serverId")).map(string -> { + try { + final var trimmed = string.trim(); + final var corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed; + if (!corrected.equals(string)) saveConfig.set(true); + return UUID.fromString(corrected); + } catch (final IllegalArgumentException e) { + saveConfig.set(true); + return UUID.randomUUID(); + } + }).orElseGet(() -> { + saveConfig.set(true); + return UUID.randomUUID(); + }); + + final BiPredicate predicate = (key, defaultValue) -> { + return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> { + saveConfig.set(true); + return defaultValue; + }); + }; + + final var errorTracking = predicate.test("submitErrors", true); + final var additionalMetrics = predicate.test("submitAdditionalMetrics", true); + final var debug = predicate.test("debug", false); + + if (saveConfig.get()) try { + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var store = new Properties(); + + store.setProperty("serverId", serverId.toString()); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("debug", Boolean.toString(debug)); + + store.store(writer, COMMENT); + } + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + + final var enabled = Sponge.metricsConfigManager().effectiveCollectionState(plugin).asBoolean(); + return new SpongeConfig(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun); + } + + private static Optional readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return Optional.empty(); + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return Optional.of(properties); + } catch (final IOException e) { + throw new RuntimeException("Failed to read metrics config", e); + } + } + + @SuppressWarnings("PatternValidation") + public boolean preSubmissionStart() { + if (Boolean.getBoolean("faststats.first-run")) return false; + + if (firstRun()) { + var separatorLength = 0; + final var split = ONBOARDING_MESSAGE.split("\n"); + for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); + + final var logger = LoggerFactory.factory().getLogger(getClass()); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + for (final var s : split) logger.log(Level.CONFIG, s); + logger.log(Level.CONFIG, "-".repeat(separatorLength)); + + System.setProperty("faststats.first-run", "true"); + return false; + } + return true; + } +} + + diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java new file mode 100644 index 0000000..48239aa --- /dev/null +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -0,0 +1,79 @@ +package dev.faststats.sponge; + +import com.google.inject.Inject; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import org.apache.logging.log4j.Logger; +import org.spongepowered.api.config.ConfigDir; +import org.spongepowered.plugin.PluginContainer; + +import java.nio.file.Path; + +/** + * Sponge FastStats context. + * + * @since 0.23.0 + */ +public final class SpongeContext extends SimpleContext { + final PluginContainer plugin; + final Logger logger; + + public SpongeContext( + final PluginContainer plugin, + final Logger logger, + @ConfigDir(sharedRoot = true) final Path dataDirectory, + @Token final String token + ) { + super(SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), token); + this.plugin = plugin; + this.logger = logger; + } + + @Override + public SpongeMetrics.Factory metrics() { + return new SpongeMetrics.Factory(this); + } + + /** + * Injectable Sponge context builder. + * + * @since 0.23.0 + */ + public static final class Builder { + private final PluginContainer plugin; + private final Logger logger; + private final Path dataDirectory; + + /** + * Creates a new Sponge context builder. + * + * @param plugin the plugin container + * @param logger the plugin logger + * @param dataDirectory the shared Sponge config directory + * @apiNote This instance can be injected into your plugin. + * @since 0.23.0 + */ + @Inject + public Builder( + final PluginContainer plugin, + final Logger logger, + @ConfigDir(sharedRoot = true) final Path dataDirectory + ) { + this.plugin = plugin; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + /** + * Builds the finalized Sponge context. + * + * @param token the FastStats project token + * @return the Sponge context + * @throws IllegalArgumentException if the token is invalid + * @since 0.23.0 + */ + public SpongeContext build(@Token final String token) throws IllegalArgumentException { + return new SpongeContext(plugin, logger, dataDirectory, token); + } + } +} diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java index 380df38..8dcc125 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java @@ -1,11 +1,7 @@ package dev.faststats.sponge; -import com.google.inject.Inject; -import dev.faststats.core.Metrics; -import org.apache.logging.log4j.Logger; -import org.spongepowered.api.config.ConfigDir; - -import java.nio.file.Path; +import dev.faststats.FastStatsContext; +import dev.faststats.Metrics; /** * Sponge metrics implementation. @@ -14,17 +10,8 @@ */ public sealed interface SpongeMetrics extends Metrics permits SpongeMetricsImpl { final class Factory extends SpongeMetricsImpl.Factory { - /** - * Creates a new metrics factory for Sponge. - * - * @param logger the logger - * @param dataDirectory the data directory - * @apiNote This instance is automatically injected into your plugin. - * @since 0.12.0 - */ - @Inject - private Factory(final Logger logger, @ConfigDir(sharedRoot = true) final Path dataDirectory) { - super(logger, dataDirectory); + public Factory(final FastStatsContext context) { + super(context); } } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index b029e84..5746096 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -1,37 +1,18 @@ package dev.faststats.sponge; import com.google.gson.JsonObject; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.FastStatsContext; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import org.spongepowered.api.Platform; import org.spongepowered.api.Sponge; import org.spongepowered.plugin.PluginContainer; -import java.nio.file.Path; - final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { - public static final String COMMENT = """ - FastStats (https://faststats.dev) collects anonymous usage statistics 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. - # 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, - # 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 @@ -39,26 +20,16 @@ final class SpongeMetricsImpl extends SimpleMetrics implements SpongeMetrics { private SpongeMetricsImpl( final Factory factory, final Logger logger, - final PluginContainer plugin, - final Path config + final PluginContainer plugin ) throws IllegalStateException { - super(factory, SimpleMetrics.Config.read(config, COMMENT, true, Sponge.metricsConfigManager() - .effectiveCollectionState(plugin).asBoolean())); - - this.logger = logger; + super(factory); this.plugin = plugin; - startSubmitting(); } @Override - protected String getOnboardingMessage() { - return """ - This plugin uses FastStats to collect anonymous usage statistics. - No personal or identifying information is ever collected. - It is recommended to enable metrics by setting 'global-state=TRUE' in the sponge metrics config. - Learn more at: https://faststats.dev/info - """; + protected boolean preSubmissionStart() { + return ((SpongeConfig) getConfig()).preSubmissionStart(); } @Override @@ -70,34 +41,15 @@ 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; - - public Factory(final Logger logger, final Path dataDirectory) { - this.logger = logger; - this.dataDirectory = dataDirectory; + static class Factory extends SimpleMetrics.Factory { + public Factory(final FastStatsContext context) { + super(context); } @Override - public Metrics create(final PluginContainer plugin) throws IllegalStateException, IllegalArgumentException { - final var faststats = dataDirectory.resolve("faststats"); - return new SpongeMetricsImpl(this, logger, plugin, faststats.resolve("config.properties")); + public Metrics create() throws IllegalStateException, IllegalArgumentException { + final var context = (SpongeContext) this.context; + return new SpongeMetricsImpl(this, context.logger, context.plugin); } } } diff --git a/sponge/src/main/java/module-info.java b/sponge/src/main/java/module-info.java index b01a156..61c8a82 100644 --- a/sponge/src/main/java/module-info.java +++ b/sponge/src/main/java/module-info.java @@ -6,8 +6,9 @@ requires com.google.gson; requires com.google.guice; - requires dev.faststats.core; + requires dev.faststats; + requires java.logging; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 74da85b..ef8247d 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -4,5 +4,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT") } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index b852d12..80d8a6d 100644 --- a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -5,67 +5,37 @@ import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.plugin.Plugin; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.velocity.VelocityMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; +import dev.faststats.velocity.VelocityContext; 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 final VelocityContext context; private @Nullable Metrics metrics = null; @Inject - public ExamplePlugin(final VelocityMetrics.Factory factory) { - this.metricsFactory = factory; + public ExamplePlugin(final VelocityContext.Builder contextBuilder) { + this.context = contextBuilder.build("YOUR_TOKEN_HERE"); } @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 + this.metrics = context.metrics() + // 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); + .create(); } @Subscribe 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/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java new file mode 100644 index 0000000..6dad69d --- /dev/null +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -0,0 +1,101 @@ +package dev.faststats.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.Config; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.slf4j.Logger; + +import java.nio.file.Path; + +/** + * Velocity FastStats context. + * + * @since 0.23.0 + */ +public final class VelocityContext extends SimpleContext { + private final PluginContainer plugin; + private final ProxyServer server; + private final Logger logger; + private final Path dataDirectory; + + public VelocityContext( + final Config config, + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + final Path dataDirectory, + @Token final String token + ) { + super(config, token); + this.plugin = plugin; + this.server = server; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + public VelocityContext( + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory, + @Token final String token + ) { + this(SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), plugin, server, logger, dataDirectory, token); + } + + @Override + public VelocityMetrics.Factory metrics() { + return new VelocityMetrics.Factory(this, plugin, server, logger, dataDirectory); + } + + /** + * Injectable Velocity context builder. + * + * @since 0.23.0 + */ + public static final class Builder { + private final PluginContainer plugin; + private final ProxyServer server; + private final Logger logger; + private final Path dataDirectory; + + /** + * Creates a new Velocity context builder. + * + * @param server the velocity server + * @param logger the plugin logger + * @param dataDirectory the plugin data directory + * @apiNote This instance can be injected into your plugin. + * @since 0.23.0 + */ + @Inject + public Builder( + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory + ) { + this.plugin = plugin; + this.server = server; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + /** + * Builds the finalized Velocity context. + * + * @param token the FastStats project token + * @return the Velocity context + * @throws IllegalArgumentException if the token is invalid + * @since 0.23.0 + */ + public VelocityContext build(@Token final String token) throws IllegalArgumentException { + return new VelocityContext(plugin, server, logger, dataDirectory, token); + } + } +} diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java index d552d33..d5b36f1 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java @@ -1,9 +1,9 @@ package dev.faststats.velocity; -import com.google.inject.Inject; +import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.core.Metrics; +import dev.faststats.Metrics; import org.slf4j.Logger; import java.nio.file.Path; @@ -15,18 +15,14 @@ */ public sealed interface VelocityMetrics extends Metrics permits VelocityMetricsImpl { final class Factory extends VelocityMetricsImpl.Factory { - /** - * Creates a new metrics factory for Velocity. - * - * @param server the velocity server - * @param logger the logger - * @param dataDirectory the data directory - * @apiNote This instance is automatically injected into your plugin. - * @since 0.1.0 - */ - @Inject - private Factory(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { - super(server, logger, dataDirectory); + public Factory( + final VelocityContext context, + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory + ) { + super(context, plugin, server, logger, dataDirectory); } } } diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 0c64cf8..51159fd 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -4,17 +4,18 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +import dev.faststats.Config; +import dev.faststats.FastStatsContext; +import dev.faststats.Metrics; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; import org.jetbrains.annotations.Async; import org.jetbrains.annotations.Contract; -import org.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; @@ -24,18 +25,22 @@ private VelocityMetricsImpl( final Factory factory, final Logger logger, final ProxyServer server, - final Path config, + final Config config, final PluginContainer plugin ) throws IllegalStateException { super(factory, config); - this.logger = logger; this.server = server; this.plugin = plugin; startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { final var pluginVersion = plugin.getDescription().getVersion().orElse("unknown"); @@ -46,27 +51,21 @@ 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 { + static class Factory extends SimpleMetrics.Factory { protected final Logger logger; protected final Path dataDirectory; protected final ProxyServer server; + protected final PluginContainer plugin; - public Factory(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { + public Factory( + final FastStatsContext context, + final PluginContainer plugin, + final ProxyServer server, + final Logger logger, + @DataDirectory final Path dataDirectory + ) { + super(context); + this.plugin = plugin; this.logger = logger; this.dataDirectory = dataDirectory; this.server = server; @@ -77,18 +76,13 @@ public Factory(final ProxyServer server, final Logger logger, @DataDirectory fin *

* Metrics submission will start automatically. * - * @param plugin the plugin instance * @return the metrics instance * @throws IllegalStateException if the token is not specified - * @throws IllegalArgumentException if the given object is not a valid plugin - * @see #token(String) - * @since 0.1.0 + * @since 0.23.0 */ @Override - public Metrics create(final Object plugin) throws IllegalStateException, IllegalArgumentException { - final var faststats = dataDirectory.resolveSibling("faststats"); - final var container = server.getPluginManager().ensurePluginContainer(plugin); - return new VelocityMetricsImpl(this, logger, server, faststats.resolve("config.properties"), container); + public Metrics create() throws IllegalStateException { + return new VelocityMetricsImpl(this, logger, server, context.getConfig(), plugin); } } } diff --git a/velocity/src/main/java/module-info.java b/velocity/src/main/java/module-info.java index 2855dcb..0d80b4f 100644 --- a/velocity/src/main/java/module-info.java +++ b/velocity/src/main/java/module-info.java @@ -7,9 +7,10 @@ requires com.google.gson; requires com.google.guice; requires com.velocitypowered.api; - requires dev.faststats.core; + requires dev.faststats.config; + requires dev.faststats; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; -} \ No newline at end of file +}