@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.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- 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
|
+
};
|