@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 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
  [![Documentation](https://img.shields.io/badge/docs-viewcounter.harshankur.com-blueviolet)](https://viewcounter.harshankur.com)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Test Suite](https://github.com/harshankur/viewcounter/actions/workflows/test.yml/badge.svg)](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
6
- [![Tests](https://img.shields.io/badge/tests-1098%20passing-success)](TEST_REPORT.md)
6
+ [![Tests](https://img.shields.io/badge/tests-1109%20passing-success)](TEST_REPORT.md)
7
7
  [![npm](https://img.shields.io/npm/v/@harshankur/viewcounter?logo=npm)](https://www.npmjs.com/package/@harshankur/viewcounter)
8
8
  [![provenance](https://img.shields.io/badge/provenance-signed-brightgreen?logo=github)](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, views per minute over
224
- the last half hour, and the pages open, refreshed while you look;
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. A report
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; clicks on links to other sites (the other site's hostname only);
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
- # Run tests for CI/CD (no report generation)
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: 'schema_v5',
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
  };
@@ -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, possibly more than once
552
- * (hidden, shown again, then left), each time with its running total, so
553
- * the larger value always wins. Only a live view from the last
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 branches = appIds.map((appId) => `SELECT ? AS app_id, visitor_hash, page_path, event_type, timestamp
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 timestamp >= DATE_SUB(NOW(), INTERVAL ${ANALYSIS.REALTIME_CHART_MINUTES} MINUTE)`);
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
- app.use(rateLimit({
83
- windowMs: config.server.rateLimit.windowMs,
84
- limit: config.server.rateLimit.max,
85
- message: { message: 'Too many requests, please try again later.' },
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.2.0",
4
+ "version": "3.3.0",
5
5
  "main": "index.js",
6
6
  "engines": {
7
7
  "node": ">=24"
@@ -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
- limitPerApp,
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 };
@@ -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, clicks on links to
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 = location.pathname;
65
-
66
- /** Tell the server how long the current page was visible and how far it was scrolled. */
67
- function reportEngagement() {
68
- if (!view || !view.id) return;
69
- const ms = Math.min(MAX_ENGAGED_MS, Math.round(view.visibleMs + (view.visibleSince === null ? 0 : performance.now() - view.visibleSince)));
70
- if (ms <= view.sentMs && view.scroll <= view.sentScroll) return;
71
- view.sentMs = ms;
72
- view.sentScroll = view.scroll;
73
- const body = JSON.stringify({ appId: app, id: view.id, ms, scroll: view.scroll });
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
- reportEngagement();
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: location.pathname.slice(0, 500),
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) => { if (result && result.id) current.id = result.id; })
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: location.pathname.slice(0, 500),
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 (location.pathname === path) return;
135
- referrer = `${location.origin}${path}`;
136
- path = location.pathname;
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.