cn.nukkit.metrics.Metrics Maven / Gradle / Ivy
package cn.nukkit.metrics;
import cn.nukkit.api.PowerNukkitOnly;
import cn.nukkit.api.Since;
import cn.nukkit.utils.MainLogger;
import com.nimbusds.jose.shaded.json.JSONArray;
import com.nimbusds.jose.shaded.json.JSONObject;
import io.netty.util.internal.EmptyArrays;
import lombok.extern.log4j.Log4j2;
import javax.net.ssl.HttpsURLConnection;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPOutputStream;
/**
* bStats collects some data for plugin authors.
*
* Check out https://bStats.org/ to learn more about bStats!
*/
@Since("1.4.0.0-PN")
@Log4j2
public class Metrics {
@Since("1.4.0.0-PN")
public static final int B_STATS_VERSION = 1;
private static final String VALUES = "values";
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, t -> new Thread(t, "metrics#scheduler"));
// The url to which the data is sent
private static final String URL = "https://bStats.org/submitData/server-implementation";
// A list with all custom charts
private final List charts = new ArrayList<>();
// The name of the server software
private final String name;
// The uuid of the server
private final String serverUUID;
// Should failed requests be logged?
private final boolean logFailedRequests;
/**
* Creates a new instance and starts submitting immediately.
*
* @param name The bStats metrics identifier.
* @param serverUUID The unique identifier of this server.
* @param logFailedRequests If failed submissions should be logged.
* @param logger The server main logger, ignored by PowerNukkit.
*/
@Since("1.4.0.0-PN")
public Metrics(String name, String serverUUID, boolean logFailedRequests, @SuppressWarnings("unused") MainLogger logger) {
this(name, serverUUID, logFailedRequests);
}
/**
* Creates a new instance and starts submitting immediately.
*
* @param name The bStats metrics identifier.
* @param serverUUID The unique identifier of this server.
* @param logFailedRequests If failed submissions should be logged.
*/
@PowerNukkitOnly
@Since("1.4.0.0-PN")
public Metrics(String name, String serverUUID, boolean logFailedRequests) {
this.name = name;
this.serverUUID = serverUUID;
this.logFailedRequests = logFailedRequests;
// Start submitting the data
startSubmitting();
}
/**
* Adds a custom chart.
*
* @param chart The chart to add.
*/
@Since("1.4.0.0-PN")
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 and 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 * 30L, 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) {
log.warn("Could not submit stats of {}", name, e);
}
}
}
/**
* Sends the data to the bStats server.
*
* @param data The data to send.
* @throws IOException If the request failed.
*/
private static void sendData(JSONObject data) throws IOException {
if (data == null) {
throw new IllegalArgumentException("Data cannot be null!");
}
HttpsURLConnection connection = (HttpsURLConnection) new java.net.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 EmptyArrays.EMPTY_BYTES;
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(outputStream);
gzip.write(str.getBytes(StandardCharsets.UTF_8));
gzip.close();
return outputStream.toByteArray();
}
/**
* Represents a custom chart.
*/
@Since("1.4.0.0-PN")
public abstract static 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 (Exception t) {
return null;
}
return chart;
}
@SuppressWarnings("java:S112")
@Since("1.4.0.0-PN")
protected abstract JSONObject getChartData() throws Exception;
}
/**
* Represents a custom simple pie.
*/
@Since("1.4.0.0-PN")
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.
*/
@Since("1.4.0.0-PN")
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.
*/
@Since("1.4.0.0-PN")
public static class AdvancedPie extends CustomChart {
private final Callable