From b243f6f77630f6f3135ff789ab4f11d8e590426a Mon Sep 17 00:00:00 2001 From: Ben Soares Date: Thu, 22 Jun 2023 10:28:10 +0100 Subject: [PATCH] JAL-4001 Release_2_11_2 branch with Plausible analytics --- src/jalview/analytics/Plausible.java | 510 ++++++++++++++++++++++++++++++++++ src/jalview/bin/Cache.java | 141 ++++------ src/jalview/bin/Jalview.java | 11 +- src/jalview/util/HttpUtils.java | 45 ++- 4 files changed, 606 insertions(+), 101 deletions(-) create mode 100644 src/jalview/analytics/Plausible.java diff --git a/src/jalview/analytics/Plausible.java b/src/jalview/analytics/Plausible.java new file mode 100644 index 0000000..4dd0d35 --- /dev/null +++ b/src/jalview/analytics/Plausible.java @@ -0,0 +1,510 @@ +package jalview.analytics; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.lang.invoke.MethodHandles; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import jalview.bin.Cache; +import jalview.bin.Console; +import jalview.bin.Jalview; +import jalview.util.ChannelProperties; +import jalview.util.HttpUtils; + +public class Plausible +{ + private static final String USER_AGENT = HttpUtils.getUserAgent( + MethodHandles.lookup().lookupClass().getCanonicalName()); + + private static final String JALVIEW_ID = "Jalview Desktop"; + + private static final String DOMAIN = "jalview.org"; + + private static final String API_BASE_URL = "https://plausible.io/api/event"; + + public static final String APPLICATION_BASE_URL = "desktop://localhost"; + + private List> queryStringValues; + + private List> jsonObject; + + private List> cookieValues; + + private static boolean ENABLED = false; + + private static boolean DEBUG = true; + + private static Plausible instance = null; + + private static final Map defaultProps; + + static + { + defaultProps = new HashMap<>(); + defaultProps.put("app_name", + ChannelProperties.getProperty("app_name") + " Desktop"); + defaultProps.put("version", Cache.getProperty("VERSION")); + defaultProps.put("build_date", + Cache.getDefault("BUILD_DATE", "unknown")); + defaultProps.put("java_version", System.getProperty("java.version")); + String val = System.getProperty("sys.install4jVersion"); + if (val != null) + { + defaultProps.put("install4j_version", val); + } + val = System.getProperty("installer_template_version"); + if (val != null) + { + defaultProps.put("install4j_template_version", val); + } + val = System.getProperty("launcher_version"); + if (val != null) + { + defaultProps.put("launcher_version", val); + } + defaultProps.put("java_arch", + System.getProperty("os.arch") + " " + + System.getProperty("os.name") + " " + + System.getProperty("os.version")); + } + + private Plausible() + { + this.resetLists(); + } + + public static void setEnabled(boolean b) + { + ENABLED = b; + } + + public void sendEvent(String eventName, String path, + String... propsStrings) + { + sendEvent(eventName, path, false, propsStrings); + } + + /** + * The simplest way to send an analytic event. + * + * @param eventName + * The event name. To emulate a webpage view use "pageview" and set a + * "url" key/value. See https://plausible.io/docs/events-api + * @param sendDefaultProps + * Flag whether to add the default props about the application. + * @param propsStrings + * Optional multiple Strings in key, value pairs (there should be an + * even number of propsStrings) to be set as properties of the event. + * To emulate a webpage view use "url" as the URL in a "pageview" + * event. + */ + public void sendEvent(String eventName, String path, + boolean sendDefaultProps, String... propsStrings) + { + // clear out old lists + this.resetLists(); + + if (!ENABLED) + { + Console.debug("Plausible not enabled."); + return; + } + Map props = new HashMap<>(); + + // add these to all events from this application instance + if (sendDefaultProps) + { + props.putAll(defaultProps); + if (Jalview.isHeadlessMode()) + { + props.put("headless", "true"); + } + } + + // add (and overwrite with) the passed in props + if (propsStrings != null && propsStrings.length > 0) + { + if (propsStrings.length % 2 != 0) + { + Console.warn( + "Cannot addEvent with odd number of propsStrings. Ignoring the last one."); + } + for (int i = 0; i < propsStrings.length - 1; i += 2) + { + String key = propsStrings[i]; + String value = propsStrings[i + 1]; + props.put(key, value); + } + } + + addJsonValue("domain", DOMAIN); + addJsonValue("name", eventName); + StringBuilder recordedUrlSb = new StringBuilder(APPLICATION_BASE_URL); + if (!APPLICATION_BASE_URL.endsWith("/") && !path.startsWith("/")) + { + recordedUrlSb.append("/"); + } + recordedUrlSb.append(path); + addJsonValue("url", recordedUrlSb.toString()); + addJsonObject("props", props); + StringBuilder urlSb = new StringBuilder(); + urlSb.append(API_BASE_URL); + String qs = buildQueryString(); + if (qs != null && qs.length() > 0) + { + urlSb.append('?'); + urlSb.append(qs); + } + try + { + URL url = new URL(urlSb.toString()); + URLConnection urlConnection = url.openConnection(); + HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection; + httpURLConnection.setRequestMethod("POST"); + httpURLConnection.setDoOutput(true); + + String jsonString = buildJson(); + + Console.debug( + "Plausible: HTTP Request is: '" + urlSb.toString() + "'"); + if (DEBUG) + { + Console.debug("Plausible: User-Agent is: '" + USER_AGENT + "'"); + } + Console.debug("Plausible: POSTed JSON is:\n" + jsonString); + + byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8); + int jsonLength = jsonBytes.length; + + httpURLConnection.setFixedLengthStreamingMode(jsonLength); + httpURLConnection.setRequestProperty("Content-Type", + "application/json"); + httpURLConnection.setRequestProperty("User-Agent", USER_AGENT); + httpURLConnection.connect(); + try (OutputStream os = httpURLConnection.getOutputStream()) + { + os.write(jsonBytes); + } + int responseCode = httpURLConnection.getResponseCode(); + String responseMessage = httpURLConnection.getResponseMessage(); + + if (responseCode < 200 || responseCode > 299) + { + Console.warn("Plausible connection failed: '" + responseCode + " " + + responseMessage + "'"); + } + else + { + Console.debug("Plausible connection succeeded: '" + responseCode + + " " + responseMessage + "'"); + } + + if (DEBUG) + { + BufferedReader br = new BufferedReader(new InputStreamReader( + (httpURLConnection.getInputStream()))); + StringBuilder sb = new StringBuilder(); + String response; + while ((response = br.readLine()) != null) + { + sb.append(response); + } + String body = sb.toString(); + Console.debug("Plausible response content:\n" + body); + } + } catch (MalformedURLException e) + { + Console.debug( + "Somehow the Plausible BASE_URL and queryString is malformed: '" + + urlSb.toString() + "'", + e); + return; + } catch (IOException e) + { + Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL + + "' failed.", e); + } catch (ClassCastException e) + { + Console.debug( + "Couldn't cast URLConnection to HttpURLConnection in Plausible.", + e); + } + } + + private void addJsonObject(String key, Map map) + { + List> list = new ArrayList<>(); + for (String k : map.keySet()) + { + list.add(stringEntry(k, map.get(k))); + } + addJsonObject(key, list); + + } + + private void addJsonObject(String key, + List> object) + { + jsonObject.add(objectEntry(key, object)); + } + + private void addJsonValues(String key, List values) + { + jsonObject.add(objectEntry(key, values)); + } + + private void addJsonValue(String key, String value) + { + jsonObject.add(objectEntry(key, value)); + } + + private void addJsonValue(String key, int value) + { + jsonObject.add(objectEntry(key, Integer.valueOf(value))); + } + + private void addJsonValue(String key, boolean value) + { + jsonObject.add(objectEntry(key, Boolean.valueOf(value))); + } + + private void addQueryStringValue(String key, String value) + { + queryStringValues.add(stringEntry(key, value)); + } + + private void addCookieValue(String key, String value) + { + cookieValues.add(stringEntry(key, value)); + } + + private void resetLists() + { + jsonObject = new ArrayList<>(); + queryStringValues = new ArrayList<>(); + cookieValues = new ArrayList<>(); + } + + public static Plausible getInstance() + { + if (instance == null) + { + instance = new Plausible(); + } + return instance; + } + + public static void reset() + { + getInstance().resetLists(); + } + + private String buildQueryString() + { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : queryStringValues) + { + if (sb.length() > 0) + { + sb.append('&'); + } + try + { + sb.append(URLEncoder.encode(entry.getKey(), "UTF-8")); + } catch (UnsupportedEncodingException e) + { + sb.append(entry.getKey()); + } + sb.append('='); + try + { + sb.append(URLEncoder.encode(entry.getValue(), "UTF-8")); + } catch (UnsupportedEncodingException e) + { + sb.append(entry.getValue()); + } + } + return sb.toString(); + } + + private void buildCookieHeaders() + { + // TODO not needed yet + } + + private String buildJson() + { + StringBuilder sb = new StringBuilder(); + addJsonObject(sb, 0, jsonObject); + return sb.toString(); + } + + private void addJsonObject(StringBuilder sb, int indent, + List> entries) + { + indent(sb, indent); + sb.append('{'); + newline(sb); + Iterator> entriesI = entries.iterator(); + while (entriesI.hasNext()) + { + Map.Entry entry = entriesI.next(); + String key = entry.getKey(); + // TODO sensibly escape " characters in key + Object value = entry.getValue(); + indent(sb, indent + 1); + sb.append('"').append(quoteEscape(key)).append('"').append(':'); + space(sb); + if (value != null && value instanceof List) + { + newline(sb); + } + addJsonValue(sb, indent + 2, value); + if (entriesI.hasNext()) + { + sb.append(','); + } + newline(sb); + } + indent(sb, indent); + sb.append('}'); + } + + private void addJsonValue(StringBuilder sb, int indent, Object value) + { + if (value == null) + { + return; + } + try + { + if (value instanceof Map.Entry) + { + Map.Entry entry = (Map.Entry) value; + List> object = new ArrayList<>(); + object.add(entry); + addJsonObject(sb, indent, object); + } + else if (value instanceof List) + { + // list of Map.Entries or list of values? + List valueList = (List) value; + if (valueList.size() > 0 && valueList.get(0) instanceof Map.Entry) + { + // entries + // indent(sb, indent); + List> entryList = (List>) value; + addJsonObject(sb, indent, entryList); + } + else + { + // values + indent(sb, indent); + sb.append('['); + newline(sb); + Iterator valueListI = valueList.iterator(); + while (valueListI.hasNext()) + { + Object v = valueListI.next(); + addJsonValue(sb, indent + 1, v); + if (valueListI.hasNext()) + { + sb.append(','); + } + newline(sb); + } + indent(sb, indent); + sb.append("]"); + } + } + else if (value instanceof String) + { + sb.append('"').append(quoteEscape((String) value)).append('"'); + } + else if (value instanceof Integer) + { + sb.append(((Integer) value).toString()); + } + else if (value instanceof Boolean) + { + sb.append('"').append(((Boolean) value).toString()).append('"'); + } + } catch (ClassCastException e) + { + Console.debug( + "Could not deal with type of json Object " + value.toString(), + e); + } + } + + private static String quoteEscape(String s) + { + if (s == null) + { + return null; + } + // this escapes quotation marks (") that aren't already escaped (in the + // string) ready to go into a quoted JSON string value + return s.replaceAll("((?= 0 && whitespace != null) + { + // sb.append(whitespace.repeat(repeat)); + sb.append(String.join("", Collections.nCopies(repeat, whitespace))); + + } + else + { + sb.append(whitespace); + } + } + + private static void indent(StringBuilder sb, int indent) + { + prettyWhitespace(sb, " ", indent); + } + + private static void newline(StringBuilder sb) + { + prettyWhitespace(sb, "\n", -1); + } + + private static void space(StringBuilder sb) + { + prettyWhitespace(sb, " ", -1); + } + + protected static Map.Entry objectEntry(String s, Object o) + { + return new AbstractMap.SimpleEntry(s, o); + } + + protected static Map.Entry stringEntry(String s, String v) + { + return new AbstractMap.SimpleEntry(s, v); + } +} \ No newline at end of file diff --git a/src/jalview/bin/Cache.java b/src/jalview/bin/Cache.java index 370a243..63d4223 100755 --- a/src/jalview/bin/Cache.java +++ b/src/jalview/bin/Cache.java @@ -34,10 +34,12 @@ import java.net.PasswordAuthentication; import java.net.URL; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.Enumeration; +import java.util.List; import java.util.Locale; import java.util.Properties; import java.util.StringTokenizer; @@ -46,6 +48,7 @@ import java.util.TreeSet; import javax.swing.LookAndFeel; import javax.swing.UIManager; +import jalview.analytics.Plausible; import jalview.datamodel.PDBEntry; import jalview.gui.Preferences; import jalview.gui.UserDefinedColours; @@ -121,7 +124,7 @@ import jalview.ws.sifts.SiftsSettings; * service *
  • QUESTIONNAIRE last questionnaire:responder id string from questionnaire * service
  • - *
  • USAGESTATS (false - user prompted) Enable google analytics tracker for + *
  • USAGESTATS (false - user prompted) Enable analytics tracker for * collecting usage statistics
  • *
  • SHOW_OVERVIEW boolean for overview window display
  • *
  • ANTI_ALIAS boolean for smooth fonts
  • @@ -880,102 +883,51 @@ public class Cache } /** - * GA tracker object - actually JGoogleAnalyticsTracker null if tracking not - * enabled. + * Initialise the tracker if it is not done already. */ - protected static Object tracker = null; - - protected static Class trackerfocus = null; - - protected static Class jgoogleanalyticstracker = null; + public static void initAnalytics() + { + initAnalytics(false); + } - /** - * Initialise the google tracker if it is not done already. - */ - public static void initGoogleTracker() + public static void initAnalytics(boolean headless) { - if (tracker == null) + Plausible.setEnabled(true); + + String appName = ChannelProperties.getProperty("app_name") + " Desktop"; + String version = Cache.getProperty("VERSION") + "_" + + Cache.getDefault("BUILD_DATE", "unknown"); + String path; + /* we don't want to encode ':' as "%3A" for backward compatibility with the UA setup + try { - if (jgoogleanalyticstracker == null) - { - // try to get the tracker class - try - { - jgoogleanalyticstracker = Cache.class.getClassLoader().loadClass( - "com.boxysystems.jgoogleanalytics.JGoogleAnalyticsTracker"); - trackerfocus = Cache.class.getClassLoader() - .loadClass("com.boxysystems.jgoogleanalytics.FocusPoint"); - } catch (Exception e) - { - Console.debug( - "com.boxysystems.jgoogleanalytics package is not present - tracking not enabled."); - tracker = null; - jgoogleanalyticstracker = null; - trackerfocus = null; - return; - } - } - // now initialise tracker - Exception re = null, ex = null; - Error err = null; - String vrs = "No Version Accessible"; - try - { - // Google analytics tracking code for Library Finder - tracker = jgoogleanalyticstracker - .getConstructor(new Class[] - { String.class, String.class, String.class }) - .newInstance(new Object[] - { ChannelProperties.getProperty("app_name") + " Desktop", - (vrs = Cache.getProperty("VERSION") + "_" - + Cache.getDefault("BUILD_DATE", "unknown")), - "UA-9060947-1" }); - jgoogleanalyticstracker - .getMethod("trackAsynchronously", new Class[] - { trackerfocus }) - .invoke(tracker, new Object[] - { trackerfocus.getConstructor(new Class[] { String.class }) - .newInstance(new Object[] - { "Application Started." }) }); - } catch (RuntimeException e) - { - re = e; - } catch (Exception e) - { - ex = e; - } catch (Error e) - { - err = e; - } - if (re != null || ex != null || err != null) - { - if (re != null) - { - Console.debug("Caught runtime exception in googletracker init:", - re); - } - if (ex != null) - { - Console.warn( - "Failed to initialise GoogleTracker for Jalview Desktop with version " - + vrs, - ex); - } - if (err != null) - { - Console.error( - "Whilst initing GoogleTracker for Jalview Desktop version " - + vrs, - err); - } - } - else - { - Console.debug("Successfully initialised tracker."); - } + path = "/" + String.join("/", URLEncoder.encode(appName, "UTF-8"), + URLEncoder.encode(version, "UTF-8"), + URLEncoder.encode(APPLICATION_STARTED, "UTF-8")); + } catch (UnsupportedEncodingException e) + { + */ + List pathParts = new ArrayList<>(); + pathParts.add(appName); + pathParts.add(version); + pathParts.add(APPLICATION_STARTED); + if (headless) + { + pathParts.add("headless"); } + path = ("/" + String.join("/", pathParts)).replace(' ', '+'); + /* + } + */ + Plausible plausible = Plausible.getInstance(); + + // This will send a new "application_launch" event with parameters + // including the old-style "path", the channel name and version + plausible.sendEvent("application_launch", path, true); } + private static final String APPLICATION_STARTED = "Application Started"; + /** * get the user's default colour if available * @@ -1401,10 +1353,11 @@ public class Cache if (customProxySet && // we have a username but no password for the scheme being // requested - (protocol.equalsIgnoreCase("http") - && (httpUser != null && httpUser.length() > 0 - && (httpPassword == null - || httpPassword.length == 0))) + (protocol.equalsIgnoreCase("http") + && (httpUser != null + && httpUser.length() > 0 + && (httpPassword == null + || httpPassword.length == 0))) || (protocol.equalsIgnoreCase("https") && (httpsUser != null && httpsUser.length() > 0 diff --git a/src/jalview/bin/Jalview.java b/src/jalview/bin/Jalview.java index 1428906..4774d22 100755 --- a/src/jalview/bin/Jalview.java +++ b/src/jalview/bin/Jalview.java @@ -1187,7 +1187,7 @@ public class Jalview + "-questionnaire URL\tQueries the given URL for information about any Jalview user questionnaires.\n" + "-noquestionnaire\tTurn off questionnaire check.\n" + "-nonews\tTurn off check for Jalview news.\n" - + "-nousagestats\tTurn off google analytics tracking for this session.\n" + + "-nousagestats\tTurn off analytics tracking for this session.\n" + "-sortbytree OR -nosortbytree\tEnable or disable sorting of the given alignment by the given tree\n" // + // "-setprop PROPERTY=VALUE\tSet the given Jalview property, @@ -1210,16 +1210,15 @@ public class Jalview PromptUserConfig prompter = new PromptUserConfig(Desktop.desktop, "USAGESTATS", "Jalview Usage Statistics", "Do you want to help make Jalview better by enabling " - + "the collection of usage statistics with Google Analytics ?" + + "the collection of usage statistics with Plausible analytics?" + "\n\n(you can enable or disable usage tracking in the preferences)", new Runnable() { @Override public void run() { - Console.debug( - "Initialising googletracker for usage stats."); - Cache.initGoogleTracker(); + Console.debug("Initialising analytics for usage stats."); + Cache.initAnalytics(); Console.debug("Tracking enabled."); } }, new Runnable() @@ -1227,7 +1226,7 @@ public class Jalview @Override public void run() { - Console.debug("Not enabling Google Tracking."); + Console.debug("Not enabling analytics."); } }, null, true); desktop.addDialogThread(prompter); diff --git a/src/jalview/util/HttpUtils.java b/src/jalview/util/HttpUtils.java index 0454cab..8379777 100644 --- a/src/jalview/util/HttpUtils.java +++ b/src/jalview/util/HttpUtils.java @@ -25,10 +25,11 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.URL; -import java.util.List; import javax.ws.rs.HttpMethod; +import jalview.bin.Cache; + public class HttpUtils { @@ -101,4 +102,46 @@ public class HttpUtils return connection.getResponseCode() == 200; } + public static String getUserAgent() + { + return getUserAgent(null); + } + + public static String getUserAgent(String className) + { + StringBuilder sb = new StringBuilder(); + sb.append("Jalview"); + sb.append('/'); + sb.append(Cache.getDefault("VERSION", "Unknown")); + sb.append(" ("); + sb.append(System.getProperty("os.name")); + sb.append("; "); + sb.append(System.getProperty("os.arch")); + sb.append(' '); + sb.append(System.getProperty("os.name")); + sb.append(' '); + sb.append(System.getProperty("os.version")); + sb.append("; "); + sb.append("java/"); + sb.append(System.getProperty("java.version")); + sb.append("; "); + sb.append("jalview/"); + sb.append(ChannelProperties.getProperty("channel")); + if (className != null) + { + sb.append("; "); + sb.append(className); + } + String installation = Cache.applicationProperties + .getProperty("INSTALLATION"); + if (installation != null) + { + sb.append("; "); + sb.append(installation); + } + sb.append(')'); + sb.append(" help@jalview.org"); + return sb.toString(); + } + } -- 1.7.10.2