diff --git a/paper-server/patches/sources/net/minecraft/server/dedicated/DedicatedServer.java.patch b/paper-server/patches/sources/net/minecraft/server/dedicated/DedicatedServer.java.patch index b2a118ce76..ecd6aab9ff 100644 --- a/paper-server/patches/sources/net/minecraft/server/dedicated/DedicatedServer.java.patch +++ b/paper-server/patches/sources/net/minecraft/server/dedicated/DedicatedServer.java.patch @@ -143,7 +143,7 @@ thread.setDaemon(true); thread.setUncaughtExceptionHandler(new DefaultUncaughtExceptionHandler(DedicatedServer.LOGGER)); thread.start(); -@@ -126,13 +204,24 @@ +@@ -126,13 +204,25 @@ this.setPreventProxyConnections(dedicatedserverproperties.preventProxyConnections); this.setLocalIp(dedicatedserverproperties.serverIp); } @@ -158,6 +158,7 @@ + this.paperConfigurations.initializeWorldDefaultsConfiguration(this.registryAccess()); + // Paper end - initialize global and world-defaults configuration + io.papermc.paper.command.PaperCommands.registerCommands(this); // Paper - setup /paper command ++ com.destroystokyo.paper.Metrics.PaperMetrics.startMetrics(); // Paper - start metrics this.setPvpAllowed(dedicatedserverproperties.pvp); this.setFlightAllowed(dedicatedserverproperties.allowFlight); @@ -169,7 +170,7 @@ DedicatedServer.LOGGER.info("Default game type: {}", dedicatedserverproperties.gamemode); InetAddress inetaddress = null; -@@ -156,10 +245,23 @@ +@@ -156,10 +246,23 @@ return false; } @@ -194,7 +195,7 @@ DedicatedServer.LOGGER.warn("To change this, set \"online-mode\" to \"true\" in the server.properties file."); } -@@ -170,7 +272,7 @@ +@@ -170,7 +273,7 @@ if (!OldUsersConverter.serverReadyAfterUserconversion(this)) { return false; } else { @@ -203,7 +204,7 @@ this.debugSampleSubscriptionTracker = new DebugSampleSubscriptionTracker(this.getPlayerList()); this.tickTimeLogger = new RemoteSampleLogger(TpsDebugDimensions.values().length, this.debugSampleSubscriptionTracker, RemoteDebugSampleType.TICK_TIME); long i = Util.getNanos(); -@@ -178,13 +280,13 @@ +@@ -178,13 +281,13 @@ SkullBlockEntity.setup(this.services, this); GameProfileCache.setUsesAuthentication(this.usesAuthentication()); DedicatedServer.LOGGER.info("Preparing level \"{}\"", this.getLevelIdName()); @@ -219,7 +220,7 @@ } if (dedicatedserverproperties.enableQuery) { -@@ -197,7 +299,7 @@ +@@ -197,7 +300,7 @@ this.rconThread = RconThread.create(this); } @@ -228,7 +229,7 @@ Thread thread1 = new Thread(new ServerWatchdog(this)); thread1.setUncaughtExceptionHandler(new DefaultUncaughtExceptionHandlerWithName(DedicatedServer.LOGGER)); -@@ -293,6 +395,7 @@ +@@ -293,6 +396,7 @@ this.queryThreadGs4.stop(); } @@ -236,7 +237,7 @@ } @Override -@@ -302,8 +405,8 @@ +@@ -302,8 +406,8 @@ } @Override @@ -247,7 +248,7 @@ } public void handleConsoleInput(String command, CommandSourceStack commandSource) { -@@ -311,12 +414,22 @@ +@@ -311,12 +415,22 @@ } public void handleConsoleInputs() { @@ -271,7 +272,7 @@ } @Override -@@ -383,7 +496,7 @@ +@@ -383,7 +497,7 @@ @Override public boolean isUnderSpawnProtection(ServerLevel world, BlockPos pos, Player player) { @@ -280,7 +281,7 @@ return false; } else if (this.getPlayerList().getOps().isEmpty()) { return false; -@@ -541,16 +654,52 @@ +@@ -541,16 +655,52 @@ @Override public String getPluginNames() { @@ -337,7 +338,7 @@ } public void storeUsingWhiteList(boolean useWhitelist) { -@@ -660,4 +809,15 @@ +@@ -660,4 +810,15 @@ } } } diff --git a/paper-server/src/main/java/com/destroystokyo/paper/Metrics.java b/paper-server/src/main/java/com/destroystokyo/paper/Metrics.java new file mode 100644 index 0000000000..8f62879582 --- /dev/null +++ b/paper-server/src/main/java/com/destroystokyo/paper/Metrics.java @@ -0,0 +1,682 @@ +package com.destroystokyo.paper; + +import net.minecraft.server.MinecraftServer; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.plugin.Plugin; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + +import javax.net.ssl.HttpsURLConnection; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.*; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPOutputStream; + +/** + * bStats collects some data for plugin authors. + * + * Check out https://bStats.org/ to learn more about bStats! + */ +public class Metrics { + + // Executor service for requests + // We use an executor service because the Bukkit scheduler is affected by server lags + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + // The version of this bStats class + public static final int B_STATS_VERSION = 1; + + // The url to which the data is sent + private static final String URL = "https://bStats.org/submitData/server-implementation"; + + // Should failed requests be logged? + private static boolean logFailedRequests = false; + + // The logger for the failed requests + private static Logger logger = Logger.getLogger("bStats"); + + // The name of the server software + private final String name; + + // The uuid of the server + private final String serverUUID; + + // A list with all custom charts + private final List charts = new ArrayList<>(); + + /** + * Class constructor. + * + * @param name The name of the server software. + * @param serverUUID The uuid of the server. + * @param logFailedRequests Whether failed requests should be logged or not. + * @param logger The logger for the failed requests. + */ + public Metrics(String name, String serverUUID, boolean logFailedRequests, Logger logger) { + this.name = name; + this.serverUUID = serverUUID; + Metrics.logFailedRequests = logFailedRequests; + Metrics.logger = logger; + + // Start submitting the data + startSubmitting(); + } + + /** + * Adds a custom chart. + * + * @param chart The chart to add. + */ + public void addCustomChart(CustomChart chart) { + if (chart == null) { + throw new IllegalArgumentException("Chart cannot be null!"); + } + charts.add(chart); + } + + /** + * Starts the Scheduler which submits our data every 30 minutes. + */ + private void startSubmitting() { + final Runnable submitTask = () -> { + if (!MinecraftServer.getServer().hasStopped()) { + submitData(); + } + }; + + // Many servers tend to restart at a fixed time at xx:00 which causes an uneven distribution of requests on the + // bStats backend. To circumvent this problem, we introduce some randomness into the initial and second delay. + // WARNING: You must not modify any part of this Metrics class, including the submit delay or frequency! + // WARNING: Modifying this code will get your plugin banned on bStats. Just don't do it! + long initialDelay = (long) (1000 * 60 * (3 + Math.random() * 3)); + long secondDelay = (long) (1000 * 60 * (Math.random() * 30)); + scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS); + scheduler.scheduleAtFixedRate(submitTask, initialDelay + secondDelay, 1000 * 60 * 30, TimeUnit.MILLISECONDS); + } + + /** + * Gets the plugin specific data. + * + * @return The plugin specific data. + */ + private JSONObject getPluginData() { + JSONObject data = new JSONObject(); + + data.put("pluginName", name); // Append the name of the server software + JSONArray customCharts = new JSONArray(); + for (CustomChart customChart : charts) { + // Add the data of the custom charts + JSONObject chart = customChart.getRequestJsonObject(); + if (chart == null) { // If the chart is null, we skip it + continue; + } + customCharts.add(chart); + } + data.put("customCharts", customCharts); + + return data; + } + + /** + * Gets the server specific data. + * + * @return The server specific data. + */ + private JSONObject getServerData() { + // OS specific data + String osName = System.getProperty("os.name"); + String osArch = System.getProperty("os.arch"); + String osVersion = System.getProperty("os.version"); + int coreCount = Runtime.getRuntime().availableProcessors(); + + JSONObject data = new JSONObject(); + + data.put("serverUUID", serverUUID); + + data.put("osName", osName); + data.put("osArch", osArch); + data.put("osVersion", osVersion); + data.put("coreCount", coreCount); + + return data; + } + + /** + * Collects the data and sends it afterwards. + */ + private void submitData() { + final JSONObject data = getServerData(); + + JSONArray pluginData = new JSONArray(); + pluginData.add(getPluginData()); + data.put("plugins", pluginData); + + try { + // We are still in the Thread of the timer, so nothing get blocked :) + sendData(data); + } catch (Exception e) { + // Something went wrong! :( + if (logFailedRequests) { + logger.log(Level.WARNING, "Could not submit stats of " + name, e); + } + } + } + + /** + * Sends the data to the bStats server. + * + * @param data The data to send. + * @throws Exception If the request failed. + */ + private static void sendData(JSONObject data) throws Exception { + if (data == null) { + throw new IllegalArgumentException("Data cannot be null!"); + } + HttpsURLConnection connection = (HttpsURLConnection) new URL(URL).openConnection(); + + // Compress the data to save bandwidth + byte[] compressedData = compress(data.toString()); + + // Add headers + connection.setRequestMethod("POST"); + connection.addRequestProperty("Accept", "application/json"); + connection.addRequestProperty("Connection", "close"); + connection.addRequestProperty("Content-Encoding", "gzip"); // We gzip our request + connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length)); + connection.setRequestProperty("Content-Type", "application/json"); // We send our data in JSON format + connection.setRequestProperty("User-Agent", "MC-Server/" + B_STATS_VERSION); + + // Send data + connection.setDoOutput(true); + DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream()); + outputStream.write(compressedData); + outputStream.flush(); + outputStream.close(); + + connection.getInputStream().close(); // We don't care about the response - Just send our data :) + } + + /** + * Gzips the given String. + * + * @param str The string to gzip. + * @return The gzipped String. + * @throws IOException If the compression failed. + */ + private static byte[] compress(final String str) throws IOException { + if (str == null) { + return null; + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(outputStream); + gzip.write(str.getBytes("UTF-8")); + gzip.close(); + return outputStream.toByteArray(); + } + + /** + * Represents a custom chart. + */ + public static abstract class CustomChart { + + // The id of the chart + final String chartId; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + */ + CustomChart(String chartId) { + if (chartId == null || chartId.isEmpty()) { + throw new IllegalArgumentException("ChartId cannot be null or empty!"); + } + this.chartId = chartId; + } + + private JSONObject getRequestJsonObject() { + JSONObject chart = new JSONObject(); + chart.put("chartId", chartId); + try { + JSONObject data = getChartData(); + if (data == null) { + // If the data is null we don't send the chart. + return null; + } + chart.put("data", data); + } catch (Throwable t) { + if (logFailedRequests) { + logger.log(Level.WARNING, "Failed to get data for custom chart with id " + chartId, t); + } + return null; + } + return chart; + } + + protected abstract JSONObject getChartData() throws Exception; + + } + + /** + * Represents a custom simple pie. + */ + public static class SimplePie extends CustomChart { + + private final Callable callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SimplePie(String chartId, Callable callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + String value = callable.call(); + if (value == null || value.isEmpty()) { + // Null = skip the chart + return null; + } + data.put("value", value); + return data; + } + } + + /** + * Represents a custom advanced pie. + */ + public static class AdvancedPie extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public AdvancedPie(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + JSONObject values = new JSONObject(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == 0) { + continue; // Skip this invalid + } + allSkipped = false; + values.put(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + data.put("values", values); + return data; + } + } + + /** + * Represents a custom drilldown pie. + */ + public static class DrilldownPie extends CustomChart { + + private final Callable>> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public DrilldownPie(String chartId, Callable>> callable) { + super(chartId); + this.callable = callable; + } + + @Override + public JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + JSONObject values = new JSONObject(); + Map> map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean reallyAllSkipped = true; + for (Map.Entry> entryValues : map.entrySet()) { + JSONObject value = new JSONObject(); + boolean allSkipped = true; + for (Map.Entry valueEntry : map.get(entryValues.getKey()).entrySet()) { + value.put(valueEntry.getKey(), valueEntry.getValue()); + allSkipped = false; + } + if (!allSkipped) { + reallyAllSkipped = false; + values.put(entryValues.getKey(), value); + } + } + if (reallyAllSkipped) { + // Null = skip the chart + return null; + } + data.put("values", values); + return data; + } + } + + /** + * Represents a custom single line chart. + */ + public static class SingleLineChart extends CustomChart { + + private final Callable callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SingleLineChart(String chartId, Callable callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + int value = callable.call(); + if (value == 0) { + // Null = skip the chart + return null; + } + data.put("value", value); + return data; + } + + } + + /** + * Represents a custom multi line chart. + */ + public static class MultiLineChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public MultiLineChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + JSONObject values = new JSONObject(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == 0) { + continue; // Skip this invalid + } + allSkipped = false; + values.put(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + data.put("values", values); + return data; + } + + } + + /** + * Represents a custom simple bar chart. + */ + public static class SimpleBarChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SimpleBarChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + JSONObject values = new JSONObject(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + for (Map.Entry entry : map.entrySet()) { + JSONArray categoryValues = new JSONArray(); + categoryValues.add(entry.getValue()); + values.put(entry.getKey(), categoryValues); + } + data.put("values", values); + return data; + } + + } + + /** + * Represents a custom advanced bar chart. + */ + public static class AdvancedBarChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public AdvancedBarChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JSONObject getChartData() throws Exception { + JSONObject data = new JSONObject(); + JSONObject values = new JSONObject(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().length == 0) { + continue; // Skip this invalid + } + allSkipped = false; + JSONArray categoryValues = new JSONArray(); + for (int categoryValue : entry.getValue()) { + categoryValues.add(categoryValue); + } + values.put(entry.getKey(), categoryValues); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + data.put("values", values); + return data; + } + + } + + public static class PaperMetrics { + public static void startMetrics() { + // Get the config file + File configFile = new File(new File((File) MinecraftServer.getServer().options.valueOf("plugins"), "bStats"), "config.yml"); + YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); + + // Check if the config file exists + if (!config.isSet("serverUuid")) { + + // Add default values + config.addDefault("enabled", true); + // Every server gets it's unique random id. + config.addDefault("serverUuid", UUID.randomUUID().toString()); + // Should failed request be logged? + config.addDefault("logFailedRequests", false); + + // Inform the server owners about bStats + config.options().header( + "bStats collects some data for plugin authors like how many servers are using their plugins.\n" + + "To honor their work, you should not disable it.\n" + + "This has nearly no effect on the server performance!\n" + + "Check out https://bStats.org/ to learn more :)" + ).copyDefaults(true); + try { + config.save(configFile); + } catch (IOException ignored) { + } + } + // Load the data + String serverUUID = config.getString("serverUuid"); + boolean logFailedRequests = config.getBoolean("logFailedRequests", false); + // Only start Metrics, if it's enabled in the config + if (config.getBoolean("enabled", true)) { + Metrics metrics = new Metrics("Paper", serverUUID, logFailedRequests, Bukkit.getLogger()); + + metrics.addCustomChart(new Metrics.SimplePie("minecraft_version", () -> { + String minecraftVersion = Bukkit.getVersion(); + minecraftVersion = minecraftVersion.substring(minecraftVersion.indexOf("MC: ") + 4, minecraftVersion.length() - 1); + return minecraftVersion; + })); + + metrics.addCustomChart(new Metrics.SingleLineChart("players", () -> Bukkit.getOnlinePlayers().size())); + metrics.addCustomChart(new Metrics.SimplePie("online_mode", () -> Bukkit.getOnlineMode() ? "online" : "offline")); + final String paperVersion; + final String implVersion = org.bukkit.craftbukkit.Main.class.getPackage().getImplementationVersion(); + if (implVersion != null) { + final String buildOrHash = implVersion.substring(implVersion.lastIndexOf('-') + 1); + paperVersion = "git-Paper-%s-%s".formatted(Bukkit.getServer().getMinecraftVersion(), buildOrHash); + } else { + paperVersion = "unknown"; + } + metrics.addCustomChart(new Metrics.SimplePie("paper_version", () -> paperVersion)); + + metrics.addCustomChart(new Metrics.DrilldownPie("java_version", () -> { + Map> map = new HashMap<>(); + String javaVersion = System.getProperty("java.version"); + Map entry = new HashMap<>(); + entry.put(javaVersion, 1); + + // http://openjdk.java.net/jeps/223 + // Java decided to change their versioning scheme and in doing so modified the java.version system + // property to return $major[.$minor][.$secuity][-ea], as opposed to 1.$major.0_$identifier + // we can handle pre-9 by checking if the "major" is equal to "1", otherwise, 9+ + String majorVersion = javaVersion.split("\\.")[0]; + String release; + + int indexOf = javaVersion.lastIndexOf('.'); + + if (majorVersion.equals("1")) { + release = "Java " + javaVersion.substring(0, indexOf); + } else { + // of course, it really wouldn't be all that simple if they didn't add a quirk, now would it + // valid strings for the major may potentially include values such as -ea to deannotate a pre release + Matcher versionMatcher = Pattern.compile("\\d+").matcher(majorVersion); + if (versionMatcher.find()) { + majorVersion = versionMatcher.group(0); + } + release = "Java " + majorVersion; + } + map.put(release, entry); + + return map; + })); + + metrics.addCustomChart(new Metrics.DrilldownPie("legacy_plugins", () -> { + Map> map = new HashMap<>(); + + // count legacy plugins + int legacy = 0; + for (Plugin plugin : Bukkit.getPluginManager().getPlugins()) { + if (CraftMagicNumbers.isLegacy(plugin.getDescription())) { + legacy++; + } + } + + // insert real value as lower dimension + Map entry = new HashMap<>(); + entry.put(String.valueOf(legacy), 1); + + // create buckets as higher dimension + if (legacy == 0) { + map.put("0 \uD83D\uDE0E", entry); // :sunglasses: + } else if (legacy <= 5) { + map.put("1-5", entry); + } else if (legacy <= 10) { + map.put("6-10", entry); + } else if (legacy <= 25) { + map.put("11-25", entry); + } else if (legacy <= 50) { + map.put("26-50", entry); + } else { + map.put("50+ \uD83D\uDE2D", entry); // :cry: + } + + return map; + })); + } + + } + } +} diff --git a/paper-server/src/main/java/org/spigotmc/SpigotConfig.java b/paper-server/src/main/java/org/spigotmc/SpigotConfig.java index 744edd4012..d9e73e37b5 100644 --- a/paper-server/src/main/java/org/spigotmc/SpigotConfig.java +++ b/paper-server/src/main/java/org/spigotmc/SpigotConfig.java @@ -83,6 +83,7 @@ public class SpigotConfig MinecraftServer.getServer().server.getCommandMap().register( entry.getKey(), "Spigot", entry.getValue() ); } + /* // Paper - Replace with our own if ( SpigotConfig.metrics == null ) { try @@ -94,6 +95,7 @@ public class SpigotConfig Bukkit.getServer().getLogger().log( Level.SEVERE, "Could not start metrics service", ex ); } } + */ // Paper end } public static void readConfig(Class clazz, Object instance) // Paper - package-private -> public