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