1 package jalview.analytics;
3 import java.io.BufferedReader;
4 import java.io.IOException;
5 import java.io.InputStreamReader;
6 import java.io.OutputStream;
7 import java.io.UnsupportedEncodingException;
8 import java.lang.invoke.MethodHandles;
9 import java.net.HttpURLConnection;
10 import java.net.MalformedURLException;
12 import java.net.URLConnection;
13 import java.net.URLEncoder;
14 import java.nio.charset.StandardCharsets;
15 import java.util.AbstractMap;
16 import java.util.ArrayList;
17 import java.util.Collections;
18 import java.util.HashMap;
19 import java.util.Iterator;
20 import java.util.List;
23 import jalview.bin.Cache;
24 import jalview.bin.Console;
25 import jalview.util.ChannelProperties;
27 public class Plausible
29 private static final String USER_AGENT = ChannelProperties
30 .getProperty("app_name", "Jalview") + " "
31 + Cache.getDefault("VERSION", "Unknown") + " "
32 + MethodHandles.lookup().lookupClass() + " help@jalview.org";
34 private static final String JALVIEW_ID = "Jalview Desktop";
36 private static final String DOMAIN = "jalview.org";
38 private static final String BASE_URL = "https://plausible.io/api/event";
40 public static final String APPLICATION_BASE_URL = "desktop://localhost";
42 private List<Map.Entry<String, String>> queryStringValues;
44 private List<Map.Entry<String, Object>> jsonObject;
46 private List<Event> events;
48 private List<Map.Entry<String, String>> cookieValues;
50 private static boolean ENABLED = false;
52 private static Plausible instance = null;
54 private static final Map<String, String> defaultParams;
58 defaultParams = new HashMap<>();
59 defaultParams.put("app_name",
60 ChannelProperties.getProperty("app_name") + " Desktop");
61 defaultParams.put("version", Cache.getProperty("VERSION"));
62 defaultParams.put("build_date",
63 Cache.getDefault("BUILD_DATE", "unknown"));
64 defaultParams.put("java_version", System.getProperty("java.version"));
65 String val = System.getProperty("sys.install4jVersion");
68 defaultParams.put("install4j_version", val);
70 val = System.getProperty("installer_template_version");
73 defaultParams.put("install4j_template_version", val);
75 val = System.getProperty("launcher_version");
78 defaultParams.put("launcher_version", val);
80 defaultParams.put("java_arch",
81 System.getProperty("os.arch") + " "
82 + System.getProperty("os.name") + " "
83 + System.getProperty("os.version"));
91 public static void setEnabled(boolean b)
96 public void sendAnalytics(String eventName, String... paramsStrings)
98 sendAnalytics(eventName, false, paramsStrings);
102 * The simplest way to send an analytic event.
105 * The event name. To emulate a webpage view use "page_view" and set
106 * a "page_location" parameter. See
107 * https://developers.google.com/analytics/devguides/collection/ga4/events?client_type=gtag
108 * @param sendDefaultParams
109 * Flag whether to add the default params about the application.
110 * @param paramsStrings
111 * Optional multiple Strings in key, value pairs (there should be an
112 * even number of paramsStrings) to be set as parameters of the
113 * event. To emulate a webpage view use "page_location" as the URL in
114 * a "page_view" event.
116 public void sendAnalytics(String eventName, boolean sendDefaultParams,
117 String... paramsStrings)
119 // clear out old lists
124 Console.debug("Plausible not enabled.");
127 Map<String, String> params = new HashMap<>();
129 // add these to all events from this application instance
130 if (sendDefaultParams)
132 params.putAll(defaultParams);
135 // add (and overwrite with) the passed in params
136 if (paramsStrings != null && paramsStrings.length > 0)
138 if (paramsStrings.length % 2 != 0)
141 "Cannot addEvent with odd number of paramsStrings. Ignoring the last one.");
143 for (int i = 0; i < paramsStrings.length - 1; i += 2)
145 String key = paramsStrings[i];
146 String value = paramsStrings[i + 1];
147 params.put(key, value);
151 addEvent(eventName, params);
152 addQueryStringValue("measurement_id", MEASUREMENT_ID);
153 addQueryStringValue("api_secret", API_SECRET);
154 addQueryStringValue("_geo", "1");
155 addQueryStringValue("ep.anonymize_ip", "false");
156 addJsonValue("client_id", CLIENT_ID);
157 addJsonValues("events", Event.toObjectList(events));
158 StringBuilder urlSb = new StringBuilder();
159 urlSb.append(BASE_URL);
161 urlSb.append(buildQueryString());
164 URL url = new URL(urlSb.toString());
165 URLConnection urlConnection = url.openConnection();
166 HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection;
167 httpURLConnection.setRequestMethod("POST");
168 httpURLConnection.setDoOutput(true);
170 String jsonString = buildJson();
172 Console.debug("GA4: HTTP Request is: '" + urlSb.toString() + "'");
173 Console.debug("GA4: POSTed JSON is:\n" + jsonString);
175 byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
176 int jsonLength = jsonBytes.length;
178 httpURLConnection.setFixedLengthStreamingMode(jsonLength);
179 httpURLConnection.setRequestProperty("Content-Type",
180 "application/json; charset=UTF-8");
181 httpURLConnection.connect();
182 try (OutputStream os = httpURLConnection.getOutputStream())
186 int responseCode = httpURLConnection.getResponseCode();
187 String responseMessage = httpURLConnection.getResponseMessage();
189 if (responseCode < 200 || responseCode > 299)
191 Console.warn("Plausible connection failed: '" + responseCode + " "
192 + responseMessage + "'");
196 Console.debug("Plausible connection succeeded: '" + responseCode
197 + " " + responseMessage + "'");
202 BufferedReader br = new BufferedReader(new InputStreamReader(
203 (httpURLConnection.getInputStream())));
204 StringBuilder sb = new StringBuilder();
206 while ((response = br.readLine()) != null)
210 String body = sb.toString();
211 Console.debug("Plausible response content; " + body);
213 } catch (MalformedURLException e)
216 "Somehow the Plausible BASE_URL and queryString is malformed: '"
217 + urlSb.toString() + "'",
220 } catch (IOException e)
223 "Connection to Plausible BASE_URL '" + BASE_URL + "' failed.",
225 } catch (ClassCastException e)
228 "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
233 public void addEvent(String name, Map<String, String> params)
235 Event event = new Event(name);
236 if (params != null && params.size() > 0)
238 for (String key : params.keySet())
240 String value = params.get(key);
241 event.addParam(key, value);
247 private void addJsonObject(String key,
248 List<Map.Entry<String, Object>> object)
250 jsonObject.add(objectEntry(key, object));
253 private void addJsonValues(String key, List<Object> values)
255 jsonObject.add(objectEntry(key, values));
258 private void addJsonValue(String key, String value)
260 jsonObject.add(objectEntry(key, value));
263 private void addJsonValue(String key, int value)
265 jsonObject.add(objectEntry(key, Integer.valueOf(value)));
268 private void addJsonValue(String key, boolean value)
270 jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
273 private void addQueryStringValue(String key, String value)
275 queryStringValues.add(stringEntry(key, value));
278 private void addCookieValue(String key, String value)
280 cookieValues.add(stringEntry(key, value));
283 private void resetLists()
285 jsonObject = new ArrayList<>();
286 events = new ArrayList<Event>();
287 queryStringValues = new ArrayList<>();
288 cookieValues = new ArrayList<>();
291 public static Plausible getInstance()
293 if (instance == null)
295 instance = new Plausible();
300 public static void reset()
302 getInstance().resetLists();
305 private String buildQueryString()
307 StringBuilder sb = new StringBuilder();
308 for (Map.Entry<String, String> entry : queryStringValues)
316 sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
317 } catch (UnsupportedEncodingException e)
319 sb.append(entry.getKey());
324 sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
325 } catch (UnsupportedEncodingException e)
327 sb.append(entry.getValue());
330 return sb.toString();
333 private void buildCookieHeaders()
335 // TODO not needed yet
338 private String buildJson()
340 StringBuilder sb = new StringBuilder();
341 addJsonObject(sb, 0, jsonObject);
342 return sb.toString();
345 private void addJsonObject(StringBuilder sb, int indent,
346 List<Map.Entry<String, Object>> entries)
351 Iterator<Map.Entry<String, Object>> entriesI = entries.iterator();
352 while (entriesI.hasNext())
354 Map.Entry<String, Object> entry = entriesI.next();
355 String key = entry.getKey();
356 // TODO sensibly escape " characters in key
357 Object value = entry.getValue();
358 indent(sb, indent + 1);
359 sb.append('"').append(quoteEscape(key)).append('"').append(':');
361 if (value != null && value instanceof List)
365 addJsonValue(sb, indent + 2, value);
366 if (entriesI.hasNext())
376 private void addJsonValue(StringBuilder sb, int indent, Object value)
384 if (value instanceof Map.Entry)
386 Map.Entry<String, Object> entry = (Map.Entry<String, Object>) value;
387 List<Map.Entry<String, Object>> object = new ArrayList<>();
389 addJsonObject(sb, indent, object);
391 else if (value instanceof List)
393 // list of Map.Entries or list of values?
394 List<Object> valueList = (List<Object>) value;
395 if (valueList.size() > 0 && valueList.get(0) instanceof Map.Entry)
398 // indent(sb, indent);
399 List<Map.Entry<String, Object>> entryList = (List<Map.Entry<String, Object>>) value;
400 addJsonObject(sb, indent, entryList);
408 Iterator<Object> valueListI = valueList.iterator();
409 while (valueListI.hasNext())
411 Object v = valueListI.next();
412 addJsonValue(sb, indent + 1, v);
413 if (valueListI.hasNext())
423 else if (value instanceof String)
425 sb.append('"').append(quoteEscape((String) value)).append('"');
427 else if (value instanceof Integer)
429 sb.append(((Integer) value).toString());
431 else if (value instanceof Boolean)
433 sb.append('"').append(((Boolean) value).toString()).append('"');
435 } catch (ClassCastException e)
438 "Could not deal with type of json Object " + value.toString(),
443 private static String quoteEscape(String s)
449 // this escapes quotation marks (") that aren't already escaped (in the
450 // string) ready to go into a quoted JSON string value
451 return s.replaceAll("((?<!\\\\)(?:\\\\{2})*)\"", "$1\\\\\"");
454 private static void prettyWhitespace(StringBuilder sb, String whitespace,
457 // only add whitespace if we're in DEBUG mode
458 if (!Console.getLogger().isDebugEnabled())
462 if (repeat >= 0 && whitespace != null)
464 // sb.append(whitespace.repeat(repeat));
465 sb.append(String.join("", Collections.nCopies(repeat, whitespace)));
470 sb.append(whitespace);
474 private static void indent(StringBuilder sb, int indent)
476 prettyWhitespace(sb, " ", indent);
479 private static void newline(StringBuilder sb)
481 prettyWhitespace(sb, "\n", -1);
484 private static void space(StringBuilder sb)
486 prettyWhitespace(sb, " ", -1);
489 protected static Map.Entry<String, Object> objectEntry(String s, Object o)
491 return new AbstractMap.SimpleEntry<String, Object>(s, o);
494 protected static Map.Entry<String, String> stringEntry(String s, String v)
496 return new AbstractMap.SimpleEntry<String, String>(s, v);
499 private static class Event
503 private List<Map.Entry<String, String>> params;
506 public Event(String name, Map.Entry<String, String>... paramEntries)
509 this.params = new ArrayList<Map.Entry<String, String>>();
510 for (Map.Entry<String, String> paramEntry : paramEntries)
512 if (paramEntry == null)
516 params.add(paramEntry);
520 public void addParam(String param, String value)
522 params.add(Plausible.stringEntry(param, value));
525 protected List<Map.Entry<String, Object>> toObject()
527 List<Map.Entry<String, Object>> object = new ArrayList<>();
528 object.add(Plausible.objectEntry("name", (Object) name));
529 if (params.size() > 0)
531 object.add(Plausible.objectEntry("params", (Object) params));
536 protected static List<Object> toObjectList(List<Event> events)
538 List<Object> eventObjectList = new ArrayList<>();
539 for (Event event : events)
541 eventObjectList.add((Object) event.toObject());
543 return eventObjectList;