valueEntry : map.get(entryValues.getKey()).entrySet()) {
627 | valueBuilder.appendField(valueEntry.getKey(), valueEntry.getValue());
628 | allSkipped = false;
629 | }
630 | if (!allSkipped) {
631 | reallyAllSkipped = false;
632 | valuesBuilder.appendField(entryValues.getKey(), valueBuilder.build());
633 | }
634 | }
635 | if (reallyAllSkipped) {
636 | // Null = skip the chart
637 | return null;
638 | }
639 | return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build();
640 | }
641 | }
642 |
643 | /**
644 | * An extremely simple JSON builder.
645 | *
646 | * While this class is neither feature-rich nor the most performant one, it's sufficient enough
647 | * for its use-case.
648 | */
649 | public static class JsonObjectBuilder {
650 |
651 | private StringBuilder builder = new StringBuilder();
652 |
653 | private boolean hasAtLeastOneField = false;
654 |
655 | public JsonObjectBuilder() {
656 | builder.append("{");
657 | }
658 |
659 | /**
660 | * Appends a null field to the JSON.
661 | *
662 | * @param key The key of the field.
663 | * @return A reference to this object.
664 | */
665 | public JsonObjectBuilder appendNull(String key) {
666 | appendFieldUnescaped(key, "null");
667 | return this;
668 | }
669 |
670 | /**
671 | * Appends a string field to the JSON.
672 | *
673 | * @param key The key of the field.
674 | * @param value The value of the field.
675 | * @return A reference to this object.
676 | */
677 | public JsonObjectBuilder appendField(String key, String value) {
678 | if (value == null) {
679 | throw new IllegalArgumentException("JSON value must not be null");
680 | }
681 | appendFieldUnescaped(key, "\"" + escape(value) + "\"");
682 | return this;
683 | }
684 |
685 | /**
686 | * Appends an integer field to the JSON.
687 | *
688 | * @param key The key of the field.
689 | * @param value The value of the field.
690 | * @return A reference to this object.
691 | */
692 | public JsonObjectBuilder appendField(String key, int value) {
693 | appendFieldUnescaped(key, String.valueOf(value));
694 | return this;
695 | }
696 |
697 | /**
698 | * Appends an object to the JSON.
699 | *
700 | * @param key The key of the field.
701 | * @param object The object.
702 | * @return A reference to this object.
703 | */
704 | public JsonObjectBuilder appendField(String key, JsonObject object) {
705 | if (object == null) {
706 | throw new IllegalArgumentException("JSON object must not be null");
707 | }
708 | appendFieldUnescaped(key, object.toString());
709 | return this;
710 | }
711 |
712 | /**
713 | * Appends a string array to the JSON.
714 | *
715 | * @param key The key of the field.
716 | * @param values The string array.
717 | * @return A reference to this object.
718 | */
719 | public JsonObjectBuilder appendField(String key, String[] values) {
720 | if (values == null) {
721 | throw new IllegalArgumentException("JSON values must not be null");
722 | }
723 | String escapedValues =
724 | Arrays.stream(values)
725 | .map(value -> "\"" + escape(value) + "\"")
726 | .collect(Collectors.joining(","));
727 | appendFieldUnescaped(key, "[" + escapedValues + "]");
728 | return this;
729 | }
730 |
731 | /**
732 | * Appends an integer array to the JSON.
733 | *
734 | * @param key The key of the field.
735 | * @param values The integer array.
736 | * @return A reference to this object.
737 | */
738 | public JsonObjectBuilder appendField(String key, int[] values) {
739 | if (values == null) {
740 | throw new IllegalArgumentException("JSON values must not be null");
741 | }
742 | String escapedValues =
743 | Arrays.stream(values).mapToObj(String::valueOf).collect(Collectors.joining(","));
744 | appendFieldUnescaped(key, "[" + escapedValues + "]");
745 | return this;
746 | }
747 |
748 | /**
749 | * Appends an object array to the JSON.
750 | *
751 | * @param key The key of the field.
752 | * @param values The integer array.
753 | * @return A reference to this object.
754 | */
755 | public JsonObjectBuilder appendField(String key, JsonObject[] values) {
756 | if (values == null) {
757 | throw new IllegalArgumentException("JSON values must not be null");
758 | }
759 | String escapedValues =
760 | Arrays.stream(values).map(JsonObject::toString).collect(Collectors.joining(","));
761 | appendFieldUnescaped(key, "[" + escapedValues + "]");
762 | return this;
763 | }
764 |
765 | /**
766 | * Appends a field to the object.
767 | *
768 | * @param key The key of the field.
769 | * @param escapedValue The escaped value of the field.
770 | */
771 | private void appendFieldUnescaped(String key, String escapedValue) {
772 | if (builder == null) {
773 | throw new IllegalStateException("JSON has already been built");
774 | }
775 | if (key == null) {
776 | throw new IllegalArgumentException("JSON key must not be null");
777 | }
778 | if (hasAtLeastOneField) {
779 | builder.append(",");
780 | }
781 | builder.append("\"").append(escape(key)).append("\":").append(escapedValue);
782 | hasAtLeastOneField = true;
783 | }
784 |
785 | /**
786 | * Builds the JSON string and invalidates this builder.
787 | *
788 | * @return The built JSON string.
789 | */
790 | public JsonObject build() {
791 | if (builder == null) {
792 | throw new IllegalStateException("JSON has already been built");
793 | }
794 | JsonObject object = new JsonObject(builder.append("}").toString());
795 | builder = null;
796 | return object;
797 | }
798 |
799 | /**
800 | * Escapes the given string like stated in https://www.ietf.org/rfc/rfc4627.txt.
801 | *
802 | *
This method escapes only the necessary characters '"', '\'. and '\u0000' - '\u001F'.
803 | * Compact escapes are not used (e.g., '\n' is escaped as "\u000a" and not as "\n").
804 | *
805 | * @param value The value to escape.
806 | * @return The escaped value.
807 | */
808 | private static String escape(String value) {
809 | final StringBuilder builder = new StringBuilder();
810 | for (int i = 0; i < value.length(); i++) {
811 | char c = value.charAt(i);
812 | if (c == '"') {
813 | builder.append("\\\"");
814 | } else if (c == '\\') {
815 | builder.append("\\\\");
816 | } else if (c <= '\u000F') {
817 | builder.append("\\u000").append(Integer.toHexString(c));
818 | } else if (c <= '\u001F') {
819 | builder.append("\\u00").append(Integer.toHexString(c));
820 | } else {
821 | builder.append(c);
822 | }
823 | }
824 | return builder.toString();
825 | }
826 |
827 | /**
828 | * A super simple representation of a JSON object.
829 | *
830 | *
This class only exists to make methods of the {@link JsonObjectBuilder} type-safe and not
831 | * allow a raw string inputs for methods like {@link JsonObjectBuilder#appendField(String,
832 | * JsonObject)}.
833 | */
834 | public static class JsonObject {
835 |
836 | private final String value;
837 |
838 | private JsonObject(String value) {
839 | this.value = value;
840 | }
841 |
842 | @Override
843 | public String toString() {
844 | return value;
845 | }
846 | }
847 | }
848 | }
--------------------------------------------------------------------------------
/src/main/java/ua/coolboy/f3name/metrics/BungeeMetrics.java:
--------------------------------------------------------------------------------
1 | package ua.coolboy.f3name.metrics;
2 |
3 | import java.io.BufferedReader;
4 | import java.io.BufferedWriter;
5 | import java.io.ByteArrayOutputStream;
6 | import java.io.DataOutputStream;
7 | import java.io.File;
8 | import java.io.FileWriter;
9 | import java.io.IOException;
10 | import java.io.InputStreamReader;
11 | import java.net.URL;
12 | import java.nio.charset.StandardCharsets;
13 | import java.util.Arrays;
14 | import java.util.HashSet;
15 | import java.util.Map;
16 | import java.util.Objects;
17 | import java.util.Set;
18 | import java.util.UUID;
19 | import java.util.concurrent.Callable;
20 | import java.util.concurrent.Executors;
21 | import java.util.concurrent.ScheduledExecutorService;
22 | import java.util.concurrent.TimeUnit;
23 | import java.util.function.BiConsumer;
24 | import java.util.function.Consumer;
25 | import java.util.function.Supplier;
26 | import java.util.logging.Level;
27 | import java.util.stream.Collectors;
28 | import java.util.zip.GZIPOutputStream;
29 | import javax.net.ssl.HttpsURLConnection;
30 | import net.md_5.bungee.api.plugin.Plugin;
31 | import net.md_5.bungee.config.Configuration;
32 | import net.md_5.bungee.config.ConfigurationProvider;
33 | import net.md_5.bungee.config.YamlConfiguration;
34 |
35 | public class BungeeMetrics {
36 |
37 | private final Plugin plugin;
38 |
39 | private final MetricsBase metricsBase;
40 |
41 | private boolean enabled;
42 |
43 | private String serverUUID;
44 |
45 | private boolean logErrors = false;
46 |
47 | private boolean logSentData;
48 |
49 | private boolean logResponseStatusText;
50 |
51 | /**
52 | * Creates a new Metrics instance.
53 | *
54 | * @param plugin Your plugin instance.
55 | * @param serviceId The id of the service. It can be found at What is my plugin id?
57 | */
58 | public BungeeMetrics(Plugin plugin, int serviceId) {
59 | this.plugin = plugin;
60 | try {
61 | loadConfig();
62 | } catch (IOException e) {
63 | // Failed to load configuration
64 | plugin.getLogger().log(Level.WARNING, "Failed to load bStats config!", e);
65 | metricsBase = null;
66 | return;
67 | }
68 | metricsBase =
69 | new MetricsBase(
70 | "bungeecord",
71 | serverUUID,
72 | serviceId,
73 | enabled,
74 | this::appendPlatformData,
75 | this::appendServiceData,
76 | null,
77 | () -> true,
78 | (message, error) -> this.plugin.getLogger().log(Level.WARNING, message, error),
79 | (message) -> this.plugin.getLogger().log(Level.INFO, message),
80 | logErrors,
81 | logSentData,
82 | logResponseStatusText);
83 | }
84 |
85 | /** Loads the bStats configuration. */
86 | private void loadConfig() throws IOException {
87 | File bStatsFolder = new File(plugin.getDataFolder().getParentFile(), "bStats");
88 | bStatsFolder.mkdirs();
89 | File configFile = new File(bStatsFolder, "config.yml");
90 | if (!configFile.exists()) {
91 | writeFile(
92 | configFile,
93 | "# bStats (https://bStats.org) collects some basic information for plugin authors, like how",
94 | "# many people use their plugin and their total player count. It's recommended to keep bStats",
95 | "# enabled, but if you're not comfortable with this, you can turn this setting off. There is no",
96 | "# performance penalty associated with having metrics enabled, and data sent to bStats is fully",
97 | "# anonymous.",
98 | "enabled: true",
99 | "serverUuid: \"" + UUID.randomUUID() + "\"",
100 | "logFailedRequests: false",
101 | "logSentData: false",
102 | "logResponseStatusText: false");
103 | }
104 | Configuration configuration =
105 | ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile);
106 | // Load configuration
107 | enabled = configuration.getBoolean("enabled", true);
108 | serverUUID = configuration.getString("serverUuid");
109 | logErrors = configuration.getBoolean("logFailedRequests", false);
110 | logSentData = configuration.getBoolean("logSentData", false);
111 | logResponseStatusText = configuration.getBoolean("logResponseStatusText", false);
112 | }
113 |
114 | private void writeFile(File file, String... lines) throws IOException {
115 | try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(file))) {
116 | for (String line : lines) {
117 | bufferedWriter.write(line);
118 | bufferedWriter.newLine();
119 | }
120 | }
121 | }
122 |
123 | /**
124 | * Adds a custom chart.
125 | *
126 | * @param chart The chart to add.
127 | */
128 | public void addCustomChart(CustomChart chart) {
129 | metricsBase.addCustomChart(chart);
130 | }
131 |
132 | private void appendPlatformData(JsonObjectBuilder builder) {
133 | builder.appendField("playerAmount", plugin.getProxy().getOnlineCount());
134 | builder.appendField("managedServers", plugin.getProxy().getServers().size());
135 | builder.appendField("onlineMode", plugin.getProxy().getConfig().isOnlineMode() ? 1 : 0);
136 | builder.appendField("bungeecordVersion", plugin.getProxy().getVersion());
137 | builder.appendField("javaVersion", System.getProperty("java.version"));
138 | builder.appendField("osName", System.getProperty("os.name"));
139 | builder.appendField("osArch", System.getProperty("os.arch"));
140 | builder.appendField("osVersion", System.getProperty("os.version"));
141 | builder.appendField("coreCount", Runtime.getRuntime().availableProcessors());
142 | }
143 |
144 | private void appendServiceData(JsonObjectBuilder builder) {
145 | builder.appendField("pluginVersion", plugin.getDescription().getVersion());
146 | }
147 |
148 | public static class MetricsBase {
149 |
150 | /** The version of the Metrics class. */
151 | public static final String METRICS_VERSION = "2.2.1";
152 |
153 | private static final ScheduledExecutorService scheduler =
154 | Executors.newScheduledThreadPool(1, task -> new Thread(task, "bStats-Metrics"));
155 |
156 | private static final String REPORT_URL = "https://bStats.org/api/v2/data/%s";
157 |
158 | private final String platform;
159 |
160 | private final String serverUuid;
161 |
162 | private final int serviceId;
163 |
164 | private final Consumer appendPlatformDataConsumer;
165 |
166 | private final Consumer appendServiceDataConsumer;
167 |
168 | private final Consumer submitTaskConsumer;
169 |
170 | private final Supplier checkServiceEnabledSupplier;
171 |
172 | private final BiConsumer errorLogger;
173 |
174 | private final Consumer infoLogger;
175 |
176 | private final boolean logErrors;
177 |
178 | private final boolean logSentData;
179 |
180 | private final boolean logResponseStatusText;
181 |
182 | private final Set customCharts = new HashSet<>();
183 |
184 | private final boolean enabled;
185 |
186 | /**
187 | * Creates a new MetricsBase class instance.
188 | *
189 | * @param platform The platform of the service.
190 | * @param serviceId The id of the service.
191 | * @param serverUuid The server uuid.
192 | * @param enabled Whether or not data sending is enabled.
193 | * @param appendPlatformDataConsumer A consumer that receives a {@code JsonObjectBuilder} and
194 | * appends all platform-specific data.
195 | * @param appendServiceDataConsumer A consumer that receives a {@code JsonObjectBuilder} and
196 | * appends all service-specific data.
197 | * @param submitTaskConsumer A consumer that takes a runnable with the submit task. This can be
198 | * used to delegate the data collection to a another thread to prevent errors caused by
199 | * concurrency. Can be {@code null}.
200 | * @param checkServiceEnabledSupplier A supplier to check if the service is still enabled.
201 | * @param errorLogger A consumer that accepts log message and an error.
202 | * @param infoLogger A consumer that accepts info log messages.
203 | * @param logErrors Whether or not errors should be logged.
204 | * @param logSentData Whether or not the sent data should be logged.
205 | * @param logResponseStatusText Whether or not the response status text should be logged.
206 | */
207 | public MetricsBase(
208 | String platform,
209 | String serverUuid,
210 | int serviceId,
211 | boolean enabled,
212 | Consumer appendPlatformDataConsumer,
213 | Consumer appendServiceDataConsumer,
214 | Consumer submitTaskConsumer,
215 | Supplier checkServiceEnabledSupplier,
216 | BiConsumer errorLogger,
217 | Consumer infoLogger,
218 | boolean logErrors,
219 | boolean logSentData,
220 | boolean logResponseStatusText) {
221 | this.platform = platform;
222 | this.serverUuid = serverUuid;
223 | this.serviceId = serviceId;
224 | this.enabled = enabled;
225 | this.appendPlatformDataConsumer = appendPlatformDataConsumer;
226 | this.appendServiceDataConsumer = appendServiceDataConsumer;
227 | this.submitTaskConsumer = submitTaskConsumer;
228 | this.checkServiceEnabledSupplier = checkServiceEnabledSupplier;
229 | this.errorLogger = errorLogger;
230 | this.infoLogger = infoLogger;
231 | this.logErrors = logErrors;
232 | this.logSentData = logSentData;
233 | this.logResponseStatusText = logResponseStatusText;
234 | checkRelocation();
235 | if (enabled) {
236 | startSubmitting();
237 | }
238 | }
239 |
240 | public void addCustomChart(CustomChart chart) {
241 | this.customCharts.add(chart);
242 | }
243 |
244 | private void startSubmitting() {
245 | final Runnable submitTask =
246 | () -> {
247 | if (!enabled || !checkServiceEnabledSupplier.get()) {
248 | // Submitting data or service is disabled
249 | scheduler.shutdown();
250 | return;
251 | }
252 | if (submitTaskConsumer != null) {
253 | submitTaskConsumer.accept(this::submitData);
254 | } else {
255 | this.submitData();
256 | }
257 | };
258 | // Many servers tend to restart at a fixed time at xx:00 which causes an uneven distribution
259 | // of requests on the
260 | // bStats backend. To circumvent this problem, we introduce some randomness into the initial
261 | // and second delay.
262 | // WARNING: You must not modify and part of this Metrics class, including the submit delay or
263 | // frequency!
264 | // WARNING: Modifying this code will get your plugin banned on bStats. Just don't do it!
265 | long initialDelay = (long) (1000 * 60 * (3 + Math.random() * 3));
266 | long secondDelay = (long) (1000 * 60 * (Math.random() * 30));
267 | scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS);
268 | scheduler.scheduleAtFixedRate(
269 | submitTask, initialDelay + secondDelay, 1000 * 60 * 30, TimeUnit.MILLISECONDS);
270 | }
271 |
272 | private void submitData() {
273 | final JsonObjectBuilder baseJsonBuilder = new JsonObjectBuilder();
274 | appendPlatformDataConsumer.accept(baseJsonBuilder);
275 | final JsonObjectBuilder serviceJsonBuilder = new JsonObjectBuilder();
276 | appendServiceDataConsumer.accept(serviceJsonBuilder);
277 | JsonObjectBuilder.JsonObject[] chartData =
278 | customCharts.stream()
279 | .map(customChart -> customChart.getRequestJsonObject(errorLogger, logErrors))
280 | .filter(Objects::nonNull)
281 | .toArray(JsonObjectBuilder.JsonObject[]::new);
282 | serviceJsonBuilder.appendField("id", serviceId);
283 | serviceJsonBuilder.appendField("customCharts", chartData);
284 | baseJsonBuilder.appendField("service", serviceJsonBuilder.build());
285 | baseJsonBuilder.appendField("serverUUID", serverUuid);
286 | baseJsonBuilder.appendField("metricsVersion", METRICS_VERSION);
287 | JsonObjectBuilder.JsonObject data = baseJsonBuilder.build();
288 | scheduler.execute(
289 | () -> {
290 | try {
291 | // Send the data
292 | sendData(data);
293 | } catch (Exception e) {
294 | // Something went wrong! :(
295 | if (logErrors) {
296 | errorLogger.accept("Could not submit bStats metrics data", e);
297 | }
298 | }
299 | });
300 | }
301 |
302 | private void sendData(JsonObjectBuilder.JsonObject data) throws Exception {
303 | if (logSentData) {
304 | infoLogger.accept("Sent bStats metrics data: " + data.toString());
305 | }
306 | String url = String.format(REPORT_URL, platform);
307 | HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
308 | // Compress the data to save bandwidth
309 | byte[] compressedData = compress(data.toString());
310 | connection.setRequestMethod("POST");
311 | connection.addRequestProperty("Accept", "application/json");
312 | connection.addRequestProperty("Connection", "close");
313 | connection.addRequestProperty("Content-Encoding", "gzip");
314 | connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length));
315 | connection.setRequestProperty("Content-Type", "application/json");
316 | connection.setRequestProperty("User-Agent", "Metrics-Service/1");
317 | connection.setDoOutput(true);
318 | try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
319 | outputStream.write(compressedData);
320 | }
321 | StringBuilder builder = new StringBuilder();
322 | try (BufferedReader bufferedReader =
323 | new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
324 | String line;
325 | while ((line = bufferedReader.readLine()) != null) {
326 | builder.append(line);
327 | }
328 | }
329 | if (logResponseStatusText) {
330 | infoLogger.accept("Sent data to bStats and received response: " + builder);
331 | }
332 | }
333 |
334 | /** Checks that the class was properly relocated. */
335 | private void checkRelocation() {
336 | // You can use the property to disable the check in your test environment
337 | if (System.getProperty("bstats.relocatecheck") == null
338 | || !System.getProperty("bstats.relocatecheck").equals("false")) {
339 | // Maven's Relocate is clever and changes strings, too. So we have to use this little
340 | // "trick" ... :D
341 | final String defaultPackage =
342 | new String(new byte[] {'o', 'r', 'g', '.', 'b', 's', 't', 'a', 't', 's'});
343 | final String examplePackage =
344 | new String(new byte[] {'y', 'o', 'u', 'r', '.', 'p', 'a', 'c', 'k', 'a', 'g', 'e'});
345 | // We want to make sure no one just copy & pastes the example and uses the wrong package
346 | // names
347 | if (MetricsBase.class.getPackage().getName().startsWith(defaultPackage)
348 | || MetricsBase.class.getPackage().getName().startsWith(examplePackage)) {
349 | throw new IllegalStateException("bStats Metrics class has not been relocated correctly!");
350 | }
351 | }
352 | }
353 |
354 | /**
355 | * Gzips the given string.
356 | *
357 | * @param str The string to gzip.
358 | * @return The gzipped string.
359 | */
360 | private static byte[] compress(final String str) throws IOException {
361 | if (str == null) {
362 | return null;
363 | }
364 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
365 | try (GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) {
366 | gzip.write(str.getBytes(StandardCharsets.UTF_8));
367 | }
368 | return outputStream.toByteArray();
369 | }
370 | }
371 |
372 | public static class AdvancedBarChart extends CustomChart {
373 |
374 | private final Callable