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 46ce4c009..affc3ed96 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 d109bcae9..000000000 --- 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 de5432fd1..00cacd4cd 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 com.google.common.collect.ImmutableSet; 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 class PlayerProfile { // See #4011, #4116 if (loading.containsKey(uuid)) { Debug.log(TestCase.PLAYER_PROFILE_DATA, "Attempted to get PlayerProfile ({}) while loading", uuid); - Debug.log(TestCase.PLAYER_PROFILE_DATA, "Caller: {}", Threads.getCaller()); // We can't easily consume the callback so we will throw it away in this case // This will mean that if a user has attempted to do an action like open a block while @@ -394,7 +392,7 @@ public class PlayerProfile { } loading.put(uuid, true); - Threads.newThread(Slimefun.instance(), "PlayerProfile#get(" + uuid + ")", () -> { + Slimefun.getThreadService().newThread(Slimefun.instance(), "PlayerProfile#get(" + uuid + ")", () -> { PlayerData data = Slimefun.getPlayerStorage().loadPlayerData(p.getUniqueId()); loading.remove(uuid); @@ -428,14 +426,13 @@ public class PlayerProfile { // 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 dec31592d..e41ecddc4 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 java.util.Locale; 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 000000000..b0afd4065 --- /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 000000000..772b65d3f --- /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 2a1292225..408fdc439 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.Server; 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 @@ public class SlimefunProfiler { 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 class SlimefunProfiler { 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 28233ea74..ae065bc06 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.api.player.PlayerProfile; 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.MetricsService; 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 @@ public class Slimefun extends JavaPlugin implements SlimefunAddon { 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 class Slimefun extends JavaPlugin implements SlimefunAddon { 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 class Slimefun extends JavaPlugin implements SlimefunAddon { 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 59b0c82b9..f051a3b84 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 class LegacyStorage implements Storage { } } + 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 class LegacyStorage implements Storage { // 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 cb133170e..5e36b700b 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