0d6cd651456c56d6a255ecf2ca605feb3a0d9828
[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
28 public class Plausible
29 {
30   private static final String USER_AGENT = ChannelProperties
31           .getProperty("app_name", "Jalview") + " "
32           + Cache.getDefault("VERSION", "Unknown") + " "
33           + MethodHandles.lookup().lookupClass() + " help@jalview.org";
34
35   private static final String JALVIEW_ID = "Jalview Desktop";
36
37   private static final String DOMAIN = "jalview.org";
38
39   private static final String API_BASE_URL = "https://plausible.io/api/event";
40
41   public static final String APPLICATION_BASE_URL = "desktop://localhost";
42
43   private List<Map.Entry<String, String>> queryStringValues;
44
45   private List<Map.Entry<String, Object>> jsonObject;
46
47   private List<Event> events;
48
49   private List<Map.Entry<String, String>> cookieValues;
50
51   private static boolean ENABLED = false;
52
53   private static boolean DEBUG = true;
54
55   private static Plausible instance = null;
56
57   private static final Map<String, String> defaultParams;
58
59   static
60   {
61     defaultParams = new HashMap<>();
62     defaultParams.put("app_name",
63             ChannelProperties.getProperty("app_name") + " Desktop");
64     defaultParams.put("version", Cache.getProperty("VERSION"));
65     defaultParams.put("build_date",
66             Cache.getDefault("BUILD_DATE", "unknown"));
67     defaultParams.put("java_version", System.getProperty("java.version"));
68     String val = System.getProperty("sys.install4jVersion");
69     if (val != null)
70     {
71       defaultParams.put("install4j_version", val);
72     }
73     val = System.getProperty("installer_template_version");
74     if (val != null)
75     {
76       defaultParams.put("install4j_template_version", val);
77     }
78     val = System.getProperty("launcher_version");
79     if (val != null)
80     {
81       defaultParams.put("launcher_version", val);
82     }
83     defaultParams.put("java_arch",
84             System.getProperty("os.arch") + " "
85                     + System.getProperty("os.name") + " "
86                     + System.getProperty("os.version"));
87   }
88
89   private Plausible()
90   {
91     this.resetLists();
92   }
93
94   public static void setEnabled(boolean b)
95   {
96     ENABLED = b;
97   }
98
99   public void sendEvent(String eventName, String path,
100           String... paramsStrings)
101   {
102     sendEvent(eventName, path, false, paramsStrings);
103   }
104
105   /**
106    * The simplest way to send an analytic event.
107    * 
108    * @param eventName
109    *          The event name. To emulate a webpage view use "page_view" and set
110    *          a "page_location" parameter. See
111    *          https://developers.google.com/analytics/devguides/collection/ga4/events?client_type=gtag
112    * @param sendDefaultParams
113    *          Flag whether to add the default params about the application.
114    * @param paramsStrings
115    *          Optional multiple Strings in key, value pairs (there should be an
116    *          even number of paramsStrings) to be set as parameters of the
117    *          event. To emulate a webpage view use "page_location" as the URL in
118    *          a "page_view" event.
119    */
120   public void sendEvent(String eventName, String path,
121           boolean sendDefaultParams, String... paramsStrings)
122   {
123     // clear out old lists
124     this.resetLists();
125
126     if (!ENABLED)
127     {
128       Console.debug("Plausible not enabled.");
129       return;
130     }
131     Map<String, String> params = new HashMap<>();
132
133     // add these to all events from this application instance
134     if (sendDefaultParams)
135     {
136       params.putAll(defaultParams);
137       if (Jalview.isHeadlessMode())
138       {
139         params.put("headless", "true");
140       }
141     }
142
143     // add (and overwrite with) the passed in params
144     if (paramsStrings != null && paramsStrings.length > 0)
145     {
146       if (paramsStrings.length % 2 != 0)
147       {
148         Console.warn(
149                 "Cannot addEvent with odd number of paramsStrings.  Ignoring the last one.");
150       }
151       for (int i = 0; i < paramsStrings.length - 1; i += 2)
152       {
153         String key = paramsStrings[i];
154         String value = paramsStrings[i + 1];
155         params.put(key, value);
156       }
157     }
158
159     addJsonValue("domain", DOMAIN);
160     addJsonValue("name", eventName);
161     StringBuilder recordedUrlSb = new StringBuilder(APPLICATION_BASE_URL);
162     if (!APPLICATION_BASE_URL.endsWith("/") && !path.startsWith("/"))
163     {
164       recordedUrlSb.append("/");
165     }
166     recordedUrlSb.append(path);
167     addJsonValue("url", recordedUrlSb.toString());
168     addEvent(eventName, params);
169     addJsonObject("props", params);
170     StringBuilder urlSb = new StringBuilder();
171     urlSb.append(API_BASE_URL);
172     String qs = buildQueryString();
173     if (qs != null && qs.length() > 0)
174     {
175       urlSb.append('?');
176       urlSb.append(qs);
177     }
178     try
179     {
180       URL url = new URL(urlSb.toString());
181       URLConnection urlConnection = url.openConnection();
182       HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection;
183       httpURLConnection.setRequestMethod("POST");
184       httpURLConnection.setDoOutput(true);
185
186       String jsonString = buildJson();
187
188       Console.debug(
189               "Plausible: HTTP Request is: '" + urlSb.toString() + "'");
190       Console.debug("Plausible: POSTed JSON is:\n" + jsonString);
191
192       byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
193       int jsonLength = jsonBytes.length;
194
195       httpURLConnection.setFixedLengthStreamingMode(jsonLength);
196       httpURLConnection.setRequestProperty("Content-Type",
197               "application/json");
198       httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
199       httpURLConnection.connect();
200       try (OutputStream os = httpURLConnection.getOutputStream())
201       {
202         os.write(jsonBytes);
203       }
204       int responseCode = httpURLConnection.getResponseCode();
205       String responseMessage = httpURLConnection.getResponseMessage();
206
207       if (responseCode < 200 || responseCode > 299)
208       {
209         Console.warn("Plausible connection failed: '" + responseCode + " "
210                 + responseMessage + "'");
211       }
212       else
213       {
214         Console.debug("Plausible connection succeeded: '" + responseCode
215                 + " " + responseMessage + "'");
216       }
217
218       if (DEBUG)
219       {
220         BufferedReader br = new BufferedReader(new InputStreamReader(
221                 (httpURLConnection.getInputStream())));
222         StringBuilder sb = new StringBuilder();
223         String response;
224         while ((response = br.readLine()) != null)
225         {
226           sb.append(response);
227         }
228         String body = sb.toString();
229         Console.debug("Plausible response content; " + body);
230       }
231     } catch (MalformedURLException e)
232     {
233       Console.debug(
234               "Somehow the Plausible BASE_URL and queryString is malformed: '"
235                       + urlSb.toString() + "'",
236               e);
237       return;
238     } catch (IOException e)
239     {
240       Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
241               + "' failed.", e);
242     } catch (ClassCastException e)
243     {
244       Console.debug(
245               "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
246               e);
247     }
248   }
249
250   public void addEvent(String name, Map<String, String> params)
251   {
252     Event event = new Event(name);
253     if (params != null && params.size() > 0)
254     {
255       for (String key : params.keySet())
256       {
257         String value = params.get(key);
258         event.addParam(key, value);
259       }
260     }
261     events.add(event);
262   }
263
264   private void addJsonObject(String key, Map<String, String> map)
265   {
266     List<Map.Entry<String, ? extends Object>> list = new ArrayList<>();
267     for (String k : map.keySet())
268     {
269       list.add(stringEntry(k, map.get(k)));
270     }
271     addJsonObject(key, list);
272
273   }
274
275   private void addJsonObject(String key,
276           List<Map.Entry<String, ? extends Object>> object)
277   {
278     jsonObject.add(objectEntry(key, object));
279   }
280
281   private void addJsonValues(String key, List<Object> values)
282   {
283     jsonObject.add(objectEntry(key, values));
284   }
285
286   private void addJsonValue(String key, String value)
287   {
288     jsonObject.add(objectEntry(key, value));
289   }
290
291   private void addJsonValue(String key, int value)
292   {
293     jsonObject.add(objectEntry(key, Integer.valueOf(value)));
294   }
295
296   private void addJsonValue(String key, boolean value)
297   {
298     jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
299   }
300
301   private void addQueryStringValue(String key, String value)
302   {
303     queryStringValues.add(stringEntry(key, value));
304   }
305
306   private void addCookieValue(String key, String value)
307   {
308     cookieValues.add(stringEntry(key, value));
309   }
310
311   private void resetLists()
312   {
313     jsonObject = new ArrayList<>();
314     events = new ArrayList<Event>();
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 (!Console.getLogger().isDebugEnabled())
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 }