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