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