From: Ben Soares Date: Tue, 20 Jun 2023 15:33:20 +0000 (+0100) Subject: JAL-4001 Plausible class X-Git-Tag: Release_2_11_4_0~260^2~15 X-Git-Url: http://source.jalview.org/gitweb/?a=commitdiff_plain;h=b8fad524f57f852bcdfcfdbcf68d4360b550feb0;p=jalview.git JAL-4001 Plausible class --- diff --git a/src/jalview/analytics/Plausible.java b/src/jalview/analytics/Plausible.java new file mode 100644 index 0000000..381bcb8 --- /dev/null +++ b/src/jalview/analytics/Plausible.java @@ -0,0 +1,547 @@ +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.util.ChannelProperties; + +public class Plausible +{ + private static final String USER_AGENT = ChannelProperties + .getProperty("app_name", "Jalview") + " " + + Cache.getDefault("VERSION", "Unknown") + " " + + MethodHandles.lookup().lookupClass() + " help@jalview.org"; + + private static final String JALVIEW_ID = "Jalview Desktop"; + + private static final String DOMAIN = "jalview.org"; + + private static final String BASE_URL = "https://plausible.io/api/event"; + + public static final String APPLICATION_BASE_URL = "desktop://localhost"; + + private List> queryStringValues; + + private List> jsonObject; + + private List events; + + private List> cookieValues; + + private static boolean ENABLED = false; + + private static Plausible instance = null; + + private static final Map defaultParams; + + static + { + defaultParams = new HashMap<>(); + defaultParams.put("app_name", + ChannelProperties.getProperty("app_name") + " Desktop"); + defaultParams.put("version", Cache.getProperty("VERSION")); + defaultParams.put("build_date", + Cache.getDefault("BUILD_DATE", "unknown")); + defaultParams.put("java_version", System.getProperty("java.version")); + String val = System.getProperty("sys.install4jVersion"); + if (val != null) + { + defaultParams.put("install4j_version", val); + } + val = System.getProperty("installer_template_version"); + if (val != null) + { + defaultParams.put("install4j_template_version", val); + } + val = System.getProperty("launcher_version"); + if (val != null) + { + defaultParams.put("launcher_version", val); + } + defaultParams.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 sendAnalytics(String eventName, String... paramsStrings) + { + sendAnalytics(eventName, false, paramsStrings); + } + + /** + * The simplest way to send an analytic event. + * + * @param eventName + * The event name. To emulate a webpage view use "page_view" and set + * a "page_location" parameter. See + * https://developers.google.com/analytics/devguides/collection/ga4/events?client_type=gtag + * @param sendDefaultParams + * Flag whether to add the default params about the application. + * @param paramsStrings + * Optional multiple Strings in key, value pairs (there should be an + * even number of paramsStrings) to be set as parameters of the + * event. To emulate a webpage view use "page_location" as the URL in + * a "page_view" event. + */ + public void sendAnalytics(String eventName, boolean sendDefaultParams, + String... paramsStrings) + { + // clear out old lists + this.resetLists(); + + if (!ENABLED) + { + Console.debug("Plausible not enabled."); + return; + } + Map params = new HashMap<>(); + + // add these to all events from this application instance + if (sendDefaultParams) + { + params.putAll(defaultParams); + } + + // add (and overwrite with) the passed in params + if (paramsStrings != null && paramsStrings.length > 0) + { + if (paramsStrings.length % 2 != 0) + { + Console.warn( + "Cannot addEvent with odd number of paramsStrings. Ignoring the last one."); + } + for (int i = 0; i < paramsStrings.length - 1; i += 2) + { + String key = paramsStrings[i]; + String value = paramsStrings[i + 1]; + params.put(key, value); + } + } + + addEvent(eventName, params); + addQueryStringValue("measurement_id", MEASUREMENT_ID); + addQueryStringValue("api_secret", API_SECRET); + addQueryStringValue("_geo", "1"); + addQueryStringValue("ep.anonymize_ip", "false"); + addJsonValue("client_id", CLIENT_ID); + addJsonValues("events", Event.toObjectList(events)); + StringBuilder urlSb = new StringBuilder(); + urlSb.append(BASE_URL); + urlSb.append('?'); + urlSb.append(buildQueryString()); + 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("GA4: HTTP Request is: '" + urlSb.toString() + "'"); + Console.debug("GA4: 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; charset=UTF-8"); + 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; " + 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 '" + BASE_URL + "' failed.", + e); + } catch (ClassCastException e) + { + Console.debug( + "Couldn't cast URLConnection to HttpURLConnection in Plausible.", + e); + } + } + + public void addEvent(String name, Map params) + { + Event event = new Event(name); + if (params != null && params.size() > 0) + { + for (String key : params.keySet()) + { + String value = params.get(key); + event.addParam(key, value); + } + } + events.add(event); + } + + 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<>(); + events = 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); + } + + private static class Event + { + private String name; + + private List> params; + + @SafeVarargs + public Event(String name, Map.Entry... paramEntries) + { + this.name = name; + this.params = new ArrayList>(); + for (Map.Entry paramEntry : paramEntries) + { + if (paramEntry == null) + { + continue; + } + params.add(paramEntry); + } + } + + public void addParam(String param, String value) + { + params.add(Plausible.stringEntry(param, value)); + } + + protected List> toObject() + { + List> object = new ArrayList<>(); + object.add(Plausible.objectEntry("name", (Object) name)); + if (params.size() > 0) + { + object.add(Plausible.objectEntry("params", (Object) params)); + } + return object; + } + + protected static List toObjectList(List events) + { + List eventObjectList = new ArrayList<>(); + for (Event event : events) + { + eventObjectList.add((Object) event.toObject()); + } + return eventObjectList; + } + } + +} \ No newline at end of file