1 package jalview.analytics;
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;
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;
21 import java.util.Random;
23 import jalview.bin.Cache;
24 import jalview.bin.Console;
25 import jalview.util.ChannelProperties;
27 public class GoogleAnalytics4
29 private static final String JALVIEW_ID = "Jalview Desktop";
31 private static final String SESSION_ID = new Random().toString();
33 private static final String MEASUREMENT_ID = "G-6TMPHMXEQ0";
35 private static final String API_SECRET = "Qb9NSbqkRDqizG6j2BBJ2g";
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";
41 // set to true to use the GA4 measurement protocol validation service and
43 // validation response.
45 // https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events?client_type=gtag
46 private static boolean DEBUG = false;
48 private static final String BASE_URL = "https://www.google-analytics.com/"
49 + (DEBUG ? "debug/" : "") + "mp/collect";
51 private static final String DESKTOP_EVENT = "desktop_event";
53 public static final String APPLICATION_BASE_URL = "https://www.jalview.org";
55 private List<Map.Entry<String, String>> queryStringValues;
57 private List<Map.Entry<String, Object>> jsonObject;
59 private List<Event> events;
61 private List<Map.Entry<String, String>> cookieValues;
63 private static boolean ENABLED = false;
65 private static GoogleAnalytics4 instance = null;
67 private static final Map<String, String> defaultParams;
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");
81 defaultParams.put("install4j_version", val);
83 val = System.getProperty("installer_template_version");
86 defaultParams.put("install4j_template_version", val);
88 val = System.getProperty("launcher_version");
91 defaultParams.put("launcher_version", val);
93 defaultParams.put("java_arch",
94 System.getProperty("os.arch") + " "
95 + System.getProperty("os.name") + " "
96 + System.getProperty("os.version"));
99 private GoogleAnalytics4()
104 public static void setEnabled(boolean b)
109 public void sendAnalytics(String eventName, String... paramsStrings)
111 sendAnalytics(eventName, false, paramsStrings);
115 * The simplest way to send an analytic event.
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.
129 public void sendAnalytics(String eventName, boolean sendDefaultParams,
130 String... paramsStrings)
132 // clear out old lists
137 Console.debug("GoogleAnalytics4 not enabled.");
140 Map<String, String> params = new HashMap<>();
141 params.put("event_category", DESKTOP_EVENT);
142 params.put("event_label", eventName);
144 // add these to all events from this application instance
145 if (sendDefaultParams)
147 params.putAll(defaultParams);
150 // add (and overwrite with) the passed in params
151 if (paramsStrings != null && paramsStrings.length > 0)
153 if (paramsStrings.length % 2 != 0)
156 "Cannot addEvent with odd number of paramsStrings. Ignoring the last one.");
158 for (int i = 0; i < paramsStrings.length - 1; i += 2)
160 String key = paramsStrings[i];
161 String value = paramsStrings[i + 1];
162 params.put(key, value);
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);
176 urlSb.append(buildQueryString());
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);
185 String jsonString = buildJson();
187 Console.debug("GA4: HTTP Request is: '" + urlSb.toString() + "'");
188 Console.debug("GA4: POSTed JSON is:\n" + jsonString);
190 byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
191 int jsonLength = jsonBytes.length;
193 httpURLConnection.setFixedLengthStreamingMode(jsonLength);
194 httpURLConnection.setRequestProperty("Content-Type",
195 "application/json; charset=UTF-8");
196 httpURLConnection.connect();
197 try (OutputStream os = httpURLConnection.getOutputStream())
201 int responseCode = httpURLConnection.getResponseCode();
202 String responseMessage = httpURLConnection.getResponseMessage();
204 if (responseCode < 200 || responseCode > 299)
206 Console.warn("GoogleAnalytics4 connection failed: '" + responseCode
207 + " " + responseMessage + "'");
211 Console.debug("GoogleAnalytics4 connection succeeded: '"
212 + responseCode + " " + responseMessage + "'");
217 BufferedReader br = new BufferedReader(new InputStreamReader(
218 (httpURLConnection.getInputStream())));
219 StringBuilder sb = new StringBuilder();
221 while ((response = br.readLine()) != null)
225 String body = sb.toString();
226 Console.debug("GoogleAnalytics4 response content; " + body);
228 } catch (MalformedURLException e)
231 "Somehow the GoogleAnalytics4 BASE_URL and queryString is malformed: '"
232 + urlSb.toString() + "'",
235 } catch (IOException e)
237 Console.debug("Connection to GoogleAnalytics4 BASE_URL '" + BASE_URL
239 } catch (ClassCastException e)
242 "Couldn't cast URLConnection to HttpURLConnection in GoogleAnalytics4.",
247 public void addEvent(String name, Map<String, String> params)
249 Event event = new Event(name);
250 if (params != null && params.size() > 0)
252 for (String key : params.keySet())
254 String value = params.get(key);
255 event.addParam(key, value);
261 private void addJsonObject(String key,
262 List<Map.Entry<String, Object>> object)
264 jsonObject.add(objectEntry(key, object));
267 private void addJsonValues(String key, List<Object> values)
269 jsonObject.add(objectEntry(key, values));
272 private void addJsonValue(String key, String value)
274 jsonObject.add(objectEntry(key, value));
277 private void addJsonValue(String key, int value)
279 jsonObject.add(objectEntry(key, Integer.valueOf(value)));
282 private void addJsonValue(String key, boolean value)
284 jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
287 private void addQueryStringValue(String key, String value)
289 queryStringValues.add(stringEntry(key, value));
292 private void addCookieValue(String key, String value)
294 cookieValues.add(stringEntry(key, value));
297 private void resetLists()
299 jsonObject = new ArrayList<>();
300 events = new ArrayList<Event>();
301 queryStringValues = new ArrayList<>();
302 cookieValues = new ArrayList<>();
305 public static GoogleAnalytics4 getInstance()
307 if (instance == null)
309 instance = new GoogleAnalytics4();
314 public static void reset()
316 getInstance().resetLists();
319 private String buildQueryString()
321 StringBuilder sb = new StringBuilder();
322 for (Map.Entry<String, String> entry : queryStringValues)
330 sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
331 } catch (UnsupportedEncodingException e)
333 sb.append(entry.getKey());
338 sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
339 } catch (UnsupportedEncodingException e)
341 sb.append(entry.getValue());
344 return sb.toString();
347 private void buildCookieHeaders()
349 // TODO not needed yet
352 private String buildJson()
354 StringBuilder sb = new StringBuilder();
355 addJsonObject(sb, 0, jsonObject);
356 return sb.toString();
359 private void addJsonObject(StringBuilder sb, int indent,
360 List<Map.Entry<String, Object>> entries)
365 Iterator<Map.Entry<String, Object>> entriesI = entries.iterator();
366 while (entriesI.hasNext())
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(':');
375 if (value != null && value instanceof List)
379 addJsonValue(sb, indent + 2, value);
380 if (entriesI.hasNext())
390 private void addJsonValue(StringBuilder sb, int indent, Object value)
398 if (value instanceof Map.Entry)
400 Map.Entry<String, Object> entry = (Map.Entry<String, Object>) value;
401 List<Map.Entry<String, Object>> object = new ArrayList<>();
403 addJsonObject(sb, indent, object);
405 else if (value instanceof List)
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)
412 // indent(sb, indent);
413 List<Map.Entry<String, Object>> entryList = (List<Map.Entry<String, Object>>) value;
414 addJsonObject(sb, indent, entryList);
422 Iterator<Object> valueListI = valueList.iterator();
423 while (valueListI.hasNext())
425 Object v = valueListI.next();
426 addJsonValue(sb, indent + 1, v);
427 if (valueListI.hasNext())
437 else if (value instanceof String)
439 sb.append('"').append(quoteEscape((String) value)).append('"');
441 else if (value instanceof Integer)
443 sb.append(((Integer) value).toString());
445 else if (value instanceof Boolean)
447 sb.append('"').append(((Boolean) value).toString()).append('"');
449 } catch (ClassCastException e)
452 "Could not deal with type of json Object " + value.toString(),
457 private static String quoteEscape(String s)
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\\\\\"");
468 private static void prettyWhitespace(StringBuilder sb, String whitespace,
471 // only add whitespace if we're in DEBUG mode
472 if (!Console.getLogger().isDebugEnabled())
476 if (repeat >= 0 && whitespace != null)
478 // sb.append(whitespace.repeat(repeat));
479 sb.append(String.join("", Collections.nCopies(repeat, whitespace)));
484 sb.append(whitespace);
488 private static void indent(StringBuilder sb, int indent)
490 prettyWhitespace(sb, " ", indent);
493 private static void newline(StringBuilder sb)
495 prettyWhitespace(sb, "\n", -1);
498 private static void space(StringBuilder sb)
500 prettyWhitespace(sb, " ", -1);
503 protected static Map.Entry<String, Object> objectEntry(String s, Object o)
505 return new AbstractMap.SimpleEntry<String, Object>(s, o);
508 protected static Map.Entry<String, String> stringEntry(String s, String v)
510 return new AbstractMap.SimpleEntry<String, String>(s, v);
518 private List<Map.Entry<String, String>> params;
521 public Event(String name, Map.Entry<String, String>... paramEntries)
524 this.params = new ArrayList<Map.Entry<String, String>>();
525 for (Map.Entry<String, String> paramEntry : paramEntries)
527 if (paramEntry == null)
531 params.add(paramEntry);
535 public void addParam(String param, String value)
537 params.add(GoogleAnalytics4.stringEntry(param, value));
540 protected List<Map.Entry<String, Object>> toObject()
542 List<Map.Entry<String, Object>> object = new ArrayList<>();
543 object.add(GoogleAnalytics4.objectEntry("name", (Object) name));
544 if (params.size() > 0)
546 object.add(GoogleAnalytics4.objectEntry("params", (Object) params));
551 protected static List<Object> toObjectList(List<Event> events)
553 List<Object> eventObjectList = new ArrayList<>();
554 for (Event event : events)
556 eventObjectList.add((Object) event.toObject());
558 return eventObjectList;