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; } } }