aboutsummaryrefslogtreecommitdiffhomepage
path: root/patches/server/0008-Paper-Metrics.patch
diff options
context:
space:
mode:
Diffstat (limited to 'patches/server/0008-Paper-Metrics.patch')
-rw-r--r--patches/server/0008-Paper-Metrics.patch830
1 files changed, 208 insertions, 622 deletions
diff --git a/patches/server/0008-Paper-Metrics.patch b/patches/server/0008-Paper-Metrics.patch
index 234f6cde4c..4a7b3d452b 100644
--- a/patches/server/0008-Paper-Metrics.patch
+++ b/patches/server/0008-Paper-Metrics.patch
@@ -13,39 +13,53 @@ tangible effect on server performance. The data is used to allow the authors of
PaperMC to track version and platform usage so that we can make better management
decisions on behalf of the project.
+diff --git a/build.gradle.kts b/build.gradle.kts
+index 4db0cc3f8505747e77d314320545eb71904b4eac..6baabc30a363d132ea3d8a7da54a40aaf918f15b 100644
+--- a/build.gradle.kts
++++ b/build.gradle.kts
+@@ -16,6 +16,7 @@ dependencies {
+ implementation("org.apache.logging.log4j:log4j-iostreams:2.14.1") // Paper
+ implementation("org.ow2.asm:asm:9.2")
+ implementation("org.ow2.asm:asm-commons:9.2") // Paper - ASM event executor generation
++ implementation("org.bstats:bstats-base:2.2.1") // Paper
+ runtimeOnly("org.xerial:sqlite-jdbc:3.36.0.3")
+ runtimeOnly("mysql:mysql-connector-java:8.0.27")
+
+@@ -65,6 +66,7 @@ relocation {
+ relocate("org.bukkit.craftbukkit" to "org.bukkit.craftbukkit.v$packageVersion") {
+ exclude("org.bukkit.craftbukkit.Main*")
+ }
++ relocate("org.bstats:bstats-base" to "org.bstats") // Paper
+ }
+
+ tasks.shadowJar {
diff --git a/src/main/java/com/destroystokyo/paper/Metrics.java b/src/main/java/com/destroystokyo/paper/Metrics.java
new file mode 100644
-index 0000000000000000000000000000000000000000..e3b74dbdf8e14219a56fab939f3174e0c2f66de6
+index 0000000000000000000000000000000000000000..6402516b8b1a04489184dc2adde83d6cbc5e83d5
--- /dev/null
+++ b/src/main/java/com/destroystokyo/paper/Metrics.java
-@@ -0,0 +1,670 @@
+@@ -0,0 +1,236 @@
+package com.destroystokyo.paper;
+
++import java.io.File;
++import java.io.IOException;
++import java.util.HashMap;
++import java.util.Map;
++import java.util.UUID;
++import java.util.regex.Matcher;
++import java.util.regex.Pattern;
+import net.minecraft.server.MinecraftServer;
++import org.bstats.MetricsBase;
++import org.bstats.charts.DrilldownPie;
++import org.bstats.charts.SimplePie;
++import org.bstats.charts.SingleLineChart;
++import org.bstats.json.JsonObjectBuilder;
+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;
++import org.slf4j.Logger;
++import org.slf4j.LoggerFactory;
+
+/**
+ * bStats collects some data for plugin authors.
@@ -54,643 +68,215 @@ index 0000000000000000000000000000000000000000..e3b74dbdf8e14219a56fab939f3174e0
+ */
+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<CustomChart> 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 = this::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;
++ private static Logger logger = LoggerFactory.getLogger("bStats");
++
++ public Metrics() {
++ PaperMetricsConfig config = new PaperMetricsConfig();
++ MetricsBase metricsBase = new MetricsBase(
++ "server-implementation",
++ config.getServerUUID(),
++ 580,
++ config.isEnabled(),
++ this::appendPlatformData,
++ jsonObjectBuilder -> { /* NOP */ },
++ null,
++ () -> !MinecraftServer.getServer().hasStopped(),
++ logger::warn,
++ logger::info,
++ config.isLogErrorsEnabled(),
++ config.isLogSentDataEnabled(),
++ config.isLogResponseStatusTextEnabled()
++ );
++
++ metricsBase.addCustomChart(new SimplePie("minecraft_version", () -> {
++ String minecraftVersion = Bukkit.getVersion();
++ minecraftVersion = minecraftVersion.substring(minecraftVersion.indexOf("MC: ") + 4, minecraftVersion.length() - 1);
++ return minecraftVersion;
++ }));
++
++ metricsBase.addCustomChart(new SingleLineChart("players", () -> Bukkit.getOnlinePlayers().size()));
++ metricsBase.addCustomChart(new SimplePie("online_mode", () -> Bukkit.getOnlineMode() ? "online" : "offline"));
++ metricsBase.addCustomChart(new SimplePie("paper_version", () -> (Metrics.class.getPackage().getImplementationVersion() != null) ? Metrics.class.getPackage().getImplementationVersion() : "unknown"));
++
++ metricsBase.addCustomChart(new DrilldownPie("java_version", () -> {
++ Map<String, Map<String, Integer>> map = new HashMap<>();
++ String javaVersion = System.getProperty("java.version");
++ Map<String, Integer> 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);
+ }
-+ 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;
++ release = "Java " + majorVersion;
+ }
-+ return chart;
-+ }
++ map.put(release, entry);
+
-+ protected abstract JSONObject getChartData() throws Exception;
++ return map;
++ }));
+
-+ }
-+
-+ /**
-+ * Represents a custom simple pie.
-+ */
-+ public static class SimplePie extends CustomChart {
-+
-+ private final Callable<String> 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<String> callable) {
-+ super(chartId);
-+ this.callable = callable;
-+ }
++ metricsBase.addCustomChart(new DrilldownPie("legacy_plugins", () -> {
++ Map<String, Map<String, Integer>> map = new HashMap<>();
+
-+ @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<Map<String, Integer>> 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<Map<String, Integer>> callable) {
-+ super(chartId);
-+ this.callable = callable;
-+ }
-+
-+ @Override
-+ protected JSONObject getChartData() throws Exception {
-+ JSONObject data = new JSONObject();
-+ JSONObject values = new JSONObject();
-+ Map<String, Integer> map = callable.call();
-+ if (map == null || map.isEmpty()) {
-+ // Null = skip the chart
-+ return null;
-+ }
-+ boolean allSkipped = true;
-+ for (Map.Entry<String, Integer> entry : map.entrySet()) {
-+ if (entry.getValue() == 0) {
-+ continue; // Skip this invalid
++ // count legacy plugins
++ int legacy = 0;
++ for(Plugin plugin : Bukkit.getPluginManager().getPlugins()) {
++ if(CraftMagicNumbers.isLegacy(plugin.getDescription())) {
++ legacy++;
+ }
-+ 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<Map<String, Map<String, Integer>>> 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<Map<String, Map<String, Integer>>> callable) {
-+ super(chartId);
-+ this.callable = callable;
-+ }
+
-+ @Override
-+ public JSONObject getChartData() throws Exception {
-+ JSONObject data = new JSONObject();
-+ JSONObject values = new JSONObject();
-+ Map<String, Map<String, Integer>> map = callable.call();
-+ if (map == null || map.isEmpty()) {
-+ // Null = skip the chart
-+ return null;
-+ }
-+ boolean reallyAllSkipped = true;
-+ for (Map.Entry<String, Map<String, Integer>> entryValues : map.entrySet()) {
-+ JSONObject value = new JSONObject();
-+ boolean allSkipped = true;
-+ for (Map.Entry<String, Integer> valueEntry : map.get(entryValues.getKey()).entrySet()) {
-+ value.put(valueEntry.getKey(), valueEntry.getValue());
-+ allSkipped = false;
-+ }
-+ if (!allSkipped) {
-+ reallyAllSkipped = false;
-+ values.put(entryValues.getKey(), value);
-+ }
++ // insert real value as lower dimension
++ Map<String, Integer> 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:
+ }
-+ 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<Integer> 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<Integer> 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;
++ return map;
++ }));
++
++ metricsBase.addCustomChart(new DrilldownPie("plugins", () -> {
++ Map<String, Map<String, Integer>> map = new HashMap<>();
++
++ int count = Bukkit.getPluginManager().getPlugins().length;
++
++ // insert real value as lower dimension
++ Map<String, Integer> entry = new HashMap<>();
++ entry.put(String.valueOf(count), 1);
++
++ // create buckets as higher dimension
++ if(count == 0) {
++ map.put("0", entry);
++ } else if(count <= 5) {
++ map.put("1-5", entry);
++ } else if(count <= 10) {
++ map.put("6-10", entry);
++ } else if(count <= 25) {
++ map.put("11-25", entry);
++ } else if(count <= 50) {
++ map.put("26-50", entry);
++ } else {
++ map.put("50+", entry);
+ }
-+ data.put("value", value);
-+ return data;
-+ }
+
++ return map;
++ }));
+ }
+
-+ /**
-+ * Represents a custom multi line chart.
-+ */
-+ public static class MultiLineChart extends CustomChart {
-+
-+ private final Callable<Map<String, Integer>> 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<Map<String, Integer>> callable) {
-+ super(chartId);
-+ this.callable = callable;
-+ }
-+
-+ @Override
-+ protected JSONObject getChartData() throws Exception {
-+ JSONObject data = new JSONObject();
-+ JSONObject values = new JSONObject();
-+ Map<String, Integer> map = callable.call();
-+ if (map == null || map.isEmpty()) {
-+ // Null = skip the chart
-+ return null;
-+ }
-+ boolean allSkipped = true;
-+ for (Map.Entry<String, Integer> 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;
-+ }
-+
++ private void appendPlatformData(JsonObjectBuilder builder) {
++ builder.appendField("osName", System.getProperty("os.name"));
++ builder.appendField("osArch", System.getProperty("os.arch"));
++ builder.appendField("osVersion", System.getProperty("os.version"));
++ builder.appendField("coreCount", Runtime.getRuntime().availableProcessors());
+ }
+
-+ /**
-+ * Represents a custom simple bar chart.
-+ */
-+ public static class SimpleBarChart extends CustomChart {
-+
-+ private final Callable<Map<String, Integer>> 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<Map<String, Integer>> callable) {
-+ super(chartId);
-+ this.callable = callable;
-+ }
-+
-+ @Override
-+ protected JSONObject getChartData() throws Exception {
-+ JSONObject data = new JSONObject();
-+ JSONObject values = new JSONObject();
-+ Map<String, Integer> map = callable.call();
-+ if (map == null || map.isEmpty()) {
-+ // Null = skip the chart
-+ return null;
-+ }
-+ for (Map.Entry<String, Integer> entry : map.entrySet()) {
-+ JSONArray categoryValues = new JSONArray();
-+ categoryValues.add(entry.getValue());
-+ values.put(entry.getKey(), categoryValues);
-+ }
-+ data.put("values", values);
-+ return data;
-+ }
-+
-+ }
++ static class PaperMetricsConfig {
+
-+ /**
-+ * Represents a custom advanced bar chart.
-+ */
-+ public static class AdvancedBarChart extends CustomChart {
-+
-+ private final Callable<Map<String, int[]>> 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<Map<String, int[]>> callable) {
-+ super(chartId);
-+ this.callable = callable;
-+ }
++ private String serverUUID;
++ private boolean enabled;
++ private boolean logErrors;
++ private boolean logSentData;
++ private boolean logResponseStatusText;
+
-+ @Override
-+ protected JSONObject getChartData() throws Exception {
-+ JSONObject data = new JSONObject();
-+ JSONObject values = new JSONObject();
-+ Map<String, int[]> map = callable.call();
-+ if (map == null || map.isEmpty()) {
-+ // Null = skip the chart
-+ return null;
-+ }
-+ boolean allSkipped = true;
-+ for (Map.Entry<String, int[]> 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 PaperMetricsConfig() {
++ setupConfig();
+ }
+
-+ }
-+
-+ static class PaperMetrics {
-+ static void startMetrics() {
-+ // Get the config file
-+ File configFile = new File(new File((File) MinecraftServer.getServer().options.valueOf("plugins"), "bStats"), "config.yml");
++ private void setupConfig() {
++ File bStatsFolder = new File((File) MinecraftServer.getServer().options.valueOf("plugins"), "bStats");
++ File configFile = new File(bStatsFolder, "config.yml");
+ YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile);
+
-+ // Check if the config file exists
-+ if (!config.isSet("serverUuid")) {
-+
-+ // Add default values
++ if(!config.isSet("serverUuid")) {
+ 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);
++ config.addDefault("logSentData", false);
++ config.addDefault("logResponseStatusText", 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 :)"
++ "bStats (https://bStats.org) collects some basic information for plugin authors, like how\n" +
++ "many people use their plugin and their total player count. It's recommended to keep bStats\n" +
++ "enabled, but if you're not comfortable with this, you can turn this setting off. There is no\n" +
++ "performance penalty associated with having metrics enabled, and data sent to bStats is fully\n" +
++ "anonymous."
+ ).copyDefaults(true);
+ try {
+ config.save(configFile);
-+ } catch (IOException ignored) {
++ } catch(IOException ignored) {
+ }
++
++ // Send an info message when the bStats config file gets created for the first time
++ logger.info("Paper and some of its plugins collect metrics"
++ + " and send them to bStats (https://bStats.org).");
++ logger.info("bStats collects some basic information for plugin"
++ + " authors, like how many people use");
++ logger.info("their plugin and their total player count."
++ + " It's recommended to keep bStats enabled, but");
++ logger.info("if you're not comfortable with this, you can opt-out"
++ + " by editing the config.yml file in");
++ logger.info("the '{}' folder and setting enabled to false.", bStatsFolder.getPath());
+ }
++
+ // 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"));
-+ metrics.addCustomChart(new Metrics.SimplePie("paper_version", () -> (Metrics.class.getPackage().getImplementationVersion() != null) ? Metrics.class.getPackage().getImplementationVersion() : "unknown"));
-+
-+ metrics.addCustomChart(new Metrics.DrilldownPie("java_version", () -> {
-+ Map<String, Map<String, Integer>> map = new HashMap<>();
-+ String javaVersion = System.getProperty("java.version");
-+ Map<String, Integer> 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<String, Map<String, Integer>> 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<String, Integer> 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;
-+ }));
-+ }
++ this.enabled = config.getBoolean("enabled", true);
++ this.serverUUID = config.getString("serverUuid");
++ this.logErrors = config.getBoolean("logFailedRequests", false);
++ this.logSentData = config.getBoolean("logSentData", false);
++ this.logResponseStatusText = config.getBoolean("logResponseStatusText", false);
++ }
++
++ public String getServerUUID() {
++ return this.serverUUID;
++ }
++
++ public boolean isEnabled() {
++ return this.enabled;
++ }
++
++ public boolean isLogErrorsEnabled() {
++ return this.logErrors;
++ }
++
++ public boolean isLogSentDataEnabled() {
++ return this.logSentData;
++ }
+
++ public boolean isLogResponseStatusTextEnabled() {
++ return this.logResponseStatusText;
+ }
+ }
+}
diff --git a/src/main/java/com/destroystokyo/paper/PaperConfig.java b/src/main/java/com/destroystokyo/paper/PaperConfig.java
-index e4368db074da7b5e48b47d41875c1e63b9745c2a..ed2627f76af277c9be23da3423542d6af0bff872 100644
+index e4368db074da7b5e48b47d41875c1e63b9745c2a..f86d64b8711b4a8ef7e666fca1c88acbfadb41e8 100644
--- a/src/main/java/com/destroystokyo/paper/PaperConfig.java
+++ b/src/main/java/com/destroystokyo/paper/PaperConfig.java
@@ -42,6 +42,7 @@ public class PaperConfig {
@@ -707,14 +293,14 @@ index e4368db074da7b5e48b47d41875c1e63b9745c2a..ed2627f76af277c9be23da3423542d6a
}
+
+ if (!metricsStarted) {
-+ Metrics.PaperMetrics.startMetrics();
++ new Metrics();
+ metricsStarted = true;
+ }
}
static void readConfig(Class<?> clazz, Object instance) {
diff --git a/src/main/java/org/spigotmc/SpigotConfig.java b/src/main/java/org/spigotmc/SpigotConfig.java
-index 58c9ab2f6db97bfbf280efc56f9c9be791604a75..f8a9d6a394f796634e4663ef4078a4c98447e13c 100644
+index c90db55aadb1d16f6cc4e02e57a13a3c3fe6a420..5a912528f82e8f97229a412b0bf72e04a520b556 100644
--- a/src/main/java/org/spigotmc/SpigotConfig.java
+++ b/src/main/java/org/spigotmc/SpigotConfig.java
@@ -83,6 +83,7 @@ public class SpigotConfig