Clover icon

Coverage Report

  1. Project Clover database Thu Dec 4 2025 16:11:35 GMT
  2. Package jalview.analytics

File Plausible.java

 

Coverage histogram

../../img/srcFileCovDistChart7.png
30% of files have more coverage

Code metrics

72
215
31
1
621
504
87
0.4
6.94
31
2.81

Classes

Class Line # Actions
Plausible 49 215 87
0.66352266.4%
 

Contributing tests

No tests hitting this source file were found.

Source view

1    /*
2    * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3    * Copyright (C) $$Year-Rel$$ The Jalview Authors
4    *
5    * This file is part of Jalview.
6    *
7    * Jalview is free software: you can redistribute it and/or
8    * modify it under the terms of the GNU General Public License
9    * as published by the Free Software Foundation, either version 3
10    * of the License, or (at your option) any later version.
11    *
12    * Jalview is distributed in the hope that it will be useful, but
13    * WITHOUT ANY WARRANTY; without even the implied warranty
14    * of MERCHANTABILITY or FITNESS FOR A PARTICULAR
15    * PURPOSE. See the GNU General Public License for more details.
16    *
17    * You should have received a copy of the GNU General Public License
18    * along with Jalview. If not, see <http://www.gnu.org/licenses/>.
19    * The Jalview Authors are detailed in the 'AUTHORS' file.
20    */
21    package jalview.analytics;
22   
23    import java.io.BufferedReader;
24    import java.io.IOException;
25    import java.io.InputStreamReader;
26    import java.io.OutputStream;
27    import java.io.UnsupportedEncodingException;
28    import java.lang.invoke.MethodHandles;
29    import java.net.HttpURLConnection;
30    import java.net.MalformedURLException;
31    import java.net.URL;
32    import java.net.URLEncoder;
33    import java.nio.charset.StandardCharsets;
34    import java.util.AbstractMap;
35    import java.util.ArrayList;
36    import java.util.Collections;
37    import java.util.HashMap;
38    import java.util.Iterator;
39    import java.util.List;
40    import java.util.Map;
41    import java.util.Random;
42   
43    import jalview.bin.Cache;
44    import jalview.bin.Console;
45    import jalview.util.ChannelProperties;
46    import jalview.util.HttpUtils;
47    import jalview.util.UserAgent;
48   
 
49    public class Plausible
50    {
51    private static final String USER_AGENT;
52   
53    private static final String JALVIEW_ID = "Jalview Desktop";
54   
55    private static final String DOMAIN = "jalview.org";
56   
57    private static final String CONFIG_API_BASE_URL = "https://www.jalview.org/services/config/analytics/url";
58   
59    private static final String DEFAULT_API_BASE_URL = "https://analytics.jalview.org/api/event";
60   
61    private static final String API_BASE_URL;
62   
63    private static final String clientId;
64   
65    public static final String APPLICATION_BASE_URL = "desktop://localhost";
66   
67    private List<Map.Entry<String, String>> queryStringValues;
68   
69    private List<Map.Entry<String, Object>> jsonObject;
70   
71    private List<Map.Entry<String, String>> cookieValues;
72   
73    private static boolean ENABLED = false;
74   
75    private static boolean DEBUG = true;
76   
77    private static Plausible instance = null;
78   
79    private static final Map<String, String> defaultProps;
80   
 
81  1 toggle static
82    {
83  1 defaultProps = new HashMap<>();
84  1 defaultProps.put("app_name",
85    ChannelProperties.getProperty("app_name") + " Desktop");
86  1 defaultProps.put("version", Cache.getProperty("VERSION"));
87  1 defaultProps.put("build_date",
88    Cache.getDefault("BUILD_DATE", "unknown"));
89  1 defaultProps.put("java_version", System.getProperty("java.version"));
90  1 String val = System.getProperty("sys.install4jVersion");
91  1 if (val != null)
92    {
93  0 defaultProps.put("install4j_version", val);
94    }
95  1 val = System.getProperty("installer.template_version");
96  1 if (val != null)
97    {
98  0 defaultProps.put("install4j_template_version", val);
99    }
100  1 val = System.getProperty("launcher.version");
101  1 if (val != null)
102    {
103  0 defaultProps.put("launcher.version", val);
104    }
105  1 defaultProps.put("java_arch",
106    System.getProperty("os.arch") + " "
107    + System.getProperty("os.name") + " "
108    + System.getProperty("os.version"));
109  1 defaultProps.put("os", System.getProperty("os.name"));
110  1 defaultProps.put("os_version", System.getProperty("os.version"));
111  1 defaultProps.put("os_arch", System.getProperty("os.arch"));
112  1 String installation = Cache
113    .getProperty("INSTALLATION");
114  1 if (installation != null)
115    {
116  1 defaultProps.put("installation", installation);
117    }
118   
119    // ascertain the API_BASE_URL
120  1 API_BASE_URL = getAPIBaseURL();
121   
122    // random clientId to make User-Agent unique (to register analytic)
123  1 clientId = String.format("%08x", new Random().nextInt());
124   
125  1 USER_AGENT = UserAgent.getUserAgent(
126    MethodHandles.lookup().lookupClass().getCanonicalName() + " "
127    + clientId);
128    }
129   
 
130  1 toggle private Plausible()
131    {
132  1 this.resetLists();
133    }
134   
 
135  1 toggle public static void setEnabled(boolean b)
136    {
137  1 ENABLED = b;
138    }
139   
 
140  0 toggle public void sendEvent(String eventName, String urlString,
141    String... propsStrings)
142    {
143  0 sendEvent(eventName, urlString, false, propsStrings);
144    }
145   
 
146  0 toggle public void sendEvent(String eventName, String urlString,
147    boolean sendDefaultProps, String... propsStrings)
148    {
149  0 sendEvent(eventName, urlString, sendDefaultProps, propsStrings);
150    }
151   
152    /**
153    * The simplest way to send an analytic event.
154    *
155    * @param eventName
156    * The event name. To emulate a webpage view use "pageview" and set a
157    * "url" key/value. See https://plausible.io/docs/events-api
158    * @param sendDefaultProps
159    * Flag whether to add the default props about the application.
160    * @param appendQueryString
161    * Flag whether to append the queryString for log recording.
162    * @param propsStrings
163    * Optional multiple Strings in key, value pairs (there should be an
164    * even number of propsStrings) to be set as property of the event.
165    * To emulate a webpage view set "url" as the URL in a "pageview"
166    * event.
167    */
 
168  1 toggle public void sendEvent(String eventName, String urlString,
169    boolean sendDefaultProps, boolean appendQueryString,
170    String... propsStrings)
171    {
172    // clear out old lists
173  1 this.resetLists();
174   
175  1 if (!ENABLED)
176    {
177  0 Console.debug("Plausible not enabled.");
178  0 return;
179    }
180  1 Map<String, String> props = new HashMap<>();
181   
182    // add these to all events from this application instance
183  1 if (sendDefaultProps)
184    {
185  1 props.putAll(defaultProps);
186    }
187   
188    // add (and overwrite with) the passed in props
189  1 if (propsStrings != null && propsStrings.length > 0)
190    {
191  0 if (propsStrings.length % 2 != 0)
192    {
193  0 Console.warn(
194    "Cannot addEvent with odd number of propsStrings. Ignoring the last one.");
195    }
196  0 for (int i = 0; i < propsStrings.length - 1; i += 2)
197    {
198  0 String key = propsStrings[i];
199  0 String value = propsStrings[i + 1];
200  0 props.put(key, value);
201    }
202    }
203   
204  1 addJsonValue("domain", DOMAIN, appendQueryString);
205  1 addJsonValue("name", eventName, appendQueryString);
206  1 StringBuilder eventUrlSb = new StringBuilder(APPLICATION_BASE_URL);
207  1 if (!APPLICATION_BASE_URL.endsWith("/") && !urlString.startsWith("/"))
208    {
209  0 eventUrlSb.append("/");
210    }
211  1 eventUrlSb.append(urlString);
212  1 addJsonValue("url", eventUrlSb.toString(), appendQueryString);
213  1 addJsonObject("props", props);
214  1 StringBuilder urlSb = new StringBuilder();
215  1 urlSb.append(API_BASE_URL);
216  1 String qs = buildQueryString();
217  1 if (qs != null && qs.length() > 0)
218    {
219  1 urlSb.append('?');
220  1 urlSb.append(qs);
221    }
222  1 try
223    {
224  1 URL url = new URL(urlSb.toString());
225  1 HttpURLConnection httpURLConnection = (HttpURLConnection) HttpUtils
226    .openConnection(url);
227  1 httpURLConnection.setRequestMethod("POST");
228  1 httpURLConnection.setDoOutput(true);
229   
230  1 String jsonString = buildJson();
231   
232  1 Console.debug(
233    "Plausible: HTTP Request is: '" + urlSb.toString() + "'");
234  1 if (DEBUG)
235    {
236  1 Console.debug("Plausible: User-Agent is: '" + USER_AGENT + "'");
237    }
238  1 Console.debug("Plausible: POSTed JSON is:\n" + jsonString);
239   
240  1 byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
241  1 int jsonLength = jsonBytes.length;
242   
243  1 httpURLConnection.setFixedLengthStreamingMode(jsonLength);
244  1 httpURLConnection.setRequestProperty("Content-Type",
245    "application/json");
246  1 httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
247  1 httpURLConnection.connect();
248  1 try (OutputStream os = httpURLConnection.getOutputStream())
249    {
250  1 os.write(jsonBytes);
251    }
252  1 int responseCode = httpURLConnection.getResponseCode();
253  1 String responseMessage = httpURLConnection.getResponseMessage();
254   
255  1 if (responseCode < 200 || responseCode > 299)
256    {
257  0 Console.warn("Plausible connection failed: '" + responseCode + " "
258    + responseMessage + "'");
259    }
260    else
261    {
262  1 Console.debug("Plausible connection succeeded: '" + responseCode
263    + " " + responseMessage + "'");
264    }
265   
266  1 if (DEBUG)
267    {
268  1 BufferedReader br = new BufferedReader(new InputStreamReader(
269    (httpURLConnection.getInputStream())));
270  1 StringBuilder sb = new StringBuilder();
271  1 String response;
272  ? while ((response = br.readLine()) != null)
273    {
274  1 sb.append(response);
275    }
276  1 String body = sb.toString();
277  1 Console.debug("Plausible response content:\n" + body);
278    }
279    } catch (MalformedURLException e)
280    {
281  0 Console.debug(
282    "Somehow the Plausible BASE_URL and queryString is malformed: '"
283    + urlSb.toString() + "'",
284    e);
285  0 return;
286    } catch (IOException e)
287    {
288  0 Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
289    + "' failed.", e);
290    } catch (ClassCastException e)
291    {
292  0 Console.debug(
293    "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
294    e);
295    }
296    }
297   
 
298  1 toggle private void addJsonObject(String key, Map<String, String> map)
299    {
300  1 List<Map.Entry<String, ? extends Object>> list = new ArrayList<>();
301  1 for (String k : map.keySet())
302    {
303  9 list.add(stringEntry(k, map.get(k)));
304    }
305  1 addJsonObject(key, list);
306   
307    }
308   
 
309  1 toggle private void addJsonObject(String key,
310    List<Map.Entry<String, ? extends Object>> object)
311    {
312  1 jsonObject.add(objectEntry(key, object));
313    }
314   
 
315  0 toggle private void addJsonValues(String key, List<Object> values)
316    {
317  0 jsonObject.add(objectEntry(key, values));
318    }
319   
 
320  0 toggle private void addJsonValue(String key, String value)
321    {
322  0 addJsonValue(key, value, false);
323    }
324   
 
325  3 toggle private void addJsonValue(String key, String value,
326    boolean addToQueryString)
327    {
328  3 jsonObject.add(objectEntry(key, value));
329  3 if (addToQueryString)
330    {
331  3 addQueryStringValue(key, value);
332    }
333    }
334   
 
335  0 toggle private void addJsonValue(String key, int value)
336    {
337  0 jsonObject.add(objectEntry(key, Integer.valueOf(value)));
338    }
339   
 
340  0 toggle private void addJsonValue(String key, boolean value)
341    {
342  0 jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
343    }
344   
 
345  3 toggle private void addQueryStringValue(String key, String value)
346    {
347  3 queryStringValues.add(stringEntry(key, value));
348    }
349   
 
350  0 toggle private void addCookieValue(String key, String value)
351    {
352  0 cookieValues.add(stringEntry(key, value));
353    }
354   
 
355  2 toggle private void resetLists()
356    {
357  2 jsonObject = new ArrayList<>();
358  2 queryStringValues = new ArrayList<>();
359  2 cookieValues = new ArrayList<>();
360    }
361   
 
362  1 toggle public static Plausible getInstance()
363    {
364  1 if (instance == null)
365    {
366  1 instance = new Plausible();
367    }
368  1 return instance;
369    }
370   
 
371  0 toggle public static void reset()
372    {
373  0 getInstance().resetLists();
374    }
375   
 
376  1 toggle private String buildQueryString()
377    {
378  1 StringBuilder sb = new StringBuilder();
379  1 for (Map.Entry<String, String> entry : queryStringValues)
380    {
381  3 if (sb.length() > 0)
382    {
383  2 sb.append('&');
384    }
385  3 try
386    {
387  3 sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
388    } catch (UnsupportedEncodingException e)
389    {
390  0 sb.append(entry.getKey());
391    }
392  3 sb.append('=');
393  3 try
394    {
395  3 sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
396    } catch (UnsupportedEncodingException e)
397    {
398  0 sb.append(entry.getValue());
399    }
400    }
401  1 return sb.toString();
402    }
403   
 
404  0 toggle private void buildCookieHeaders()
405    {
406    // TODO not needed yet
407    }
408   
 
409  1 toggle private String buildJson()
410    {
411  1 StringBuilder sb = new StringBuilder();
412  1 addJsonObject(sb, 0, jsonObject);
413  1 return sb.toString();
414    }
415   
 
416  2 toggle private void addJsonObject(StringBuilder sb, int indent,
417    List<Map.Entry<String, Object>> entries)
418    {
419  2 indent(sb, indent);
420  2 sb.append('{');
421  2 newline(sb);
422  2 Iterator<Map.Entry<String, Object>> entriesI = entries.iterator();
423  15 while (entriesI.hasNext())
424    {
425  13 Map.Entry<String, Object> entry = entriesI.next();
426  13 String key = entry.getKey();
427    // TODO sensibly escape " characters in key
428  13 Object value = entry.getValue();
429  13 indent(sb, indent + 1);
430  13 sb.append('"').append(quoteEscape(key)).append('"').append(':');
431  13 space(sb);
432  13 if (value != null && value instanceof List)
433    {
434  1 newline(sb);
435    }
436  13 addJsonValue(sb, indent + 2, value);
437  13 if (entriesI.hasNext())
438    {
439  11 sb.append(',');
440    }
441  13 newline(sb);
442    }
443  2 indent(sb, indent);
444  2 sb.append('}');
445    }
446   
 
447  13 toggle private void addJsonValue(StringBuilder sb, int indent, Object value)
448    {
449  13 if (value == null)
450    {
451  0 return;
452    }
453  13 try
454    {
455  13 if (value instanceof Map.Entry)
456    {
457  0 Map.Entry<String, Object> entry = (Map.Entry<String, Object>) value;
458  0 List<Map.Entry<String, Object>> object = new ArrayList<>();
459  0 object.add(entry);
460  0 addJsonObject(sb, indent, object);
461    }
462  13 else if (value instanceof List)
463    {
464    // list of Map.Entries or list of values?
465  1 List<Object> valueList = (List<Object>) value;
466  1 if (valueList.size() > 0 && valueList.get(0) instanceof Map.Entry)
467    {
468    // entries
469    // indent(sb, indent);
470  1 List<Map.Entry<String, Object>> entryList = (List<Map.Entry<String, Object>>) value;
471  1 addJsonObject(sb, indent, entryList);
472    }
473    else
474    {
475    // values
476  0 indent(sb, indent);
477  0 sb.append('[');
478  0 newline(sb);
479  0 Iterator<Object> valueListI = valueList.iterator();
480  0 while (valueListI.hasNext())
481    {
482  0 Object v = valueListI.next();
483  0 addJsonValue(sb, indent + 1, v);
484  0 if (valueListI.hasNext())
485    {
486  0 sb.append(',');
487    }
488  0 newline(sb);
489    }
490  0 indent(sb, indent);
491  0 sb.append("]");
492    }
493    }
494  12 else if (value instanceof String)
495    {
496  12 sb.append('"').append(quoteEscape((String) value)).append('"');
497    }
498  0 else if (value instanceof Integer)
499    {
500  0 sb.append(((Integer) value).toString());
501    }
502  0 else if (value instanceof Boolean)
503    {
504  0 sb.append('"').append(((Boolean) value).toString()).append('"');
505    }
506    } catch (ClassCastException e)
507    {
508  0 Console.debug(
509    "Could not deal with type of json Object " + value.toString(),
510    e);
511    }
512    }
513   
 
514  25 toggle private static String quoteEscape(String s)
515    {
516  25 if (s == null)
517    {
518  0 return null;
519    }
520    // this escapes quotation marks (") that aren't already escaped (in the
521    // string) ready to go into a quoted JSON string value
522  25 return s.replaceAll("((?<!\\\\)(?:\\\\{2})*)\"", "$1\\\\\"");
523    }
524   
 
525  46 toggle private static void prettyWhitespace(StringBuilder sb, String whitespace,
526    int repeat)
527    {
528    // only add whitespace if we're in DEBUG mode
529  46 if (!Console.getLogger().isDebugEnabled())
530    {
531  46 return;
532    }
533  0 if (repeat >= 0 && whitespace != null)
534    {
535    // sb.append(whitespace.repeat(repeat));
536  0 sb.append(String.join("", Collections.nCopies(repeat, whitespace)));
537   
538    }
539    else
540    {
541  0 sb.append(whitespace);
542    }
543    }
544   
 
545  17 toggle private static void indent(StringBuilder sb, int indent)
546    {
547  17 prettyWhitespace(sb, " ", indent);
548    }
549   
 
550  16 toggle private static void newline(StringBuilder sb)
551    {
552  16 prettyWhitespace(sb, "\n", -1);
553    }
554   
 
555  13 toggle private static void space(StringBuilder sb)
556    {
557  13 prettyWhitespace(sb, " ", -1);
558    }
559   
 
560  4 toggle protected static Map.Entry<String, Object> objectEntry(String s, Object o)
561    {
562  4 return new AbstractMap.SimpleEntry<String, Object>(s, o);
563    }
564   
 
565  12 toggle protected static Map.Entry<String, String> stringEntry(String s, String v)
566    {
567  12 return new AbstractMap.SimpleEntry<String, String>(s, v);
568    }
569   
 
570  1 toggle private static String getAPIBaseURL()
571    {
572  1 try
573    {
574  1 URL url = new URL(CONFIG_API_BASE_URL);
575  1 HttpURLConnection httpURLConnection = (HttpURLConnection) HttpUtils
576    .openConnection(url);
577  1 httpURLConnection.setRequestMethod("GET");
578  1 httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
579  1 httpURLConnection.setConnectTimeout(5000);
580  1 httpURLConnection.setReadTimeout(3000);
581  1 httpURLConnection.connect();
582  1 int responseCode = httpURLConnection.getResponseCode();
583  1 String responseMessage = httpURLConnection.getResponseMessage();
584   
585  1 if (responseCode < 200 || responseCode > 299)
586    {
587  0 Console.warn("Config URL connection to '" + CONFIG_API_BASE_URL
588    + "' failed: '" + responseCode + " " + responseMessage
589    + "'");
590    }
591   
592  1 BufferedReader br = new BufferedReader(
593    new InputStreamReader((httpURLConnection.getInputStream())));
594  1 StringBuilder sb = new StringBuilder();
595  1 String response;
596  ? while ((response = br.readLine()) != null)
597    {
598  1 sb.append(response);
599    }
600  1 if (sb.length() > 7 && sb.substring(0, 5).equals("https"))
601    {
602  1 return sb.toString();
603    }
604   
605    } catch (MalformedURLException e)
606    {
607  0 Console.debug("Somehow the config URL is malformed: '"
608    + CONFIG_API_BASE_URL + "'", e);
609    } catch (IOException e)
610    {
611  0 Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
612    + "' failed.", e);
613    } catch (ClassCastException e)
614    {
615  0 Console.debug(
616    "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
617    e);
618    }
619  0 return DEFAULT_API_BASE_URL;
620    }
621    }