Skip to content

Commit 55e096d

Browse files
committed
Record Plausible analytics events for endpoints intended for Nextstrain CLI
These will help us in the future gain insight into Nextstrain CLI version usage and update patterns as well as pathogen repository setup and update patterns.
1 parent 2610ae9 commit 55e096d

File tree

7 files changed

+125
-1
lines changed

7 files changed

+125
-1
lines changed

env/production/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,6 @@
110110
"OIDC_GROUPS_CLAIM": "cognito:groups",
111111
"SESSION_COOKIE_DOMAIN": "nextstrain.org",
112112
"GROUPS_DATA_FILE": "groups.json",
113-
"RESOURCE_INDEX": "s3://nextstrain-inventories/resources/v4.json.gz"
113+
"RESOURCE_INDEX": "s3://nextstrain-inventories/resources/v4.json.gz",
114+
"PLAUSIBLE_ANALYTICS_DOMAIN": "nextstrain.org"
114115
}

package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"sanitize-html": "^2.13.0",
9090
"session-file-store": "^1.3.1",
9191
"styled-components": "^6.1.8",
92+
"user-agent-bag": "^0.3.0",
9293
"yaml-front-matter": "^4.0.0"
9394
},
9495
"devDependencies": {

src/analytics.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Analytics for server-side events.
3+
*
4+
* @module analytics
5+
*/
6+
import { default as UserAgentBag } from "user-agent-bag";
7+
import { PLAUSIBLE_ANALYTICS_DOMAIN, PLAUSIBLE_ANALYTICS_ENDPOINT } from "./config.js";
8+
import { fetch } from "./fetch.js";
9+
10+
11+
/**
12+
* Generate middleware to record an analytics event via a request to
13+
* Plausible's API.
14+
*
15+
* Intended for use in an Express routing chain. The generated middleware
16+
* function does not block on the analytics request before returning.
17+
*
18+
* If the User-Agent of the incoming request starts with "Nextstrain-CLI/",
19+
* then it is parsed for several bits of information and those are
20+
* automatically attached to the analytics event as custom properties.
21+
*
22+
* @function recordEvent
23+
* @param {object} data
24+
* @param {string} data.name - Event name. Defaults to "pageview" (Plausible's default).
25+
* @param {object} data.props - Custom properties. Defaults to {}.
26+
* @param {boolean} data.interactive - Interactive session or not? Defaults to
27+
* false (opposite of Plausible's default), with the reasoning that most events
28+
* we're likely to record server-side aren't for interactive browser-based
29+
* sessions.
30+
* @returns {expressMiddlewareAsync}
31+
*/
32+
export const recordEvent = ({name = "pageview", props = {}, interactive = false} = {}) => async (req, res, next) => {
33+
if (PLAUSIBLE_ANALYTICS_DOMAIN) {
34+
/* We intentionally do not "await fetch()" as we do not want to block
35+
* request processing on analytics; we'd rather ignore failures.
36+
* -trs, 28 April 2025
37+
*/
38+
fetch(
39+
// <https://plausible.io/docs/events-api>
40+
PLAUSIBLE_ANALYTICS_ENDPOINT, {
41+
method: "POST",
42+
headers: {
43+
"User-Agent": req.header("User-Agent"),
44+
"X-Forwarded-For": req.ip,
45+
"Content-Type": "application/json",
46+
},
47+
body: JSON.stringify({
48+
name,
49+
domain: PLAUSIBLE_ANALYTICS_DOMAIN,
50+
url: new URL(req.originalUrl, req.context.origin),
51+
referrer: req.header("Referer"),
52+
interactive,
53+
props: {
54+
// <https://plausible.io/docs/custom-props/introduction>
55+
...propsFromUserAgent(req.header("User-Agent")),
56+
...props,
57+
},
58+
}),
59+
}
60+
);
61+
}
62+
return next();
63+
};
64+
65+
66+
const userAgentToPlausibleKeys = new Map([
67+
["Nextstrain-CLI", "nextstrain-cli/version"],
68+
["Python", "nextstrain-cli/python"],
69+
["installer", "nextstrain-cli/installer"],
70+
["platform", "nextstrain-cli/platform"],
71+
["tty", "nextstrain-cli/tty"],
72+
]);
73+
74+
function propsFromUserAgent(ua) {
75+
if (ua && ua.match(/^Nextstrain-CLI\//)) {
76+
const uaComponents = new UserAgentBag(ua);
77+
78+
return Object.fromEntries(
79+
Array.from(userAgentToPlausibleKeys)
80+
.map(([srcKey, dstKey]) => [dstKey, uaComponents.get(srcKey) ?? null])
81+
);
82+
}
83+
84+
return {};
85+
}

src/config.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,19 @@ export const RESOURCE_INDEX = fromEnvOrConfig("RESOURCE_INDEX", null);
481481
* canonical PRODUCTION flag set here.
482482
*/
483483
export const STATIC_SITE_PRODUCTION = PRODUCTION || !!process.env.USE_PREBUILT_STATIC_SITE;
484+
485+
/**
486+
* Domain in Plausible analytics for which to record server-side analytics
487+
* events.
488+
*
489+
* Analytics are not recorded if this is not set.
490+
*/
491+
export const PLAUSIBLE_ANALYTICS_DOMAIN = fromEnvOrConfig("PLAUSIBLE_ANALYTICS_DOMAIN", null);
492+
493+
/**
494+
* URL of submission endpoint for Plausible analytics events.
495+
*
496+
* Defaults to Plausible's standard endpoint. Useful to change during
497+
* development and testing of analytics.
498+
*/
499+
export const PLAUSIBLE_ANALYTICS_ENDPOINT = fromEnvOrConfig("PLAUSIBLE_ANALYTICS_ENDPOINT", "https://plausible.io/api/event");

src/routing/cli.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import * as analytics from '../analytics.js';
12
import * as endpoints from '../endpoints/index.js';
23

34

45
export function setup(app) {
6+
app.useAsync("/cli", analytics.recordEvent());
7+
58
app.routeAsync("/cli/download/:version/:assetSuffix")
69
.getAsync(endpoints.cli.download);
710

src/routing/pathogenRepos.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import * as analytics from '../analytics.js';
12
import * as endpoints from '../endpoints/index.js';
23

34

45
export function setup(app) {
6+
app.useAsync("/pathogen-repos", analytics.recordEvent());
7+
58
app.routeAsync("/pathogen-repos/:name/versions")
69
.getAsync(endpoints.pathogenRepos.listVersions);
710

0 commit comments

Comments
 (0)