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