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