diff --git a/build.gradle.kts b/build.gradle.kts index 668852da..cc3d0743 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") + apply { plugin("com.gradleup.shadow") } } else { - apply(plugin = "maven-publish") + apply { plugin("maven-publish") } } group = "dev.faststats.metrics" @@ -51,7 +53,7 @@ subprojects { doLast { val file = outputDir.get().file("META-INF/faststats.properties").asFile file.parentFile.mkdirs() - file.writeText("name=${project.name}\nversion=${project.version}\n") + file.writeText("version=${project.version}\n") } } diff --git a/bukkit/build.gradle.kts b/bukkit/build.gradle.kts index 10ff4af6..dce2e74c 100644 --- a/bukkit/build.gradle.kts +++ b/bukkit/build.gradle.kts @@ -14,5 +14,6 @@ configurations.compileClasspath { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") } diff --git a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java index 2b1d9cf5..710d7219 100644 --- a/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -1,74 +1,39 @@ package com.example; -import dev.faststats.bukkit.BukkitMetrics; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.data.Metric; +import dev.faststats.ErrorTracker; +import dev.faststats.bukkit.BukkitContext; +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 { + public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware(); private final AtomicInteger gameCount = new AtomicInteger(); - private final BukkitMetrics metrics = BukkitMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only - - // Custom example metrics - // For this to work you have to create a corresponding data source in your project settings first - .addMetric(Metric.number("example_metric", () -> 42)) - .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})) - - // Attach an error tracker - // This must be enabled in the project settings - .errorTracker(ERROR_TRACKER) + private final BukkitContext context = new BukkitContext.Factory(this, "YOUR_TOKEN_HERE") + .errorTrackerService(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - .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()) + .create(); @Override public void onEnable() { - metrics.ready(); // register additional error handlers + context.ready(); // register additional error handlers } @Override public void onDisable() { - metrics.shutdown(); // safely shut down metrics submission - } - - 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); - } + context.shutdown(); // safely shut down configured services } public void startGame() { diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java new file mode 100644 index 00000000..8939fed7 --- /dev/null +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -0,0 +1,71 @@ +package dev.faststats.bukkit; + +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Contract; + +import java.nio.file.Path; + +/** + * Bukkit FastStats context. + * + * @since 0.24.0 + */ +public final class BukkitContext extends SimpleContext { + final Plugin plugin; + + private BukkitContext(final Factory factory, final Plugin plugin, @Token final String token) { + super(factory, SimpleConfig.read(getConfigPath(plugin)), "bukkit", token); + this.plugin = plugin; + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected BukkitMetrics.Factory metricsFactory() { + return new BukkitMetricsImpl.Factory(this); + } + + @Override + public void ready() { + try { + Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); + plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, this), plugin); + } catch (final ClassNotFoundException ignored) { + } + } + + private static Path getConfigPath(final Plugin plugin) { + return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties"); + } + + private static Path getPluginsFolder(final Plugin plugin) { + try { + return plugin.getServer().getPluginsFolder().toPath(); + } catch (final NoSuchMethodError e) { + return plugin.getDataFolder().getParentFile().toPath(); + } + } + + @Override + public String getProjectName() { + return plugin.getName(); + } + + public static final class Factory extends SimpleContext.Factory { + private final Plugin plugin; + private final @Token String token; + + public Factory(final Plugin plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + public BukkitContext create() { + return new BukkitContext(this, plugin, token); + } + } +} diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java index 810cca6a..1be54a7b 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java @@ -1,9 +1,7 @@ package dev.faststats.bukkit; -import dev.faststats.core.Metrics; -import org.bukkit.plugin.IllegalPluginAccessException; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.Contract; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; /** * Bukkit metrics implementation. @@ -11,29 +9,14 @@ * @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(); - } + sealed interface Factory extends Metrics.Factory permits BukkitMetricsImpl.Factory { + @Override + Factory addMetric(Metric metric) throws IllegalArgumentException; - /** - * Registers additional exception handlers on Paper-based implementations. - * - * @throws IllegalPluginAccessException if the plugin is not yet enabled - * @apiNote This method may only be called {@link Plugin#onEnable() onEnable()}. - * @since 0.14.0 - */ - @Override - void ready() throws IllegalPluginAccessException; + @Override + Factory onFlush(Runnable flush); - interface Factory extends Metrics.Factory { @Override - BukkitMetrics create(Plugin object) throws IllegalStateException; + BukkitMetrics create() throws IllegalStateException; } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java index 58e30e5f..d6e372a9 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java @@ -1,16 +1,15 @@ package dev.faststats.bukkit; import com.google.gson.JsonObject; -import dev.faststats.core.SimpleMetrics; +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 +21,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(); @@ -38,10 +37,6 @@ private BukkitMetricsImpl(final Factory factory, final Plugin plugin, final Path startSubmitting(); } - Plugin plugin() { - return plugin; - } - private boolean checkOnlineMode() { final var server = plugin.getServer(); return tryOrEmpty(() -> server.getServerConfig().isProxyOnlineMode()) @@ -63,6 +58,11 @@ private boolean isProxyOnlineMode() { return settings.getBoolean("bungeecord") && proxies.getBoolean("bungee-cord.online-mode"); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", minecraftVersion); @@ -76,35 +76,12 @@ 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); + context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(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 { - Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent"); - plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(this), plugin); - } catch (final ClassNotFoundException ignored) { - } - } - private Optional tryOrEmpty(final Supplier supplier) { try { return Optional.of(supplier.get()); @@ -113,20 +90,24 @@ 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 { + Factory(final BukkitContext context) { + super(context); + } + + @Override + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + return (Factory) super.addMetric(metric); + } + @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 onFlush(final Runnable flush) { + return (Factory) super.onFlush(flush); } - private static Path getPluginsFolder(final Plugin plugin) { - try { - return plugin.getServer().getPluginsFolder().toPath(); - } catch (final NoSuchMethodError e) { - return plugin.getDataFolder().getParentFile().toPath(); - } + @Override + public BukkitMetrics create() throws IllegalStateException { + return new BukkitMetricsImpl(this, ((BukkitContext) context).plugin); } } } diff --git a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java index 2f2448ed..8e352614 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/PaperEventListener.java @@ -2,16 +2,20 @@ import com.destroystokyo.paper.event.server.ServerExceptionEvent; import com.destroystokyo.paper.exception.ServerPluginException; +import dev.faststats.SimpleContext; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; -record PaperEventListener(BukkitMetricsImpl metrics) implements Listener { +record PaperEventListener(Plugin plugin, SimpleContext context) implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onServerException(final ServerExceptionEvent event) { if (!(event.getException() instanceof final ServerPluginException exception)) return; - if (!exception.getResponsiblePlugin().equals(metrics.plugin())) return; + if (!exception.getResponsiblePlugin().equals(plugin)) return; final var report = exception.getCause() != null ? exception.getCause() : exception; - metrics.getErrorTracker().ifPresent(tracker -> tracker.trackError(report, false)); + context.errorTrackerService().ifPresent(service -> { + service.globalErrorTracker().trackError(report).handled(false); + }); } } diff --git a/bukkit/src/main/java/module-info.java b/bukkit/src/main/java/module-info.java index afc285b2..f74287e8 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 ed20e028..7bf56fae 100644 --- a/bungeecord/build.gradle.kts +++ b/bungeecord/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("net.md-5:bungeecord-api:26.1-R0.1-SNAPSHOT") } diff --git a/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java b/bungeecord/example-plugin/src/main/java/com/example/ExamplePlugin.java index 37d245a9..e9eeb551 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.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(); - // context-unaware error tracker, does not automatically track errors - public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware(); + private final BungeeContext context = new BungeeContext.Factory(this, "YOUR_TOKEN_HERE") + .errorTrackerService(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .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 + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush - // 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})) - - // 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()) + .create(); @Override public void onDisable() { - metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services } - 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 00000000..ef7ff6c1 --- /dev/null +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -0,0 +1,55 @@ +package dev.faststats.bungee; + +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.md_5.bungee.api.plugin.Plugin; +import org.jetbrains.annotations.Contract; + +/** + * BungeeCord FastStats context. + * + * @since 0.24.0 + */ +public final class BungeeContext extends SimpleContext { + final Plugin plugin; + + private BungeeContext(final Factory factory, final Plugin plugin, @Token final String token) { + super(factory, SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath().resolve("faststats").resolve("config.properties")), "bungeecord", token); + this.plugin = plugin; + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new BungeeMetricsImpl(this, plugin); + } + }; + } + + @Override + public String getProjectName() { + return plugin.getDescription().getName(); + } + + public static final class Factory extends SimpleContext.Factory { + private final Plugin plugin; + private final @Token String token; + + public Factory(final Plugin plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + public BungeeContext create() { + return new BungeeContext(this, plugin, token); + } + } +} 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 434f6550..00000000 --- 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 34fd29f0..e38075e4 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeMetricsImpl.java @@ -1,35 +1,33 @@ package dev.faststats.bungee; import com.google.gson.JsonObject; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +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); + 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) context.getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("online_mode", server.getConfig().isOnlineMode()); @@ -38,28 +36,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("proxy_version", server.getVersion()); metrics.addProperty("server_type", server.getName()); } - - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.log(Level.SEVERE, message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warning(message); - } - - static final class Factory extends SimpleMetrics.Factory implements BungeeMetrics.Factory { - @Override - public Metrics create(final Plugin plugin) throws IllegalStateException { - final var dataFolder = plugin.getProxy().getPluginsFolder().toPath().resolve("faststats"); - final var config = dataFolder.resolve("config.properties"); - return new BungeeMetricsImpl(this, plugin, config); - } - } } diff --git a/bungeecord/src/main/java/module-info.java b/bungeecord/src/main/java/module-info.java index 5764d134..1f8b5d5e 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 00000000..e762f00d --- /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 00000000..8308bb00 --- /dev/null +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -0,0 +1,179 @@ +package dev.faststats.config; + +import dev.faststats.Config; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.logging.Level; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApiStatus.Internal +public record SimpleConfig( + UUID serverId, + boolean enabled, + boolean additionalMetrics, + boolean debug, + boolean submitMetrics, + boolean errorTracking, + boolean firstRun +) implements Config { + private static final Logger logger = LoggerFactory.factory().getLogger(SimpleConfig.class); + private static final int CONFIG_VERSION = 1; + + private static final String COMMENT = """ + 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 FastStats enabled is recommended. + # To disable all FastStats features, set 'enabled=false'. + # To disable only metrics submission, set 'submitMetrics=false'. + # To disable only additional metrics, set 'submitAdditionalMetrics=false'. + # To disable only error tracking, set 'submitErrors=false'. + # + # If you suspect a developer is collecting personal data or bypassing any opt-out 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 == null; + final var saveConfig = new AtomicBoolean(firstRun); + + final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { + final var corrected = value.length() > 36 ? value.substring(0, 36) : value; + final var uuid = UUID.fromString(corrected); + if (!value.equals(uuid.toString())) saveConfig.set(true); + return uuid; + }); + final var configVersion = parse(properties, saveConfig, "configVersion", null, Integer::parseInt); + final boolean enabled = parse(properties, saveConfig, "enabled", () -> true, Boolean::parseBoolean); + final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean); + final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean); + final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean); + final boolean debug = parse(properties, saveConfig, "debug", () -> false, Boolean::parseBoolean); + + if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); + else if (configVersion > CONFIG_VERSION) saveConfig.set(false); + + if (saveConfig.get()) try { + if (configVersion == null || configVersion < CONFIG_VERSION) + logger.info("Updating config version to %s", CONFIG_VERSION); + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var store = new Properties(); + + store.setProperty("enabled", Boolean.toString(enabled)); + store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); + store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + + store.setProperty("serverId", serverId.toString()); + + store.setProperty("debug", Boolean.toString(debug)); + store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); + + store.store(writer, COMMENT); + } + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + + return new SimpleConfig( + serverId, + enabled, + enabled && additionalMetrics, + debug, + enabled && submitMetrics, + enabled && errorTracking, + firstRun + ); + } + + // fixme: this code sucks ass + @Contract(value = "_, _, _, !null, _ -> !null") + private static @Nullable T parse( + @Nullable final Properties properties, + final AtomicBoolean saveConfig, + final String key, + @Nullable final Supplier defaultValue, + final Function parser + ) { + if (properties == null) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + final var property = properties.getProperty(key); + if (property == null) { + logger.warn("Missing configuration property: %s", key); + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + try { + return parser.apply(property.trim()); + } catch (final Exception e) { + logger.error("Failed to read property '%s' from config", e, key); + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + } + + private static @Nullable Properties readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return null; + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return 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 00000000..251f4001 --- /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 84a53911..8b573fab 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { compileOnlyApi("org.jetbrains:annotations:26.1.0") compileOnlyApi("org.jspecify:jspecify:1.0.0") + testImplementation(project(":config")) testImplementation("com.google.code.gson:gson:2.14.0") testImplementation("org.junit.jupiter:junit-jupiter") testImplementation(platform("org.junit:junit-bom:6.1.0-RC1")) diff --git a/core/example/build.gradle.kts b/core/example/build.gradle.kts new file mode 100644 index 00000000..eca1ab1c --- /dev/null +++ b/core/example/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":core")) +} diff --git a/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java new file mode 100644 index 00000000..e874eb89 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/ErrorTrackerExample.java @@ -0,0 +1,44 @@ +package dev.faststats.example; + +import dev.faststats.ErrorTracker; +import dev.faststats.FastStatsContext; +import dev.faststats.SimpleContext; + +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 final FastStatsContext CONTEXT = getContextFactory() + .errorTrackerService(CONTEXT_AWARE) // Set the global/internal error tracker + .create(); + + static { + // Register an additional tracker for submission + CONTEXT.errorTrackerService().orElseThrow().registerErrorTracker(CONTEXT_UNAWARE); + } + + public static void manualTracking() { + try { + throw new RuntimeException("Something went wrong!"); + } catch (final Exception e) { + CONTEXT_UNAWARE.trackError(e); + } + } + + private static SimpleContext.Factory getContextFactory() { + return null; + } +} diff --git a/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java new file mode 100644 index 00000000..8e32e979 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/FeatureFlagExample.java @@ -0,0 +1,74 @@ +package dev.faststats.example; + +import dev.faststats.Attributes; +import dev.faststats.FastStatsContext; +import dev.faststats.FeatureFlag; +import dev.faststats.FeatureFlagService; +import dev.faststats.SimpleContext; + +import java.time.Duration; + +public final class FeatureFlagExample { + public static final FastStatsContext CONTEXT = getContextFactory() + // .featureFlagService(FeatureFlagService.Factory::create) // Define a feature flag service with default settings + .featureFlagService(factory -> factory + .attributes(Attributes.empty() // Define global attributes + .put("version", "1.2.3") + .put("java_version", System.getProperty("java.version")) + .put("java_vendor", System.getProperty("java.vendor"))) + .ttl(Duration.ofMinutes(10)) // Custom cache TTL for resolved flag values + .create()) + .create(); + + public static final FeatureFlagService SERVICE = CONTEXT.featureFlagService().orElseThrow(); + + // Define flags with default values + public static final FeatureFlag NEW_COMMANDS = SERVICE.define("new_commands", false); + public static final FeatureFlag COMPRESSION = SERVICE.define("compression", "zstd"); + + 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.isExpired()) { + COMPRESSION.fetch().thenAccept(string -> { + // do stuff with the value + }); + } + + // Opt-in/out (requires allow_specific_opt_in on server) + NEW_COMMANDS.optIn().thenAccept(updatedValue -> { + if (updatedValue) { + // react to the updated server value + } + }); + NEW_COMMANDS.optOut().thenAccept(updatedValue -> { + if (!updatedValue) { + // react to the updated server value + } + }); + } + + private static SimpleContext.Factory getContextFactory() { + 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 00000000..5d7ac3e1 --- /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/example/src/main/java/dev/faststats/example/package-info.java b/core/example/src/main/java/dev/faststats/example/package-info.java new file mode 100644 index 00000000..6e5bd0b2 --- /dev/null +++ b/core/example/src/main/java/dev/faststats/example/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.faststats.example; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file 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 00000000..aad4a817 --- /dev/null +++ b/core/src/main/java/dev/faststats/Attributes.java @@ -0,0 +1,122 @@ +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.24.0 + */ +public sealed interface Attributes permits SimpleAttributes { + /** + * Create new empty attributes. + * + * @return new attributes + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + static Attributes empty() { + 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.24.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.24.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.24.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.24.0 + */ + @Contract(value = "_, _ -> this", mutates = "this") + Attributes put(String key, boolean value); + + /** + * Remove a value. + * + * @param key the key + * @return these attributes + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + Attributes remove(String key); + + /** + * Returns whether a value is set for the given key. + * + * @param key the key + * @return whether a value is set + * @since 0.24.0 + */ + @Contract(pure = true) + boolean containsKey(String key); + + /** + * Visit each stored attribute as its underlying JSON primitive value. + * + * @param action the action to invoke for each key-value pair + * @since 0.24.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.24.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 00000000..e2d570df --- /dev/null +++ b/core/src/main/java/dev/faststats/Config.java @@ -0,0 +1,69 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +import java.util.UUID; + +/** + * A representation of the metrics configuration. + * + * @since 0.24.0 + */ +public interface Config { + /** + * The server id. + * + * @return the server id + * @since 0.24.0 + */ + @Contract(pure = true) + UUID serverId(); + + /** + * Whether all FastStats features are enabled. + * + * @return {@code true} if FastStats features are enabled, {@code false} otherwise + * @since 0.24.0 + */ + @Contract(pure = true) + boolean enabled(); + + /** + * Whether metrics submission is enabled. + *

+ * 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.24.0 + */ + @Contract(pure = true) + boolean submitMetrics(); + + /** + * Whether error tracking is enabled across all metrics instances. + * + * @return {@code true} if error tracking is enabled, {@code false} otherwise + * @since 0.24.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 79% rename from core/src/main/java/dev/faststats/core/ErrorHelper.java rename to core/src/main/java/dev/faststats/ErrorHelper.java index 1ec3fcaa..3649dcd5 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; @@ -20,8 +20,21 @@ final class ErrorHelper { private static final int STACK_TRACE_LENGTH = Math.min(500, Integer.getInteger("faststats.stack-trace-length", 300)); private static final int STACK_TRACE_LIMIT = Math.min(50, Integer.getInteger("faststats.stack-trace-limit", 15)); - public static JsonObject compile(final Throwable error, @Nullable final List suppress, final boolean handled, - final List> customPatterns) { + private static final Set allowedNames = Set.of("minecraft", "server", "root", "ubuntu"); + private static final List> defaultAnonymizationEntries = defaultAnonymizationEntries(); + + public static JsonObject compile(final TrackedError error, @Nullable final List suppress, + final List> customPatterns, + @Nullable final Attributes attributes) { + final var patterns = new ArrayList<>(customPatterns); + patterns.addAll(defaultAnonymizationEntries); + return compileAll(error, suppress, patterns, attributes); + } + + private static JsonObject compileAll(final TrackedError trackedError, @Nullable final List suppress, + final List> customPatterns, + @Nullable final Attributes defaultAttributes) { + final var error = trackedError.error(); final var report = new JsonObject(); final var message = getAnonymizedMessage(error, customPatterns); @@ -44,11 +57,17 @@ public static JsonObject compile(final Throwable error, @Nullable final List parentStack, @Nullable final List suppress, final JsonArray stacktrace, final List> customPatterns) { @@ -154,7 +173,7 @@ private static boolean isSameLoader(final ClassLoader loader, @Nullable final Th for (var i = 0; i < framesToCheck; i++) { final var frame = stackTrace[firstNonLibraryIndex + i]; - if (isLibraryClass(frame.getClassName())) continue; + if (isLibraryFrame(frame.getClassName())) continue; if (!isFromLoader(frame, loader)) return isSameLoader(loader, error.getCause(), visited); } @@ -163,17 +182,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) { @@ -206,15 +225,27 @@ private static boolean isSameClassLoader(final ClassLoader classLoader, final Cl return truncated; } - public static Pattern discordWebhookPattern() { + private static List> defaultAnonymizationEntries() { + final var entries = new ArrayList<>(List.of( + Map.entry(ipv4Pattern(), "[IP hidden]"), + Map.entry(ipv6Pattern(), "[IP hidden]"), + Map.entry(userHomePathPattern(), "$1$2$3[username hidden]"), + Map.entry(discordWebhookPattern(), "$1[token hidden]"), + Map.entry(jdbcUrlPattern(), "$1[password hidden]$2") + )); + usernamePattern().ifPresent(pattern -> entries.add(Map.entry(pattern, "[username hidden]"))); + return entries; + } + + private static Pattern discordWebhookPattern() { return Pattern.compile("(https://discord\\.com/api/webhooks/\\d+/)[\\w-]+"); } - public static Pattern ipv4Pattern() { + private static Pattern ipv4Pattern() { return Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b"); } - public static Pattern ipv6Pattern() { + private static Pattern ipv6Pattern() { return Pattern.compile("(?i)\\b([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\\b|" + // Full form "(?i)\\b([0-9a-f]{1,4}:){1,7}:\\b|" + // Trailing :: "(?i)\\b([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\\b|" + // :: in middle (1 group after) @@ -228,19 +259,17 @@ public static Pattern ipv6Pattern() { "(?i)\\b::\\b"); // Just :: } - public static Pattern jdbcUrlPattern() { + private static Pattern jdbcUrlPattern() { return Pattern.compile("(jdbc:[^:]+://[^:]+:(?:\\d+:)?)[^@]+(@)"); } - public static Pattern userHomePathPattern() { + private static Pattern userHomePathPattern() { return Pattern.compile("(/home/)[^/\\s]+" + // Linux: /home/username "|(/Users/)[^/\\s]+" + // macOS: /Users/username "|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"); // Windows: A-Z:\\Users\\username } - private static final Set allowedNames = Set.of("minecraft", "server", "root", "ubuntu"); - - public static Optional usernamePattern() { + private static Optional usernamePattern() { return Optional.ofNullable(System.getProperty("user.name")) .filter(s -> s.trim().length() > 2) .filter(s -> !allowedNames.contains(s.toLowerCase(Locale.ROOT))) diff --git a/core/src/main/java/dev/faststats/core/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java similarity index 77% rename from core/src/main/java/dev/faststats/core/ErrorTracker.java rename to core/src/main/java/dev/faststats/ErrorTracker.java index 1fe010d0..687b1c7b 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,92 +11,79 @@ /** * An error tracker. * - * @since 0.10.0 + * @since 0.24.0 */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** - * Create and attach a new context-aware error tracker. - *

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

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

- * This tracker will not automatically track any errors. + * Creates a context-aware error tracker policy for the given class loader. *

- * You have to manually track errors using {@code #trackError}. + * The returned tracker has its error context attached immediately. If the class + * loader is {@code null}, the tracker will track all errors. * - * @return the error tracker - * @see #contextAware() - * @see #trackError(String) - * @see #trackError(Throwable) - * @since 0.10.0 + * @param classLoader the class loader whose errors should be tracked, or {@code null} to track all errors + * @return the error tracker policy + * @throws IllegalStateException if the error context is already attached + * @see #attachErrorContext(ClassLoader) + * @since 0.24.0 */ - @Contract(value = " -> new") - static ErrorTracker contextUnaware() { - return new SimpleErrorTracker(); + @Contract(value = "_ -> new", pure = true) + static ErrorTracker contextAware(@Nullable final ClassLoader classLoader) { + final var tracker = new SimpleErrorTracker(); + tracker.attachErrorContext(classLoader); + return tracker; } /** - * Tracks a handled error. + * Creates a context-unaware error tracker policy. * - * @param message the error message - * @see #trackError(Throwable) - * @see #trackError(String, boolean) - * @since 0.10.0 + * @return the error tracker policy + * @since 0.24.0 */ - @Contract(mutates = "this") - void trackError(String message); + @Contract(value = " -> new", pure = true) + static ErrorTracker contextUnaware() { + return new SimpleErrorTracker(); + } /** - * Tracks a handled error. + * Returns the global error context attributes configured for this tracker. * - * @param error the error - * @see #trackError(Throwable, boolean) - * @since 0.10.0 + * @return the global error context attributes + * @since 0.24.0 */ - @Contract(mutates = "this") - void trackError(Throwable error); + @Contract(pure = true) + Attributes getAttributes(); /** - * Tracks an error. - *

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

- * A {@code handled=true} error is expected and properly handled. + * Tracks a handled error. * - * @param error the error - * @param handled whether the error was handled - * @since 0.20.0 + * @param error the error + * @return a new mutable tracked error + * @since 0.24.0 */ @Contract(mutates = "this") - void trackError(Throwable error, boolean handled); + TrackedError trackError(Throwable error); /** * Adds an error type that will not be reported to FastStats. @@ -106,7 +93,7 @@ static ErrorTracker contextUnaware() { * * @param type the error type * @return the error tracker - * @since 0.22.0 + * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Class type); @@ -125,7 +112,7 @@ static ErrorTracker contextUnaware() { * * @param pattern the regex pattern to match against error messages * @return the error tracker - * @since 0.21.0 + * @since 0.24.0 */ @Contract(value = "_ -> this", mutates = "this") ErrorTracker ignoreError(Pattern pattern); @@ -138,7 +125,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.24.0 */ @Contract(value = "_ -> this", mutates = "this") default ErrorTracker ignoreError(@RegExp final String pattern) { @@ -156,7 +143,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.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker ignoreError(Class type, Pattern pattern); @@ -170,7 +157,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.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker ignoreError(final Class type, @RegExp final String pattern) { @@ -187,7 +174,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.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") ErrorTracker anonymize(Pattern pattern, String replacement); @@ -200,7 +187,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.24.0 */ @Contract(value = "_, _ -> this", mutates = "this") default ErrorTracker anonymize(@RegExp final String pattern, final String replacement) { @@ -214,7 +201,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 +214,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 +222,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 +232,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 +241,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 +252,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/ErrorTrackerService.java b/core/src/main/java/dev/faststats/ErrorTrackerService.java new file mode 100644 index 00000000..6e4054db --- /dev/null +++ b/core/src/main/java/dev/faststats/ErrorTrackerService.java @@ -0,0 +1,34 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +/** + * A service for managing error trackers. + *

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

+ * Additional trackers registered here are submitted by the same context, but are not + * used for internal FastStats errors. + * + * @param errorTracker the additional error tracker + * @return this service + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + ErrorTrackerService registerErrorTracker(ErrorTracker errorTracker); +} diff --git a/core/src/main/java/dev/faststats/FastStatsContext.java b/core/src/main/java/dev/faststats/FastStatsContext.java new file mode 100644 index 00000000..6a667283 --- /dev/null +++ b/core/src/main/java/dev/faststats/FastStatsContext.java @@ -0,0 +1,86 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +import java.util.Optional; + +/** + * Shared FastStats context. + *

+ * Platform-specific contexts should extend this class to provide a shared + * configuration, token, metrics, feature flag service, and error tracker service + * for their environment. + * + * @since 0.24.0 + */ +public sealed interface FastStatsContext permits SimpleContext { + /** + * Get the metrics configuration shared by services created from this context. + * + * @return the shared configuration + * @since 0.24.0 + */ + @Contract(pure = true) + Config getConfig(); + + /** + * Get the token shared by services created from this context. + * + * @return the shared token + * @since 0.24.0 + */ + @Token + @Contract(pure = true) + String getToken(); + + /** + * Gets the metrics instance bound to this context. + * + * @return the context metrics instance, if one was configured + * @since 0.24.0 + */ + @Contract(pure = true) + Optional metrics(); + + /** + * Gets the feature flag service bound to this context. + * + * @return the context feature flag service, if one was configured + * @since 0.24.0 + */ + @Contract(pure = true) + Optional featureFlagService(); + + /** + * Gets the error tracker service bound to this context. + * + * @return the context error tracker service, if one was configured + * @since 0.24.0 + */ + @Contract(pure = true) + Optional errorTrackerService(); + + /** + * Performs additional post-startup tasks for configured context services. + * + * @since 0.24.0 + */ + void ready(); + + /** + * Safely shuts down configured context services. + * + * @since 0.24.0 + */ + @Contract(mutates = "this") + void shutdown(); + + /** + * Get the SDK information shared by services created from this context. + * + * @return the shared SDK information + * @since 0.24.0 + */ + @Contract(pure = true) + SdkInfo getSdkInfo(); +} 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 00000000..e713dce9 --- /dev/null +++ b/core/src/main/java/dev/faststats/FeatureFlag.java @@ -0,0 +1,206 @@ +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.24.0 + */ +public sealed interface FeatureFlag permits SimpleFeatureFlag { + /** + * Get the flag identifier. + * + * @return the flag id + * @since 0.24.0 + */ + @Contract(pure = true) + String getId(); + + /** + * Returns the type representing the value type of this flag. + * + * @return the value type class + * @since 0.24.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.24.0 + */ + @Contract(pure = true) + Class getTypeClass(); + + /** + * Get the current cached flag value. + *

+ * It returns {@link Optional#empty() empty} until a value has been fetched and + * stored locally. + * + * @return the cached value, if present + * @see #fetch() + * @since 0.24.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.24.0 + */ + @Contract(pure = true) + Optional getExpiration(); + + /** + * Returns whether the current cached value is expired. + *

+ * A value is expired when no fetch has completed yet or when the + * configured TTL has elapsed since the last fetch. + * + * @return {@code true} if the cached value is absent or stale + * @see #getExpiration() + * @see #isValid() + * @since 0.24.0 + */ + @Contract(pure = true) + boolean isExpired(); + + /** + * 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.24.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.24.0 + */ + CompletableFuture whenReady(); + + /** + * Force a fresh fetch of the flag value from the server. + *

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

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

+ * Failed fetches do not update the cached value. Failure details are only + * logged when debug logging is enabled. + * + * @return a future completing with the latest server value + * @see #whenReady() + * @since 0.24.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 + * @see #fetch() + * @since 0.24.0 + */ + @Contract(mutates = "this") + CompletableFuture optIn(); + + /** + * Request that the server opt out of this flag, then invalidate the local + * value and fetch the current server value again. + *

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

+ * The returned future completes with the updated value after the local + * cache has been reset and the follow-up fetch finishes. + * + * @return a future completing with the updated flag value + * @see #fetch() + * @since 0.24.0 + */ + @Contract(mutates = "this") + CompletableFuture optOut(); + + /** + * Get the default value for this flag. + * + * @return the default value + * @since 0.24.0 + */ + @Contract(pure = true) + T getDefaultValue(); + + /** + * Supported value types for feature flags. + * + * @since 0.24.0 + */ + enum Type { + /** + * A string-valued flag. + * + * @since 0.24.0 + */ + STRING, + + /** + * A boolean-valued flag. + * + * @since 0.24.0 + */ + BOOLEAN, + + /** + * A numeric flag. + * + * @since 0.24.0 + */ + NUMBER + } +} diff --git a/core/src/main/java/dev/faststats/FeatureFlagService.java b/core/src/main/java/dev/faststats/FeatureFlagService.java new file mode 100644 index 00000000..d6914131 --- /dev/null +++ b/core/src/main/java/dev/faststats/FeatureFlagService.java @@ -0,0 +1,144 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +import java.time.Duration; + +/** + * A service for managing feature flags. + *

+ * Use {@link FastStatsContext#featureFlagService()} to access the context service instance. + * + * @since 0.24.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.24.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.24.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.24.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.24.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.24.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.24.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 + * @since 0.24.0 + */ + @Contract(pure = true) + Attributes getAttributes(); + + /** + * Returns the cache time-to-live used for resolved flag values. + * + * @return the configured cache time-to-live + * @since 0.24.0 + */ + Duration getTTL(); + + /** + * A feature flag service factory. + * + * @since 0.24.0 + */ + sealed interface Factory permits SimpleFeatureFlagService.Factory { + /** + * Sets the global targeting attributes for services created by this factory. + *

+ * These attributes apply to every flag defined by the service and are + * merged with any per-flag attributes supplied during definition. + * + * @param attributes the global targeting attributes + * @return the feature flag service factory + * @since 0.24.0 + */ + @Contract(mutates = "this") + Factory attributes(Attributes attributes); + + /** + * Sets the cache time-to-live for resolved flag values. + * + * @param ttl the cache time-to-live for resolved flag values + * @return the feature flag service factory + * @throws IllegalArgumentException if the TTL is negative + * @since 0.24.0 + */ + @Contract(mutates = "this") + Factory ttl(Duration ttl) throws IllegalArgumentException; + + /** + * Creates a new feature flag service. + * + * @return the feature flag service + * @throws IllegalArgumentException if the TTL is negative + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + FeatureFlagService create() throws IllegalArgumentException; + } +} diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java new file mode 100644 index 00000000..b842cca2 --- /dev/null +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -0,0 +1,58 @@ +package dev.faststats; + +import dev.faststats.data.Metric; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; + +/** + * Metrics interface. + * + * @since 0.24.0 + */ +public interface Metrics { + /** + * A metrics factory. + * + * @since 0.24.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.24.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.24.0 + */ + @Contract(mutates = "this") + Factory onFlush(Runnable flush); + + /** + * Creates a new metrics instance. + *

+ * Metrics submission will start automatically. + * + * @return the metrics instance + * @throws IllegalStateException if the token is not specified + * @since 0.24.0 + */ + @Async.Schedule + @Contract(value = " -> new", mutates = "io") + Metrics create() throws IllegalStateException; + } + +} diff --git a/core/src/main/java/dev/faststats/SdkInfo.java b/core/src/main/java/dev/faststats/SdkInfo.java new file mode 100644 index 00000000..6f00f195 --- /dev/null +++ b/core/src/main/java/dev/faststats/SdkInfo.java @@ -0,0 +1,52 @@ +package dev.faststats; + +import java.util.Optional; + +/** + * Information that identifies the SDK implementation using FastStats. + * + * @since 0.24.0 + */ +public sealed interface SdkInfo permits SimpleSdkInfo { + /** + * Get the build identifier of the project that implements this SDK. + *

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

+ * The user agent should include enough information to identify the client + * implementation, including the vendor name, SDK name, and SDK version. + * It may also include contact information, such as an email address, + * repository URL, Discord server, or website, so FastStats can reach the + * implementation owner in case of abuse or operational problems. + * + * @return the HTTP user agent + * @since 0.24.0 + */ + String getUserAgent(); +} 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 00000000..584e27cf --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleAttributes.java @@ -0,0 +1,43 @@ +package dev.faststats; + +import com.google.gson.JsonPrimitive; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +record SimpleAttributes(ConcurrentHashMap 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 boolean containsKey(final String key) { + return attributes.containsKey(key); + } + + @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 00000000..ef17a68a --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -0,0 +1,212 @@ +package dev.faststats; + +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.MustBeInvokedByOverriders; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.HashSet; +import java.util.Optional; +import java.util.Properties; +import java.util.function.Function; + +public non-sealed abstract class SimpleContext implements FastStatsContext { + private final Logger logger = LoggerFactory.factory().getLogger(getClass()); + + private final Config config; + private final @Token String token; + private final SdkInfo sdkInfo; + + private @Nullable Metrics metrics; + private @Nullable FeatureFlagService featureFlagService; + private @Nullable ErrorTrackerService errorTrackerService; + + /** + * Creates a new context that stores the shared configuration and token for all FastStats services. + * + * @param config the shared configuration + * @param name the name of the SDK + * @param token the FastStats project token + * @throws IllegalArgumentException if the token is invalid + * @throws IllegalArgumentException if the SDK information is invalid + * @throws IllegalStateException if the SDK information is incomplete or missing + * @throws UncheckedIOException if an IO error occurs + * @since 0.24.0 + */ + protected SimpleContext(final Factory factory, final Config config, final String name, @Token final String token) throws IllegalArgumentException { + if (!token.matches(Token.PATTERN)) + throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'"); + + this.sdkInfo = constructSdkInfo(name); + this.config = config; + this.token = token; + } + + @MustBeInvokedByOverriders + protected final void initializeServices(final Factory factory) throws IllegalStateException { + this.metrics = factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; + this.errorTrackerService = factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; + this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(config, token)) : null; + + if (metrics == null && errorTrackerService == null && featureFlagService == null) + throw new IllegalStateException("Context created without any service attached, was this intentional?"); + + final var features = new HashSet(3); + features.add("metrics=" + (metrics != null ? "yes" : "no")); + features.add("error-tracking=" + (errorTrackerService != null ? "yes" : "no")); + features.add("feature-flags=" + (featureFlagService != null ? "yes" : "no")); + + logger.info("Created FastStats context for %s using %s (%s)", + getProjectName(), sdkInfo.getUserAgent(), + String.join(", ", features) + ); + } + + private SdkInfo constructSdkInfo(final String name) throws UncheckedIOException, IllegalStateException, IllegalArgumentException { + try (final var stream = getClass().getResourceAsStream("/META-INF/faststats.properties")) { + if (stream == null) throw new IllegalStateException("Resource '/META-INF/faststats.properties' not found"); + + final var properties = new Properties(); + properties.load(stream); + + final var version = properties.getProperty("version", null); + if (version == null) throw new IllegalStateException("Missing 'version' in faststats.properties"); + + final var buildId = properties.getProperty("build-id", null); + + return new SimpleSdkInfo(name, version, buildId); + } catch (final IOException e) { + throw new UncheckedIOException("Failed to read faststats.properties from META-INF", e); + } + } + + @Contract(pure = true) + public abstract String getProjectName(); + + @Override + @Contract(pure = true) + public final Config getConfig() { + return config; + } + + @Override + @Contract(pure = true) + public final @Token String getToken() { + return token; + } + + @Override + @Contract(pure = true) + public final Optional metrics() { + return Optional.ofNullable(metrics); + } + + @Override + @Contract(pure = true) + public final Optional featureFlagService() { + return Optional.ofNullable(featureFlagService); + } + + @Contract(value = " -> new", pure = true) + protected abstract Metrics.Factory metricsFactory(); + + @Contract(value = " -> new", pure = true) + protected FeatureFlagService.Factory featureFlagServiceFactory() { + return new SimpleFeatureFlagService.Factory(config, token); + } + + @Override + @Contract(pure = true) + public final Optional errorTrackerService() { + return Optional.ofNullable(errorTrackerService); + } + + @Override + public void ready() { + } + + @Override + public final void shutdown() { + if (errorTrackerService instanceof final SimpleErrorTrackerService service) service.clear(); + if (featureFlagService instanceof final SimpleFeatureFlagService service) service.shutdown(); + if (metrics instanceof final SimpleMetrics simpleMetrics) simpleMetrics.shutdown(); + } + + @Override + @Contract(pure = true) + public SdkInfo getSdkInfo() { + return sdkInfo; + } + + /** + * Factory for creating a configured FastStats context. + *

+ * Platform implementations may extend this class with constructors that accept + * platform-specific objects before creating the context. + * + * @param the context type created by this factory + * @param the concrete factory type + * @since 0.24.0 + */ + public abstract static class Factory> { + private @Nullable Function metrics = null; + private @Nullable Function featureFlagService; + private @Nullable ErrorTracker errorTracker; + + /** + * Configures the global/internal error tracker for the context. + * + * @param errorTracker the global/internal error tracker + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F errorTrackerService(final ErrorTracker errorTracker) { + this.errorTracker = errorTracker; + return self(); + } + + /** + * Configures and creates the single metrics instance for the context. + * + * @param metrics a function that receives a new metrics factory and returns the built metrics instance + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F metrics(final Function metrics) { + this.metrics = metrics; + return self(); + } + + /** + * Configures and creates the single feature flag service instance for the context. + * + * @param featureFlagService a function that receives a new service factory and returns the built service instance + * @return this factory + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public F featureFlagService(final Function featureFlagService) { + this.featureFlagService = featureFlagService; + return self(); + } + + /** + * Creates the configured context. + * + * @return the configured context + * @since 0.24.0 + */ + @Contract(value = " -> new", mutates = "io") + public abstract C create(); + + @SuppressWarnings("unchecked") + private F self() { + return (F) this; + } + } +} diff --git a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java similarity index 50% rename from core/src/main/java/dev/faststats/core/SimpleErrorTracker.java rename to core/src/main/java/dev/faststats/SimpleErrorTracker.java index b0969e85..4872366a 100644 --- a/core/src/main/java/dev/faststats/core/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -1,10 +1,9 @@ -package dev.faststats.core; +package dev.faststats; import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import org.jetbrains.annotations.VisibleForTesting; import org.jspecify.annotations.Nullable; -import java.lang.Thread.UncaughtExceptionHandler; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; @@ -19,54 +18,39 @@ import java.util.regex.Pattern; final class SimpleErrorTracker implements ErrorTracker { - private final Map collected = new ConcurrentHashMap<>(); - private final Map reports = new ConcurrentHashMap<>(); + private final Map reports = new ConcurrentHashMap<>(); + private final Attributes attributes = Attributes.empty(); private final Map, Set> ignoredTypedPatterns = new ConcurrentHashMap<>(); private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); private final Set ignoredPatterns = new CopyOnWriteArraySet<>(); - private final List> anonymizationEntries = new CopyOnWriteArrayList<>(List.of( - Map.entry(ErrorHelper.ipv4Pattern(), "[IP hidden]"), - Map.entry(ErrorHelper.ipv6Pattern(), "[IP hidden]"), - Map.entry(ErrorHelper.userHomePathPattern(), "$1$2$3[username hidden]"), - Map.entry(ErrorHelper.discordWebhookPattern(), "$1[token hidden]"), - Map.entry(ErrorHelper.jdbcUrlPattern(), "$1[password hidden]$2") - )); + private final List> anonymizationEntries = new CopyOnWriteArrayList<>(); - private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent = null; - private volatile @Nullable UncaughtExceptionHandler originalHandler = null; - - public SimpleErrorTracker() { - ErrorHelper.usernamePattern().ifPresent(pattern -> anonymizationEntries.add(Map.entry(pattern, "[username hidden]"))); - } - - @Override - public void trackError(final String message) { - trackError(message, true); - } + private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent; + private volatile @Nullable ClassLoader attachedLoader; + private volatile boolean contextAttached; @Override - public void trackError(final Throwable error) { - trackError(error, true); + public Attributes getAttributes() { + return attributes; } @Override - public void trackError(final String message, final boolean handled) { - trackError(new RuntimeException(message), handled); + public TrackedError trackError(final String message) { + return trackError(new RuntimeException(message)); } @Override - public void trackError(final Throwable error, final boolean handled) { + public TrackedError trackError(final Throwable error) { + final var trackedError = new SimpleTrackedError(error); try { - if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return; - final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries); - final var hashed = MurmurHash3.hash(compiled); - if (collected.compute(hashed, (k, v) -> { - return v == null ? 1 : v + 1; - }) > 1) return; - reports.put(hashed, compiled); + if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return trackedError; + reports.compute(trackedError, (key, reports) -> { + return reports != null ? reports + 1 : 1; + }); } catch (final NoClassDefFoundError ignored) { } + return trackedError; } private boolean isIgnored(@Nullable final Throwable error, final Set visited) { @@ -108,64 +92,45 @@ public ErrorTracker anonymize(final Pattern pattern, final String replacement) { return this; } - public JsonArray getData(final String buildId) { - final var report = new JsonArray(reports.size()); - - reports.forEach((hash, object) -> { - final var copy = object.deepCopy(); - copy.addProperty("hash", hash); - copy.addProperty("buildId", buildId); - final var count = collected.getOrDefault(hash, 1); - if (count > 1) copy.addProperty("count", count); - report.add(copy); - }); - - collected.forEach((hash, count) -> { - if (count <= 0 || reports.containsKey(hash)) return; - final var entry = new JsonObject(); - - entry.addProperty("hash", hash); - if (count > 1) entry.addProperty("count", count); + @VisibleForTesting + public JsonArray getFullData() { + return getData(true); + } - report.add(entry); + JsonArray getData(final boolean includeTrackerAttributes) { + final var report = new JsonArray(reports.size()); + reports.forEach((error, count) -> { + final var attributes = includeTrackerAttributes ? this.attributes : null; + final var compiled = ErrorHelper.compile(error, null, anonymizationEntries, attributes); + if (count > 1) compiled.addProperty("count", count); + report.add(compiled); }); - return report; } + @VisibleForTesting public void clear() { - collected.replaceAll((k, v) -> 0); reports.clear(); } @Override public synchronized void attachErrorContext(@Nullable final ClassLoader loader) throws IllegalStateException { - if (originalHandler != null) throw new IllegalStateException("Error context already attached"); - originalHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { - final var handler = originalHandler; - if (handler != null) handler.uncaughtException(thread, error); - try { - if (loader != null && !ErrorTracker.isSameLoader(loader, error)) return; - final var event = errorEvent; - if (event != null) event.accept(loader, error); - trackError(error, false); - } catch (final Throwable t) { - trackError(t, false); - } - }); + if (contextAttached) throw new IllegalStateException("Error context already attached"); + contextAttached = true; + attachedLoader = loader; + SimpleErrorTrackerService.attachErrorTracker(this); } @Override public synchronized void detachErrorContext() { - if (originalHandler == null) return; - Thread.setDefaultUncaughtExceptionHandler(originalHandler); - originalHandler = null; + if (!contextAttached) return; + contextAttached = false; + SimpleErrorTrackerService.detachErrorTracker(this); } @Override - public synchronized boolean isContextAttached() { - return originalHandler != null; + public boolean isContextAttached() { + return contextAttached; } @Override @@ -177,4 +142,8 @@ public synchronized void setContextErrorHandler(@Nullable final BiConsumer<@Null public synchronized Optional> getContextErrorHandler() { return Optional.ofNullable(errorEvent); } + + @Nullable ClassLoader attachedLoader() { + return attachedLoader; + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java new file mode 100644 index 00000000..743ee237 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -0,0 +1,251 @@ +package dev.faststats; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.VisibleForTesting; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.net.ConnectException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPOutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; + +final class SimpleErrorTrackerService implements ErrorTrackerService { + private final Logger logger = LoggerFactory.factory().getLogger(getClass()); + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + private final SimpleContext context; + private final URI url = getErrorTrackerServerUrl(); + private final SimpleErrorTracker globalErrorTracker; + + final Set errorTrackers = new CopyOnWriteArraySet<>(); + final Set> submissionJobs = new CopyOnWriteArraySet<>(); + + private volatile @Nullable ScheduledExecutorService submissionScheduler; + private volatile @Nullable ScheduledFuture errorSubmissionJob; + + private static final Object DISPATCHER_LOCK = new Object(); + private static final Set DISPATCHER_TRACKERS = new CopyOnWriteArraySet<>(); + private static final ThreadLocal DISPATCHING = ThreadLocal.withInitial(() -> false); + private static Thread.@Nullable UncaughtExceptionHandler originalHandler; + + SimpleErrorTrackerService(final SimpleContext context, final ErrorTracker globalErrorTracker) { + this.context = context; + this.globalErrorTracker = ((SimpleErrorTracker) globalErrorTracker); + startErrorSubmission(); + } + + @Override + public ErrorTracker globalErrorTracker() { + return globalErrorTracker; + } + + @Override + public ErrorTrackerService registerErrorTracker(final ErrorTracker errorTracker) { + errorTrackers.add(((SimpleErrorTracker) errorTracker)); + startErrorSubmission(); + return this; + } + + // fixme: hacky shit; it only has to compile and pass tests for now + static void attachErrorTracker(final SimpleErrorTracker tracker) { + synchronized (DISPATCHER_LOCK) { + if (DISPATCHER_TRACKERS.isEmpty()) { + originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(SimpleErrorTrackerService::handleUncaughtException); + } + DISPATCHER_TRACKERS.add(tracker); + } + } + + // fixme: hacky shit; it only has to compile and pass tests for now + static void detachErrorTracker(final SimpleErrorTracker tracker) { + synchronized (DISPATCHER_LOCK) { + DISPATCHER_TRACKERS.remove(tracker); + if (DISPATCHER_TRACKERS.isEmpty()) { + Thread.setDefaultUncaughtExceptionHandler(originalHandler); + originalHandler = null; + } + } + } + + // fixme: hacky shit; it only has to compile and pass tests for now + private static void handleUncaughtException(final Thread thread, final Throwable error) { + if (!DISPATCHING.get()) { + DISPATCHING.set(true); + try { + for (final var tracker : DISPATCHER_TRACKERS) { + final var loader = tracker.attachedLoader(); + if (loader != null && !ErrorHelper.isSameLoader(loader, error)) continue; + tracker.trackError(error).handled(false); + tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); + } + } finally { + DISPATCHING.set(false); + } + } + + final var handler = originalHandler; + if (handler != null) handler.uncaughtException(thread, error); + } + + private static URI getErrorTrackerServerUrl() { + final var property = System.getProperty("faststats.error-tracker-server"); + if (property != null) try { + return new URI(property); + } catch (final URISyntaxException e) { + final var logger = LoggerFactory.factory().getLogger(SimpleMetrics.class); + logger.error("Failed to parse error tracker server url: %s", e, property); + } + return URI.create("https://metrics.faststats.dev/v1/error"); + } + + // todo: improve logging to be less cluttered; dedupe code + void submit() { + if (!context.getConfig().errorTracking()) return; + + final var data = createData(); + if (data == null) return; + + try (final var byteOutput = new ByteArrayOutputStream(); + final var output = new GZIPOutputStream(byteOutput)) { + output.write(data.toString().getBytes(UTF_8)); + output.finish(); + + final var compressed = byteOutput.toByteArray(); + logger.info("Sending errors to: %s", url); + logger.info("Uncompressed data: %s", data); + // todo: dedupe this + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofByteArray(compressed)) + .header("Content-Encoding", "gzip") + .header("Content-Type", "application/octet-stream") + .header("Authorization", "Bearer " + context.getToken()) + .header("User-Agent", context.getSdkInfo().getUserAgent()) + .timeout(Duration.ofSeconds(3)) + .uri(url) + .build(); + + final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); + final var statusCode = response.statusCode(); + final var body = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + logger.info("Errors submitted with status code: %s (%s)", statusCode, body); + clear(); + } else if (statusCode >= 300 && statusCode < 400) { + logger.warn("Received redirect response from error server: %s (%s)", statusCode, body); + } else if (statusCode >= 400 && statusCode < 500) { + logger.error("Submitted invalid request to error server: %s (%s)", null, statusCode, body); + } else if (statusCode >= 500 && statusCode < 600) { + logger.error("Received server error response from error server: %s (%s)", null, statusCode, body); + } else { + logger.warn("Received unexpected response from error server: %s (%s)", statusCode, body); + } + } catch (final HttpConnectTimeoutException t) { + logger.error("Error submission timed out after 3 seconds: %s", null, url); + } catch (final ConnectException t) { + logger.error("Failed to connect to error server: %s", null, url); + } catch (final Throwable t) { + logger.error("Failed to submit errors", t); + } + } + + @VisibleForTesting + public @Nullable JsonObject createData() { + final var globalErrorTrackerData = globalErrorTracker.getData(false); + if (errorTrackers.isEmpty() && globalErrorTrackerData.isEmpty()) return null; + + final var data = new JsonObject(); + context.getSdkInfo().getBuildId().ifPresent(id -> data.addProperty("buildId", id)); + data.addProperty("identifier", context.getConfig().serverId().toString()); + data.addProperty("language", "java"); + data.addProperty("project_name", context.getProjectName()); + data.addProperty("sdk_name", context.getSdkInfo().getName()); + data.addProperty("sdk_version", context.getSdkInfo().getVersion()); + + final var defaultContext = new JsonObject(); + context.metrics().ifPresent(metrics -> { + final var simpleMetrics = (SimpleMetrics) metrics; + simpleMetrics.appendData(defaultContext); + }); + globalErrorTracker.getAttributes().forEachPrimitive(defaultContext::add); + data.add("context", defaultContext); + + final var errors = new JsonArray(); + errors.addAll(globalErrorTrackerData); + errorTrackers.forEach(tracker -> errors.addAll(tracker.getFullData())); + data.add("errors", errors); + return data; + } + + void clear() { + globalErrorTracker.clear(); + errorTrackers.forEach(SimpleErrorTracker::clear); + } + + ScheduledFuture scheduleSubmission( + final Runnable task, + final long initialDelay, + final long period, + final TimeUnit unit + ) { + final var scheduler = submissionScheduler(); + final var future = scheduler.scheduleAtFixedRate(task, Math.max(0, initialDelay), Math.max(1000, period), unit); + submissionJobs.add(future); + return future; + } + + void unregisterSubmission(final ScheduledFuture future) { + future.cancel(false); + submissionJobs.remove(future); + } + + boolean isSubmissionSchedulerRunning() { + final var scheduler = submissionScheduler; + return scheduler != null && !scheduler.isShutdown(); + } + + private ScheduledExecutorService submissionScheduler() { + var scheduler = submissionScheduler; + if (scheduler != null && !scheduler.isShutdown()) return scheduler; + synchronized (this) { + scheduler = submissionScheduler; + if (scheduler != null && !scheduler.isShutdown()) return scheduler; + submissionScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + final var thread = new Thread(runnable, "faststats-submitter"); + thread.setDaemon(true); + return thread; + }); + return submissionScheduler; + } + } + + void startErrorSubmission() { + if (!context.getConfig().errorTracking() || errorSubmissionJob != null) return; + errorSubmissionJob = scheduleSubmission( + this::submit, + TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30)), + TimeUnit.MINUTES.toMillis(30), + TimeUnit.MILLISECONDS + ); + } +} 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 00000000..b60b6d4e --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlag.java @@ -0,0 +1,141 @@ +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; + + private volatile @Nullable T value; + private volatile @Nullable Long lastFetch; + + 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()); + } + + @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; + }; + } + + public void setValue(@Nullable final T value) { + this.value = value; + } + + public void setLastFetch(@Nullable final Long lastFetch) { + this.lastFetch = lastFetch; + } + + @Override + public Optional getCached() { + return Optional.ofNullable(value); + } + + @Override + public Optional getExpiration() { + final var lastFetch = this.lastFetch; + if (lastFetch == null) return Optional.empty(); + return Optional.of(Instant.ofEpochMilli(lastFetch).plus(service.getTTL())); + } + + @Override + public boolean isValid() { + return value != null && !isExpired(); + } + + @Override + public boolean isExpired() { + final var lastFetch = this.lastFetch; + if (lastFetch == null) return true; + return System.currentTimeMillis() - lastFetch > service.getTTL().toMillis(); + } + + @Override + public CompletableFuture whenReady() { + final var cached = value; + if (cached == null || isExpired()) return fetch(); + return CompletableFuture.completedFuture(cached); + } + + @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 00000000..b6cf97a4 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleFeatureFlagService.java @@ -0,0 +1,254 @@ +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.math.BigDecimal; +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.util.Map; +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 static final Duration DEFAULT_TTL = Duration.ofMinutes(5); + + 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 Attributes attributes; + private final Duration ttl; + + private final Map> fetchesInProgress = new ConcurrentHashMap<>(); + + SimpleFeatureFlagService( + final Config config, + final @Token String token, + final 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"); + } + + @SuppressWarnings("unchecked") + CompletableFuture fetch(final SimpleFeatureFlag flag) { + return (CompletableFuture) fetchesInProgress.computeIfAbsent(flag.getId(), ignored -> createFetch(flag)); + } + + CompletableFuture optIn(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/v1/opt-in"); + } + + CompletableFuture optOut(final SimpleFeatureFlag flag) { + return sendOptRequest(flag, "/v1/opt-out"); + } + + private CompletableFuture sendOptRequest(final SimpleFeatureFlag flag, final String path) { + final var requestBody = new JsonObject(); + requestBody.addProperty("identifier", serverId.toString()); + requestBody.addProperty("flag", flag.getId()); + + final var request = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .timeout(Duration.ofSeconds(3)) + .uri(url.resolve(path)) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenCompose(response -> { + if (response.statusCode() >= 200 && response.statusCode() < 300) return fetch(flag); + logger.error("Feature flag opt request failed with status %s (%s)", null, response.statusCode(), response.body()); + return CompletableFuture.failedFuture(new IllegalStateException( + "Feature flag opt request failed with status %s (%s)".formatted(response.statusCode(), response.body()) + )); + }); + } + + private CompletableFuture createFetch(final SimpleFeatureFlag flag) { + final var requestBody = new JsonObject(); + requestBody.addProperty("identifier", serverId.toString()); + requestBody.addProperty("key", flag.getId()); + + final var attributes = new JsonObject(); + 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("/v1/check")) + .build(); + + logger.info("Fetching %s: %s", request.uri(), requestBody.toString()); + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { + try { + final var body = JsonParser.parseString(response.body()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + logger.warn("Unexpected response status: %s (%s)", response.statusCode(), body); + throw new IllegalStateException("Unexpected response status: %s (%s)".formatted(response.statusCode(), body)); + } + + final var value = getValue(flag, body); + logger.info("Fetch returned body: %s (value: %s)", body, value); + flag.setLastFetch(System.currentTimeMillis()); + flag.setValue(value); + return value; + } catch (final JsonParseException e) { + logger.error("Unexpected response body: %s (%s)", e, response.body(), response.statusCode()); + throw new IllegalStateException("Unexpected response body: %s (%s)".formatted(response.body(), response.statusCode()), e); + } + }).whenComplete((ignored, throwable) -> fetchesInProgress.remove(flag.getId())); + } + + @SuppressWarnings("unchecked") + private static T getValue(final SimpleFeatureFlag flag, final JsonElement body) { + if (!(body instanceof final JsonObject object)) { + logger.warn("Unexpected JSON response: %s", body); + throw new IllegalStateException("Unexpected JSON response: " + body); + } + if (!(object.get("value") instanceof final JsonPrimitive primitive)) { + logger.warn("Missing or invalid 'value' in JSON response: %s", body); + throw new IllegalStateException("Missing or invalid 'value' in JSON response: " + body); + } + + return (T) switch (flag.getType()) { + case STRING -> primitive.getAsString(); + case NUMBER -> getAsNumber(primitive); + case BOOLEAN -> getAsBoolean(primitive); + }; + } + + private static Number getAsNumber(final JsonPrimitive primitive) { + try { + if (primitive.isNumber()) return primitive.getAsNumber(); + return new BigDecimal(primitive.getAsString()); + } catch (final NumberFormatException e) { + logger.warn("Expected a number but got: %s", primitive.getAsString()); + throw new IllegalStateException("Expected a number but got: " + primitive.getAsString(), e); + } + } + + private static boolean getAsBoolean(final JsonPrimitive primitive) { + if (primitive.isBoolean()) return primitive.getAsBoolean(); + return switch (primitive.getAsString()) { + case "true" -> true; + case "false" -> false; + default -> { + logger.warn("Expected a boolean but got: %s", primitive.getAsString()); + throw new IllegalStateException("Expected a boolean but got: " + primitive.getAsString()); + } + }; + } + + @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 Attributes getAttributes() { + return attributes; + } + + @Override + public Duration getTTL() { + return ttl; + } + + public void shutdown() { + fetchesInProgress.values().forEach(fetch -> fetch.cancel(true)); + fetchesInProgress.clear(); + } + + static final class Factory implements FeatureFlagService.Factory { + private final Config config; + private final @Token String token; + private @Nullable Attributes attributes; + private Duration ttl = DEFAULT_TTL; + + Factory(final Config config, final @Token String token) { + this.config = config; + this.token = token; + } + + @Override + public FeatureFlagService.Factory attributes(final Attributes attributes) { + this.attributes = attributes; + return this; + } + + @Override + public FeatureFlagService.Factory ttl(final Duration ttl) throws IllegalArgumentException { + if (ttl.isNegative()) throw new IllegalArgumentException("TTL cannot be negative"); + this.ttl = ttl; + return this; + } + + @Override + public FeatureFlagService create() throws IllegalArgumentException { + final var attributes = this.attributes != null ? this.attributes : Attributes.empty(); + return new SimpleFeatureFlagService(config, token, attributes, ttl); + } + } +} diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java new file mode 100644 index 00000000..0cc26d29 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -0,0 +1,268 @@ +package dev.faststats; + +import com.google.gson.JsonObject; +import dev.faststats.data.Metric; +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.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.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.zip.GZIPOutputStream; + +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 final ScheduledExecutorService submissionScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + final var thread = new Thread(runnable, "faststats-submitter"); + thread.setDaemon(true); + return thread; + }); + private @Nullable ScheduledFuture submissionJob = null; + + private final @Nullable Runnable flush; + private final Set> metrics; + private final URI url; + + protected final SimpleContext context; + + @Contract(mutates = "io") + protected SimpleMetrics(final Factory factory) throws IllegalStateException { + this(factory, getMetricsServerUrl()); + } + + private static URI getMetricsServerUrl() { + final var property = System.getProperty("faststats.metrics-server"); + if (property != null) try { + return new URI(property); + } catch (final URISyntaxException e) { + final var logger = LoggerFactory.factory().getLogger(SimpleMetrics.class); + logger.error("Failed to parse metrics server url: %s", e, property); + } + return URI.create("https://metrics.faststats.dev/v1/collect"); + } + + @VisibleForTesting + protected SimpleMetrics( + final Factory factory, + final URI url + ) { + this.context = factory.context; + this.metrics = context.getConfig().additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); + final var debug = context.getConfig().debug() || Boolean.getBoolean("faststats.debug"); + this.logger.setFilter(level -> debug || level.equals(Level.CONFIG)); + this.flush = factory.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 (!context.getConfig().submitMetrics() || !enabled) { + logger.warn("Metrics disabled, not starting submission"); + return; + } + + if (isSubmitting()) { + logger.warn("Metrics already submitting, not starting again"); + return; + } + + logger.info("Starting metrics submission"); + submissionJob = submissionScheduler.scheduleAtFixedRate( + this::submit, + Math.max(0, initialDelay), + Math.max(1000, period), + unit + ); + } + + protected boolean isSubmitting() { + return submissionJob != null && !submissionJob.isCancelled(); + } + + // todo: improve logging to be less cluttered + @VisibleForTesting + public boolean submit() { + 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 " + context.getToken()) + .header("User-Agent", context.getSdkInfo().getUserAgent()) + .timeout(Duration.ofSeconds(3)) + .uri(url) + .build(); + + logger.info("Sending metrics to: %s", url); + final var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)); + final var statusCode = response.statusCode(); + final var body = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + logger.info("Metrics submitted with status code: %s (%s)", statusCode, body); + if (flush != null) flush.run(); + return true; + } else if (statusCode >= 300 && statusCode < 400) { + logger.warn("Received redirect response from metrics server: %s (%s)", statusCode, body); + } else if (statusCode >= 400 && statusCode < 500) { + logger.error("Submitted invalid request to metrics server: %s (%s)", null, statusCode, body); + } else if (statusCode >= 500 && statusCode < 600) { + logger.error("Received server error response from metrics server: %s (%s)", null, statusCode, body); + } else { + logger.warn("Received unexpected response from metrics server: %s (%s)", statusCode, body); + } + } 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(); + + private void appendInternalData(final JsonObject metrics) { + metrics.addProperty("core_count", coreCount); + metrics.addProperty("java_vendor", javaVendor); + metrics.addProperty("java_version", javaVersion); + metrics.addProperty("os_arch", osArch); + metrics.addProperty("os_name", osName); + metrics.addProperty("os_version", osVersion); + } + + private void appendCustomData(final JsonObject metrics) { + this.metrics.forEach(metric -> { + try { + if (metrics.has(metric.getId())) + logger.warn("Skipped duplicated metrics entry: %s", metric.getId()); + else metric.getData().ifPresent(element -> metrics.add(metric.getId(), element)); + } catch (final Throwable t) { + logger.error("Failed to append custom metric data: %s", t, metric.getId()); + context.errorTrackerService().ifPresent(service -> service.globalErrorTracker().trackError(t)); + } + }); + } + + public final void appendData(final JsonObject metrics) { + appendInternalData(metrics); + appendDefaultData(metrics); + appendCustomData(metrics); + } + + private JsonObject createData() { + final var data = new JsonObject(); + final var metrics = new JsonObject(); + + appendData(metrics); + + data.addProperty("project_name", context.getProjectName()); + data.addProperty("identifier", context.getConfig().serverId().toString()); + data.add("data", metrics); + + return data; + } + + @Contract(mutates = "param1") + protected abstract void appendDefaultData(JsonObject metrics); + + protected void shutdown() { + if (submissionJob != null) try { + logger.info("Shutting down metrics submission"); + submissionJob.cancel(false); + submit(); + } catch (final Throwable t) { + logger.error("Failed to submit metrics on shutdown", t); + } finally { + submissionJob = null; + submissionScheduler.shutdown(); + } + } + + public abstract static class Factory implements Metrics.Factory { + private @Nullable Runnable flush; + private final Set> metrics = new HashSet<>(0); + protected final SimpleContext context; + + protected Factory(final SimpleContext 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; + } + } +} diff --git a/core/src/main/java/dev/faststats/SimpleSdkInfo.java b/core/src/main/java/dev/faststats/SimpleSdkInfo.java new file mode 100644 index 00000000..8c4e998a --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleSdkInfo.java @@ -0,0 +1,39 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +final class SimpleSdkInfo implements SdkInfo { + private final @Nullable String buildId; + private final String name; + private final String version; + + SimpleSdkInfo(final String name, final String version, @Nullable final String buildId) throws IllegalArgumentException { + if (name.isBlank()) throw new IllegalArgumentException("name must not be blank"); + if (version.isBlank()) throw new IllegalArgumentException("version must not be blank"); + this.name = name; + this.version = version; + this.buildId = buildId; + } + + @Override + public Optional getBuildId() { + return Optional.ofNullable(buildId); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getVersion() { + return version; + } + + @Override + public String getUserAgent() { + return "FastStats Metrics " + name + "/" + version; + } +} diff --git a/core/src/main/java/dev/faststats/SimpleTrackedError.java b/core/src/main/java/dev/faststats/SimpleTrackedError.java new file mode 100644 index 00000000..7d85aef8 --- /dev/null +++ b/core/src/main/java/dev/faststats/SimpleTrackedError.java @@ -0,0 +1,85 @@ +package dev.faststats; + +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.Set; + +final class SimpleTrackedError implements TrackedError { + private Attributes attributes = Attributes.empty(); + private boolean handled = true; + private final Throwable error; + + SimpleTrackedError(final Throwable error) { + this.error = error; + } + + @Override + public Throwable error() { + return error; + } + + @Override + public boolean handled() { + return handled; + } + + @Override + public TrackedError handled(final boolean handled) { + this.handled = handled; + return this; + } + + @Override + public Attributes attributes() { + return Attributes.copyOf(attributes); + } + + @Override + public TrackedError attributes(final Attributes attributes) { + this.attributes = Attributes.copyOf(attributes); + return this; + } + + @Override + public boolean equals(@Nullable final Object o) { + if (o == null || getClass() != o.getClass()) return false; + final SimpleTrackedError that = (SimpleTrackedError) o; + return handled == that.handled + && Objects.equals(attributes, that.attributes) + && deepEquals(error, that.error, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + @Override + public int hashCode() { + return Objects.hash(attributes, handled, hash(error, Collections.newSetFromMap(new IdentityHashMap<>()))); + } + + // fixme: hacky shit; it only has to compile and pass tests for now + private static boolean deepEquals( + @Nullable final Throwable first, + @Nullable final Throwable second, + final Set visited + ) { + if (first == second) return true; + if (first == null || second == null) return false; + if (first.getClass() != second.getClass()) return false; + if (!Objects.equals(first.getMessage(), second.getMessage())) return false; + if (!Arrays.equals(first.getStackTrace(), second.getStackTrace())) return false; + if (!visited.add(first)) return true; + return deepEquals(first.getCause(), second.getCause(), visited); + } + + private static int hash(@Nullable final Throwable error, final Set visited) { + if (error == null || !visited.add(error)) return 0; + return Objects.hash( + error.getClass(), + error.getMessage(), + Arrays.hashCode(error.getStackTrace()), + hash(error.getCause(), visited) + ); + } +} diff --git a/core/src/main/java/dev/faststats/core/Token.java b/core/src/main/java/dev/faststats/Token.java similarity index 93% rename from core/src/main/java/dev/faststats/core/Token.java rename to core/src/main/java/dev/faststats/Token.java index 35ed3d3f..a9d38f81 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.24.0 */ @NonNls @Pattern(Token.PATTERN) diff --git a/core/src/main/java/dev/faststats/TrackedError.java b/core/src/main/java/dev/faststats/TrackedError.java new file mode 100644 index 00000000..02e282cb --- /dev/null +++ b/core/src/main/java/dev/faststats/TrackedError.java @@ -0,0 +1,57 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; + +/** + * An error report with tracking metadata. + * + * @since 0.24.0 + */ +public sealed interface TrackedError permits SimpleTrackedError { + /** + * Returns the tracked error. + * + * @return the tracked error + * @since 0.24.0 + */ + @Contract(pure = true) + Throwable error(); + + /** + * Returns whether the error was handled. + * + * @return whether the error was handled + * @since 0.24.0 + */ + @Contract(pure = true) + boolean handled(); + + /** + * Sets whether the error was handled. + * + * @param handled whether the error was handled + * @return this tracked error + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + TrackedError handled(boolean handled); + + /** + * Returns a copy of the additional error attributes. + * + * @return a copy of the additional error attributes + * @since 0.24.0 + */ + @Contract(value = " -> new", pure = true) + Attributes attributes(); + + /** + * Sets the additional error attributes. + * + * @param attributes the additional error attributes + * @return this tracked error + * @since 0.24.0 + */ + @Contract(value = "_ -> this", mutates = "this") + TrackedError attributes(Attributes attributes); +} 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 7a60ede1..00000000 --- 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/MurmurHash3.java b/core/src/main/java/dev/faststats/core/MurmurHash3.java deleted file mode 100644 index 157b765f..00000000 --- a/core/src/main/java/dev/faststats/core/MurmurHash3.java +++ /dev/null @@ -1,188 +0,0 @@ -package dev.faststats.core; - -import com.google.gson.JsonObject; -import org.jetbrains.annotations.Contract; - -import java.nio.charset.StandardCharsets; - -/** - * Implementation of the MurmurHash3 128-bit hash algorithm. - *

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

- *

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

- *

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

- */ -final class MurmurHash3 { - public static String hash(final JsonObject object) { - final var hash = MurmurHash3.hash(object.toString()); - return Long.toHexString(hash[0]) + Long.toHexString(hash[1]); - } - - /** - * Computes the 128-bit MurmurHash3 hash of the input string. - *

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

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

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

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

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

- * - * @param bytes the byte array to read from - * @param offset the starting index in the byte array (must have at least 4 bytes from offset) - * @return the 32-bit integer value read in little-endian order - */ - @Contract(pure = true) - private static int getInt(final byte[] bytes, final int offset) { - return (bytes[offset] & 0xff) | - ((bytes[offset + 1] & 0xff) << 8) | - ((bytes[offset + 2] & 0xff) << 16) | - ((bytes[offset + 3] & 0xff) << 24); - } -} 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 bd624bd4..00000000 --- a/core/src/main/java/dev/faststats/core/SimpleMetrics.java +++ /dev/null @@ -1,494 +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(); - 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 4a6e027b..00b55b23 100644 --- a/core/src/main/java/dev/faststats/core/data/ArrayMetric.java +++ b/core/src/main/java/dev/faststats/data/ArrayMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonArray; import com.google.gson.JsonElement; diff --git a/core/src/main/java/dev/faststats/core/data/MapMetric.java b/core/src/main/java/dev/faststats/data/MapMetric.java similarity index 96% rename from core/src/main/java/dev/faststats/core/data/MapMetric.java rename to core/src/main/java/dev/faststats/data/MapMetric.java index c9a5e80e..d95943d6 100644 --- a/core/src/main/java/dev/faststats/core/data/MapMetric.java +++ b/core/src/main/java/dev/faststats/data/MapMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import com.google.gson.JsonObject; 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 40ee7830..3f59ed40 100644 --- a/core/src/main/java/dev/faststats/core/data/Metric.java +++ b/core/src/main/java/dev/faststats/data/Metric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import org.jetbrains.annotations.Contract; @@ -12,14 +12,14 @@ * A metric. * * @param the metric data type - * @since 0.16.0 + * @since 0.24.0 */ public interface Metric { /** * Get the source id. * * @return the source id - * @since 0.16.0 + * @since 0.24.0 */ @SourceId @Contract(pure = true) @@ -31,7 +31,7 @@ public interface Metric { * @return an optional containing the metric data * @throws Exception if unable to compute the metric data * @implSpec The implementation must be thread-safe and pure (i.e. not modify any shared state). - * @since 0.16.0 + * @since 0.24.0 */ @Contract(pure = true) Optional compute() throws Exception; @@ -44,7 +44,7 @@ public interface Metric { * @implSpec The implementation must call {@link #compute()} to get the metric data * and follow the same thread-safety and pureness requirements. * @see #compute() - * @since 0.16.0 + * @since 0.24.0 */ @Contract(pure = true) Optional getData() throws Exception; @@ -58,7 +58,7 @@ public interface Metric { * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric stringArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -74,7 +74,7 @@ static Metric stringArray(@SourceId final String id, final Callable booleanArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -90,7 +90,7 @@ static Metric booleanArray(@SourceId final String id, final Callable< * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric numberArray(@SourceId final String id, final Callable callable) throws IllegalArgumentException { @@ -106,7 +106,7 @@ static Metric numberArray(@SourceId final String id, final Callable> stringMap(@SourceId final String id, final Callable> callable) throws IllegalArgumentException { @@ -122,7 +122,7 @@ static Metric numberArray(@SourceId final String id, final Callable> booleanMap(@SourceId final String id, final Callable> callable) throws IllegalArgumentException { @@ -138,7 +138,7 @@ static Metric numberArray(@SourceId final String id, final Callable> numberMap(@SourceId final String id, final Callable> callable) throws IllegalArgumentException { @@ -154,7 +154,7 @@ static Metric numberArray(@SourceId final String id, final Callable bool(@SourceId final String id, final Callable<@Nullable Boolean> callable) throws IllegalArgumentException { @@ -170,7 +170,7 @@ static Metric bool(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric string(@SourceId final String id, final Callable<@Nullable String> callable) throws IllegalArgumentException { @@ -186,7 +186,7 @@ static Metric string(@SourceId final String id, final Callable<@Nullable * @throws IllegalArgumentException if the source id is invalid * @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state). * @see #compute() - * @since 0.16.0 + * @since 0.24.0 */ @Contract(value = "_, _ -> new", pure = true) static Metric number(@SourceId final String id, final Callable<@Nullable Number> callable) throws IllegalArgumentException { diff --git a/core/src/main/java/dev/faststats/core/data/SimpleMetric.java b/core/src/main/java/dev/faststats/data/SimpleMetric.java similarity index 97% rename from core/src/main/java/dev/faststats/core/data/SimpleMetric.java rename to core/src/main/java/dev/faststats/data/SimpleMetric.java index e3f55476..28082ade 100644 --- a/core/src/main/java/dev/faststats/core/data/SimpleMetric.java +++ b/core/src/main/java/dev/faststats/data/SimpleMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/dev/faststats/core/data/SingleValueMetric.java b/core/src/main/java/dev/faststats/data/SingleValueMetric.java similarity index 95% rename from core/src/main/java/dev/faststats/core/data/SingleValueMetric.java rename to core/src/main/java/dev/faststats/data/SingleValueMetric.java index cbfc7e69..26034748 100644 --- a/core/src/main/java/dev/faststats/core/data/SingleValueMetric.java +++ b/core/src/main/java/dev/faststats/data/SingleValueMetric.java @@ -1,4 +1,4 @@ -package dev.faststats.core.data; +package dev.faststats.data; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; diff --git a/core/src/main/java/dev/faststats/core/data/SourceId.java b/core/src/main/java/dev/faststats/data/SourceId.java similarity index 93% rename from core/src/main/java/dev/faststats/core/data/SourceId.java rename to core/src/main/java/dev/faststats/data/SourceId.java index c7295ec0..6fc30d6b 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.24.0 */ @NonNls @Pattern(SourceId.PATTERN) 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 00000000..3f5f232e --- /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 00000000..eb927e03 --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/LoggerFactory.java @@ -0,0 +1,25 @@ +package dev.faststats.internal; + +import org.jetbrains.annotations.Contract; + +import java.util.ServiceLoader; + +public interface LoggerFactory { + @Contract(pure = true) + static LoggerFactory factory() { + final class Holder { + private static final LoggerFactory INSTANCE = ServiceLoader.load(LoggerFactory.class) + .findFirst() + .orElseGet(SimpleLoggerFactory::new); + } + return Holder.INSTANCE; + } + + @Contract(value = "_ -> new", pure = true) + default Logger getLogger(final Class clazz) { + return getLogger(clazz.getName()); + } + + @Contract(value = "_ -> new", pure = true) + Logger getLogger(String name); +} 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 00000000..147b410d --- /dev/null +++ b/core/src/main/java/dev/faststats/internal/SimpleLogger.java @@ -0,0 +1,46 @@ +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.setLoggerName(logger.getName()); + 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 00000000..dcb8b9cf --- /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 00000000..dfe3b560 --- /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 612834e2..0f763101 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 c356144b..05b4d0e3 100644 --- a/core/src/test/java/dev/faststats/AnonymizationTest.java +++ b/core/src/test/java/dev/faststats/AnonymizationTest.java @@ -1,171 +1,136 @@ package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.core.ErrorTracker; -import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; -import java.util.UUID; import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -@NullMarked public final class AnonymizationTest { - private static MockMetrics createMetrics(final ErrorTracker tracker) { - return new MockMetrics(UUID.randomUUID(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", tracker, false); - } + private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + private final FastStatsContext context = new MockContext.Factory() + .errorTrackerService(tracker) + .metrics(Metrics.Factory::create) + .create(); - private static JsonObject getError(final MockMetrics metrics) { - final var data = metrics.createData(); - return data.getAsJsonArray("errors").get(0).getAsJsonObject(); + private JsonObject getError() { + return tracker.getFullData().get(0).getAsJsonObject(); } - private static String getErrorMessage(final MockMetrics metrics) { - return getError(metrics).get("message").getAsString(); + private String getErrorMessage() { + return getError().get("message").getAsString(); } @Test public void ipv4Anonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Connection refused at 192.168.1.100"); - assertEquals("Connection refused at [IP hidden]", getErrorMessage(metrics)); + assertEquals("Connection refused at [IP hidden]", getErrorMessage()); } @Test public void ipv6Anonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed to connect to f833:be65:65da:975b:4896:88f7:6964:44c0"); - assertEquals("Failed to connect to [IP hidden]", getErrorMessage(metrics)); + assertEquals("Failed to connect to [IP hidden]", getErrorMessage()); } @Test public void userHomePathAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("File not found: /home/" + username + "/config.yml"); - assertEquals("File not found: /home/[username hidden]/config.yml", getErrorMessage(metrics)); + assertEquals("File not found: /home/[username hidden]/config.yml", getErrorMessage()); } @Test public void windowsUserPathAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("File not found: C:\\Users\\" + username + "\\config.yml"); - assertEquals("File not found: C:\\Users\\[username hidden]\\config.yml", getErrorMessage(metrics)); + assertEquals("File not found: C:\\Users\\[username hidden]\\config.yml", getErrorMessage()); } @Test public void macUserPathAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("File not found: /Users/" + username + "/config.yml"); - assertEquals("File not found: /Users/[username hidden]/config.yml", getErrorMessage(metrics)); + assertEquals("File not found: /Users/[username hidden]/config.yml", getErrorMessage()); } @Test public void usernameAnonymizationIsCaseInsensitive() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); final var username = System.getProperty("user.name", "user"); tracker.trackError("Error for " + swapCase(username)); - assertEquals("Error for [username hidden]", getErrorMessage(metrics)); + assertEquals("Error for [username hidden]", getErrorMessage()); } @Test public void discordWebhookAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/aAaAaAaa0AAaAAaaaAAAAa_0AAAAAAAaaaAaaAaaAAAA0aA00AAA0AAA0aAAaA0a0a0A"); - assertEquals("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/[token hidden]", getErrorMessage(metrics)); + assertEquals("Webhook failed: https://discord.com/api/webhooks/1234567890987654321/[token hidden]", getErrorMessage()); } @Test public void jdbcUrlAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed: jdbc:mysql://localhost:3306:secretpass@mydb"); - assertEquals("Failed: jdbc:mysql://localhost:3306:[password hidden]@mydb", getErrorMessage(metrics)); + assertEquals("Failed: jdbc:mysql://localhost:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlNoPortAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed: jdbc:mysql://mydb.com:secretpass@mydb"); - assertEquals("Failed: jdbc:mysql://mydb.com:[password hidden]@mydb", getErrorMessage(metrics)); + assertEquals("Failed: jdbc:mysql://mydb.com:[password hidden]@mydb", getErrorMessage()); } @Test public void jdbcUrlIpAnonymization() { - final var tracker = ErrorTracker.contextUnaware(); - final var metrics = createMetrics(tracker); tracker.trackError("Failed: jdbc:mysql://127.0.0.1:3306:secretpass@mydb"); - assertEquals("Failed: jdbc:mysql://[IP hidden]:3306:[password hidden]@mydb", getErrorMessage(metrics)); + assertEquals("Failed: jdbc:mysql://[IP hidden]:3306:[password hidden]@mydb", getErrorMessage()); } @Test public void customPatternAnonymizesMessage() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("token=[^&]+", "token=[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("token=[^&]+", "token=[redacted]"); tracker.trackError("Request failed with token=abc123secret&user=test"); - assertEquals("Request failed with token=[redacted]&user=test", getErrorMessage(metrics)); + assertEquals("Request failed with token=[redacted]&user=test", getErrorMessage()); } @Test public void customPatternWithCompiledPattern() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize(Pattern.compile("Bearer [A-Za-z0-9._~+/=-]+"), "Bearer [redacted]"); tracker.trackError("Auth failed: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"); - assertEquals("Auth failed: Bearer [redacted]", getErrorMessage(metrics)); + assertEquals("Auth failed: Bearer [redacted]", getErrorMessage()); } @Test public void customPatternWithCaptureGroupReplacement() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("(api_key=)[^&\\s]+", "$1[redacted]"); tracker.trackError("GET /data?api_key=sk_live_12345&format=json failed"); - assertEquals("GET /data?api_key=[redacted]&format=json failed", getErrorMessage(metrics)); + assertEquals("GET /data?api_key=[redacted]&format=json failed", getErrorMessage()); } @Test public void multipleCustomPatterns() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]") + tracker.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [redacted]") .anonymize("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[email hidden]"); - final var metrics = createMetrics(tracker); tracker.trackError("Auth failed for user@example.com with Bearer eyJ0eXAi"); - assertEquals("Auth failed for [email hidden] with Bearer [redacted]", getErrorMessage(metrics)); + assertEquals("Auth failed for [email hidden] with Bearer [redacted]", getErrorMessage()); } @Test public void customPatternChaining() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("secret-[a-z]+", "[secret hidden]") + tracker.anonymize("secret-[a-z]+", "[secret hidden]") .anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]"); - final var metrics = createMetrics(tracker); tracker.trackError("Credentials: secret-abcdef / AKIA1234567890ABCDEF"); - assertEquals("Credentials: [secret hidden] / [aws-key hidden]", getErrorMessage(metrics)); + assertEquals("Credentials: [secret hidden] / [aws-key hidden]", getErrorMessage()); } @Test public void customPatternAppliedToCauseChain() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); - final var metrics = createMetrics(tracker); + final var tracker = this.tracker.anonymize("ssn=\\d{3}-\\d{2}-\\d{4}", "ssn=[redacted]"); final var cause = new IllegalArgumentException("Validation failed for ssn=123-45-6789"); tracker.trackError(new RuntimeException("Processing error", cause)); - final var error = getError(metrics); + final var error = getError(); final var stack = error.getAsJsonArray("stack"); var causeAnonymized = false; for (final var element : stack) { @@ -178,39 +143,31 @@ public void customPatternAppliedToCauseChain() { @Test public void nullMessageNotAffected() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("anything", "[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("anything", "[redacted]"); tracker.trackError(new RuntimeException((String) null)); - assertFalse(getError(metrics).has("message")); + assertFalse(getError().has("message")); } @Test public void customAndBuiltInPatternsCombined() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("session=[a-f0-9]+", "session=[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("session=[a-f0-9]+", "session=[redacted]"); final var username = System.getProperty("user.name", "user"); tracker.trackError("Error for 192.168.1.1 with session=deadbeef01 at /home/" + username + "/app"); - assertEquals("Error for [IP hidden] with session=[redacted] at /home/[username hidden]/app", getErrorMessage(metrics)); + assertEquals("Error for [IP hidden] with session=[redacted] at /home/[username hidden]/app", getErrorMessage()); } @Test public void emptyReplacementRemovesMatch() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("\\(internal ref: [^)]+\\)", ""); - final var metrics = createMetrics(tracker); + tracker.anonymize("\\(internal ref: [^)]+\\)", ""); tracker.trackError("Request failed (internal ref: REF-98765)"); - assertEquals("Request failed ", getErrorMessage(metrics)); + assertEquals("Request failed ", getErrorMessage()); } @Test public void patternDoesNotMatchLeavesMessageUnchanged() { - final var tracker = ErrorTracker.contextUnaware() - .anonymize("SECRET_[A-Z]+", "[redacted]"); - final var metrics = createMetrics(tracker); + tracker.anonymize("SECRET_[A-Z]+", "[redacted]"); tracker.trackError("just a normal error"); - assertEquals("just a normal error", getErrorMessage(metrics)); + assertEquals("just a normal error", getErrorMessage()); } private static String swapCase(final String value) { diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index f500cd49..88722cd3 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,19 +1,23 @@ 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.assertNotNull; +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 + private final SimpleErrorTracker tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + private final MockContext context = new MockContext.Factory() + .errorTrackerService(tracker) + .create(); @Test public void sameClassLoader() { @@ -127,73 +131,275 @@ private IllegalArgumentException createExceptionWithStack() { } @Test - // todo: fix this mess - public void testCompile() throws InterruptedException { - final var tracker = ErrorTracker.contextUnaware(); - tracker.attachErrorContext(null); + public void redactsBuiltInSensitiveValuesFromMessageAndStackHeader() { + tracker.trackError("connect jdbc:postgresql://localhost:5432/secret@db from 192.168.1.20"); - 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; + final var report = tracker.getFullData().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() { + tracker.anonymize("session=[^ ]+", "session=[hidden]"); + tracker.trackError("failed with session=abc123 from 10.0.0.1"); + + final var message = tracker.getFullData() + .get(0) + .getAsJsonObject() + .get("message") + .getAsString(); + + assertEquals("failed with session=[hidden] from [IP hidden]", message); + } + + @Test + public void nullMessagesAreNotSerializedAsMessageProperty() { + tracker.trackError(new RuntimeException((String) null)); + + final var report = tracker.getFullData().get(0).getAsJsonObject(); + assertFalse(report.has("message")); + assertEquals("java.lang.RuntimeException", report.getAsJsonArray("stack").get(0).getAsString()); + } + + @Test + public void nestedCausesAreSerializedInOrder() { + 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) + }); + 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).handled(false); + + final var report = tracker.getFullData().get(0).getAsJsonObject(); + final var stack = report.getAsJsonArray("stack"); + + 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()); + } + + @Test + public void cyclicCauseChainStopsAfterFirstVisit() { + final var first = new RuntimeException("first"); + final var second = new IllegalStateException("second", first); + first.initCause(second); + + tracker.trackError(first); + + final var stack = tracker.getFullData().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++; } - tracker.trackError("Test error"); - final var nestedError = new RuntimeException("Nested error"); - final var error = new RuntimeException(null, nestedError); - tracker.trackError(error); + assertEquals(1, firstCauseCount); + assertEquals(1, secondCauseCount); + } - 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"); + @Test + public void duplicateErrorsAreAggregatedWithCount() { + final var first = createStableError(); + final var second = createStableError(); + + tracker.trackError(first); + tracker.trackError(second); - final var deepAsyncError = new RuntimeException("deep async error"); + final var reports = tracker.getFullData(); + final var report = reports.get(0).getAsJsonObject(); + + assertEquals(1, reports.size()); + assertEquals(2, report.get("count").getAsInt()); + assertEquals("duplicate", report.get("message").getAsString()); + } - final var thisIsANiceError = new Thread(() -> { - final var nestedAsyncError = new RuntimeException("nested async error", deepAsyncError); - throw new CompletionException("async error", nestedAsyncError); + @Test + public void clearKeepsDuplicateCountButRemovesPayloadUntilRepeated() { + tracker.trackError(createStableError()); + tracker.trackError(createStableError()); + + tracker.clear(); + + assertEquals(0, tracker.getFullData().size()); + + tracker.trackError(createStableError()); + + final var report = tracker.getFullData().get(0).getAsJsonObject(); + assertEquals("duplicate", report.get("message").getAsString()); + assertNull(report.get("count")); + } + + @Test + public void ignoredNestedCauseSuppressesWholeReport() { + tracker.ignoreError(IllegalArgumentException.class, "ignore me"); + + tracker.trackError(new RuntimeException("wrapper", new IllegalArgumentException("ignore me"))); + + assertEquals(0, tracker.getFullData().size()); + } + + @Test + public void repeatingStackFramesAreCollapsed() { + 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) }); - thisIsANiceError.start(); - thisIsANiceError.join(1000); - Thread.sleep(1000); + tracker.trackError(error); + + final var stack = tracker.getFullData().get(0).getAsJsonObject().getAsJsonArray("stack"); + assertEquals("java.lang.StackOverflowError: recursive", stack.get(0).getAsString()); + assertEquals(" at example.Recursive.a(Recursive.java:1)", stack.get(1).getAsString()); + assertEquals(" at example.Recursive.b(Recursive.java:2)", stack.get(2).getAsString()); + assertEquals(" ... 4 more", stack.get(3).getAsString()); + assertEquals(4, stack.size()); + } + + @Test + public void longMessagesAreTruncatedBeforeSerialization() { + final var message = "a".repeat(600); + + tracker.trackError(message); - tracker.trackError("Test error"); + final var report = tracker.getFullData().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()); } - public void recursiveError() throws StackOverflowError { - goRoundAndRound(); + @Test + public void attachedContextTracksUnhandledThreadError() throws InterruptedException { + 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.getFullData().get(0).getAsJsonObject(); + assertEquals("async failure", report.get("message").getAsString()); + assertFalse(report.get("handled").getAsBoolean()); + } finally { + tracker.detachErrorContext(); + } } - public void goRoundAndRound() throws StackOverflowError { - andRoundAndRound(); + @Test + public void trackedErrorSerializesProperties() { + final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); + final var error = tracker.trackError("with properties"); + error.attributes(Attributes.empty() + .put("stage", "startup") + .put("attempt", 2) + .put("retrying", true)); + + final var report = tracker.getFullData().get(0).getAsJsonObject(); + final var context = report.getAsJsonObject("context"); + + assertTrue(report.get("handled").getAsBoolean()); + assertEquals("startup", context.get("stage").getAsString()); + assertEquals(2, context.get("attempt").getAsInt()); + assertTrue(context.get("retrying").getAsBoolean()); } - public void andRoundAndRound() throws StackOverflowError { - goRoundAndRound(); + @Test + public void errorTrackerServiceSerializesGlobalAttributes() { + final var tracker = ErrorTracker.contextUnaware(); + final var context = new MockContext.Factory() + .errorTrackerService(tracker) + .create(); + tracker.getAttributes() + .put("stage", "startup") + .put("attempt", 2) + .put("retrying", true); + tracker.trackError("with global attributes"); + + final var service = context.errorTrackerService() + .map(SimpleErrorTrackerService.class::cast) + .orElseThrow(); + final var data = service.createData(); + assertNotNull(data); + final var globalContext = data.getAsJsonObject("context"); + + assertEquals("startup", globalContext.get("stage").getAsString()); + assertEquals(2, globalContext.get("attempt").getAsInt()); + assertTrue(globalContext.get("retrying").getAsBoolean()); } - public void aroundAndAround() throws StackOverflowError { - aroundAndAround(); + @Test + public void errorTrackerServiceSerializesRegisteredTrackerAttributesBeforeErrorAttributes() { + final var globalTracker = ErrorTracker.contextUnaware(); + final var tracker = ErrorTracker.contextUnaware(); + final var context = new MockContext.Factory() + .errorTrackerService(globalTracker) + .create(); + tracker.getAttributes() + .put("stage", "startup") + .put("attempt", 1); + tracker.trackError("with tracker attributes") + .attributes(Attributes.empty() + .put("attempt", 2) + .put("retrying", true)); + + final var service = context.errorTrackerService() + .map(SimpleErrorTrackerService.class::cast) + .orElseThrow(); + service.registerErrorTracker(tracker); + final var data = service.createData(); + assertNotNull(data); + final var errorContext = data.getAsJsonArray("errors") + .get(0) + .getAsJsonObject() + .getAsJsonObject("context"); + + assertEquals("startup", errorContext.get("stage").getAsString()); + assertEquals(2, errorContext.get("attempt").getAsInt()); + assertTrue(errorContext.get("retrying").getAsBoolean()); } - public void roundAndRound(final int i) throws RuntimeException { - if (i <= 0) throw new RuntimeException("out of stack"); - roundAndRound(i - 1); + 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/FeatureFlagTest.java b/core/src/test/java/dev/faststats/FeatureFlagTest.java new file mode 100644 index 00000000..3be1dcdf --- /dev/null +++ b/core/src/test/java/dev/faststats/FeatureFlagTest.java @@ -0,0 +1,340 @@ +package dev.faststats; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class FeatureFlagTest { + private static final UUID SERVER_ID = UUID.fromString("76a88a60-1329-4913-9525-fb16b588d07e"); + private static FlagServer server; + + @BeforeAll + public static void startServer() throws IOException { + server = new FlagServer(); + System.setProperty("faststats.flags-server", server.url()); + } + + @AfterAll + public static void stopServer() throws IOException { + System.clearProperty("faststats.flags-server"); + server.close(); + } + + @BeforeEach + public void resetServer() { + server.reset(); + } + + @Test + public void booleanFlagFetchesAndCachesValue() throws Exception { + server.enqueue(200, "{\"value\":true}"); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("new_commands", false); + + assertEquals("new_commands", flag.getId()); + assertEquals(FeatureFlag.Type.BOOLEAN, flag.getType()); + assertEquals(Boolean.class, flag.getTypeClass()); + assertFalse(flag.getDefaultValue()); + assertEquals(true, flag.whenReady().get(1, TimeUnit.SECONDS)); + assertEquals(Optional.of(true), flag.getCached()); + assertTrue(flag.isValid()); + assertTrue(flag.getExpiration().isPresent()); + + final var request = server.takeRequest(); + assertEquals("/v1/check", request.path()); + assertEquals("Bearer test-token", request.headers().get("authorization").getAsString()); + assertEquals(SERVER_ID.toString(), request.body().get("identifier").getAsString()); + assertEquals("new_commands", request.body().get("key").getAsString()); + } + + @Test + public void stringAndNumberFlagsUseDefaultValueTypes() throws Exception { + server.enqueue(200, "{\"value\":\"zstd\"}"); + server.enqueue(200, "{\"value\":12.5}"); + + final var service = service(Duration.ofMinutes(5)); + final var stringFlag = service.define("compression", "gzip"); + + assertEquals(FeatureFlag.Type.STRING, stringFlag.getType()); + assertEquals(String.class, stringFlag.getTypeClass()); + assertEquals("gzip", stringFlag.getDefaultValue()); + assertEquals("zstd", stringFlag.whenReady().get(1, TimeUnit.SECONDS)); + + final var numberFlag = service.define("sample_rate", 1); + assertEquals(FeatureFlag.Type.NUMBER, numberFlag.getType()); + assertEquals(Number.class, numberFlag.getTypeClass()); + assertEquals(1, numberFlag.getDefaultValue()); + assertEquals(12.5, numberFlag.whenReady().get(1, TimeUnit.SECONDS).doubleValue()); + } + + @Test + public void serviceAndFlagAttributesAreMergedInFetchRequest() throws Exception { + server.enqueue(200, "{\"value\":true}"); + + final var serviceAttributes = Attributes.empty() + .put("region", "global") + .put("players", 20) + .put("premium", false); + final var flagAttributes = Attributes.empty() + .put("region", "flag") + .put("beta", true); + final var service = service(serviceAttributes, Duration.ofMinutes(5)); + final var flag = service.define("targeted", false, flagAttributes); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + + final var attributes = server.takeRequest().body().getAsJsonObject("attributes"); + assertEquals("flag", attributes.get("region").getAsString()); + assertEquals(20, attributes.get("players").getAsInt()); + assertTrue(attributes.get("beta").getAsBoolean()); + assertFalse(attributes.get("premium").getAsBoolean()); + } + + @Test + public void whenReadyUsesValidCachedValueWithoutFetchingAgain() throws Exception { + server.enqueue(200, "{\"value\":true}"); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("cached", false); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + server.takeRequest(); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + assertEquals(0, server.requestCountAfterWaiting(Duration.ofMillis(150))); + } + + @Test + public void whenReadyRefetchesExpiredCachedValue() throws Exception { + server.enqueue(200, "{\"value\":false}"); + server.enqueue(200, "{\"value\":true}"); + + final var service = service(Duration.ofMillis(1)); + final var flag = service.define("expired", false); + + assertFalse(flag.whenReady().get(1, TimeUnit.SECONDS)); + server.takeRequest(); + Thread.sleep(5); + + assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS)); + assertEquals("/v1/check", server.takeRequest().path()); + assertEquals(Optional.of(true), flag.getCached()); + Thread.sleep(5); + assertFalse(flag.isValid()); + } + + @Test + public void concurrentFetchesShareInProgressRequest() throws Exception { + final var releaseResponse = new CountDownLatch(1); + server.enqueue(200, "{\"value\":true}", releaseResponse); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("shared", false); + + final CompletableFuture first = flag.fetch(); + final CompletableFuture second = flag.fetch(); + + assertSame(first, second); + assertEquals(1, server.requestCountAfterWaiting(Duration.ofMillis(150))); + + releaseResponse.countDown(); + assertTrue(first.get(1, TimeUnit.SECONDS)); + } + + @Test + public void nonSuccessfulFetchResponseFails() { + server.enqueue(500, "{\"value\":true}"); + + final var service = service(Duration.ofMinutes(5)); + final var flag = service.define("broken", false); + + final var error = assertThrows(Exception.class, () -> flag.whenReady().get(1, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, error.getCause()); + } + + private static SimpleFeatureFlagService service(final Duration ttl) { + return service(Attributes.empty(), ttl); + } + + private static SimpleFeatureFlagService service(final Attributes attributes, final Duration ttl) { + return new SimpleFeatureFlagService(new TestConfig(), "test-token", attributes, ttl); + } + + private record TestConfig() implements Config { + @Override + public UUID serverId() { + return SERVER_ID; + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean submitMetrics() { + return true; + } + + @Override + public boolean errorTracking() { + return true; + } + + @Override + public boolean additionalMetrics() { + return true; + } + + @Override + public boolean debug() { + return false; + } + } + + private static final class FlagServer implements AutoCloseable { + private final ServerSocket socket; + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final LinkedBlockingQueue responses = new LinkedBlockingQueue<>(); + private final LinkedBlockingQueue requests = new LinkedBlockingQueue<>(); + + FlagServer() throws IOException { + socket = new ServerSocket(0); + executor.execute(() -> { + while (!socket.isClosed()) { + try { + final var client = socket.accept(); + executor.execute(() -> handle(client)); + } catch (final IOException e) { + if (!socket.isClosed()) throw new UncheckedIOException(e); + } + } + }); + } + + String url() { + return "http://127.0.0.1:" + socket.getLocalPort(); + } + + void enqueue(final int status, final String body) { + enqueue(status, body, null); + } + + void enqueue(final int status, final String body, @Nullable final CountDownLatch release) { + responses.add(new Response(status, body, release)); + } + + void reset() { + requests.clear(); + responses.clear(); + } + + Request takeRequest() throws InterruptedException { + final var request = requests.poll(1, TimeUnit.SECONDS); + if (request == null) throw new AssertionError("Timed out waiting for request"); + return request; + } + + int requestCountAfterWaiting(final Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); + return requests.size(); + } + + private void handle(final Socket client) { + try (client) { + final var reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + final var requestLine = reader.readLine(); + if (requestLine == null) return; + + final var path = requestLine.split(" ")[1]; + final var headers = new JsonObject(); + int contentLength = 0; + + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + final var separator = line.indexOf(':'); + final var name = line.substring(0, separator).toLowerCase(); + final var value = line.substring(separator + 1).trim(); + headers.addProperty(name, value); + if (name.equals("content-length")) contentLength = Integer.parseInt(value); + } + + final var bodyChars = new char[contentLength]; + var read = 0; + while (read < contentLength) { + final var count = reader.read(bodyChars, read, contentLength - read); + if (count == -1) break; + read += count; + } + + final var body = new String(bodyChars, 0, read); + final var e = new Request(path, headers, body.isEmpty() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject()); + System.out.println("parsed body: " + body + ", " + e.body); + requests.add(e); + + final var response = responses.poll(1, TimeUnit.SECONDS); + if (response == null) throw new AssertionError("No response enqueued"); + if (response.release() != null) response.release().await(1, TimeUnit.SECONDS); + writeResponse(client.getOutputStream(), response); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + + private void writeResponse(final OutputStream output, final Response response) throws IOException { + final var bytes = response.body().getBytes(StandardCharsets.UTF_8); + final var headers = "HTTP/1.1 " + response.status() + " OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + bytes.length + "\r\n" + + "Connection: close\r\n" + + "\r\n"; + output.write(headers.getBytes(StandardCharsets.UTF_8)); + output.write(bytes); + } + + @Override + public void close() throws IOException { + socket.close(); + executor.shutdownNow(); + } + } + + private record Request(String path, JsonObject headers, JsonObject body) { + } + + private record Response(int status, String body, @Nullable CountDownLatch release) { + } +} diff --git a/core/src/test/java/dev/faststats/MetricsTest.java b/core/src/test/java/dev/faststats/MetricsTest.java deleted file mode 100644 index e0533186..00000000 --- a/core/src/test/java/dev/faststats/MetricsTest.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.faststats; - -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -public class MetricsTest { - @Test - public void testCreateData() { - final var mock = new MockMetrics(UUID.randomUUID(), "24f9fc423ed06194065a42d00995c600", null, true); - assumeTrue(mock.submit(), "For this test to run, the server must be running"); - } -} diff --git a/core/src/test/java/dev/faststats/MockContext.java b/core/src/test/java/dev/faststats/MockContext.java new file mode 100644 index 00000000..0a1bff96 --- /dev/null +++ b/core/src/test/java/dev/faststats/MockContext.java @@ -0,0 +1,59 @@ +package dev.faststats; + +import java.util.UUID; + +public final class MockContext extends SimpleContext { + private MockContext(final Factory factory) throws IllegalArgumentException { + super(factory, new MockConfig(UUID.randomUUID()), "test", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + initializeServices(factory); + } + + @Override + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new MockMetrics(this); + } + }; + } + + @Override + public String getProjectName() { + return "Mock"; + } + + private record MockConfig(UUID serverId) implements Config { + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean submitMetrics() { + return true; + } + + @Override + public boolean errorTracking() { + return true; + } + + @Override + public boolean additionalMetrics() { + return true; + } + + @Override + public boolean debug() { + return true; + } + } + + public static final class Factory extends SimpleContext.Factory { + @Override + public MockContext create() { + return new MockContext(this); + } + } +} diff --git a/core/src/test/java/dev/faststats/MockMetrics.java b/core/src/test/java/dev/faststats/MockMetrics.java index 3d9df9e5..3a960f5a 100644 --- a/core/src/test/java/dev/faststats/MockMetrics.java +++ b/core/src/test/java/dev/faststats/MockMetrics.java @@ -1,41 +1,21 @@ package dev.faststats; import com.google.gson.JsonObject; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.SimpleMetrics; -import dev.faststats.core.Token; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; import java.net.URI; -import java.util.Set; -import java.util.UUID; -@NullMarked -public final class MockMetrics extends SimpleMetrics { - public MockMetrics(final UUID serverId, @Token final String token, @Nullable final ErrorTracker tracker, final boolean debug) { - super(new Config(serverId, true, debug, true, true, false, false), Set.of(), token, tracker, null, URI.create("http://localhost:5000/v1/collect"), debug); +final class MockMetrics extends SimpleMetrics { + MockMetrics(final Factory factory) { + super(factory, URI.create("http://localhost:5000/v1/collect")); } @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - System.err.println(message); - if (throwable != null) throwable.printStackTrace(System.err); + protected boolean preSubmissionStart() { + return true; } - @Override - protected void printInfo(final String message) { - System.out.println(message); - } - - @Override - protected void printWarning(final String message) { - System.out.println(message); - } - - @Override - public JsonObject createData() { - return super.createData(); + void startTestSubmitting() { + startSubmitting(); } @Override diff --git a/core/src/test/java/dev/faststats/SimpleContextTest.java b/core/src/test/java/dev/faststats/SimpleContextTest.java new file mode 100644 index 00000000..153408f0 --- /dev/null +++ b/core/src/test/java/dev/faststats/SimpleContextTest.java @@ -0,0 +1,14 @@ +package dev.faststats; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public final class SimpleContextTest { + @Test + public void contextWithoutAttachedServicesThrows() { + final var error = assertThrows(IllegalStateException.class, () -> new MockContext.Factory().create()); + assertEquals("Context created without any service attached, was this intentional?", error.getMessage()); + } +} diff --git a/core/src/test/java/dev/faststats/package-info.java b/core/src/test/java/dev/faststats/package-info.java new file mode 100644 index 00000000..6eba45ca --- /dev/null +++ b/core/src/test/java/dev/faststats/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.faststats; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/core/src/test/resources/META-INF/faststats.properties b/core/src/test/resources/META-INF/faststats.properties new file mode 100644 index 00000000..beb72cc4 --- /dev/null +++ b/core/src/test/resources/META-INF/faststats.properties @@ -0,0 +1 @@ +version=1.0.0 \ No newline at end of file diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index d9caf071..4b1a38fe 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { api(project(":core")) + implementation(project(":config")) mappings(loom.officialMojangMappings()) minecraft("com.mojang:minecraft:1.21.11") modCompileOnly("net.fabricmc.fabric-api:fabric-api:0.139.4+1.21.11") diff --git a/fabric/example-mod/src/main/java/com/example/ExampleMod.java b/fabric/example-mod/src/main/java/com/example/ExampleMod.java index 47d8eae7..95923819 100644 --- a/fabric/example-mod/src/main/java/com/example/ExampleMod.java +++ b/fabric/example-mod/src/main/java/com/example/ExampleMod.java @@ -1,51 +1,40 @@ 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.data.Metric; +import dev.faststats.fabric.FabricContext; import net.fabricmc.api.ModInitializer; -import java.net.URI; +import java.util.concurrent.atomic.AtomicInteger; 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 - .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 - - .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); - } - } + private final AtomicInteger gameCount = new AtomicInteger(); + + private final FabricContext context = new FabricContext.Factory( + "example-mod", // your mod id as defined in fabric.mod.json + "YOUR_TOKEN_HERE" + ) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) + .errorTrackerService(ERROR_TRACKER) + .create(); @Override public void onInitialize() { + context.ready(); // register additional error handlers + } + + public void startGame() { + gameCount.incrementAndGet(); } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java new file mode 100644 index 00000000..f8a8bdca --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -0,0 +1,62 @@ +package dev.faststats.fabric; + +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import org.jetbrains.annotations.Contract; + +/** + * Fabric FastStats context. + * + * @since 0.24.0 + */ +public final class FabricContext extends SimpleContext { + final ModContainer mod; + + private FabricContext(final Factory factory, final String modId, @Token final String token) { + super(factory, SimpleConfig.read(FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties")), "fabric", token); + this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { + return new IllegalArgumentException("Mod not found: " + modId); + }); + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + final var mod = ((FabricContext) context).mod; + return switch (FabricLoader.getInstance().getEnvironmentType()) { + case CLIENT -> new FabricMetricsClientImpl(this, mod); + case SERVER -> new FabricMetricsServerImpl(this, mod); + }; + } + }; + } + + @Override + public String getProjectName() { + return mod.getMetadata().getId(); + } + + public static final class Factory extends SimpleContext.Factory { + private final String modId; + private final @Token String token; + + public Factory(final String modId, @Token final String token) { + this.modId = modId; + this.token = token; + } + + @Override + public FabricContext create() { + return new FabricContext(this, modId, token); + } + } +} 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 f6ce2df7..00000000 --- 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 00000000..b5f427b5 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsClientImpl.java @@ -0,0 +1,46 @@ +package dev.faststats.fabric; + +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.SharedConstants; +import net.minecraft.client.Minecraft; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +final class FabricMetricsClientImpl extends FabricMetricsImpl { + private @Nullable Minecraft client; + + @Async.Schedule + @Contract(mutates = "io") + FabricMetricsClientImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory, mod); + + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + this.client = client; + startSubmitting(); + }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> shutdown()); + } + + @Override + protected void appendDefaultData(final JsonObject metrics) { + assert client != null : "Client not initialized"; + metrics.addProperty("minecraft_version", SharedConstants.getCurrentVersion().name()); // todo: doublecheck + metrics.addProperty("online_mode", client.getUser().getXuid().isPresent() && !client.isOfflineDeveloperMode()); // todo: doublecheck + metrics.addProperty("player_count", getPlayerCount()); + appendFabricData(metrics, "Fabric Client"); + } + + private int getPlayerCount() { + assert client != null : "Client not initialized"; + final var connection = client.getConnection(); + if (connection != null) return connection.getOnlinePlayers().size(); + + final var server = client.getSingleplayerServer(); + if (server != null) return server.getPlayerCount(); + + return client.player == null ? 0 : 1; + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java index ba48e450..9f0f992d 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsImpl.java @@ -1,87 +1,30 @@ 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 net.fabricmc.loader.api.FabricLoader; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; 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); } @Override - protected void printInfo(final String message) { - logger.info(message); + protected boolean preSubmissionStart() { + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); } - @Override - protected void printWarning(final String message) { - logger.warn(message); - } - - 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 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); - } + protected void appendFabricData(final JsonObject metrics, final String serverType) { + metrics.addProperty("plugin_version", mod.getMetadata().getVersion().getFriendlyString()); + metrics.addProperty("server_type", serverType); } } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java new file mode 100644 index 00000000..ebc9e441 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/FabricMetricsServerImpl.java @@ -0,0 +1,34 @@ +package dev.faststats.fabric; + +import com.google.gson.JsonObject; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Async; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +final class FabricMetricsServerImpl extends FabricMetricsImpl { + private @Nullable MinecraftServer server; + + @Async.Schedule + @Contract(mutates = "io") + FabricMetricsServerImpl(final Factory factory, final ModContainer mod) throws IllegalStateException { + super(factory, mod); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + this.server = server; + startSubmitting(); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); + } + + @Override + protected void appendDefaultData(final JsonObject metrics) { + assert server != null : "Server not initialized"; + metrics.addProperty("minecraft_version", server.getServerVersion()); + metrics.addProperty("online_mode", server.usesAuthentication()); + metrics.addProperty("player_count", server.getPlayerCount()); + appendFabricData(metrics, "Fabric"); + } +} diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index c6601e4e..2ca33731 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 4433e215..31662ead 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=0.23.0 +version=0.24.0 diff --git a/hytale/README.md b/hytale/README.md deleted file mode 100644 index b4948f3f..00000000 --- a/hytale/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Hytale Module - -Since the Hytale API is not public yet, and redistribution is not allowed, -you have to download the Hytale server yourself. - -## Initial Setup - -Before building this module, you need to authenticate with your Hytale account. You have two options: - -### Option 1: Using Environment Variable (Recommended for CI) - -Set the `HYTALE_DOWNLOADER_CREDENTIALS` environment variable with your Hytale authentication credentials: - -```bash -export HYTALE_DOWNLOADER_CREDENTIALS='{"your":"auth","json":"here"}' -./gradlew :hytale:download-server -``` - -This token can be obtained by running the download task without credentials once ( -see [Obtaining Hytale Authentication](#obtaining-hytale-authentication)). - -### Option 2: Using Credentials File (Recommended for Local Development) - -1. Create `.hytale-downloader-credentials.json` in the `hytale/` directory -2. Paste your Hytale authentication JSON credentials in the file -3. Run the download task: - -```bash -./gradlew :hytale:download-server -``` - -The credentials file is gitignored and won't be committed. - -## Obtaining Hytale Authentication - -To get your Hytale authentication credentials: - -1. Run the download task without credentials: - ```bash - ./gradlew :hytale:download-server - ``` -2. The Hytale downloader will prompt you to authenticate -3. After successful authentication, the credentials will be saved to - `.hytale-downloader-credentials.json` for future use - -## Updating the Server - -To update the Hytale server: - -```bash -./gradlew :hytale:update-server -``` \ No newline at end of file diff --git a/hytale/build.gradle.kts b/hytale/build.gradle.kts index 3380a610..5bc9ebac 100644 --- a/hytale/build.gradle.kts +++ b/hytale/build.gradle.kts @@ -6,5 +6,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("com.hypixel.hytale:Server:2026.05.07-5efa15f6d") } diff --git a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java index a2154878..72db9444 100644 --- a/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java +++ b/hytale/example-plugin/src/main/java/com/example/ExamplePlugin.java @@ -2,56 +2,42 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; -import dev.faststats.core.ErrorTracker; -import dev.faststats.core.Metrics; -import dev.faststats.core.data.Metric; -import dev.faststats.hytale.HytaleMetrics; +import dev.faststats.ErrorTracker; +import dev.faststats.data.Metric; +import dev.faststats.hytale.HytaleContext; -import java.net.URI; +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(); + private final AtomicInteger gameCount = new AtomicInteger(); - // context-unaware error tracker, does not automatically track errors - public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware(); + private final HytaleContext context = new HytaleContext.Factory(this, "YOUR_TOKEN_HERE") + .errorTrackerService(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) - private final Metrics metrics = HytaleMetrics.factory() - .url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush - // 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})) - - // 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()) + .create(); public ExamplePlugin(final JavaPluginInit init) { super(init); + context.ready(); // register additional error handlers } @Override protected void shutdown() { - metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services } - public void 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/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java new file mode 100644 index 00000000..60dd8827 --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -0,0 +1,56 @@ +package dev.faststats.hytale; + +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; + +/** + * Hytale FastStats context. + * + * @since 0.24.0 + */ +public final class HytaleContext extends SimpleContext { + private final String pluginName; + + private HytaleContext(final Factory factory, final JavaPlugin plugin, @Token final String token) { + super(factory, SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent().resolve("faststats").resolve("config.properties")), "hytale", token); + this.pluginName = plugin.getName(); + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + // todo: add client support? + return new HytaleMetricsImpl(this); + } + }; + } + + @Override + public String getProjectName() { + return pluginName; + } + + public static final class Factory extends SimpleContext.Factory { + private final JavaPlugin plugin; + private final @Token String token; + + public Factory(final JavaPlugin plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + public HytaleContext create() { + return new HytaleContext(this, plugin, token); + } + } +} 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 96748f74..00000000 --- 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 d8372021..88613a74 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -1,58 +1,31 @@ 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.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; + HytaleMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("server_version", HytaleServer.get().getServerName()); metrics.addProperty("player_count", Universe.get().getPlayerCount()); metrics.addProperty("server_type", "Hytale"); } - - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.atSevere().log(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.atInfo().log(message); - } - - @Override - protected void printWarning(final String message) { - logger.atWarning().log(message); - } - - static final class Factory extends SimpleMetrics.Factory implements HytaleMetrics.Factory { - @Override - public Metrics create(final JavaPlugin plugin) throws IllegalStateException { - final var mods = plugin.getDataDirectory().toAbsolutePath().getParent(); - final var config = mods.resolve("faststats").resolve("config.properties"); - return new HytaleMetricsImpl(this, plugin.getLogger(), config); - } - } } diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java new file mode 100644 index 00000000..6213d4e6 --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLogger.java @@ -0,0 +1,54 @@ +package dev.faststats.hytale.logger; + +import dev.faststats.internal.Logger; +import org.intellij.lang.annotations.PrintFormat; +import org.jspecify.annotations.Nullable; + +import java.util.function.Predicate; +import java.util.logging.Level; + +final class HytaleLogger implements Logger { + private final com.hypixel.hytale.logger.HytaleLogger logger; + private volatile @Nullable Predicate filter; + + HytaleLogger(final String name) { + this.logger = com.hypixel.hytale.logger.HytaleLogger.get(name); + } + + @Override + public void setLevel(final Level level) { + logger.setLevel(level); + } + + @Override + public boolean isLoggable(final Level level) { + final var loggerLevel = logger.getLevel(); + if (level.intValue() < loggerLevel.intValue()) return false; + + final var currentFilter = filter; + return currentFilter != null && currentFilter.test(level); + } + + @Override + public void setFilter(@Nullable final Predicate filter) { + this.filter = filter; + } + + @Override + public void error(@PrintFormat final String message, @Nullable final Throwable throwable, @Nullable final Object... args) { + if (!isLoggable(Level.SEVERE)) return; + + final var api = logger.atSevere(); + if (throwable != null) { + api.withCause(throwable).logVarargs(message, args); + return; + } + api.logVarargs(message, args); + } + + @Override + public void log(final Level level, @PrintFormat final String message, @Nullable final Object... args) { + if (!isLoggable(level)) return; + logger.at(level).logVarargs(message, args); + } +} diff --git a/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java new file mode 100644 index 00000000..5af5e688 --- /dev/null +++ b/hytale/src/main/java/dev/faststats/hytale/logger/HytaleLoggerFactory.java @@ -0,0 +1,11 @@ +package dev.faststats.hytale.logger; + +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; + +public final class HytaleLoggerFactory implements LoggerFactory { + @Override + public 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 a091bad2..a9372166 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 00000000..9affb6ba --- /dev/null +++ b/hytale/src/main/resources/META-INF/services/dev.faststats.core.internal.LoggerFactory @@ -0,0 +1 @@ +dev.faststats.hytale.logger.HytaleLoggerFactory diff --git a/minestom/build.gradle.kts b/minestom/build.gradle.kts index 755bdaca..08a06eeb 100644 --- a/minestom/build.gradle.kts +++ b/minestom/build.gradle.kts @@ -1,4 +1,5 @@ dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("net.minestom:minestom:2026.05.11-1.21.11") -} \ No newline at end of file +} diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java new file mode 100644 index 00000000..e7239014 --- /dev/null +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -0,0 +1,60 @@ +package dev.faststats.minestom; + +import dev.faststats.ErrorTracker; +import dev.faststats.ErrorTrackerService; +import dev.faststats.SimpleContext; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import net.minestom.server.MinecraftServer; +import org.jetbrains.annotations.Contract; + +import java.nio.file.Path; + +/** + * Minestom FastStats context. + * + * @since 0.24.0 + */ +public final class MinestomContext extends SimpleContext { + MinestomContext(final Factory factory, @Token final String token) { + super(factory, SimpleConfig.read(Path.of("faststats", "config.properties")), "minestom", token); + initializeServices(factory); + } + + @Override + public void ready() { + super.ready(); + errorTrackerService().map(ErrorTrackerService::globalErrorTracker).ifPresent(errorTracker -> { + final var handler = MinecraftServer.getExceptionManager().getExceptionHandler(); + MinecraftServer.getExceptionManager().setExceptionHandler(error -> { + handler.handleException(error); + if (!ErrorTracker.isSameLoader(getClass().getClassLoader(), error)) return; + errorTracker.trackError(error); + }); + }); + } + + @Override + @Contract(value = " -> new", pure = true) + protected MinestomMetrics.Factory metricsFactory() { + return new MinestomMetricsImpl.Factory(this); + } + + @Override + public String getProjectName() { + return MinecraftServer.getBrandName(); + } + + public static final class Factory extends SimpleContext.Factory { + private final @Token String token; + + public Factory(@Token final String token) { + this.token = token; + } + + @Override + public MinestomContext create() { + return new MinestomContext(this, token); + } + } +} diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java index f4df6b98..7ae318c8 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetrics.java @@ -1,9 +1,7 @@ package dev.faststats.minestom; -import dev.faststats.core.Metrics; -import net.minestom.server.Auth; -import net.minestom.server.MinecraftServer; -import org.jetbrains.annotations.Contract; +import dev.faststats.Metrics; +import dev.faststats.data.Metric; /** * Minestom metrics implementation. @@ -11,26 +9,14 @@ * @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(); - } + sealed interface Factory extends Metrics.Factory permits MinestomMetricsImpl.Factory { + @Override + Factory addMetric(Metric metric) throws IllegalArgumentException; - /** - * Registers additional exception handlers. - * - * @apiNote This method may only be called after {@link MinecraftServer#init(Auth)}. - * @since 0.14.0 - */ - @Override - void ready(); + @Override + Factory onFlush(Runnable flush); - interface Factory extends Metrics.Factory { + @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 217f1942..d864024d 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomMetricsImpl.java @@ -1,30 +1,28 @@ 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.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) context.getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", MinecraftServer.VERSION_NAME); @@ -33,40 +31,24 @@ 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); - } + public static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { + Factory(final MinestomContext context) { + super(context); + } - @Override - public void ready() { - getErrorTracker().ifPresent(this::registerExceptionHandler); - } + @Override + public Factory addMetric(final Metric metric) throws IllegalArgumentException { + return (Factory) super.addMetric(metric); + } - private void registerExceptionHandler(final ErrorTracker errorTracker) { - final var handler = MinecraftServer.getExceptionManager().getExceptionHandler(); - MinecraftServer.getExceptionManager().setExceptionHandler(error -> { - handler.handleException(error); - if (!ErrorTracker.isSameLoader(getClass().getClassLoader(), error)) return; - errorTracker.trackError(error); - }); - } + @Override + public Factory onFlush(final Runnable flush) { + return (Factory) super.onFlush(flush); + } - static final class Factory extends SimpleMetrics.Factory implements MinestomMetrics.Factory { @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 629f4d5f..e0c4b0c3 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 af632e8a..d4a090a7 100644 --- a/nukkit/build.gradle.kts +++ b/nukkit/build.gradle.kts @@ -7,5 +7,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("cn.nukkit:nukkit:1.0-SNAPSHOT") } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java new file mode 100644 index 00000000..031007bd --- /dev/null +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -0,0 +1,57 @@ +package dev.faststats.nukkit; + +import cn.nukkit.plugin.PluginBase; +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; + +import java.nio.file.Path; + +/** + * Nukkit FastStats context. + * + * @since 0.24.0 + */ +public final class NukkitContext extends SimpleContext { + final PluginBase plugin; + + private NukkitContext(final Factory factory, final PluginBase plugin, @Token final String token) { + super(factory, SimpleConfig.read(Path.of(plugin.getServer().getPluginPath(), "faststats", "config.properties")), "nukkit", token); + this.plugin = plugin; + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new NukkitMetricsImpl(this, ((NukkitContext) context).plugin); + } + }; + } + + @Override + public String getProjectName() { + return plugin.getName(); + } + + public static final class Factory extends SimpleContext.Factory { + private final PluginBase plugin; + private final @Token String token; + + public Factory(final PluginBase plugin, @Token final String token) { + this.plugin = plugin; + this.token = token; + } + + @Override + public NukkitContext create() { + return new NukkitContext(this, plugin, token); + } + } +} 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 2420e2a9..00000000 --- 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 1316d890..75da2a3a 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitMetricsImpl.java @@ -2,35 +2,35 @@ 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.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); + public 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) context.getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", server.getVersion()); @@ -40,21 +40,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()); @@ -62,13 +47,4 @@ private Optional tryOrEmpty(final Supplier supplier) { return Optional.empty(); } } - - static final class Factory extends SimpleMetrics.Factory implements NukkitMetrics.Factory { - @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); - } - } } diff --git a/nukkit/src/main/java/module-info.java b/nukkit/src/main/java/module-info.java index b7b0b2bd..c8722c46 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 e67c2efd..402a4cd3 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 3c43e7d2..4fc917d2 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,52 @@ 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.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; - +import java.util.concurrent.atomic.AtomicInteger; @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(); + private @Inject SpongeContext.Builder contextBuilder; - // 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 @Nullable Metrics metrics = null; + private final AtomicInteger gameCount = new AtomicInteger(); + private @Nullable SpongeContext context = 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 - .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 - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(pluginContainer); + this.context = contextBuilder + .token("YOUR_TOKEN_HERE") + .errorTrackerService(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) + .create(); + context.ready(); // register additional error handlers } @Listener public void onServerStop(final StoppingEngineEvent event) { - if (metrics != null) metrics.shutdown(); // safely shut down metrics submission + if (context != null) context.shutdown(); // safely shut down configured services } - public void 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/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java new file mode 100644 index 00000000..1934befe --- /dev/null +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -0,0 +1,180 @@ +package dev.faststats.sponge; + +import dev.faststats.Config; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; +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.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.logging.Level; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApiStatus.Internal +public record SpongeConfig( + UUID serverId, + boolean enabled, + boolean additionalMetrics, + boolean debug, + boolean submitMetrics, + boolean errorTracking, + boolean firstRun +) implements Config { + private static final Logger logger = LoggerFactory.factory().getLogger(SpongeConfig.class); + private static final int CONFIG_VERSION = 1; + + private static final String COMMENT = """ + 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 highly recommended, you can do so in the Sponge metrics.config, + # by setting the "global-state" property to "TRUE". + # To disable only metrics submission, set 'submitMetrics=false'. + # To disable additional metrics, set 'submitAdditionalMetrics=false'. + # To disable error tracking, set 'submitErrors=false'. + # + # If you suspect a developer is collecting personal data or bypassing the Sponge config, + # please report it at: https://faststats.dev/abuse + # + # 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 + + 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 SpongeConfig read(final PluginContainer plugin, final Path file) throws RuntimeException { + final var properties = readOrEmpty(file); + final var firstRun = properties == null; + final var saveConfig = new AtomicBoolean(firstRun); + + final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { + final var corrected = value.length() > 36 ? value.substring(0, 36) : value; + final var uuid = UUID.fromString(corrected); + if (!value.equals(uuid.toString())) saveConfig.set(true); + return uuid; + }); + final var configVersion = parse(properties, saveConfig, "configVersion", null, Integer::parseInt); + final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean); + final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean); + final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean); + final boolean debug = parse(properties, saveConfig, "debug", () -> true, Boolean::parseBoolean); + + if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); + else if (configVersion > CONFIG_VERSION) saveConfig.set(false); + + if (saveConfig.get()) try { + if (configVersion == null || configVersion < CONFIG_VERSION) + logger.info("Updating config version to %s", CONFIG_VERSION); + Files.createDirectories(file.getParent()); + try (final var out = Files.newOutputStream(file); + final var writer = new OutputStreamWriter(out, UTF_8)) { + final var store = new Properties(); + + store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); + store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); + store.setProperty("submitErrors", Boolean.toString(errorTracking)); + + store.setProperty("serverId", serverId.toString()); + + store.setProperty("debug", Boolean.toString(debug)); + store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); + + store.store(writer, COMMENT); + } + } 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, + enabled, + enabled && additionalMetrics, + debug, + enabled && submitMetrics, + enabled && errorTracking, + firstRun + ); + } + + @Contract(value = "_, _, _, !null, _ -> !null") + private static @Nullable T parse( + @Nullable final Properties properties, + final AtomicBoolean saveConfig, + final String key, + @Nullable final Supplier defaultValue, + final Function parser + ) { + if (properties == null) { + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + final var property = properties.getProperty(key); + if (property == null) { + logger.warn("Missing configuration property: %s", key); + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + try { + return parser.apply(property.trim()); + } catch (final Exception e) { + logger.error("Failed to read property '%s' from config", e, key); + saveConfig.set(true); + return defaultValue != null ? defaultValue.get() : null; + } + } + + private static @Nullable Properties readOrEmpty(final Path file) throws RuntimeException { + if (!Files.isRegularFile(file)) return null; + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return 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 00000000..38bedda5 --- /dev/null +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -0,0 +1,111 @@ +package dev.faststats.sponge; + +import com.google.inject.Inject; +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.Token; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; +import org.spongepowered.api.config.ConfigDir; +import org.spongepowered.plugin.PluginContainer; + +import java.nio.file.Path; + +/** + * Sponge FastStats context. + * + * @since 0.24.0 + */ +public final class SpongeContext extends SimpleContext { + final PluginContainer plugin; + + private SpongeContext( + final Factory factory, final PluginContainer plugin, + @ConfigDir(sharedRoot = true) final Path dataDirectory, + @Token final String token + ) { + super(factory, SpongeConfig.read(plugin, dataDirectory.resolve("faststats").resolve("config.properties")), "sponge", token); + this.plugin = plugin; + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new SpongeMetricsImpl(this); + } + }; + } + + @Override + public String getProjectName() { + return plugin.metadata().id(); + } + + /** + * Injectable Sponge context builder. + * + * @since 0.24.0 + */ + public static class Factory extends SimpleContext.Factory { + private final PluginContainer plugin; + private final Path dataDirectory; + private @Token + @Nullable String token; + + /** + * Creates a new Sponge context builder. + * + * @param plugin the plugin container + * @param dataDirectory the shared Sponge config directory + * @apiNote This instance can be injected into your plugin. + * @since 0.24.0 + */ + @Inject + public Factory( + final PluginContainer plugin, + @ConfigDir(sharedRoot = true) final Path dataDirectory + ) { + this.plugin = plugin; + this.dataDirectory = dataDirectory; + } + + /** + * Sets the FastStats project token used by the context created from this factory. + * + * @param token the FastStats project token + * @return this factory + * @throws IllegalArgumentException if the token is invalid + * @since 0.24.0 + */ + public SpongeContext.Factory token(@Token final String token) throws IllegalArgumentException { + this.token = token; + return this; + } + + @Override + public SpongeContext create() { + if (token == null) throw new IllegalStateException("Token not configured"); + return new SpongeContext(this, plugin, dataDirectory, token); + } + } + + /** + * Injectable Sponge context builder. + * + * @since 0.24.0 + */ + public static final class Builder extends Factory { + @Inject + public Builder( + final PluginContainer plugin, + @ConfigDir(sharedRoot = true) final Path dataDirectory + ) { + super(plugin, dataDirectory); + } + } +} diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java deleted file mode 100644 index 380df380..00000000 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetrics.java +++ /dev/null @@ -1,30 +0,0 @@ -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; - -/** - * Sponge metrics implementation. - * - * @since 0.12.0 - */ -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); - } - } -} diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java index b029e849..05919e5a 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeMetricsImpl.java @@ -1,64 +1,27 @@ package dev.faststats.sponge; import com.google.gson.JsonObject; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; -import org.apache.logging.log4j.Logger; +import dev.faststats.SimpleMetrics; 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; +final class SpongeMetricsImpl extends SimpleMetrics { private final PluginContainer plugin; @Async.Schedule @Contract(mutates = "io") - private SpongeMetricsImpl( - final Factory factory, - final Logger logger, - final PluginContainer plugin, - final Path config - ) throws IllegalStateException { - super(factory, SimpleMetrics.Config.read(config, COMMENT, true, Sponge.metricsConfigManager() - .effectiveCollectionState(plugin).asBoolean())); - - this.logger = logger; - this.plugin = plugin; - + SpongeMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); + this.plugin = ((SpongeContext) this.context).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) context.getConfig()).preSubmissionStart(); } @Override @@ -69,35 +32,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("minecraft_version", Sponge.platform().minecraftVersion().name()); metrics.addProperty("server_type", Sponge.platform().container(Platform.Component.IMPLEMENTATION).metadata().id()); } - - @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; - } - - @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")); - } - } } diff --git a/sponge/src/main/java/module-info.java b/sponge/src/main/java/module-info.java index b01a1562..61c8a824 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 74da85bf..ef8247d1 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -4,5 +4,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT") } diff --git a/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java b/velocity/example-plugin/src/main/java/com/example/ExamplePlugin.java index b852d120..e84256e5 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,50 @@ 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 org.jspecify.annotations.Nullable; - -import java.net.URI; +import dev.faststats.ErrorTracker; +import dev.faststats.data.Metric; +import dev.faststats.velocity.VelocityContext; +import java.util.concurrent.atomic.AtomicInteger; @Plugin(id = "example", name = "Example Plugin", version = "1.0.0", url = "https://example.com", authors = {"Your Name"}) public class ExamplePlugin { - // 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(); - // context-unaware error tracker, does not automatically track errors - public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware(); - - private final VelocityMetrics.Factory metricsFactory; - private @Nullable Metrics metrics = null; + private final VelocityContext context; @Inject - public ExamplePlugin(final VelocityMetrics.Factory factory) { - this.metricsFactory = factory; + public ExamplePlugin(final VelocityContext.Builder contextBuilder) { + this.context = contextBuilder + .token("YOUR_TOKEN_HERE") + .errorTrackerService(ERROR_TRACKER) + // .metrics(Metrics.Factory::create) // Define a minimal metrics instance without any custom metrics + .metrics(factory -> factory + // Custom metrics require a corresponding data source in your project settings + .addMetric(Metric.number("game_count", gameCount::get)) + .addMetric(Metric.string("server_version", () -> "1.0.0")) + + // #onFlush is invoked after successful metrics submission + // This is useful for cleaning up cached data + .onFlush(() -> gameCount.set(0)) // reset game count on flush + + .create()) + .create(); } @Subscribe public void onProxyInitialize(final ProxyInitializeEvent event) { - this.metrics = 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 - .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 - - .token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project - .create(this); + context.ready(); // register additional error handlers } @Subscribe public void onProxyStop(final ProxyShutdownEvent event) { - if (metrics != null) metrics.shutdown(); // safely shut down metrics submission + context.shutdown(); // safely shut down configured services } - public void 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/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java new file mode 100644 index 00000000..2531d012 --- /dev/null +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -0,0 +1,120 @@ +package dev.faststats.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import dev.faststats.Metrics; +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.Token; +import dev.faststats.config.SimpleConfig; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.nio.file.Path; + +/** + * Velocity FastStats context. + * + * @since 0.24.0 + */ +public final class VelocityContext extends SimpleContext { + final PluginContainer plugin; + final ProxyServer server; + + private VelocityContext( + final Factory factory, final PluginContainer plugin, + final ProxyServer server, + @DataDirectory final Path dataDirectory, + @Token final String token + ) { + super(factory, SimpleConfig.read(dataDirectory.resolveSibling("faststats").resolve("config.properties")), "velocity", token); + this.plugin = plugin; + this.server = server; + initializeServices(factory); + } + + @Override + @Contract(value = " -> new", pure = true) + protected Metrics.Factory metricsFactory() { + return new SimpleMetrics.Factory(this) { + @Override + public Metrics create() throws IllegalStateException { + return new VelocityMetricsImpl(this); + } + }; + } + + @Override + public String getProjectName() { + return plugin.getDescription().getId(); + } + + /** + * Injectable Velocity context builder. + * + * @since 0.24.0 + */ + public static class Factory extends SimpleContext.Factory { + private final PluginContainer plugin; + private final ProxyServer server; + private final Path dataDirectory; + private @Token + @Nullable String token; + + /** + * Creates a new Velocity context builder. + * + * @param server the velocity server + * @param dataDirectory the plugin data directory + * @apiNote This instance can be injected into your plugin. + * @since 0.24.0 + */ + @Inject + public Factory( + final PluginContainer plugin, + final ProxyServer server, + @DataDirectory final Path dataDirectory + ) { + this.plugin = plugin; + this.server = server; + this.dataDirectory = dataDirectory; + } + + /** + * Sets the FastStats project token used by the context created from this factory. + * + * @param token the FastStats project token + * @return this factory + * @throws IllegalArgumentException if the token is invalid + * @since 0.24.0 + */ + public Factory token(@Token final String token) { + this.token = token; + return this; + } + + @Override + public VelocityContext create() { + if (token == null) throw new IllegalStateException("Token not configured"); + return new VelocityContext(this, plugin, server, dataDirectory, token); + } + } + + /** + * Injectable Velocity context builder. + * + * @since 0.24.0 + */ + public static final class Builder extends Factory { + @Inject + public Builder( + final PluginContainer plugin, + final ProxyServer server, + @DataDirectory final Path dataDirectory + ) { + super(plugin, server, dataDirectory); + } + } +} diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java deleted file mode 100644 index d552d332..00000000 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetrics.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.faststats.velocity; - -import com.google.inject.Inject; -import com.velocitypowered.api.plugin.annotation.DataDirectory; -import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.core.Metrics; -import org.slf4j.Logger; - -import java.nio.file.Path; - -/** - * Velocity metrics implementation. - * - * @since 0.1.0 - */ -public sealed interface VelocityMetrics extends Metrics permits VelocityMetricsImpl { - final class Factory extends VelocityMetricsImpl.Factory { - /** - * 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); - } - } -} diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java index 0c64cf8a..47b05cec 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityMetricsImpl.java @@ -2,40 +2,33 @@ import com.google.gson.JsonObject; import com.velocitypowered.api.plugin.PluginContainer; -import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; -import dev.faststats.core.Metrics; -import dev.faststats.core.SimpleMetrics; +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; +final class VelocityMetricsImpl extends SimpleMetrics { private final ProxyServer server; private final PluginContainer plugin; @Async.Schedule @Contract(mutates = "io") - private VelocityMetricsImpl( - final Factory factory, - final Logger logger, - final ProxyServer server, - final Path config, - final PluginContainer plugin - ) throws IllegalStateException { - super(factory, config); + VelocityMetricsImpl(final Factory factory) throws IllegalStateException { + super(factory); - this.logger = logger; - this.server = server; - this.plugin = plugin; + final var context = (VelocityContext) this.context; + this.server = context.server; + this.plugin = context.plugin; startSubmitting(); } + @Override + protected boolean preSubmissionStart() { + return ((SimpleConfig) context.getConfig()).preSubmissionStart(); + } + @Override protected void appendDefaultData(final JsonObject metrics) { final var pluginVersion = plugin.getDescription().getVersion().orElse("unknown"); @@ -45,50 +38,4 @@ protected void appendDefaultData(final JsonObject metrics) { metrics.addProperty("proxy_version", server.getVersion().getVersion()); metrics.addProperty("server_type", server.getVersion().getName()); } - - @Override - protected void printError(final String message, @Nullable final Throwable throwable) { - logger.error(message, throwable); - } - - @Override - protected void printInfo(final String message) { - logger.info(message); - } - - @Override - protected void printWarning(final String message) { - logger.warn(message); - } - - static class Factory extends SimpleMetrics.Factory { - protected final Logger logger; - protected final Path dataDirectory; - protected final ProxyServer server; - - public Factory(final ProxyServer server, final Logger logger, @DataDirectory final Path dataDirectory) { - this.logger = logger; - this.dataDirectory = dataDirectory; - this.server = server; - } - - /** - * Creates a new metrics instance. - *

- * 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 - */ - @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); - } - } } diff --git a/velocity/src/main/java/module-info.java b/velocity/src/main/java/module-info.java index 2855dcb6..0d80b4fc 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 +}