From bf402068f7b08190da0ce0a74d9b278e5d125637 Mon Sep 17 00:00:00 2001 From: Daniel Walsh Date: Sat, 17 Feb 2024 16:23:39 +0000 Subject: [PATCH] Add new analytics service (#4067) Co-authored-by: Alessio Colombo <37039432+Sfiguz7@users.noreply.github.com> --- README.md | 8 + .../thebusybiscuit/slimefun4/Threads.java | 23 --- .../slimefun4/api/player/PlayerProfile.java | 7 +- .../slimefun4/core/debug/TestCase.java | 9 +- .../core/services/AnalyticsService.java | 158 ++++++++++++++++++ .../core/services/ThreadService.java | 99 +++++++++++ .../services/profiler/SlimefunProfiler.java | 29 ++++ .../slimefun4/implementation/Slimefun.java | 28 +++- .../storage/backend/legacy/LegacyStorage.java | 10 ++ src/main/resources/config.yml | 1 + 10 files changed, 342 insertions(+), 30 deletions(-) delete mode 100644 src/main/java/io/github/thebusybiscuit/slimefun4/Threads.java create mode 100644 src/main/java/io/github/thebusybiscuit/slimefun4/core/services/AnalyticsService.java create mode 100644 src/main/java/io/github/thebusybiscuit/slimefun4/core/services/ThreadService.java diff --git a/README.md b/README.md index 46ce4c0096..affc3ed96c 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,14 @@ For more info see [bStats' Privacy Policy](https://bstats.org/privacy-policy) Our [bStats Module](https://github.com/Slimefun/MetricsModule) is downloaded automatically when installing this Plugin, this module will automatically update on server starts independently from the main plugin. This way we can automatically roll out updates to the bStats module, in cases of severe performance issues for example where live data and insight into what is impacting performance can be crucial. These updates can of course be disabled under `/plugins/Slimefun/config.yml`. To disable metrics collection as a whole, see the paragraph above. +--- + +Slimefun also uses its own analytics system to collect anonymous information about the performance of this plugin.
+This is solely for statistical purposes, as we are interested in how it's performing for all servers.
+All available data is anonymous and aggregated, at no point can we see individual server information.
+ +You can also disable this behaviour under `/plugins/Slimefun/config.yml`.
+
diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/Threads.java b/src/main/java/io/github/thebusybiscuit/slimefun4/Threads.java deleted file mode 100644 index d109bcae9d..0000000000 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/Threads.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.thebusybiscuit.slimefun4; - -import javax.annotation.ParametersAreNonnullByDefault; - -import org.bukkit.plugin.java.JavaPlugin; - -public class Threads { - - @ParametersAreNonnullByDefault - public static void newThread(JavaPlugin plugin, String name, Runnable runnable) { - // TODO: Change to thread pool - new Thread(runnable, plugin.getName() + " - " + name).start(); - } - - public static String getCaller() { - // First item will be getting the call stack - // Second item will be this call - // Third item will be the func we care about being called - // And finally will be the caller - StackTraceElement element = Thread.currentThread().getStackTrace()[3]; - return element.getClassName() + "." + element.getMethodName() + ":" + element.getLineNumber(); - } -} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/player/PlayerProfile.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/player/PlayerProfile.java index de5432fd1b..00cacd4cd9 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/api/player/PlayerProfile.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/player/PlayerProfile.java @@ -30,7 +30,6 @@ import io.github.bakedlibs.dough.common.ChatColors; import io.github.bakedlibs.dough.common.CommonPatterns; import io.github.bakedlibs.dough.config.Config; -import io.github.thebusybiscuit.slimefun4.Threads; import io.github.thebusybiscuit.slimefun4.api.events.AsyncProfileLoadEvent; import io.github.thebusybiscuit.slimefun4.api.gps.Waypoint; import io.github.thebusybiscuit.slimefun4.api.items.HashedArmorpiece; @@ -383,7 +382,6 @@ public static boolean get(@Nonnull OfflinePlayer p, @Nonnull Consumer { + Slimefun.getThreadService().newThread(Slimefun.instance(), "PlayerProfile#get(" + uuid + ")", () -> { PlayerData data = Slimefun.getPlayerStorage().loadPlayerData(p.getUniqueId()); loading.remove(uuid); @@ -428,14 +426,13 @@ public static boolean request(@Nonnull OfflinePlayer p) { // See #4011, #4116 if (loading.containsKey(uuid)) { Debug.log(TestCase.PLAYER_PROFILE_DATA, "Attempted to request PlayerProfile ({}) while loading", uuid); - Debug.log(TestCase.PLAYER_PROFILE_DATA, "Caller: {}", Threads.getCaller()); return false; } if (!Slimefun.getRegistry().getPlayerProfiles().containsKey(uuid)) { loading.put(uuid, true); // Should probably prevent multiple requests for the same profile in the future - Threads.newThread(Slimefun.instance(), "PlayerProfile#request(" + uuid + ")", () -> { + Slimefun.getThreadService().newThread(Slimefun.instance(), "PlayerProfile#request(" + uuid + ")", () -> { PlayerData data = Slimefun.getPlayerStorage().loadPlayerData(uuid); loading.remove(uuid); diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/debug/TestCase.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/debug/TestCase.java index dec31592d2..e41ecddc42 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/core/debug/TestCase.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/debug/TestCase.java @@ -6,6 +6,8 @@ import javax.annotation.Nonnull; +import io.github.thebusybiscuit.slimefun4.core.services.AnalyticsService; + /** * Test cases in Slimefun. These are very useful for debugging why behavior is happening. * Server owners can enable these with {@code /sf debug } @@ -25,7 +27,12 @@ public enum TestCase { * Debug information regarding player profile loading, saving and handling. * This is an area we're currently changing quite a bit and this will help ensure we're doing it safely */ - PLAYER_PROFILE_DATA; + PLAYER_PROFILE_DATA, + + /** + * Debug information regarding our {@link AnalyticsService}. + */ + ANALYTICS; public static final List VALUES_LIST = Arrays.stream(values()).map(TestCase::toString).toList(); diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/AnalyticsService.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/AnalyticsService.java new file mode 100644 index 0000000000..b0afd40657 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/AnalyticsService.java @@ -0,0 +1,158 @@ +package io.github.thebusybiscuit.slimefun4.core.services; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import io.github.thebusybiscuit.slimefun4.core.debug.Debug; +import io.github.thebusybiscuit.slimefun4.core.debug.TestCase; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +/** + * This class represents an analytics service that sends data. + * This data is used to analyse performance of this {@link Plugin}. + *

+ * You can find more info in the README file of this Project on GitHub. + * + * @author WalshyDev + */ +public class AnalyticsService { + + private static final int VERSION = 1; + private static final String API_URL = "https://analytics.slimefun.dev/ingest"; + + private final JavaPlugin plugin; + private final HttpClient client = HttpClient.newHttpClient(); + + private boolean enabled; + + public AnalyticsService(JavaPlugin plugin) { + this.plugin = plugin; + } + + public void start() { + this.enabled = Slimefun.getCfg().getBoolean("metrics.analytics"); + + if (enabled) { + plugin.getLogger().info("Enabled Analytics Service"); + + // Send the timings data every minute + Slimefun.getThreadService().newScheduledThread( + plugin, + "AnalyticsService - Timings", + sendTimingsAnalytics(), + 1, + 1, + TimeUnit.MINUTES + ); + } + } + + // We'll send some timing data every minute. + // To date, we collect the tick interval, the avg timing per tick and avg timing per machine + @Nonnull + private Runnable sendTimingsAnalytics() { + return () -> { + double tickInterval = Slimefun.getTickerTask().getTickRate(); + // This is currently used by bStats in a ranged way, we'll move this + double totalTimings = Slimefun.getProfiler().getAndResetAverageNanosecondTimings(); + double avgPerMachine = Slimefun.getProfiler().getAverageTimingsPerMachine(); + + if (totalTimings == 0 || avgPerMachine == 0) { + Debug.log(TestCase.ANALYTICS, "Ignoring analytics data for server_timings as no data was found" + + " - total: " + totalTimings + ", avg: " + avgPerMachine); + // Ignore if no data + return; + } + + send("server_timings", new double[]{ + // double1 is schema version + tickInterval, // double2 + totalTimings, // double3 + avgPerMachine // double4 + }, null); + }; + } + + public void recordPlayerProfileDataTime(@Nonnull String backend, boolean load, long nanoseconds) { + send( + "player_profile_data_load_time", + new double[]{ + // double1 is schema version + nanoseconds, // double2 + load ? 1 : 0 // double3 - 1 if load, 0 if save + }, + new String[]{ + // blob1 is version + backend // blob2 + } + ); + } + + // Important: Keep the order of these doubles and blobs the same unless you increment the version number + // If a value is no longer used, just send null or replace it with a new value - don't shift the order + @ParametersAreNonnullByDefault + private void send(String id, double[] doubles, String[] blobs) { + // If not enabled or not official build (e.g. local build) or a unit test, just ignore. + if ( + !enabled + || !Slimefun.getUpdater().getBranch().isOfficial() + || Slimefun.instance().isUnitTest() + ) return; + + JsonObject object = new JsonObject(); + // Up to 1 index + JsonArray indexes = new JsonArray(); + indexes.add(id); + object.add("indexes", indexes); + + // Up to 20 doubles (including the version) + JsonArray doublesArray = new JsonArray(); + doublesArray.add(VERSION); + if (doubles != null) { + for (double d : doubles) { + doublesArray.add(d); + } + } + object.add("doubles", doublesArray); + + // Up to 20 blobs (including the version) + JsonArray blobsArray = new JsonArray(); + blobsArray.add(Slimefun.getVersion()); + if (blobs != null) { + for (String s : blobs) { + blobsArray.add(s); + } + } + object.add("blobs", blobsArray); + + Debug.log(TestCase.ANALYTICS, "Sending analytics data for " + id); + Debug.log(TestCase.ANALYTICS, object.toString()); + + // Send async, we do not care about the result. If it fails, that's fine. + client.sendAsync(HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .header("User-Agent", "Mozilla/5.0 Slimefun4 AnalyticsService") + .POST(HttpRequest.BodyPublishers.ofString(object.toString())) + .build(), + HttpResponse.BodyHandlers.discarding() + ).thenAcceptAsync((res) -> { + if (res.statusCode() == 200) { + Debug.log(TestCase.ANALYTICS, "Analytics data for " + id + " sent successfully"); + } else { + Debug.log(TestCase.ANALYTICS, "Analytics data for " + id + " failed to send - " + res.statusCode()); + } + }); + } +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/ThreadService.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/ThreadService.java new file mode 100644 index 0000000000..772b65d3fe --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/ThreadService.java @@ -0,0 +1,99 @@ +package io.github.thebusybiscuit.slimefun4.core.services; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; + +public final class ThreadService { + + private final ThreadGroup group; + private final ExecutorService cachedPool; + private final ScheduledExecutorService scheduledPool; + + public ThreadService(JavaPlugin plugin) { + this.group = new ThreadGroup(plugin.getName()); + this.cachedPool = Executors.newCachedThreadPool(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + return new Thread(group, r, plugin.getName() + " - ThreadService"); + } + }); + + this.scheduledPool = Executors.newScheduledThreadPool(1, new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + return new Thread(group, r, plugin.getName() + " - ScheduledThreadService"); + } + }); + } + + /** + * Invoke a new thread from the cached thread pool with the given name. + * This is a much better alternative to using + * {@link BukkitScheduler#runTaskAsynchronously(org.bukkit.plugin.Plugin, Runnable)} + * as this will show not only the plugin but a useful name. + * By default, Bukkit will use "Craft Scheduler Thread - - " which is nice to show the plugin but + * it's impossible to track exactly what thread that is. + * + * @param plugin The {@link JavaPlugin} that is creating this thread + * @param name The name of this thread, this will be prefixed with the plugin's name + * @param runnable The {@link Runnable} to execute + */ + @ParametersAreNonnullByDefault + public void newThread(JavaPlugin plugin, String name, Runnable runnable) { + cachedPool.submit(() -> { + // This is a bit of a hack, but it's the only way to have the thread name be as desired + Thread.currentThread().setName(plugin.getName() + " - " + name); + runnable.run(); + }); + } + + /** + * Invoke a new scheduled thread from the cached thread pool with the given name. + * This is a much better alternative to using + * {@link BukkitScheduler#runTaskTimerAsynchronously(org.bukkit.plugin.Plugin, Runnable, long, long)} + * as this will show not only the plugin but a useful name. + * By default, Bukkit will use "Craft Scheduler Thread - - " which is nice to show the plugin but + * it's impossible to track exactly what thread that is. + * + * @param plugin The {@link JavaPlugin} that is creating this thread + * @param name The name of this thread, this will be prefixed with the plugin's name + * @param runnable The {@link Runnable} to execute + */ + @ParametersAreNonnullByDefault + public void newScheduledThread( + JavaPlugin plugin, + String name, + Runnable runnable, + long delay, + long period, + TimeUnit unit + ) { + this.scheduledPool.scheduleWithFixedDelay(() -> { + // This is a bit of a hack, but it's the only way to have the thread name be as desired + Thread.currentThread().setName(plugin.getName() + " - " + name); + runnable.run(); + }, delay, delay, unit); + } + + /** + * Get the caller of a given method, this should only be used for debugging purposes and is not performant. + * + * @return The caller of the method that called this method. + */ + public static String getCaller() { + // First item will be getting the call stack + // Second item will be this call + // Third item will be the func we care about being called + // And finally will be the caller + StackTraceElement element = Thread.currentThread().getStackTrace()[3]; + return element.getClassName() + "." + element.getMethodName() + ":" + element.getLineNumber(); + } +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/profiler/SlimefunProfiler.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/profiler/SlimefunProfiler.java index 2a1292225a..408fdc439e 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/profiler/SlimefunProfiler.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/profiler/SlimefunProfiler.java @@ -22,6 +22,8 @@ import org.bukkit.block.Block; import org.bukkit.scheduler.BukkitScheduler; +import com.google.common.util.concurrent.AtomicDouble; + import io.github.thebusybiscuit.slimefun4.api.SlimefunAddon; import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; @@ -87,6 +89,8 @@ public class SlimefunProfiler { private final AtomicLong totalMsTicked = new AtomicLong(); private final AtomicInteger ticksPassed = new AtomicInteger(); + private final AtomicLong totalNsTicked = new AtomicLong(); + private final AtomicDouble averageTimingsPerMachine = new AtomicDouble(); /** * This method terminates the {@link SlimefunProfiler}. @@ -222,11 +226,14 @@ private void finishReport() { totalElapsedTime = timings.values().stream().mapToLong(Long::longValue).sum(); + averageTimingsPerMachine.getAndSet(timings.values().stream().mapToLong(Long::longValue).average().orElse(0)); + /* * We log how many milliseconds have been ticked, and how many ticks have passed * This is so when bStats requests the average timings, they're super quick to figure out */ totalMsTicked.addAndGet(TimeUnit.NANOSECONDS.toMillis(totalElapsedTime)); + totalNsTicked.addAndGet(totalElapsedTime); ticksPassed.incrementAndGet(); if (!requests.isEmpty()) { @@ -416,4 +423,26 @@ public long getAndResetAverageTimings() { return l; } + + /** + * Get and reset the average nanosecond timing for this {@link SlimefunProfiler}. + * + * @return The average nanosecond timing for this {@link SlimefunProfiler}. + */ + public double getAndResetAverageNanosecondTimings() { + long l = totalNsTicked.get() / ticksPassed.get(); + totalNsTicked.set(0); + ticksPassed.set(0); + + return l; + } + + /** + * Get and reset the average millisecond timing for each machine. + * + * @return The average millisecond timing for each machine. + */ + public double getAverageTimingsPerMachine() { + return averageTimingsPerMachine.getAndSet(0); + } } diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java index 28233ea741..ae065bc062 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java @@ -42,6 +42,7 @@ import io.github.thebusybiscuit.slimefun4.core.SlimefunRegistry; import io.github.thebusybiscuit.slimefun4.core.commands.SlimefunCommand; import io.github.thebusybiscuit.slimefun4.core.networks.NetworkManager; +import io.github.thebusybiscuit.slimefun4.core.services.AnalyticsService; import io.github.thebusybiscuit.slimefun4.core.services.AutoSavingService; import io.github.thebusybiscuit.slimefun4.core.services.BackupService; import io.github.thebusybiscuit.slimefun4.core.services.BlockDataService; @@ -52,6 +53,7 @@ import io.github.thebusybiscuit.slimefun4.core.services.MinecraftRecipeService; import io.github.thebusybiscuit.slimefun4.core.services.PerWorldSettingsService; import io.github.thebusybiscuit.slimefun4.core.services.PermissionsService; +import io.github.thebusybiscuit.slimefun4.core.services.ThreadService; import io.github.thebusybiscuit.slimefun4.core.services.UpdaterService; import io.github.thebusybiscuit.slimefun4.core.services.github.GitHubService; import io.github.thebusybiscuit.slimefun4.core.services.holograms.HologramsService; @@ -182,6 +184,8 @@ public class Slimefun extends JavaPlugin implements SlimefunAddon { private final MinecraftRecipeService recipeService = new MinecraftRecipeService(this); private final HologramsService hologramsService = new HologramsService(this); private final SoundService soundService = new SoundService(this); + private final ThreadService threadService = new ThreadService(this); + private final AnalyticsService analyticsService = new AnalyticsService(this); // Some other things we need private final IntegrationsManager integrations = new IntegrationsManager(this); @@ -309,8 +313,9 @@ private void onPluginStart() { playerStorage = new LegacyStorage(); logger.log(Level.INFO, "Using legacy storage for player data"); - // Setting up bStats + // Setting up bStats and analytics new Thread(metricsService::start, "Slimefun Metrics").start(); + analyticsService.start(); // Starting the Auto-Updater if (config.getBoolean("options.auto-update")) { @@ -901,6 +906,17 @@ public static SoundService getSoundService() { return instance.metricsService; } + /** + * This method returns the {@link AnalyticsService} of Slimefun. + * It is used to handle sending analytic information. + * + * @return The {@link AnalyticsService} for Slimefun + */ + public static @Nonnull AnalyticsService getAnalyticsService() { + validateInstance(); + return instance.analyticsService; + } + /** * This method returns the {@link GitHubService} of Slimefun. * It is used to retrieve data from GitHub repositories. @@ -1068,4 +1084,14 @@ public static boolean isNewlyInstalled() { public static @Nonnull Storage getPlayerStorage() { return instance().playerStorage; } + + /** + * This method returns the {@link ThreadService} of Slimefun. + * Do not use this if you're an addon. Please make your own {@link ThreadService}. + * + * @return The {@link ThreadService} for Slimefun + */ + public static @Nonnull ThreadService getThreadService() { + return instance().threadService; + } } diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/storage/backend/legacy/LegacyStorage.java b/src/main/java/io/github/thebusybiscuit/slimefun4/storage/backend/legacy/LegacyStorage.java index 59b0c82b96..f051a3b846 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/storage/backend/legacy/LegacyStorage.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/storage/backend/legacy/LegacyStorage.java @@ -27,6 +27,8 @@ public class LegacyStorage implements Storage { @Override public PlayerData loadPlayerData(@Nonnull UUID uuid) { + long start = System.nanoTime(); + Config playerFile = new Config("data-storage/Slimefun/Players/" + uuid + ".yml"); // Not too sure why this is its own file Config waypointsFile = new Config("data-storage/Slimefun/waypoints/" + uuid + ".yml"); @@ -73,12 +75,17 @@ public PlayerData loadPlayerData(@Nonnull UUID uuid) { } } + long end = System.nanoTime(); + Slimefun.getAnalyticsService().recordPlayerProfileDataTime("legacy", true, end - start); + return new PlayerData(researches, backpacks, waypoints); } // The current design of saving all at once isn't great, this will be refined. @Override public void savePlayerData(@Nonnull UUID uuid, @Nonnull PlayerData data) { + long start = System.nanoTime(); + Config playerFile = new Config("data-storage/Slimefun/Players/" + uuid + ".yml"); // Not too sure why this is its own file Config waypointsFile = new Config("data-storage/Slimefun/waypoints/" + uuid + ".yml"); @@ -133,5 +140,8 @@ public void savePlayerData(@Nonnull UUID uuid, @Nonnull PlayerData data) { // Save files playerFile.save(); waypointsFile.save(); + + long end = System.nanoTime(); + Slimefun.getAnalyticsService().recordPlayerProfileDataTime("legacy", false, end - start); } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cb133170e4..5e36b700b7 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -50,6 +50,7 @@ talismans: metrics: auto-update: true + analytics: true research-ranks: - Chicken