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