JAL-4001 Release_2_11_2 branch with Plausible analytics
[jalview.git] / src / jalview / analytics / Plausible.java
1 package jalview.analytics;
2
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;
11 import java.net.URL;
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;
21 import java.util.Map;
22
23 import jalview.bin.Cache;
24 import jalview.bin.Console;
25 import jalview.bin.Jalview;
26 import jalview.util.ChannelProperties;
27 import jalview.util.HttpUtils;
28
29 public class Plausible
30 {
31   private static final String USER_AGENT = HttpUtils.getUserAgent(
32           MethodHandles.lookup().lookupClass().getCanonicalName());
33
34   private static final String JALVIEW_ID = "Jalview Desktop";
35
36   private static final String DOMAIN = "jalview.org";
37
38   private static final String API_BASE_URL = "https://plausible.io/api/event";
39
40   public static final String APPLICATION_BASE_URL = "desktop://localhost";
41
42   private List<Map.Entry<String, String>> queryStringValues;
43
44   private List<Map.Entry<String, Object>> jsonObject;
45
46   private List<Map.Entry<String, String>> cookieValues;
47
48   private static boolean ENABLED = false;
49
50   private static boolean DEBUG = true;
51
52   private static Plausible instance = null;
53
54   private static final Map<String, String> defaultProps;
55
56   static
57   {
58     defaultProps = new HashMap<>();
59     defaultProps.put("app_name",
60             ChannelProperties.getProperty("app_name") + " Desktop");
61     defaultProps.put("version", Cache.getProperty("VERSION"));
62     defaultProps.put("build_date",
63             Cache.getDefault("BUILD_DATE", "unknown"));
64     defaultProps.put("java_version", System.getProperty("java.version"));
65     String val = System.getProperty("sys.install4jVersion");
66     if (val != null)
67     {
68       defaultProps.put("install4j_version", val);
69     }
70     val = System.getProperty("installer_template_version");
71     if (val != null)
72     {
73       defaultProps.put("install4j_template_version", val);
74     }
75     val = System.getProperty("launcher_version");
76     if (val != null)
77     {
78       defaultProps.put("launcher_version", val);
79     }
80     defaultProps.put("java_arch",
81             System.getProperty("os.arch") + " "
82                     + System.getProperty("os.name") + " "
83                     + System.getProperty("os.version"));
84   }
85
86   private Plausible()
87   {
88     this.resetLists();
89   }
90
91   public static void setEnabled(boolean b)
92   {
93     ENABLED = b;
94   }
95
96   public void sendEvent(String eventName, String path,
97           String... propsStrings)
98   {
99     sendEvent(eventName, path, false, propsStrings);
100   }
101
102   /**
103    * The simplest way to send an analytic event.
104    * 
105    * @param eventName
106    *          The event name. To emulate a webpage view use "pageview" and set a
107    *          "url" key/value. See https://plausible.io/docs/events-api
108    * @param sendDefaultProps
109    *          Flag whether to add the default props about the application.
110    * @param propsStrings
111    *          Optional multiple Strings in key, value pairs (there should be an
112    *          even number of propsStrings) to be set as properties of the event.
113    *          To emulate a webpage view use "url" as the URL in a "pageview"
114    *          event.
115    */
116   public void sendEvent(String eventName, String path,
117           boolean sendDefaultProps, String... propsStrings)
118   {
119     // clear out old lists
120     this.resetLists();
121
122     if (!ENABLED)
123     {
124       Console.debug("Plausible not enabled.");
125       return;
126     }
127     Map<String, String> props = new HashMap<>();
128
129     // add these to all events from this application instance
130     if (sendDefaultProps)
131     {
132       props.putAll(defaultProps);
133       if (Jalview.isHeadlessMode())
134       {
135         props.put("headless", "true");
136       }
137     }
138
139     // add (and overwrite with) the passed in props
140     if (propsStrings != null && propsStrings.length > 0)
141     {
142       if (propsStrings.length % 2 != 0)
143       {
144         Console.warn(
145                 "Cannot addEvent with odd number of propsStrings.  Ignoring the last one.");
146       }
147       for (int i = 0; i < propsStrings.length - 1; i += 2)
148       {
149         String key = propsStrings[i];
150         String value = propsStrings[i + 1];
151         props.put(key, value);
152       }
153     }
154
155     addJsonValue("domain", DOMAIN);
156     addJsonValue("name", eventName);
157     StringBuilder recordedUrlSb = new StringBuilder(APPLICATION_BASE_URL);
158     if (!APPLICATION_BASE_URL.endsWith("/") && !path.startsWith("/"))
159     {
160       recordedUrlSb.append("/");
161     }
162     recordedUrlSb.append(path);
163     addJsonValue("url", recordedUrlSb.toString());
164     addJsonObject("props", props);
165     StringBuilder urlSb = new StringBuilder();
166     urlSb.append(API_BASE_URL);
167     String qs = buildQueryString();
168     if (qs != null && qs.length() > 0)
169     {
170       urlSb.append('?');
171       urlSb.append(qs);
172     }
173     try
174     {
175       URL url = new URL(urlSb.toString());
176       URLConnection urlConnection = url.openConnection();
177       HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection;
178       httpURLConnection.setRequestMethod("POST");
179       httpURLConnection.setDoOutput(true);
180
181       String jsonString = buildJson();
182
183       Console.debug(
184               "Plausible: HTTP Request is: '" + urlSb.toString() + "'");
185       if (DEBUG)
186       {
187         Console.debug("Plausible: User-Agent is: '" + USER_AGENT + "'");
188       }
189       Console.debug("Plausible: POSTed JSON is:\n" + jsonString);
190
191       byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
192       int jsonLength = jsonBytes.length;
193
194       httpURLConnection.setFixedLengthStreamingMode(jsonLength);
195       httpURLConnection.setRequestProperty("Content-Type",
196               "application/json");
197       httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
198       httpURLConnection.connect();
199       try (OutputStream os = httpURLConnection.getOutputStream())
200       {
201         os.write(jsonBytes);
202       }
203       int responseCode = httpURLConnection.getResponseCode();
204       String responseMessage = httpURLConnection.getResponseMessage();
205
206       if (responseCode < 200 || responseCode > 299)
207       {
208         Console.warn("Plausible connection failed: '" + responseCode + " "
209                 + responseMessage + "'");
210       }
211       else
212       {
213         Console.debug("Plausible connection succeeded: '" + responseCode
214                 + " " + responseMessage + "'");
215       }
216
217       if (DEBUG)
218       {
219         BufferedReader br = new BufferedReader(new InputStreamReader(
220                 (httpURLConnection.getInputStream())));
221         StringBuilder sb = new StringBuilder();
222         String response;
223         while ((response = br.readLine()) != null)
224         {
225           sb.append(response);
226         }
227         String body = sb.toString();
228         Console.debug("Plausible response content:\n" + body);
229       }
230     } catch (MalformedURLException e)
231     {
232       Console.debug(
233               "Somehow the Plausible BASE_URL and queryString is malformed: '"
234                       + urlSb.toString() + "'",
235               e);
236       return;
237     } catch (IOException e)
238     {
239       Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
240               + "' failed.", e);
241     } catch (ClassCastException e)
242     {
243       Console.debug(
244               "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
245               e);
246     }
247   }
248
249   private void addJsonObject(String key, Map<String, String> map)
250   {
251     List<Map.Entry<String, ? extends Object>> list = new ArrayList<>();
252     for (String k : map.keySet())
253     {
254       list.add(stringEntry(k, map.get(k)));
255     }
256     addJsonObject(key, list);
257
258   }
259
260   private void addJsonObject(String key,
261           List<Map.Entry<String, ? extends Object>> object)
262   {
263     jsonObject.add(objectEntry(key, object));
264   }
265
266   private void addJsonValues(String key, List<Object> values)
267   {
268     jsonObject.add(objectEntry(key, values));
269   }
270
271   private void addJsonValue(String key, String value)
272   {
273     jsonObject.add(objectEntry(key, value));
274   }
275
276   private void addJsonValue(String key, int value)
277   {
278     jsonObject.add(objectEntry(key, Integer.valueOf(value)));
279   }
280
281   private void addJsonValue(String key, boolean value)
282   {
283     jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
284   }
285
286   private void addQueryStringValue(String key, String value)
287   {
288     queryStringValues.add(stringEntry(key, value));
289   }
290
291   private void addCookieValue(String key, String value)
292   {
293     cookieValues.add(stringEntry(key, value));
294   }
295
296   private void resetLists()
297   {
298     jsonObject = new ArrayList<>();
299     queryStringValues = new ArrayList<>();
300     cookieValues = new ArrayList<>();
301   }
302
303   public static Plausible getInstance()
304   {
305     if (instance == null)
306     {
307       instance = new Plausible();
308     }
309     return instance;
310   }
311
312   public static void reset()
313   {
314     getInstance().resetLists();
315   }
316
317   private String buildQueryString()
318   {
319     StringBuilder sb = new StringBuilder();
320     for (Map.Entry<String, String> entry : queryStringValues)
321     {
322       if (sb.length() > 0)
323       {
324         sb.append('&');
325       }
326       try
327       {
328         sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
329       } catch (UnsupportedEncodingException e)
330       {
331         sb.append(entry.getKey());
332       }
333       sb.append('=');
334       try
335       {
336         sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
337       } catch (UnsupportedEncodingException e)
338       {
339         sb.append(entry.getValue());
340       }
341     }
342     return sb.toString();
343   }
344
345   private void buildCookieHeaders()
346   {
347     // TODO not needed yet
348   }
349
350   private String buildJson()
351   {
352     StringBuilder sb = new StringBuilder();
353     addJsonObject(sb, 0, jsonObject);
354     return sb.toString();
355   }
356
357   private void addJsonObject(StringBuilder sb, int indent,
358           List<Map.Entry<String, Object>> entries)
359   {
360     indent(sb, indent);
361     sb.append('{');
362     newline(sb);
363     Iterator<Map.Entry<String, Object>> entriesI = entries.iterator();
364     while (entriesI.hasNext())
365     {
366       Map.Entry<String, Object> entry = entriesI.next();
367       String key = entry.getKey();
368       // TODO sensibly escape " characters in key
369       Object value = entry.getValue();
370       indent(sb, indent + 1);
371       sb.append('"').append(quoteEscape(key)).append('"').append(':');
372       space(sb);
373       if (value != null && value instanceof List)
374       {
375         newline(sb);
376       }
377       addJsonValue(sb, indent + 2, value);
378       if (entriesI.hasNext())
379       {
380         sb.append(',');
381       }
382       newline(sb);
383     }
384     indent(sb, indent);
385     sb.append('}');
386   }
387
388   private void addJsonValue(StringBuilder sb, int indent, Object value)
389   {
390     if (value == null)
391     {
392       return;
393     }
394     try
395     {
396       if (value instanceof Map.Entry)
397       {
398         Map.Entry<String, Object> entry = (Map.Entry<String, Object>) value;
399         List<Map.Entry<String, Object>> object = new ArrayList<>();
400         object.add(entry);
401         addJsonObject(sb, indent, object);
402       }
403       else if (value instanceof List)
404       {
405         // list of Map.Entries or list of values?
406         List<Object> valueList = (List<Object>) value;
407         if (valueList.size() > 0 && valueList.get(0) instanceof Map.Entry)
408         {
409           // entries
410           // indent(sb, indent);
411           List<Map.Entry<String, Object>> entryList = (List<Map.Entry<String, Object>>) value;
412           addJsonObject(sb, indent, entryList);
413         }
414         else
415         {
416           // values
417           indent(sb, indent);
418           sb.append('[');
419           newline(sb);
420           Iterator<Object> valueListI = valueList.iterator();
421           while (valueListI.hasNext())
422           {
423             Object v = valueListI.next();
424             addJsonValue(sb, indent + 1, v);
425             if (valueListI.hasNext())
426             {
427               sb.append(',');
428             }
429             newline(sb);
430           }
431           indent(sb, indent);
432           sb.append("]");
433         }
434       }
435       else if (value instanceof String)
436       {
437         sb.append('"').append(quoteEscape((String) value)).append('"');
438       }
439       else if (value instanceof Integer)
440       {
441         sb.append(((Integer) value).toString());
442       }
443       else if (value instanceof Boolean)
444       {
445         sb.append('"').append(((Boolean) value).toString()).append('"');
446       }
447     } catch (ClassCastException e)
448     {
449       Console.debug(
450               "Could not deal with type of json Object " + value.toString(),
451               e);
452     }
453   }
454
455   private static String quoteEscape(String s)
456   {
457     if (s == null)
458     {
459       return null;
460     }
461     // this escapes quotation marks (") that aren't already escaped (in the
462     // string) ready to go into a quoted JSON string value
463     return s.replaceAll("((?<!\\\\)(?:\\\\{2})*)\"", "$1\\\\\"");
464   }
465
466   private static void prettyWhitespace(StringBuilder sb, String whitespace,
467           int repeat)
468   {
469     // only add whitespace if we're in DEBUG mode
470     if (!DEBUG)
471     {
472       return;
473     }
474     if (repeat >= 0 && whitespace != null)
475     {
476       // sb.append(whitespace.repeat(repeat));
477       sb.append(String.join("", Collections.nCopies(repeat, whitespace)));
478
479     }
480     else
481     {
482       sb.append(whitespace);
483     }
484   }
485
486   private static void indent(StringBuilder sb, int indent)
487   {
488     prettyWhitespace(sb, "  ", indent);
489   }
490
491   private static void newline(StringBuilder sb)
492   {
493     prettyWhitespace(sb, "\n", -1);
494   }
495
496   private static void space(StringBuilder sb)
497   {
498     prettyWhitespace(sb, " ", -1);
499   }
500
501   protected static Map.Entry<String, Object> objectEntry(String s, Object o)
502   {
503     return new AbstractMap.SimpleEntry<String, Object>(s, o);
504   }
505
506   protected static Map.Entry<String, String> stringEntry(String s, String v)
507   {
508     return new AbstractMap.SimpleEntry<String, String>(s, v);
509   }
510 }