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