From f878557b79a0c83f11a5e2486945c5802e798a2c Mon Sep 17 00:00:00 2001 From: gomeow Date: Sat, 28 Dec 2013 23:30:19 -0800 Subject: [PATCH] Update Metrics --- .../drtshock/playervaults/util/Metrics.java | 454 +++++++++++------- 1 file changed, 292 insertions(+), 162 deletions(-) diff --git a/src/main/java/com/drtshock/playervaults/util/Metrics.java b/src/main/java/com/drtshock/playervaults/util/Metrics.java index a53455a..2f8ec13 100644 --- a/src/main/java/com/drtshock/playervaults/util/Metrics.java +++ b/src/main/java/com/drtshock/playervaults/util/Metrics.java @@ -1,7 +1,5 @@ -package com.drtshock.playervaults.util; - /* - * Copyright 2011 Tyler Blair. All rights reserved. + * Copyright 2011-2013 Tyler Blair. All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are * permitted provided that the following conditions are met: @@ -27,17 +25,21 @@ package com.drtshock.playervaults.util; * authors and contributors and should not be interpreted as representing official policies, * either expressed or implied, of anybody else. */ +package io.snw.tutorial.util; + import org.bukkit.Bukkit; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.scheduler.BukkitTask; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; -import java.io.OutputStreamWriter; +import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.Proxy; import java.net.URL; @@ -50,77 +52,69 @@ import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; import java.util.logging.Level; +import java.util.zip.GZIPOutputStream; -/** - *

- * The metrics class obtains data about a plugin and submits statistics about it - * to the metrics backend. - *

- *

- * Public methods provided by this class: - *

- * - * Graph createGraph(String name);
- * void addCustomData(Metrics.Plotter plotter);
- * void start();
- *
- */ public class Metrics { /** * The current revision number */ - private final static int REVISION = 5; + private final static int REVISION = 7; + /** * The base url of the metrics domain */ private static final String BASE_URL = "http://report.mcstats.org"; + /** * The url used to report a server's status */ - private static final String REPORT_URL = "/report/%s"; - /** - * The separator to use for custom data. This MUST NOT change unless you are - * hosting your own version of metrics and want to change it. - */ - private static final String CUSTOM_DATA_SEPARATOR = "~~"; + private static final String REPORT_URL = "/plugin/%s"; + /** * Interval of time to ping (in minutes) */ - private static final int PING_INTERVAL = 10; + private static final int PING_INTERVAL = 15; + /** * The plugin this metrics submits for */ private final Plugin plugin; + /** * All of the custom graphs to submit to metrics */ - private final Set graphs = Collections.synchronizedSet(new HashSet()); - /** - * The default graph, used for addCustomData when you don't want a specific - * graph - */ - private final Metrics.Graph defaultGraph = new Metrics.Graph("Default"); + private final Set graphs = Collections.synchronizedSet(new HashSet()); + /** * The plugin configuration file */ private final YamlConfiguration configuration; + /** * The plugin configuration file */ private final File configurationFile; + /** * Unique server id */ private final String guid; + + /** + * Debug mode + */ + private final boolean debug; + /** * Lock for synchronization */ private final Object optOutLock = new Object(); + /** - * Id of the scheduled task + * The scheduled task */ - private volatile int taskId = -1; + private volatile BukkitTask task = null; public Metrics(final Plugin plugin) throws IOException { if (plugin == null) { @@ -136,6 +130,7 @@ public class Metrics { // add some defaults configuration.addDefault("opt-out", false); configuration.addDefault("guid", UUID.randomUUID().toString()); + configuration.addDefault("debug", false); // Do we need to create the file? if (configuration.get("guid", null) == null) { @@ -145,24 +140,23 @@ public class Metrics { // Load the guid then guid = configuration.getString("guid"); + debug = configuration.getBoolean("debug", false); } /** - * Construct and create a Graph that can be used to separate specific - * plotters to their own graphs on the metrics website. Plotters can be - * added to the graph object returned. + * Construct and create a Graph that can be used to separate specific plotters to their own graphs on the metrics + * website. Plotters can be added to the graph object returned. * * @param name The name of the graph - * @return Graph object created. Will never return NULL under normal - * circumstances unless bad parameters are given + * @return Graph object created. Will never return NULL under normal circumstances unless bad parameters are given */ - public Metrics.Graph createGraph(final String name) { + public Graph createGraph(final String name) { if (name == null) { throw new IllegalArgumentException("Graph name cannot be null"); } // Construct the graph object - final Metrics.Graph graph = new Metrics.Graph(name); + final Graph graph = new Graph(name); // Now we can add our graph graphs.add(graph); @@ -172,12 +166,11 @@ public class Metrics { } /** - * Add a Graph object to Metrics that represents data for the plugin that - * should be sent to the backend + * Add a Graph object to BukkitMetrics that represents data for the plugin that should be sent to the backend * * @param graph The name of the graph */ - public void addGraph(final Metrics.Graph graph) { + public void addGraph(final Graph graph) { if (graph == null) { throw new IllegalArgumentException("Graph cannot be null"); } @@ -186,31 +179,12 @@ public class Metrics { } /** - * Adds a custom data plotter to the default graph - * - * @param plotter The plotter to use to plot custom data - */ - public void addCustomData(final Metrics.Plotter plotter) { - if (plotter == null) { - throw new IllegalArgumentException("Plotter cannot be null"); - } - - // Add the plotter to the graph o/ - defaultGraph.addPlotter(plotter); - - // Ensure the default graph is included in the submitted graphs - graphs.add(defaultGraph); - } - - /** - * Start measuring statistics. This will immediately create an async - * repeating task as the plugin and send the initial data to the metrics - * backend, and then after that it will post in increments of PING_INTERVAL - * * 1200 ticks. + * Start measuring statistics. This will immediately create an async repeating task as the plugin and send the + * initial data to the metrics backend, and then after that it will post in increments of PING_INTERVAL * 1200 + * ticks. * * @return True if statistics measuring is running, otherwise false. */ - @SuppressWarnings("deprecation") public boolean start() { synchronized (optOutLock) { // Did we opt out? @@ -219,12 +193,13 @@ public class Metrics { } // Is metrics already running? - if (taskId >= 0) { + if (task != null) { return true; } // Begin hitting the server with glorious data - taskId = plugin.getServer().getScheduler().scheduleAsyncRepeatingTask(plugin, new Runnable() { + task = plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, new Runnable() { + private boolean firstPost = true; public void run() { @@ -232,11 +207,11 @@ public class Metrics { // This has to be synchronized or it can collide with the disable method. synchronized (optOutLock) { // Disable Task, if it is running and the server owner decided to opt-out - if (isOptOut() && taskId > 0) { - plugin.getServer().getScheduler().cancelTask(taskId); - taskId = -1; + if (isOptOut() && task != null) { + task.cancel(); + task = null; // Tell all plotters to stop gathering information. - for (Metrics.Graph graph : graphs) { + for (Graph graph : graphs) { graph.onOptOut(); } } @@ -251,7 +226,9 @@ public class Metrics { // Each post thereafter will be a ping firstPost = false; } catch (IOException e) { - Bukkit.getLogger().log(Level.INFO, "[Metrics] " + e.getMessage()); + if (debug) { + Bukkit.getLogger().log(Level.INFO, "[Metrics] " + e.getMessage()); + } } } }, 0, PING_INTERVAL * 1200); @@ -271,10 +248,14 @@ public class Metrics { // Reload the metrics file configuration.load(getConfigFile()); } catch (IOException ex) { - Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); + if (debug) { + Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); + } return true; } catch (InvalidConfigurationException ex) { - Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); + if (debug) { + Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); + } return true; } return configuration.getBoolean("opt-out", false); @@ -282,10 +263,9 @@ public class Metrics { } /** - * Enables metrics for the server by setting "opt-out" to false in the - * config file and starting the metrics task. + * Enables metrics for the server by setting "opt-out" to false in the config file and starting the metrics task. * - * @throws IOException + * @throws java.io.IOException */ public void enable() throws IOException { // This has to be synchronized or it can collide with the check in the task. @@ -297,17 +277,16 @@ public class Metrics { } // Enable Task, if it is not running - if (taskId < 0) { + if (task == null) { start(); } } } /** - * Disables metrics for the server by setting "opt-out" to true in the - * config file and canceling the metrics task. + * Disables metrics for the server by setting "opt-out" to true in the config file and canceling the metrics task. * - * @throws IOException + * @throws java.io.IOException */ public void disable() throws IOException { // This has to be synchronized or it can collide with the check in the task. @@ -319,16 +298,15 @@ public class Metrics { } // Disable Task, if it is running - if (taskId > 0) { - this.plugin.getServer().getScheduler().cancelTask(taskId); - taskId = -1; + if (task != null) { + task.cancel(); + task = null; } } } /** - * Gets the File object of the config file that should be used to store data - * such as the GUID and opt-out status + * Gets the File object of the config file that should be used to store data such as the GUID and opt-out status * * @return the File object for the config file */ @@ -348,48 +326,95 @@ public class Metrics { * Generic method that posts a plugin to the metrics website */ private void postPlugin(final boolean isPing) throws IOException { - // The plugin's description file containg all of the plugin data such as name, version, author, etc - final PluginDescriptionFile description = plugin.getDescription(); + // Server software specific section + PluginDescriptionFile description = plugin.getDescription(); + String pluginName = description.getName(); + boolean onlineMode = Bukkit.getServer().getOnlineMode(); // TRUE if online mode is enabled + String pluginVersion = description.getVersion(); + String serverVersion = Bukkit.getVersion(); + int playersOnline = Bukkit.getServer().getOnlinePlayers().length; + + // END server software specific section -- all code below does not use any code outside of this class / Java // Construct the post data - final StringBuilder data = new StringBuilder(); - data.append(encode("guid")).append('=').append(encode(guid)); - encodeDataPair(data, "version", description.getVersion()); - encodeDataPair(data, "server", Bukkit.getVersion()); - encodeDataPair(data, "players", Integer.toString(Bukkit.getServer().getOnlinePlayers().length)); - encodeDataPair(data, "revision", String.valueOf(REVISION)); + StringBuilder json = new StringBuilder(1024); + json.append('{'); + + // The plugin's description file containg all of the plugin data such as name, version, author, etc + appendJSONPair(json, "guid", guid); + appendJSONPair(json, "plugin_version", pluginVersion); + appendJSONPair(json, "server_version", serverVersion); + appendJSONPair(json, "players_online", Integer.toString(playersOnline)); + + // New data as of R6 + String osname = System.getProperty("os.name"); + String osarch = System.getProperty("os.arch"); + String osversion = System.getProperty("os.version"); + String java_version = System.getProperty("java.version"); + int coreCount = Runtime.getRuntime().availableProcessors(); + + // normalize os arch .. amd64 -> x86_64 + if (osarch.equals("amd64")) { + osarch = "x86_64"; + } + + appendJSONPair(json, "osname", osname); + appendJSONPair(json, "osarch", osarch); + appendJSONPair(json, "osversion", osversion); + appendJSONPair(json, "cores", Integer.toString(coreCount)); + appendJSONPair(json, "auth_mode", onlineMode ? "1" : "0"); + appendJSONPair(json, "java_version", java_version); // If we're pinging, append it if (isPing) { - encodeDataPair(data, "ping", "true"); + appendJSONPair(json, "ping", "1"); } - // Acquire a lock on the graphs, which lets us make the assumption we also lock everything - // inside of the graph (e.g plotters) - synchronized (graphs) { - final Iterator iter = graphs.iterator(); + if (graphs.size() > 0) { + synchronized (graphs) { + json.append(','); + json.append('"'); + json.append("graphs"); + json.append('"'); + json.append(':'); + json.append('{'); - while (iter.hasNext()) { - final Metrics.Graph graph = iter.next(); + boolean firstGraph = true; - for (Metrics.Plotter plotter : graph.getPlotters()) { - // The key name to send to the metrics server - // The format is C-GRAPHNAME-PLOTTERNAME where separator - is defined at the top - // Legacy (R4) submitters use the format Custom%s, or CustomPLOTTERNAME - final String key = String.format("C%s%s%s%s", CUSTOM_DATA_SEPARATOR, graph.getName(), CUSTOM_DATA_SEPARATOR, plotter.getColumnName()); + final Iterator iter = graphs.iterator(); - // The value to send, which for the foreseeable future is just the string - // value of plotter.getValue() - final String value = Integer.toString(plotter.getValue()); + while (iter.hasNext()) { + Graph graph = iter.next(); - // Add it to the http post data :) - encodeDataPair(data, key, value); + StringBuilder graphJson = new StringBuilder(); + graphJson.append('{'); + + for (Plotter plotter : graph.getPlotters()) { + appendJSONPair(graphJson, plotter.getColumnName(), Integer.toString(plotter.getValue())); + } + + graphJson.append('}'); + + if (!firstGraph) { + json.append(','); + } + + json.append(escapeJSON(graph.getName())); + json.append(':'); + json.append(graphJson); + + firstGraph = false; } + + json.append('}'); } } + // close json + json.append('}'); + // Create the url - URL url = new URL(BASE_URL + String.format(REPORT_URL, encode(plugin.getDescription().getName()))); + URL url = new URL(BASE_URL + String.format(REPORT_URL, urlEncode(pluginName))); // Connect to the website URLConnection connection; @@ -402,33 +427,55 @@ public class Metrics { connection = url.openConnection(); } + + byte[] uncompressed = json.toString().getBytes(); + byte[] compressed = gzip(json.toString()); + + // Headers + connection.addRequestProperty("User-Agent", "MCStats/" + REVISION); + connection.addRequestProperty("Content-Type", "application/json"); + connection.addRequestProperty("Content-Encoding", "gzip"); + connection.addRequestProperty("Content-Length", Integer.toString(compressed.length)); + connection.addRequestProperty("Accept", "application/json"); + connection.addRequestProperty("Connection", "close"); + connection.setDoOutput(true); + if (debug) { + System.out.println("[Metrics] Prepared request for " + pluginName + " uncompressed=" + uncompressed.length + " compressed=" + compressed.length); + } + // Write the data - final OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream()); - writer.write(data.toString()); - writer.flush(); + OutputStream os = connection.getOutputStream(); + os.write(compressed); + os.flush(); // Now read the response final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); - final String response = reader.readLine(); + String response = reader.readLine(); // close resources - writer.close(); + os.close(); reader.close(); - if (response == null || response.startsWith("ERR")) { - throw new IOException(response); //Throw the exception + if (response == null || response.startsWith("ERR") || response.startsWith("7")) { + if (response == null) { + response = "null"; + } else if (response.startsWith("7")) { + response = response.substring(response.startsWith("7,") ? 2 : 1); + } + + throw new IOException(response); } else { // Is this the first update this hour? - if (response.contains("OK This is your first update this hour")) { + if (response.equals("1") || response.contains("This is your first update this hour")) { synchronized (graphs) { - final Iterator iter = graphs.iterator(); + final Iterator iter = graphs.iterator(); while (iter.hasNext()) { - final Metrics.Graph graph = iter.next(); + final Graph graph = iter.next(); - for (Metrics.Plotter plotter : graph.getPlotters()) { + for (Plotter plotter : graph.getPlotters()) { plotter.reset(); } } @@ -438,8 +485,32 @@ public class Metrics { } /** - * Check if mineshafter is present. If it is, we need to bypass it to send - * POST requests + * GZip compress a string of bytes + * + * @param input + * @return + */ + public static byte[] gzip(String input) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + GZIPOutputStream gzos = null; + + try { + gzos = new GZIPOutputStream(baos); + gzos.write(input.getBytes("UTF-8")); + } catch (IOException e) { + e.printStackTrace(); + } finally { + if (gzos != null) try { + gzos.close(); + } catch (IOException ignore) { + } + } + + return baos.toByteArray(); + } + + /** + * Check if mineshafter is present. If it is, we need to bypass it to send POST requests * * @return true if mineshafter is installed on the server */ @@ -453,21 +524,83 @@ public class Metrics { } /** - *

Encode a key/value data pair to be used in a HTTP post request. This - * INCLUDES a & so the first key/value pair MUST be included manually, - * e.g:

- * - * StringBuffer data = new StringBuffer(); - * data.append(encode("guid")).append('=').append(encode(guid)); - * encodeDataPair(data, "version", description.getVersion()); - * + * Appends a json encoded key/value pair to the given string builder. * - * @param buffer the stringbuilder to append the data pair onto - * @param key the key value - * @param value the value + * @param json + * @param key + * @param value + * @throws UnsupportedEncodingException */ - private static void encodeDataPair(final StringBuilder buffer, final String key, final String value) throws UnsupportedEncodingException { - buffer.append('&').append(encode(key)).append('=').append(encode(value)); + private static void appendJSONPair(StringBuilder json, String key, String value) throws UnsupportedEncodingException { + boolean isValueNumeric = false; + + try { + if (value.equals("0") || !value.endsWith("0")) { + Double.parseDouble(value); + isValueNumeric = true; + } + } catch (NumberFormatException e) { + isValueNumeric = false; + } + + if (json.charAt(json.length() - 1) != '{') { + json.append(','); + } + + json.append(escapeJSON(key)); + json.append(':'); + + if (isValueNumeric) { + json.append(value); + } else { + json.append(escapeJSON(value)); + } + } + + /** + * Escape a string to create a valid JSON string + * + * @param text + * @return + */ + private static String escapeJSON(String text) { + StringBuilder builder = new StringBuilder(); + + builder.append('"'); + for (int index = 0; index < text.length(); index++) { + char chr = text.charAt(index); + + switch (chr) { + case '"': + case '\\': + builder.append('\\'); + builder.append(chr); + break; + case '\b': + builder.append("\\b"); + break; + case '\t': + builder.append("\\t"); + break; + case '\n': + builder.append("\\n"); + break; + case '\r': + builder.append("\\r"); + break; + default: + if (chr < ' ') { + String t = "000" + Integer.toHexString(chr); + builder.append("\\u" + t.substring(t.length() - 4)); + } else { + builder.append(chr); + } + break; + } + } + builder.append('"'); + + return builder.toString(); } /** @@ -476,7 +609,7 @@ public class Metrics { * @param text the text to encode * @return the encoded text, as UTF-8 */ - private static String encode(final String text) throws UnsupportedEncodingException { + private static String urlEncode(final String text) throws UnsupportedEncodingException { return URLEncoder.encode(text, "UTF-8"); } @@ -486,14 +619,15 @@ public class Metrics { public static class Graph { /** - * The graph's name, alphanumeric and spaces only :) If it does not - * comply to the above when submitted, it is rejected + * The graph's name, alphanumeric and spaces only :) If it does not comply to the above when submitted, it is + * rejected */ private final String name; + /** * The set of plotters that are contained within this graph */ - private final Set plotters = new LinkedHashSet(); + private final Set plotters = new LinkedHashSet(); private Graph(final String name) { this.name = name; @@ -513,7 +647,7 @@ public class Metrics { * * @param plotter the plotter to add to the graph */ - public void addPlotter(final Metrics.Plotter plotter) { + public void addPlotter(final Plotter plotter) { plotters.add(plotter); } @@ -522,16 +656,16 @@ public class Metrics { * * @param plotter the plotter to remove from the graph */ - public void removePlotter(final Metrics.Plotter plotter) { + public void removePlotter(final Plotter plotter) { plotters.remove(plotter); } /** * Gets an unmodifiable set of the plotter objects in the graph * - * @return an unmodifiable {@link Set} of the plotter objects + * @return an unmodifiable {@link java.util.Set} of the plotter objects */ - public Set getPlotters() { + public Set getPlotters() { return Collections.unmodifiableSet(plotters); } @@ -542,17 +676,16 @@ public class Metrics { @Override public boolean equals(final Object object) { - if (!(object instanceof Metrics.Graph)) { + if (!(object instanceof Graph)) { return false; } - final Metrics.Graph graph = (Metrics.Graph) object; + final Graph graph = (Graph) object; return graph.name.equals(name); } /** - * Called when the server owner decides to opt-out of Metrics while the - * server is running. + * Called when the server owner decides to opt-out of BukkitMetrics while the server is running. */ protected void onOptOut() { } @@ -578,19 +711,16 @@ public class Metrics { /** * Construct a plotter with a specific plot name * - * @param name the name of the plotter to use, which will show up on the - * website + * @param name the name of the plotter to use, which will show up on the website */ public Plotter(final String name) { this.name = name; } /** - * Get the current value for the plotted point. Since this function - * defers to an external function it may or may not return immediately - * thus cannot be guaranteed to be thread friendly or safe. This - * function can be called from any thread so care should be taken when - * accessing resources that need to be synchronized. + * Get the current value for the plotted point. Since this function defers to an external function it may or may + * not return immediately thus cannot be guaranteed to be thread friendly or safe. This function can be called + * from any thread so care should be taken when accessing resources that need to be synchronized. * * @return the current value for the point to be plotted. */ @@ -618,12 +748,12 @@ public class Metrics { @Override public boolean equals(final Object object) { - if (!(object instanceof Metrics.Plotter)) { + if (!(object instanceof Plotter)) { return false; } - final Metrics.Plotter plotter = (Metrics.Plotter) object; + final Plotter plotter = (Plotter) object; return plotter.name.equals(name) && plotter.getValue() == getValue(); } } -} +} \ No newline at end of file