@harshankur/viewcounter 3.0.1 → 3.2.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.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Admin sessions in the database, so a restart or deploy signs nobody out.
3
+ *
4
+ * Same interface as the in-memory store in middleware/adminAuth.js. The table
5
+ * holds each token's SHA-256, never the token. Ages are computed by the
6
+ * database's clock, the same clock that wrote the timestamps, and a session's
7
+ * last-seen time is written at most once a minute however busy the admin is.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ const { ADMIN, ADMIN_SESSIONS_TABLE } = require('../constants');
13
+ const { hashToken, newToken } = require('../middleware/adminAuth');
14
+
15
+ const TABLE = `\`${ADMIN_SESSIONS_TABLE}\``;
16
+ const seconds = (ms) => Math.floor(ms / 1000);
17
+
18
+ /**
19
+ * @param {{ pool: object }} db a DatabaseManager; its pool exists once initialized
20
+ * @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number }} [options]
21
+ */
22
+ function createDbSessionStore(db, {
23
+ idleMs = ADMIN.SESSION_IDLE_TIMEOUT_MS,
24
+ absoluteMs = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
25
+ maxSessions = ADMIN.MAX_SESSIONS,
26
+ } = {}) {
27
+ const query = (sql, params) => db.pool.query(sql, params);
28
+
29
+ return {
30
+ idleMs,
31
+ absoluteMs,
32
+
33
+ async create() {
34
+ // Expired sessions go first, so the table never outgrows the
35
+ // sessions that could still be used.
36
+ await query(
37
+ `DELETE FROM ${TABLE}
38
+ WHERE last_seen_at < DATE_SUB(NOW(3), INTERVAL ? SECOND)
39
+ OR created_at < DATE_SUB(NOW(3), INTERVAL ? SECOND)`,
40
+ [seconds(idleMs), seconds(absoluteMs)]
41
+ );
42
+ const token = newToken();
43
+ const id = crypto.randomUUID();
44
+ await query(
45
+ `INSERT INTO ${TABLE} (token_hash, id, created_at, last_seen_at, password_at)
46
+ VALUES (?, ?, NOW(3), NOW(3), NOW(3))`,
47
+ [hashToken(token), id]
48
+ );
49
+ // Bounded like the in-memory store: beyond the limit, the least
50
+ // recently used sessions end. The derived table lets MySQL read
51
+ // the table it is deleting from.
52
+ await query(
53
+ `DELETE FROM ${TABLE} WHERE token_hash NOT IN (
54
+ SELECT token_hash FROM (
55
+ SELECT token_hash FROM ${TABLE} ORDER BY last_seen_at DESC, created_at DESC LIMIT ?
56
+ ) AS newest
57
+ )`,
58
+ [maxSessions]
59
+ );
60
+ return { token, session: { id, passwordAgeMs: 0 } };
61
+ },
62
+
63
+ async get(token) {
64
+ if (typeof token !== 'string' || token.length === 0) return null;
65
+ const key = hashToken(token);
66
+ const [rows] = await query(
67
+ `SELECT id,
68
+ TIMESTAMPDIFF(SECOND, created_at, NOW(3)) AS age_s,
69
+ TIMESTAMPDIFF(SECOND, last_seen_at, NOW(3)) AS idle_s,
70
+ TIMESTAMPDIFF(SECOND, password_at, NOW(3)) AS password_age_s
71
+ FROM ${TABLE} WHERE token_hash = ?`,
72
+ [key]
73
+ );
74
+ const row = rows[0];
75
+ if (!row) return null;
76
+
77
+ const idle = Number(row.idle_s) * 1000;
78
+ if (idle > idleMs || Number(row.age_s) * 1000 > absoluteMs) {
79
+ await query(`DELETE FROM ${TABLE} WHERE token_hash = ?`, [key]);
80
+ return null;
81
+ }
82
+ if (idle >= ADMIN.SESSION_TOUCH_INTERVAL_MS) {
83
+ await query(`UPDATE ${TABLE} SET last_seen_at = NOW(3) WHERE token_hash = ?`, [key]);
84
+ }
85
+ return { id: row.id, passwordAgeMs: Number(row.password_age_s) * 1000 };
86
+ },
87
+
88
+ async destroy(token) {
89
+ if (typeof token !== 'string' || token.length === 0) return false;
90
+ const [result] = await query(`DELETE FROM ${TABLE} WHERE token_hash = ?`, [hashToken(token)]);
91
+ return result.affectedRows > 0;
92
+ },
93
+
94
+ async confirmPassword(token) {
95
+ if (typeof token !== 'string' || token.length === 0) return;
96
+ await query(
97
+ `UPDATE ${TABLE} SET password_at = NOW(3), last_seen_at = NOW(3) WHERE token_hash = ?`,
98
+ [hashToken(token)]
99
+ );
100
+ },
101
+ };
102
+ }
103
+
104
+ module.exports = { createDbSessionStore };
package/db/analysis.js ADDED
@@ -0,0 +1,479 @@
1
+ /**
2
+ * The admin analysis: everything the insights show, read from exactly the rows
3
+ * a listing with the same filters returns.
4
+ *
5
+ * Nothing here collects anything. Visits, bounces, and page flow are read from
6
+ * the rotating visitor hash the way privacy-first analytics does it: a
7
+ * visitor's page views belong to one visit until they pause for
8
+ * ANALYSIS.VISIT_GAP_SECONDS. The hash changes every unique-visitor window, so
9
+ * a visit can never be linked to the same person on another day.
10
+ *
11
+ * Every identifier and expression interpolated into SQL here is a fixed
12
+ * literal from this file or constants.js. Caller input reaches the statements
13
+ * only as bound parameters, through the shared filter.
14
+ */
15
+
16
+ const {
17
+ ANALYSIS,
18
+ ANALYSIS_TOP_N,
19
+ EVENT_TYPE,
20
+ TREND_BUCKET,
21
+ TREND_BUCKET_MAX_DAYS,
22
+ } = require('../constants');
23
+
24
+ const PAGEVIEW = `'${EVENT_TYPE.PAGEVIEW}'`;
25
+
26
+ /**
27
+ * Columns the analysis reads. `visitor_hash` is here only so the database can
28
+ * count visitors and group visits; it is never selected into a result. `id`
29
+ * only orders views that share a timestamp.
30
+ */
31
+ const ANALYSIS_COLUMNS = [
32
+ 'id', 'timestamp', 'visitor_hash', 'is_unique', 'admin_modified_at', 'event_type',
33
+ 'page_path', 'page_title', 'hostname', 'referrer', 'referrer_domain', 'source_type',
34
+ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
35
+ 'country', 'region', 'city', 'language',
36
+ 'devicesize', 'device_type', 'browser', 'browser_version', 'os', 'os_version',
37
+ 'engaged_ms', 'scroll_depth',
38
+ ];
39
+
40
+ /**
41
+ * A time column as UTC wall-clock time, whatever the database session's time
42
+ * zone. Rows are written with NOW() in that zone, so UNIX_TIMESTAMP() recovers
43
+ * the instant, and adding it to the epoch gives the UTC date and hour the UI
44
+ * and every other bucket assume.
45
+ * @param {string} column a fixed column name
46
+ */
47
+ const utc = (column) => `DATE_ADD('1970-01-01 00:00:00', INTERVAL FLOOR(UNIX_TIMESTAMP(${column})) SECOND)`;
48
+
49
+ /**
50
+ * Time-series bucket expressions over a time column, keyed by TREND_BUCKET.
51
+ * Each bucket is labelled by the moment it starts, in UTC (YYYY-MM-DD, or
52
+ * YYYY-MM-DD HH:00 by hour).
53
+ * @param {string} column a fixed column name
54
+ */
55
+ const bucketExpressions = (column) => {
56
+ const at = utc(column);
57
+ return {
58
+ [TREND_BUCKET.HOUR]: `DATE_FORMAT(${at}, '%Y-%m-%d %H:00')`,
59
+ [TREND_BUCKET.DAY]: `DATE_FORMAT(${at}, '%Y-%m-%d')`,
60
+ [TREND_BUCKET.WEEK]: `DATE_FORMAT(DATE_SUB(DATE(${at}), INTERVAL WEEKDAY(${at}) DAY), '%Y-%m-%d')`,
61
+ [TREND_BUCKET.MONTH]: `DATE_FORMAT(${at}, '%Y-%m-01')`,
62
+ };
63
+ };
64
+
65
+ /** Buckets of a view's own time. */
66
+ const BUCKET_EXPRESSION = bucketExpressions('timestamp');
67
+ /** Buckets of a visit's start, so a visit counts in the period it began. */
68
+ const VISIT_BUCKET_EXPRESSION = bucketExpressions('started_at');
69
+
70
+ /** "Bavaria, DE": a region or city name alone is ambiguous across countries. */
71
+ const placeIn = (column) => `CASE WHEN ${column} IS NULL THEN NULL ELSE CONCAT(${column}, ', ', COALESCE(country, '?')) END`;
72
+ /** "Chrome 129": the name with its major version (or major.minor for systems). */
73
+ const withVersion = (name, version, parts) =>
74
+ `CASE WHEN ${name} IS NULL THEN NULL ELSE CONCAT_WS(' ', ${name}, SUBSTRING_INDEX(${version}, '.', ${parts})) END`;
75
+
76
+ /** Breakdown dimensions: API name -> expression. Fixed literals. */
77
+ const BREAKDOWN_COLUMNS = {
78
+ source: 'source_type',
79
+ referrer: 'referrer_domain',
80
+ referrerUrl: 'referrer',
81
+ utmSource: 'utm_source',
82
+ utmMedium: 'utm_medium',
83
+ utmCampaign: 'utm_campaign',
84
+ utmTerm: 'utm_term',
85
+ utmContent: 'utm_content',
86
+ page: 'page_path',
87
+ title: 'page_title',
88
+ hostname: 'hostname',
89
+ country: 'country',
90
+ region: placeIn('region'),
91
+ city: placeIn('city'),
92
+ language: 'language',
93
+ deviceSize: 'devicesize',
94
+ deviceType: 'device_type',
95
+ browser: 'browser',
96
+ browserVersion: withVersion('browser', 'browser_version', 1),
97
+ os: 'os',
98
+ osVersion: withVersion('os', 'os_version', 2),
99
+ eventType: 'event_type',
100
+ app: 'app_id',
101
+ };
102
+
103
+ /**
104
+ * Breakdowns of how a visit arrived. They count page views only: a custom
105
+ * event is sent from a page already open and carries no referrer or campaign.
106
+ */
107
+ const ACQUISITION_DIMENSIONS = new Set(['source', 'referrer', 'referrerUrl', 'utmSource', 'utmMedium', 'utmCampaign', 'utmTerm', 'utmContent']);
108
+
109
+ /**
110
+ * The breakdowns the analysis and the listing can be narrowed to, such as one
111
+ * country or one page. Every one but the app, which is chosen by the route
112
+ * (and is not a column of an app's table).
113
+ */
114
+ const FILTER_COLUMNS = Object.fromEntries(Object.entries(BREAKDOWN_COLUMNS).filter(([dim]) => dim !== 'app'));
115
+
116
+ const DAY_MS = 24 * 60 * 60 * 1000;
117
+
118
+ /** The coarsest bucket that keeps a chart of this span readable. */
119
+ function chooseBucket(firstAt, lastAt) {
120
+ if (!firstAt || !lastAt) return TREND_BUCKET.DAY;
121
+ const days = (new Date(lastAt).getTime() - new Date(firstAt).getTime()) / DAY_MS;
122
+ for (const bucket of [TREND_BUCKET.HOUR, TREND_BUCKET.DAY, TREND_BUCKET.WEEK]) {
123
+ if (days <= TREND_BUCKET_MAX_DAYS[bucket]) return bucket;
124
+ }
125
+ return TREND_BUCKET.MONTH;
126
+ }
127
+
128
+ /**
129
+ * Page views grouped into visits. Appended after `WITH v AS (...)`, it adds:
130
+ * t each page view with its step in the visit, the steps left, and the next page
131
+ * visits one row per visit: pages, duration, entry and exit page
132
+ * A visit's duration runs from its first page view to its last, plus how long
133
+ * the last page was visible when the tracker reported it.
134
+ */
135
+ const VISITS_CTE = `,
136
+ p AS (
137
+ SELECT app_id, visitor_hash, id, timestamp, page_path, engaged_ms,
138
+ CASE WHEN LAG(timestamp) OVER (PARTITION BY app_id, visitor_hash ORDER BY timestamp, id) IS NULL
139
+ OR TIMESTAMPDIFF(SECOND,
140
+ LAG(timestamp) OVER (PARTITION BY app_id, visitor_hash ORDER BY timestamp, id),
141
+ timestamp) > ${ANALYSIS.VISIT_GAP_SECONDS}
142
+ THEN 1 ELSE 0 END AS starts_visit
143
+ FROM v WHERE event_type = ${PAGEVIEW}
144
+ ),
145
+ s AS (
146
+ SELECT p.*, SUM(starts_visit) OVER (
147
+ PARTITION BY app_id, visitor_hash ORDER BY timestamp, id ROWS UNBOUNDED PRECEDING) AS visit_no
148
+ FROM p
149
+ ),
150
+ t AS (
151
+ SELECT s.*,
152
+ ROW_NUMBER() OVER (PARTITION BY app_id, visitor_hash, visit_no ORDER BY timestamp, id) AS step,
153
+ ROW_NUMBER() OVER (PARTITION BY app_id, visitor_hash, visit_no ORDER BY timestamp DESC, id DESC) AS steps_left,
154
+ LEAD(page_path) OVER (PARTITION BY app_id, visitor_hash, visit_no ORDER BY timestamp, id) AS next_page
155
+ FROM s
156
+ ),
157
+ visits AS (
158
+ SELECT app_id, visitor_hash, visit_no, COUNT(*) AS pages, MIN(timestamp) AS started_at,
159
+ TIMESTAMPDIFF(SECOND, MIN(timestamp), MAX(timestamp)) * 1000
160
+ + COALESCE(MAX(CASE WHEN steps_left = 1 THEN engaged_ms END), 0) AS duration_ms,
161
+ MAX(CASE WHEN step = 1 THEN page_path END) AS entry_page,
162
+ MAX(CASE WHEN steps_left = 1 THEN page_path END) AS exit_page
163
+ FROM t GROUP BY app_id, visitor_hash, visit_no
164
+ )`;
165
+
166
+ const num = (value) => (value === null || value === undefined ? null : Number(value));
167
+ const rate = (part, whole) => (whole > 0 ? Number(part) / whole : null);
168
+
169
+ /** The rows each breakdown counts: page views for how visits arrived, every view otherwise. */
170
+ function breakdownTotals(totals) {
171
+ return Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) =>
172
+ [dim, ACQUISITION_DIMENSIONS.has(dim) ? totals.pageviews : totals.views]));
173
+ }
174
+
175
+ /** Everything but the totals, for a filter that matches nothing. */
176
+ function emptySections(totals = { views: 0, pageviews: 0 }) {
177
+ return {
178
+ bucket: TREND_BUCKET.DAY,
179
+ trend: [],
180
+ breakdowns: Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) => [dim, []])),
181
+ breakdownTotals: breakdownTotals(totals),
182
+ pages: [],
183
+ entryPages: [],
184
+ exitPages: [],
185
+ transitions: [],
186
+ scrollDepth: [],
187
+ timeOnPage: [],
188
+ hours: [],
189
+ countries: [],
190
+ eventProperties: [],
191
+ };
192
+ }
193
+
194
+ /**
195
+ * @param {{ query: (sql: string, params?: unknown[]) => Promise<[any]> }} pool
196
+ * @param {(appId: string) => string} table the quoted table name of a validated app ID
197
+ * @param {string[]} appIds
198
+ * @param {(window?: string) => { clause: string, params: unknown[] }} filter the WHERE clause for the
199
+ * requested rows, or with 'previous' for the period of the same length before them
200
+ * @param {{ hasPrevious: boolean, spanDays?: number }} options spanDays is the length of a bounded
201
+ * range, which sets the trend's bucket; without one, the span of the data does
202
+ */
203
+ async function runAnalysis(pool, table, appIds, filter, { hasPrevious, spanDays }) {
204
+ // A bounded range ends now; the UI charts all of it, from this window.
205
+ const now = new Date();
206
+ const window = spanDays ? { from: new Date(now.getTime() - spanDays * DAY_MS), to: now } : null;
207
+ const cte = (window, columns = ANALYSIS_COLUMNS) => {
208
+ const { clause, params } = filter(window);
209
+ const branches = appIds.map((appId) => `SELECT ? AS app_id, ${columns.join(', ')} FROM ${table(appId)} WHERE ${clause}`);
210
+ return { sql: `WITH v AS (${branches.join(' UNION ALL ')})`, params: appIds.flatMap((appId) => [appId, ...params]) };
211
+ };
212
+ const run = async (window, body, extraParams = [], columns) => {
213
+ const { sql, params } = cte(window, columns);
214
+ const [rows] = await pool.query(`${sql} ${body}`, [...params, ...extraParams]);
215
+ return rows;
216
+ };
217
+
218
+ const readTotals = async (window) => {
219
+ const [row = {}] = await run(window, `SELECT
220
+ COUNT(*) AS views,
221
+ COALESCE(SUM(event_type = ${PAGEVIEW}), 0) AS pageviews,
222
+ COALESCE(SUM(is_unique), 0) AS unique_views,
223
+ COUNT(DISTINCT visitor_hash) AS visitors,
224
+ COUNT(DISTINCT country) AS countries,
225
+ COALESCE(SUM(admin_modified_at IS NOT NULL), 0) AS modified,
226
+ AVG(CASE WHEN event_type = ${PAGEVIEW} THEN engaged_ms END) AS avg_engaged_ms,
227
+ AVG(CASE WHEN event_type = ${PAGEVIEW} THEN scroll_depth END) AS avg_scroll,
228
+ COUNT(CASE WHEN event_type = ${PAGEVIEW} THEN engaged_ms END) AS engaged_views,
229
+ MIN(timestamp) AS first_at,
230
+ MAX(timestamp) AS last_at
231
+ FROM v`);
232
+ const [visitRow = {}] = await run(window, `${VISITS_CTE}
233
+ SELECT COUNT(*) AS visits, COALESCE(SUM(pages = 1), 0) AS bounces,
234
+ AVG(duration_ms) AS avg_visit_ms, AVG(pages) AS pages_per_visit
235
+ FROM visits`);
236
+ const visits = Number(visitRow.visits || 0);
237
+ return {
238
+ views: Number(row.views || 0),
239
+ pageviews: Number(row.pageviews || 0),
240
+ uniqueViews: Number(row.unique_views || 0),
241
+ visitors: Number(row.visitors || 0),
242
+ countries: Number(row.countries || 0),
243
+ modified: Number(row.modified || 0),
244
+ visits,
245
+ bounceRate: rate(visitRow.bounces || 0, visits),
246
+ avgVisitMs: num(visitRow.avg_visit_ms),
247
+ pagesPerVisit: num(visitRow.pages_per_visit),
248
+ avgEngagedMs: num(row.avg_engaged_ms),
249
+ avgScroll: num(row.avg_scroll),
250
+ engagedViews: Number(row.engaged_views || 0),
251
+ firstAt: row.first_at ?? null,
252
+ lastAt: row.last_at ?? null,
253
+ };
254
+ };
255
+
256
+ const totals = await readTotals();
257
+ const previous = hasPrevious ? await readTotals('previous') : null;
258
+ if (totals.views === 0) return { totals, previous, window, ...emptySections(totals) };
259
+
260
+ // A bounded range is charted across all of it, so its bucket follows the
261
+ // range: "last 7 days" is seven days even when only two had views.
262
+ const bucket = window ? chooseBucket(window.from, window.to) : chooseBucket(totals.firstAt, totals.lastAt);
263
+ const trend = await run(undefined, `SELECT ${BUCKET_EXPRESSION[bucket]} AS period, COUNT(*) AS views,
264
+ COALESCE(SUM(event_type = ${PAGEVIEW}), 0) AS pageviews,
265
+ COALESCE(SUM(is_unique), 0) AS unique_views, COUNT(DISTINCT visitor_hash) AS visitors,
266
+ AVG(CASE WHEN event_type = ${PAGEVIEW} THEN engaged_ms END) AS avg_engaged_ms,
267
+ AVG(CASE WHEN event_type = ${PAGEVIEW} THEN scroll_depth END) AS avg_scroll
268
+ FROM v GROUP BY period ORDER BY period`);
269
+ // Visits by the period they began in, so every headline number has a trend.
270
+ const visitTrend = await run(undefined, `${VISITS_CTE}
271
+ SELECT ${VISIT_BUCKET_EXPRESSION[bucket]} AS period, COUNT(*) AS visits, SUM(pages = 1) AS bounces,
272
+ AVG(duration_ms) AS avg_visit_ms, AVG(pages) AS pages_per_visit
273
+ FROM visits GROUP BY period ORDER BY period`);
274
+ const visitsByPeriod = new Map(visitTrend.map((row) => [String(row.period), row]));
275
+
276
+ const groups = Object.entries(BREAKDOWN_COLUMNS).map(([dim, expression]) =>
277
+ `SELECT '${dim}' AS dim, CAST(${expression} AS CHAR) AS value, COUNT(*) AS views,
278
+ COUNT(DISTINCT visitor_hash) AS visitors FROM v
279
+ ${ACQUISITION_DIMENSIONS.has(dim) ? `WHERE event_type = ${PAGEVIEW}` : ''} GROUP BY value`);
280
+ const breakdownRows = await run(undefined, `SELECT dim, value, views, visitors FROM (
281
+ SELECT dim, value, views, visitors,
282
+ ROW_NUMBER() OVER (PARTITION BY dim ORDER BY views DESC, value) AS rank_in_dim
283
+ FROM (${groups.join(' UNION ALL ')}) AS g
284
+ ) AS ranked
285
+ WHERE rank_in_dim <= ?
286
+ ORDER BY dim, views DESC, value`, [ANALYSIS_TOP_N]);
287
+ const breakdowns = Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) => [dim, []]));
288
+ for (const row of breakdownRows) {
289
+ breakdowns[row.dim]?.push({ value: row.value ?? null, views: Number(row.views), visitors: Number(row.visitors) });
290
+ }
291
+
292
+ const pages = await run(undefined, `SELECT app_id, page_path AS page, COUNT(*) AS views,
293
+ COUNT(DISTINCT visitor_hash) AS visitors, AVG(engaged_ms) AS avg_engaged_ms, AVG(scroll_depth) AS avg_scroll
294
+ FROM v WHERE event_type = ${PAGEVIEW}
295
+ GROUP BY app_id, page_path ORDER BY views DESC, page LIMIT ?`, [ANALYSIS_TOP_N]);
296
+
297
+ const landings = await run(undefined, `${VISITS_CTE}
298
+ SELECT kind, app_id, page, visits, bounces FROM (
299
+ SELECT kind, app_id, page, visits, bounces,
300
+ ROW_NUMBER() OVER (PARTITION BY kind ORDER BY visits DESC, page) AS rank_in_kind
301
+ FROM (
302
+ SELECT 'entry' AS kind, app_id, entry_page AS page, COUNT(*) AS visits, SUM(pages = 1) AS bounces
303
+ FROM visits GROUP BY app_id, entry_page
304
+ UNION ALL
305
+ SELECT 'exit' AS kind, app_id, exit_page AS page, COUNT(*) AS visits, 0 AS bounces
306
+ FROM visits GROUP BY app_id, exit_page
307
+ ) AS g
308
+ ) AS ranked
309
+ WHERE rank_in_kind <= ?
310
+ ORDER BY kind, visits DESC, page`, [ANALYSIS_TOP_N]);
311
+
312
+ const transitions = await run(undefined, `${VISITS_CTE}
313
+ SELECT app_id, page_path AS from_page, next_page AS to_page, COUNT(*) AS steps
314
+ FROM t WHERE next_page IS NOT NULL AND NOT (next_page <=> page_path)
315
+ GROUP BY app_id, page_path, next_page
316
+ ORDER BY steps DESC, from_page, to_page LIMIT ?`, [ANALYSIS.TRANSITIONS_TOP_N]);
317
+
318
+ const distributions = await run(undefined, `
319
+ SELECT 'scroll' AS kind,
320
+ CASE WHEN scroll_depth < 25 THEN 0 WHEN scroll_depth < 50 THEN 25 WHEN scroll_depth < 75 THEN 50
321
+ WHEN scroll_depth < 100 THEN 75 ELSE 100 END AS bucket,
322
+ COUNT(*) AS views
323
+ FROM v WHERE event_type = ${PAGEVIEW} AND scroll_depth IS NOT NULL GROUP BY bucket
324
+ UNION ALL
325
+ SELECT 'time' AS kind,
326
+ CASE WHEN engaged_ms < 10000 THEN 0 WHEN engaged_ms < 30000 THEN 10 WHEN engaged_ms < 60000 THEN 30
327
+ WHEN engaged_ms < 180000 THEN 60 WHEN engaged_ms < 600000 THEN 180 ELSE 600 END AS bucket,
328
+ COUNT(*) AS views
329
+ FROM v WHERE event_type = ${PAGEVIEW} AND engaged_ms IS NOT NULL GROUP BY bucket
330
+ ORDER BY kind, bucket`);
331
+
332
+ const hours = await run(undefined, `SELECT FLOOR(UNIX_TIMESTAMP(timestamp) / 3600) AS hour, COUNT(*) AS views
333
+ FROM v WHERE timestamp >= DATE_SUB(?, INTERVAL ${ANALYSIS.HEATMAP_MAX_DAYS} DAY)
334
+ GROUP BY hour ORDER BY hour`, [totals.lastAt]);
335
+
336
+ // Per-country counts, split by the leading event types so the map can
337
+ // show where each type comes from; the rest are grouped as null.
338
+ const types = breakdowns.eventType.map((entry) => entry.value).filter((value) => value !== null);
339
+ const countryRows = await run(undefined, `SELECT country,
340
+ CASE WHEN event_type IN (?) THEN event_type ELSE NULL END AS event_type,
341
+ COUNT(*) AS views
342
+ FROM v WHERE country IS NOT NULL
343
+ GROUP BY country, CASE WHEN event_type IN (?) THEN event_type ELSE NULL END
344
+ ORDER BY country`, [types.length ? types : [''], types.length ? types : ['']]);
345
+
346
+ const eventRows = await run(undefined, `SELECT event_type, event_data FROM v
347
+ WHERE event_type <> ${PAGEVIEW} AND event_data IS NOT NULL
348
+ ORDER BY timestamp DESC LIMIT ?`, [ANALYSIS.EVENT_PROPERTIES_SAMPLE], [...ANALYSIS_COLUMNS, 'event_data']);
349
+
350
+ return {
351
+ totals,
352
+ previous,
353
+ window,
354
+ bucket,
355
+ trend: trend.map((row) => {
356
+ const visit = visitsByPeriod.get(String(row.period)) || {};
357
+ const visits = Number(visit.visits || 0);
358
+ return {
359
+ period: String(row.period),
360
+ views: Number(row.views),
361
+ pageviews: Number(row.pageviews),
362
+ uniqueViews: Number(row.unique_views),
363
+ visitors: Number(row.visitors),
364
+ visits,
365
+ bounceRate: rate(visit.bounces || 0, visits),
366
+ avgVisitMs: num(visit.avg_visit_ms),
367
+ pagesPerVisit: num(visit.pages_per_visit),
368
+ avgEngagedMs: num(row.avg_engaged_ms),
369
+ avgScroll: num(row.avg_scroll),
370
+ };
371
+ }),
372
+ breakdowns,
373
+ // What each breakdown counted, for its shares and its "everything else".
374
+ breakdownTotals: breakdownTotals(totals),
375
+ pages: pages.map((row) => ({
376
+ appId: row.app_id,
377
+ page: row.page ?? null,
378
+ views: Number(row.views),
379
+ visitors: Number(row.visitors),
380
+ avgEngagedMs: num(row.avg_engaged_ms),
381
+ avgScroll: num(row.avg_scroll),
382
+ })),
383
+ entryPages: landings.filter((row) => row.kind === 'entry').map((row) => ({
384
+ appId: row.app_id, page: row.page ?? null, visits: Number(row.visits), bounceRate: rate(row.bounces, Number(row.visits)),
385
+ })),
386
+ exitPages: landings.filter((row) => row.kind === 'exit').map((row) => ({
387
+ appId: row.app_id, page: row.page ?? null, visits: Number(row.visits),
388
+ })),
389
+ transitions: transitions.map((row) => ({
390
+ appId: row.app_id, from: row.from_page ?? null, to: row.to_page ?? null, steps: Number(row.steps),
391
+ })),
392
+ scrollDepth: distributions.filter((row) => row.kind === 'scroll').map((row) => ({ from: Number(row.bucket), views: Number(row.views) })),
393
+ timeOnPage: distributions.filter((row) => row.kind === 'time').map((row) => ({ fromSeconds: Number(row.bucket), views: Number(row.views) })),
394
+ hours: hours.map((row) => ({ hour: Number(row.hour), views: Number(row.views) })),
395
+ countries: countryRows.map((row) => ({ country: row.country, eventType: row.event_type ?? null, views: Number(row.views) })),
396
+ eventProperties: tallyEventProperties(eventRows),
397
+ };
398
+ }
399
+
400
+ /**
401
+ * The most common property values of custom events: for each event type,
402
+ * each top-level key with a text, number, or true/false value.
403
+ *
404
+ * @param {{ event_type: string, event_data: unknown }[]} rows newest first
405
+ * @returns {{ eventType: string, key: string, value: string, count: number }[]}
406
+ */
407
+ function tallyEventProperties(rows) {
408
+ const counts = new Map();
409
+ for (const row of rows) {
410
+ let data = row.event_data;
411
+ if (typeof data === 'string') {
412
+ try { data = JSON.parse(data); } catch { continue; }
413
+ }
414
+ if (!data || typeof data !== 'object' || Array.isArray(data)) continue;
415
+ for (const [key, value] of Object.entries(data)) {
416
+ if (!['string', 'number', 'boolean'].includes(typeof value)) continue;
417
+ const text = String(value).slice(0, 100);
418
+ const id = JSON.stringify([row.event_type, key, text]);
419
+ const entry = counts.get(id) || { eventType: row.event_type, key: key.slice(0, 100), value: text, count: 0 };
420
+ entry.count += 1;
421
+ counts.set(id, entry);
422
+ }
423
+ }
424
+ return [...counts.values()]
425
+ .sort((a, b) => b.count - a.count || a.eventType.localeCompare(b.eventType)
426
+ || a.key.localeCompare(b.key) || a.value.localeCompare(b.value))
427
+ .slice(0, ANALYSIS.EVENT_PROPERTIES_TOP_N);
428
+ }
429
+
430
+ /**
431
+ * Right now: who is on the sites in the last few minutes, and views per
432
+ * minute over the last half hour. Live views only, no other filter.
433
+ *
434
+ * @param {object} pool
435
+ * @param {(appId: string) => string} table
436
+ * @param {string[]} appIds
437
+ */
438
+ async function runRealtime(pool, table, appIds) {
439
+ const branches = appIds.map((appId) => `SELECT ? AS app_id, visitor_hash, page_path, event_type, timestamp
440
+ FROM ${table(appId)}
441
+ WHERE deleted_at IS NULL AND timestamp >= DATE_SUB(NOW(), INTERVAL ${ANALYSIS.REALTIME_CHART_MINUTES} MINUTE)`);
442
+ const cte = `WITH v AS (${branches.join(' UNION ALL ')})`;
443
+ const params = [...appIds];
444
+ const recent = `timestamp >= DATE_SUB(NOW(), INTERVAL ${ANALYSIS.REALTIME_VISITOR_MINUTES} MINUTE)`;
445
+
446
+ const [[summary = {}]] = await pool.query(`${cte}
447
+ SELECT COUNT(DISTINCT CASE WHEN ${recent} THEN visitor_hash END) AS visitors,
448
+ FLOOR(UNIX_TIMESTAMP(NOW()) / 60) AS now_minute
449
+ FROM v`, params);
450
+ const [minutes] = await pool.query(`${cte}
451
+ SELECT FLOOR(UNIX_TIMESTAMP(timestamp) / 60) AS minute, COUNT(*) AS views
452
+ FROM v GROUP BY minute ORDER BY minute`, params);
453
+ const [pages] = await pool.query(`${cte}
454
+ SELECT app_id, page_path AS page, COUNT(DISTINCT visitor_hash) AS visitors
455
+ FROM v WHERE ${recent} AND event_type = ${PAGEVIEW}
456
+ GROUP BY app_id, page_path ORDER BY visitors DESC, page LIMIT ?`, [...params, ANALYSIS_TOP_N]);
457
+
458
+ return {
459
+ visitors: Number(summary.visitors || 0),
460
+ nowMinute: Number(summary.now_minute || 0),
461
+ minutes: minutes.map((row) => ({ minute: Number(row.minute), views: Number(row.views) })),
462
+ pages: pages.map((row) => ({ appId: row.app_id, page: row.page ?? null, visitors: Number(row.visitors) })),
463
+ };
464
+ }
465
+
466
+ module.exports = {
467
+ ACQUISITION_DIMENSIONS,
468
+ ANALYSIS_COLUMNS,
469
+ BUCKET_EXPRESSION,
470
+ BREAKDOWN_COLUMNS,
471
+ FILTER_COLUMNS,
472
+ VISITS_CTE,
473
+ breakdownTotals,
474
+ chooseBucket,
475
+ emptySections,
476
+ runAnalysis,
477
+ runRealtime,
478
+ tallyEventProperties,
479
+ };