@harshankur/viewcounter 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -1
- package/README.md +30 -8
- package/constants.js +1 -1
- package/db/DatabaseManager.js +6 -4
- package/db/adminSchema.js +5 -1
- package/db/analysis.js +9 -4
- package/index.js +5 -16
- package/package.json +1 -1
- package/routes/analytics.js +41 -2
- package/tracker/tracker.js +68 -19
package/.env.example
CHANGED
|
@@ -115,7 +115,9 @@ PORT=3030
|
|
|
115
115
|
LOG_LEVEL=info
|
|
116
116
|
|
|
117
117
|
# Rate limiting, applied per client IP across all endpoints. This is the
|
|
118
|
-
# single-abuser backstop.
|
|
118
|
+
# single-abuser backstop. Engagement reports (/engage, two a minute from each
|
|
119
|
+
# page being read) have a separate budget of the same size, here and per app,
|
|
120
|
+
# so they never use up the one page views depend on.
|
|
119
121
|
RATE_LIMIT_WINDOW_MS=60000
|
|
120
122
|
RATE_LIMIT_MAX=100
|
|
121
123
|
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://viewcounter.harshankur.com)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
|
|
6
|
-
[](TEST_REPORT.md)
|
|
7
7
|
[](https://www.npmjs.com/package/@harshankur/viewcounter)
|
|
8
8
|
[](https://www.npmjs.com/package/@harshankur/viewcounter#provenance)
|
|
9
9
|
|
|
@@ -220,8 +220,9 @@ every app together under **All apps**; the choice follows you between them.
|
|
|
220
220
|
bounce rate, visit duration, pages per visit, time on page, scroll depth),
|
|
221
221
|
each against the period before, with a sparkline; choose one to chart it
|
|
222
222
|
over time, or read every number per period as a table;
|
|
223
|
-
- **Right now**: visitors in the last few minutes,
|
|
224
|
-
|
|
223
|
+
- **Right now**: visitors in the last few minutes (by a new view, or by the
|
|
224
|
+
tracker's report from a page still being read), views per minute over the
|
|
225
|
+
last half hour, and the pages open, refreshed while you look;
|
|
225
226
|
- where visits come from (channels, referrers, referring pages, and every
|
|
226
227
|
campaign tag), pages (top, entry with bounce rate, exit, titles, sites),
|
|
227
228
|
locations (a world map, countries, regions, cities, languages), devices,
|
|
@@ -392,7 +393,10 @@ Content-Type: text/plain # or application/json
|
|
|
392
393
|
{"appId": "blog", "id": "<the id /registerView returned>", "ms": 42000, "scroll": 80}
|
|
393
394
|
```
|
|
394
395
|
How long the page was visible (`ms`, up to 6 hours) and how much of it had been
|
|
395
|
-
on screen (`scroll`, 0 to 100). A later report can only raise either.
|
|
396
|
+
on screen (`scroll`, 0 to 100). A later report can only raise either. Each
|
|
397
|
+
report also marks the view as seen just now, which keeps its visitor in the
|
|
398
|
+
admin's **Right now** for the next few minutes; the tracker script sends one
|
|
399
|
+
every half minute while the page is being read. A report
|
|
396
400
|
for a view that is unknown, trashed, or older than a day changes nothing and is
|
|
397
401
|
counted in the tracking log as refused. `text/plain` is accepted so
|
|
398
402
|
`navigator.sendBeacon` can deliver it as the page closes, without a CORS
|
|
@@ -631,7 +635,7 @@ returned by any API.
|
|
|
631
635
|
| **Device Size** | `deviceSize` | small, medium, large | Layout decisions |
|
|
632
636
|
| **Browser, OS, and versions** | User-Agent, parsed in memory | Names and versions, such as Chrome 140 on macOS 15 | Compatibility |
|
|
633
637
|
| **Device Type** | User-Agent | desktop, mobile, tablet, tv, console, wearable | Compatibility |
|
|
634
|
-
| **Time on page, Scroll depth** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen | Whether pages are read |
|
|
638
|
+
| **Time on page, Scroll depth, Last seen** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen; when the page last reported | Whether pages are read |
|
|
635
639
|
| **Event Type, Event Data** | `/event` | Type name; JSON up to 4 kB, as your site sends it | Custom events |
|
|
636
640
|
| **Session ID** | `sessionId` (optional) | As your site sends it | Your own grouping; the tracker never sends one |
|
|
637
641
|
|
|
@@ -821,6 +825,15 @@ Two independent limits apply to writes:
|
|
|
821
825
|
everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
|
|
822
826
|
be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
|
|
823
827
|
|
|
828
|
+
Each limit is applied twice, as two separate budgets of that size: one for
|
|
829
|
+
engagement reports (`/engage`) and one for everything else. A page being read
|
|
830
|
+
reports every half minute, so each open, active tab costs two reports a minute;
|
|
831
|
+
on a shared budget, the readers behind one office address could have used it up
|
|
832
|
+
and had their page views refused. Apart, reports can only crowd out other
|
|
833
|
+
reports. With the defaults that is room for about 50 readers at once per
|
|
834
|
+
address and 500 per app; raise the limits if you expect more, or a reader's
|
|
835
|
+
time on page is only updated when their page is hidden or left.
|
|
836
|
+
|
|
824
837
|
### What is still yours to build
|
|
825
838
|
|
|
826
839
|
Tenancy here is data isolation and quota, not a billing system. There is no
|
|
@@ -943,7 +956,8 @@ One tag, anywhere in the page:
|
|
|
943
956
|
It records a view of each page, including page changes in single-page apps
|
|
944
957
|
(`history.pushState`, `replaceState`, and the back button, each referred by
|
|
945
958
|
the page it left); how long each page was visible and how far it was
|
|
946
|
-
scrolled
|
|
959
|
+
scrolled, reported when the page is hidden or left and every half minute
|
|
960
|
+
while it is being read; clicks on links to other sites (the other site's hostname only);
|
|
947
961
|
clicks on downloads (the file name only); and the landing URL's campaign tags.
|
|
948
962
|
It stores nothing on the device and sends no identifier, and it skips
|
|
949
963
|
automated browsers.
|
|
@@ -953,6 +967,8 @@ automated browsers.
|
|
|
953
967
|
| `data-app` | required | The app ID the views belong to |
|
|
954
968
|
| `data-hosts` | every host | Only track on these hostnames, comma-separated, so development servers and previews stay out of the data |
|
|
955
969
|
| `data-spa` | `true` | Treat history changes as page views |
|
|
970
|
+
| `data-hash` | none | Fragment prefixes, comma-separated (`#docs/,#spec/`), that count as their own page, for pages that route by fragment. Any other fragment stays part of the same page. A matching fragment is stored whole as part of the page, so list only prefixes whose fragments carry nothing private. As a referrer, such a page is its path alone |
|
|
971
|
+
| `data-heartbeat` | `true` | Report time on page every half minute while the page is visible and in use, not only when it is hidden or left. It keeps the visitor in the admin's **Right now** while they read one page, and saves the time of a tab the browser closes without warning. It stops after half an hour without any input |
|
|
956
972
|
| `data-outbound` | `true` | Record clicks on links to other sites, as `outbound` events |
|
|
957
973
|
| `data-downloads` | `true` | Record clicks on downloads (pdf, zip, dmg, docx, and so on), as `download` events |
|
|
958
974
|
| `data-respect-dnt` | `false` | Send nothing when the browser's Do Not Track is on |
|
|
@@ -1030,10 +1046,10 @@ npm run test:watch
|
|
|
1030
1046
|
# Run tests and persist database for inspection
|
|
1031
1047
|
npm run test:persist
|
|
1032
1048
|
|
|
1033
|
-
#
|
|
1049
|
+
# The fast gate CI runs: lint and Jest with coverage, no browser, no report
|
|
1034
1050
|
npm run test:ci
|
|
1035
1051
|
|
|
1036
|
-
# Run only the admin UI tests in a real browser (Playwright)
|
|
1052
|
+
# Run only the admin UI and tracker tests in a real browser (Playwright)
|
|
1037
1053
|
npx playwright install chromium # once
|
|
1038
1054
|
npm run test:ui
|
|
1039
1055
|
```
|
|
@@ -1041,6 +1057,12 @@ npm run test:ui
|
|
|
1041
1057
|
`npm test` includes the Playwright suite, so run `npx playwright install
|
|
1042
1058
|
chromium` once before the first run.
|
|
1043
1059
|
|
|
1060
|
+
CI runs everything except the browser tests: lint, Jest with its coverage
|
|
1061
|
+
floor, the dependency audit, the tarball check, and the end-to-end run against
|
|
1062
|
+
a real MySQL. It does not download a browser, to save CI time, so the browser
|
|
1063
|
+
tests are a local step: run `npm run test:ui` whenever you change anything
|
|
1064
|
+
under `admin/` or `tracker/`, and the full `npm test` before a release.
|
|
1065
|
+
|
|
1044
1066
|
### Test Database
|
|
1045
1067
|
|
|
1046
1068
|
**Automatic Management:**
|
package/constants.js
CHANGED
|
@@ -97,7 +97,7 @@ const DATABASE = {
|
|
|
97
97
|
QUERY_TIMEOUT_MS: 5_000,
|
|
98
98
|
CONNECT_TIMEOUT_MS: 10_000,
|
|
99
99
|
DEFAULT_PORT: 3306,
|
|
100
|
-
SCHEMA_VERSION: '
|
|
100
|
+
SCHEMA_VERSION: 'schema_v6',
|
|
101
101
|
/** Rows given a public_id per statement when backfilling an old table. */
|
|
102
102
|
BACKFILL_BATCH_SIZE: 500,
|
|
103
103
|
};
|
package/db/DatabaseManager.js
CHANGED
|
@@ -548,9 +548,10 @@ class DatabaseManager {
|
|
|
548
548
|
/**
|
|
549
549
|
* Record how long a view's page was visible and how far it was scrolled.
|
|
550
550
|
*
|
|
551
|
-
* A page reports this when it is hidden or left,
|
|
552
|
-
*
|
|
553
|
-
*
|
|
551
|
+
* A page reports this when it is hidden or left, and every half minute
|
|
552
|
+
* while it is being read, each time with its running total, so the larger
|
|
553
|
+
* value always wins. Each report also marks the view as seen just now,
|
|
554
|
+
* which is what keeps its visitor in "right now" between page views. Only a live view from the last
|
|
554
555
|
* TRACKING.ENGAGE_WINDOW_HOURS is updated: an old or trashed view keeps
|
|
555
556
|
* what it had.
|
|
556
557
|
*
|
|
@@ -563,7 +564,8 @@ class DatabaseManager {
|
|
|
563
564
|
const [result] = await this.pool.query(
|
|
564
565
|
`UPDATE \`${appId}\`
|
|
565
566
|
SET engaged_ms = GREATEST(COALESCE(engaged_ms, 0), ?),
|
|
566
|
-
scroll_depth = GREATEST(COALESCE(scroll_depth, 0), ?)
|
|
567
|
+
scroll_depth = GREATEST(COALESCE(scroll_depth, 0), ?),
|
|
568
|
+
last_seen_at = NOW()
|
|
567
569
|
WHERE public_id = ? AND ${LIVE_ROW}
|
|
568
570
|
AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
|
|
569
571
|
[engagedMs, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
|
package/db/adminSchema.js
CHANGED
|
@@ -42,7 +42,9 @@ const ADMIN_COLUMNS = [
|
|
|
42
42
|
* Columns 3.2 added for richer, still identifier-free analysis: which of the
|
|
43
43
|
* app's sites and which language, the campaign tags of the landing URL, an
|
|
44
44
|
* optional region and city (only with a city database configured), and how
|
|
45
|
-
* long the page was visible and how far it was scrolled.
|
|
45
|
+
* long the page was visible and how far it was scrolled. 3.3 added
|
|
46
|
+
* `last_seen_at`: when the page last reported its engagement, which is how
|
|
47
|
+
* "right now" still counts a visitor who has been reading one page for a while.
|
|
46
48
|
*/
|
|
47
49
|
const TRACKING_COLUMNS = [
|
|
48
50
|
{ name: 'hostname', ddl: `VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL` },
|
|
@@ -56,6 +58,7 @@ const TRACKING_COLUMNS = [
|
|
|
56
58
|
{ name: 'city', ddl: `VARCHAR(${FIELD_MAX_LENGTH.CITY}) DEFAULT NULL` },
|
|
57
59
|
{ name: 'engaged_ms', ddl: 'INT UNSIGNED DEFAULT NULL' },
|
|
58
60
|
{ name: 'scroll_depth', ddl: 'TINYINT UNSIGNED DEFAULT NULL' },
|
|
61
|
+
{ name: 'last_seen_at', ddl: 'DATETIME DEFAULT NULL' },
|
|
59
62
|
];
|
|
60
63
|
|
|
61
64
|
/** Indexes the admin columns need, keyed by index name. */
|
|
@@ -63,6 +66,7 @@ const ADMIN_INDEXES = {
|
|
|
63
66
|
uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
|
|
64
67
|
idx_deleted_at: 'INDEX `idx_deleted_at` (`deleted_at`)',
|
|
65
68
|
idx_admin_modified_at: 'INDEX `idx_admin_modified_at` (`admin_modified_at`)',
|
|
69
|
+
idx_last_seen_at: 'INDEX `idx_last_seen_at` (`last_seen_at`)',
|
|
66
70
|
};
|
|
67
71
|
|
|
68
72
|
/**
|
package/db/analysis.js
CHANGED
|
@@ -436,12 +436,17 @@ function tallyEventProperties(rows) {
|
|
|
436
436
|
* @param {string[]} appIds
|
|
437
437
|
*/
|
|
438
438
|
async function runRealtime(pool, table, appIds) {
|
|
439
|
-
const
|
|
439
|
+
const within = (column, minutes) => `${column} >= DATE_SUB(NOW(), INTERVAL ${minutes} MINUTE)`;
|
|
440
|
+
// A visitor is here now when a view of theirs was recorded, or last
|
|
441
|
+
// reported its engagement, in the last few minutes: someone reading one
|
|
442
|
+
// page for a quarter of an hour sends no new view, only those reports.
|
|
443
|
+
const recent = `(${within('timestamp', ANALYSIS.REALTIME_VISITOR_MINUTES)} OR ${within('last_seen_at', ANALYSIS.REALTIME_VISITOR_MINUTES)})`;
|
|
444
|
+
const charted = within('timestamp', ANALYSIS.REALTIME_CHART_MINUTES);
|
|
445
|
+
const branches = appIds.map((appId) => `SELECT ? AS app_id, visitor_hash, page_path, event_type, timestamp, last_seen_at
|
|
440
446
|
FROM ${table(appId)}
|
|
441
|
-
WHERE deleted_at IS NULL AND
|
|
447
|
+
WHERE deleted_at IS NULL AND (${charted} OR ${within('last_seen_at', ANALYSIS.REALTIME_VISITOR_MINUTES)})`);
|
|
442
448
|
const cte = `WITH v AS (${branches.join(' UNION ALL ')})`;
|
|
443
449
|
const params = [...appIds];
|
|
444
|
-
const recent = `timestamp >= DATE_SUB(NOW(), INTERVAL ${ANALYSIS.REALTIME_VISITOR_MINUTES} MINUTE)`;
|
|
445
450
|
|
|
446
451
|
const [[summary = {}]] = await pool.query(`${cte}
|
|
447
452
|
SELECT COUNT(DISTINCT CASE WHEN ${recent} THEN visitor_hash END) AS visitors,
|
|
@@ -449,7 +454,7 @@ async function runRealtime(pool, table, appIds) {
|
|
|
449
454
|
FROM v`, params);
|
|
450
455
|
const [minutes] = await pool.query(`${cte}
|
|
451
456
|
SELECT FLOOR(UNIX_TIMESTAMP(timestamp) / 60) AS minute, COUNT(*) AS views
|
|
452
|
-
FROM v GROUP BY minute ORDER BY minute`, params);
|
|
457
|
+
FROM v WHERE ${charted} GROUP BY minute ORDER BY minute`, params);
|
|
453
458
|
const [pages] = await pool.query(`${cte}
|
|
454
459
|
SELECT app_id, page_path AS page, COUNT(DISTINCT visitor_hash) AS visitors
|
|
455
460
|
FROM v WHERE ${recent} AND event_type = ${PAGEVIEW}
|
package/index.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const cors = require('cors');
|
|
3
3
|
const helmet = require('helmet');
|
|
4
|
-
const rateLimit = require('express-rate-limit');
|
|
5
4
|
|
|
6
5
|
const { ADMIN, APP_NAME, PAYLOAD_LIMITS, REJECTION_REASON, SERVER } = require('./constants');
|
|
7
6
|
const config = require('./config');
|
|
8
7
|
const DatabaseManager = require('./db/DatabaseManager');
|
|
9
8
|
const logger = require('./utils/logger');
|
|
10
9
|
const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
|
|
11
|
-
const { createAnalyticsRouter, trackingSourceFor } = require('./routes/analytics');
|
|
10
|
+
const { createAnalyticsRouter, buildPerIpLimiters, trackingSourceFor } = require('./routes/analytics');
|
|
12
11
|
const { createAdminRouter } = require('./routes/admin');
|
|
13
12
|
const { startRetention } = require('./db/retention');
|
|
14
13
|
const { createDbSessionStore } = require('./db/adminSessionStore');
|
|
@@ -79,20 +78,10 @@ function createApp() {
|
|
|
79
78
|
// endpoint taking a body and its payload is small.
|
|
80
79
|
app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
standardHeaders: true,
|
|
87
|
-
legacyHeaders: false,
|
|
88
|
-
// A tracking request turned away here is counted in the tracking log
|
|
89
|
-
// like any other refusal (in memory, written in batches).
|
|
90
|
-
handler: (req, res, next, options) => {
|
|
91
|
-
if (trackingSourceFor(req.path)) {
|
|
92
|
-
router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
|
|
93
|
-
}
|
|
94
|
-
res.status(options.statusCode).json(options.message);
|
|
95
|
-
},
|
|
81
|
+
// A tracking request turned away here is counted in the tracking log like
|
|
82
|
+
// any other refusal (in memory, written in batches).
|
|
83
|
+
app.use(buildPerIpLimiters(config.server.rateLimit, (req) => {
|
|
84
|
+
if (trackingSourceFor(req.path)) router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
|
|
96
85
|
}));
|
|
97
86
|
|
|
98
87
|
app.use(router);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harshankur/viewcounter",
|
|
3
3
|
"description": "A middleware backend server that registers views to my db server when requested to register a view from my other projects.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
package/routes/analytics.js
CHANGED
|
@@ -155,6 +155,40 @@ function buildPerAppLimiter(rateLimitConfig, onLimit = () => {}) {
|
|
|
155
155
|
});
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** Whether a request is an engagement report, the one tracking request a page repeats. */
|
|
159
|
+
const isEngagement = (req) => trackingSourceFor(req.path) === VIEW_LOG_SOURCE.ENGAGE;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The per-IP limiters: one budget for engagement reports, one for everything
|
|
163
|
+
* else, each of `max` requests per window.
|
|
164
|
+
*
|
|
165
|
+
* They are separate because a page being read reports its engagement every
|
|
166
|
+
* half minute. On one shared budget, a few dozen readers behind one address
|
|
167
|
+
* (an office, a campus) would use it up with those reports alone, and the page
|
|
168
|
+
* views of everyone at that address would be refused. Apart, reports can only
|
|
169
|
+
* ever crowd out other reports.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ max: number, windowMs: number }} rateLimitConfig
|
|
172
|
+
* @param {(req: import('express').Request) => void} [onLimit] called for a refused request
|
|
173
|
+
* @returns {import('express').RequestHandler[]}
|
|
174
|
+
*/
|
|
175
|
+
function buildPerIpLimiters(rateLimitConfig, onLimit = () => {}) {
|
|
176
|
+
const { max, windowMs } = rateLimitConfig || {};
|
|
177
|
+
const limiter = (skip) => rateLimit({
|
|
178
|
+
windowMs,
|
|
179
|
+
limit: max,
|
|
180
|
+
message: { message: 'Too many requests, please try again later.' },
|
|
181
|
+
standardHeaders: true,
|
|
182
|
+
legacyHeaders: false,
|
|
183
|
+
skip,
|
|
184
|
+
handler: (req, res, next, options) => {
|
|
185
|
+
onLimit(req);
|
|
186
|
+
res.status(options.statusCode).json(options.message);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return [limiter(isEngagement), limiter((req) => !isEngagement(req))];
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
/**
|
|
159
193
|
* Attach a request id used for correlating a client-visible error with the
|
|
160
194
|
* server-side log line that has the real detail.
|
|
@@ -232,6 +266,11 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
232
266
|
});
|
|
233
267
|
const limitPerApp = buildPerAppLimiter(config.server?.rateLimit,
|
|
234
268
|
(req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'app' }));
|
|
269
|
+
// Engagement reports draw on a per-app budget of their own, for the reason
|
|
270
|
+
// buildPerIpLimiters gives: an app with many readers must not have its
|
|
271
|
+
// views refused because of the reports those readers' pages send.
|
|
272
|
+
const limitEngagePerApp = buildPerAppLimiter(config.server?.rateLimit,
|
|
273
|
+
(req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'app' }));
|
|
235
274
|
const trackingValidation = handleTrackingValidation(reject);
|
|
236
275
|
|
|
237
276
|
/** Views from these are counted in the tracking log and never stored. */
|
|
@@ -429,7 +468,7 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
429
468
|
router.post('/engage',
|
|
430
469
|
express.text({ type: () => true, limit: TRACKING.ENGAGE_BODY_BYTES }),
|
|
431
470
|
parseBeaconBody,
|
|
432
|
-
|
|
471
|
+
limitEngagePerApp,
|
|
433
472
|
requireOrigin,
|
|
434
473
|
validateEngage(config.allowed),
|
|
435
474
|
trackingValidation,
|
|
@@ -641,4 +680,4 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
641
680
|
return router;
|
|
642
681
|
}
|
|
643
682
|
|
|
644
|
-
module.exports = { createAnalyticsRouter, handleRouteError, logContext, withRequestId, trackingSourceFor };
|
|
683
|
+
module.exports = { createAnalyticsRouter, buildPerIpLimiters, handleRouteError, logContext, withRequestId, trackingSourceFor };
|
package/tracker/tracker.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* <script defer src="https://your-server/tracker.js" data-app="blog"></script>
|
|
5
5
|
*
|
|
6
6
|
* Records a view of each page (including page changes in single-page apps),
|
|
7
|
-
* how long it was visible and how far it was scrolled
|
|
7
|
+
* how long it was visible and how far it was scrolled (reported when the page
|
|
8
|
+
* is hidden or left, and every half minute while it is being read), clicks on links to
|
|
8
9
|
* other sites and on downloads, and the campaign tags of the landing URL.
|
|
9
10
|
* The page before is sent as its origin and path only.
|
|
10
11
|
*
|
|
@@ -18,6 +19,10 @@
|
|
|
18
19
|
* only track on these hostnames (keeps dev
|
|
19
20
|
* servers and previews out of the data)
|
|
20
21
|
* data-spa="false" do not treat history changes as page views
|
|
22
|
+
* data-hash="#docs/,#spec/" count a URL fragment that starts with one of
|
|
23
|
+
* these as its own page (hash-routed pages)
|
|
24
|
+
* data-heartbeat="false" report time on page only when the page is
|
|
25
|
+
* hidden or left, not while it is being read
|
|
21
26
|
* data-outbound="false" do not record clicks on links to other sites
|
|
22
27
|
* data-downloads="false" do not record clicks on downloads
|
|
23
28
|
* data-respect-dnt="true" send nothing when Do Not Track is on
|
|
@@ -32,6 +37,7 @@
|
|
|
32
37
|
const option = (name, fallback) => (script.dataset[name] === undefined ? fallback : script.dataset[name] !== 'false');
|
|
33
38
|
const hosts = (script.dataset.hosts || '').split(',').map((host) => host.trim().toLowerCase()).filter(Boolean);
|
|
34
39
|
if (hosts.length && !hosts.includes(location.hostname.toLowerCase())) return;
|
|
40
|
+
const hashRoutes = (script.dataset.hash || '').split(',').map((prefix) => prefix.trim()).filter(Boolean);
|
|
35
41
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
|
|
36
42
|
// Automated browsers are not visitors.
|
|
37
43
|
if (navigator.webdriver) return;
|
|
@@ -41,6 +47,10 @@
|
|
|
41
47
|
const UTM = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
42
48
|
const DOWNLOAD = /\.(pdf|zip|gz|tgz|rar|7z|dmg|exe|msi|pkg|deb|rpm|apk|iso|csv|xlsx?|docx?|pptx?|odt|ods|epub|mp3|mp4|mov|avi|wav)$/i;
|
|
43
49
|
const MAX_ENGAGED_MS = 6 * 60 * 60 * 1000;
|
|
50
|
+
const ENGAGE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
const HEARTBEAT_MS = 30 * 1000;
|
|
52
|
+
// A tab left open with nobody at it stops reporting after this long without input.
|
|
53
|
+
const IDLE_MS = 30 * 60 * 1000;
|
|
44
54
|
|
|
45
55
|
const deviceSize = () => (innerWidth < 768 ? 'small' : innerWidth < 1200 ? 'medium' : 'large');
|
|
46
56
|
/** How much of the page has been on screen, from 0 to 100. */
|
|
@@ -59,18 +69,26 @@
|
|
|
59
69
|
}
|
|
60
70
|
};
|
|
61
71
|
|
|
72
|
+
/** The page on screen: its path, and its fragment when that is one of the site's own routes. */
|
|
73
|
+
const currentPage = () => location.pathname
|
|
74
|
+
+ (hashRoutes.some((prefix) => location.hash.startsWith(prefix)) ? location.hash : '');
|
|
75
|
+
|
|
62
76
|
let view = null;
|
|
63
77
|
let referrer = document.referrer ? originAndPath(document.referrer) : '';
|
|
64
|
-
let path =
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
78
|
+
let path = currentPage();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tell the server how long a page was visible and how far it was scrolled.
|
|
82
|
+
* `alive` sends it even when neither has grown since the last report: that
|
|
83
|
+
* is the heartbeat saying the visitor is still there.
|
|
84
|
+
*/
|
|
85
|
+
function reportEngagement(of = view, alive = false) {
|
|
86
|
+
if (!of || !of.id) return;
|
|
87
|
+
const ms = Math.min(MAX_ENGAGED_MS, Math.round(of.visibleMs + (of.visibleSince === null ? 0 : performance.now() - of.visibleSince)));
|
|
88
|
+
if (!alive && ms <= of.sentMs && of.scroll <= of.sentScroll) return;
|
|
89
|
+
of.sentMs = ms;
|
|
90
|
+
of.sentScroll = of.scroll;
|
|
91
|
+
const body = JSON.stringify({ appId: app, id: of.id, ms, scroll: of.scroll });
|
|
74
92
|
// text/plain needs no CORS preflight, so the beacon survives the page closing.
|
|
75
93
|
if (!(navigator.sendBeacon && navigator.sendBeacon(`${base}engage`, new Blob([body], { type: 'text/plain' })))) {
|
|
76
94
|
fetch(`${base}engage`, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } })
|
|
@@ -79,11 +97,16 @@
|
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
function pageview() {
|
|
82
|
-
|
|
100
|
+
// The page being left stops counting here, and reports what it has.
|
|
101
|
+
if (view) {
|
|
102
|
+
if (view.visibleSince !== null) view.visibleMs += performance.now() - view.visibleSince;
|
|
103
|
+
view.visibleSince = null;
|
|
104
|
+
reportEngagement();
|
|
105
|
+
}
|
|
83
106
|
const params = new URLSearchParams({
|
|
84
107
|
appId: app,
|
|
85
108
|
deviceSize: deviceSize(),
|
|
86
|
-
page:
|
|
109
|
+
page: currentPage().slice(0, 500),
|
|
87
110
|
title: document.title.slice(0, 200),
|
|
88
111
|
referrer: referrer.slice(0, 500),
|
|
89
112
|
});
|
|
@@ -97,6 +120,7 @@
|
|
|
97
120
|
|
|
98
121
|
const current = {
|
|
99
122
|
id: null,
|
|
123
|
+
startedAt: Date.now(),
|
|
100
124
|
visibleMs: 0,
|
|
101
125
|
visibleSince: document.visibilityState === 'visible' ? performance.now() : null,
|
|
102
126
|
scroll: seen(),
|
|
@@ -106,7 +130,12 @@
|
|
|
106
130
|
view = current;
|
|
107
131
|
fetch(`${base}registerView?${params}`, { keepalive: true, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
|
108
132
|
.then((response) => (response.ok ? response.json() : null))
|
|
109
|
-
.then((result) => {
|
|
133
|
+
.then((result) => {
|
|
134
|
+
if (!result || !result.id) return;
|
|
135
|
+
current.id = result.id;
|
|
136
|
+
// Left before the server answered: its report could not go then, so it goes now.
|
|
137
|
+
if (view !== current) reportEngagement(current);
|
|
138
|
+
})
|
|
110
139
|
.catch(() => {});
|
|
111
140
|
}
|
|
112
141
|
|
|
@@ -122,7 +151,7 @@
|
|
|
122
151
|
appId: app,
|
|
123
152
|
eventType: eventType.slice(0, 50),
|
|
124
153
|
eventData: eventData && typeof eventData === 'object' ? eventData : undefined,
|
|
125
|
-
page:
|
|
154
|
+
page: currentPage().slice(0, 500),
|
|
126
155
|
title: document.title.slice(0, 200),
|
|
127
156
|
}),
|
|
128
157
|
}).catch(() => {});
|
|
@@ -131,9 +160,10 @@
|
|
|
131
160
|
// A page change in a single-page app is a new page view, with the page it
|
|
132
161
|
// came from as its referrer (which the server files as internal).
|
|
133
162
|
function navigated() {
|
|
134
|
-
if (
|
|
135
|
-
referrer
|
|
136
|
-
|
|
163
|
+
if (currentPage() === path) return;
|
|
164
|
+
// Like every referrer, without the fragment: the server keeps an origin and a path.
|
|
165
|
+
referrer = `${location.origin}${path.split('#')[0]}`;
|
|
166
|
+
path = currentPage();
|
|
137
167
|
pageview();
|
|
138
168
|
}
|
|
139
169
|
if (option('spa', true)) {
|
|
@@ -147,6 +177,8 @@
|
|
|
147
177
|
}
|
|
148
178
|
addEventListener('popstate', navigated);
|
|
149
179
|
}
|
|
180
|
+
// Asked for by name, so it does not wait on data-spa.
|
|
181
|
+
if (hashRoutes.length) addEventListener('hashchange', navigated);
|
|
150
182
|
|
|
151
183
|
let scrollQueued = false;
|
|
152
184
|
addEventListener('scroll', () => {
|
|
@@ -168,7 +200,24 @@
|
|
|
168
200
|
view.visibleSince = performance.now();
|
|
169
201
|
}
|
|
170
202
|
});
|
|
171
|
-
addEventListener('pagehide', reportEngagement);
|
|
203
|
+
addEventListener('pagehide', () => reportEngagement());
|
|
204
|
+
|
|
205
|
+
// While the page is being read, report as it goes: the server then knows the
|
|
206
|
+
// visitor is still there, and a tab the browser kills without warning (common
|
|
207
|
+
// on phones) loses half a minute of its time at most, not all of it.
|
|
208
|
+
if (option('heartbeat', true)) {
|
|
209
|
+
let lastInput = performance.now();
|
|
210
|
+
const active = () => { lastInput = performance.now(); };
|
|
211
|
+
for (const type of ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart']) {
|
|
212
|
+
addEventListener(type, active, { passive: true, capture: true });
|
|
213
|
+
}
|
|
214
|
+
setInterval(() => {
|
|
215
|
+
if (!view || document.visibilityState !== 'visible' || performance.now() - lastInput > IDLE_MS) return;
|
|
216
|
+
// The server takes reports for a view for a day. A page open longer says no more.
|
|
217
|
+
if (Date.now() - view.startedAt > ENGAGE_WINDOW_MS) return;
|
|
218
|
+
reportEngagement(view, true);
|
|
219
|
+
}, HEARTBEAT_MS);
|
|
220
|
+
}
|
|
172
221
|
|
|
173
222
|
// Links out and downloads. Only the other site's hostname, or the file's
|
|
174
223
|
// name, is recorded: never the whole URL, which can carry personal data.
|