@harshankur/viewcounter 3.1.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 +37 -11
- package/README.md +330 -138
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +57 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +93 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +484 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +49 -26
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +236 -18
- package/tracker/tracker.js +240 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -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
- package/admin/js/insights.js +0 -192
package/db/analysis.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
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 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
|
|
446
|
+
FROM ${table(appId)}
|
|
447
|
+
WHERE deleted_at IS NULL AND (${charted} OR ${within('last_seen_at', ANALYSIS.REALTIME_VISITOR_MINUTES)})`);
|
|
448
|
+
const cte = `WITH v AS (${branches.join(' UNION ALL ')})`;
|
|
449
|
+
const params = [...appIds];
|
|
450
|
+
|
|
451
|
+
const [[summary = {}]] = await pool.query(`${cte}
|
|
452
|
+
SELECT COUNT(DISTINCT CASE WHEN ${recent} THEN visitor_hash END) AS visitors,
|
|
453
|
+
FLOOR(UNIX_TIMESTAMP(NOW()) / 60) AS now_minute
|
|
454
|
+
FROM v`, params);
|
|
455
|
+
const [minutes] = await pool.query(`${cte}
|
|
456
|
+
SELECT FLOOR(UNIX_TIMESTAMP(timestamp) / 60) AS minute, COUNT(*) AS views
|
|
457
|
+
FROM v WHERE ${charted} GROUP BY minute ORDER BY minute`, params);
|
|
458
|
+
const [pages] = await pool.query(`${cte}
|
|
459
|
+
SELECT app_id, page_path AS page, COUNT(DISTINCT visitor_hash) AS visitors
|
|
460
|
+
FROM v WHERE ${recent} AND event_type = ${PAGEVIEW}
|
|
461
|
+
GROUP BY app_id, page_path ORDER BY visitors DESC, page LIMIT ?`, [...params, ANALYSIS_TOP_N]);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
visitors: Number(summary.visitors || 0),
|
|
465
|
+
nowMinute: Number(summary.now_minute || 0),
|
|
466
|
+
minutes: minutes.map((row) => ({ minute: Number(row.minute), views: Number(row.views) })),
|
|
467
|
+
pages: pages.map((row) => ({ appId: row.app_id, page: row.page ?? null, visitors: Number(row.visitors) })),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = {
|
|
472
|
+
ACQUISITION_DIMENSIONS,
|
|
473
|
+
ANALYSIS_COLUMNS,
|
|
474
|
+
BUCKET_EXPRESSION,
|
|
475
|
+
BREAKDOWN_COLUMNS,
|
|
476
|
+
FILTER_COLUMNS,
|
|
477
|
+
VISITS_CTE,
|
|
478
|
+
breakdownTotals,
|
|
479
|
+
chooseBucket,
|
|
480
|
+
emptySections,
|
|
481
|
+
runAnalysis,
|
|
482
|
+
runRealtime,
|
|
483
|
+
tallyEventProperties,
|
|
484
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts tracking requests that were not stored (bots and rejections) and
|
|
3
|
+
* writes the counts to the tracking log in batches.
|
|
4
|
+
*
|
|
5
|
+
* A rejected request must cost less than an accepted one, or the tracking log
|
|
6
|
+
* would turn every flood the rate limiter turns away into database writes. So
|
|
7
|
+
* nothing is written per request: counts accumulate in memory, keyed by
|
|
8
|
+
* minute, endpoint, reason, app, detail, and hostname, and one upsert every
|
|
9
|
+
* TRACKING.REJECTION_FLUSH_MS adds them to what is stored.
|
|
10
|
+
*
|
|
11
|
+
* The key space is bounded as well, and so are the rows written. App IDs,
|
|
12
|
+
* details, and hostnames can be made up by whoever sends the request, so only
|
|
13
|
+
* TRACKING.REJECTION_MAX_KEYS_PER_MINUTE distinct keys a minute, and
|
|
14
|
+
* TRACKING.REJECTION_MAX_KEYS_PER_HOUR an hour, keep them. The budgets hold
|
|
15
|
+
* across flushes: writing the counts does not start a new allowance. Beyond
|
|
16
|
+
* them a key keeps only its minute, endpoint, and reason. The count stays
|
|
17
|
+
* exact; the made-up values are dropped.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { TRACKING } = require('../constants');
|
|
21
|
+
|
|
22
|
+
/** The shape an app ID must have to be kept; anything else is counted as blank. */
|
|
23
|
+
const APP_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
24
|
+
/** Details are our own short labels (a field name, a bot's name), but are bounded anyway. */
|
|
25
|
+
const DETAIL_MAX = 64;
|
|
26
|
+
|
|
27
|
+
const cleanAppId = (value) => (typeof value === 'string' && APP_ID_SHAPE.test(value) ? value : '');
|
|
28
|
+
const cleanDetail = (value) => (typeof value === 'string' ? value.replace(/[^\x20-\x7e]/g, '').slice(0, DETAIL_MAX) : '');
|
|
29
|
+
const cleanHostname = (value) => (typeof value === 'string' ? value : '');
|
|
30
|
+
|
|
31
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ write: (rows: object[]) => Promise<unknown>, now?: () => number,
|
|
35
|
+
* flushMs?: number, maxKeysPerMinute?: number, maxKeysPerHour?: number }} options
|
|
36
|
+
*/
|
|
37
|
+
function createRejectionCounter({
|
|
38
|
+
write,
|
|
39
|
+
now = Date.now,
|
|
40
|
+
flushMs = TRACKING.REJECTION_FLUSH_MS,
|
|
41
|
+
maxKeysPerMinute = TRACKING.REJECTION_MAX_KEYS_PER_MINUTE,
|
|
42
|
+
maxKeysPerHour = TRACKING.REJECTION_MAX_KEYS_PER_HOUR,
|
|
43
|
+
}) {
|
|
44
|
+
const pending = new Map();
|
|
45
|
+
/** Keys already kept whole, by minute: this minute's and the one before. */
|
|
46
|
+
const keptByMinute = new Map();
|
|
47
|
+
/** How many keys were kept whole, by hour: this hour's. */
|
|
48
|
+
const keptByHour = new Map();
|
|
49
|
+
let timer = null;
|
|
50
|
+
|
|
51
|
+
/** Whether a key may keep its app, detail, and hostname, spending the budgets if new. */
|
|
52
|
+
function keepWhole(minute, key) {
|
|
53
|
+
for (const old of keptByMinute.keys()) if (old < minute - TRACKING.REJECTION_BUCKET_MS) keptByMinute.delete(old);
|
|
54
|
+
const hour = Math.floor(minute / HOUR_MS) * HOUR_MS;
|
|
55
|
+
for (const old of keptByHour.keys()) if (old < hour) keptByHour.delete(old);
|
|
56
|
+
|
|
57
|
+
const kept = keptByMinute.get(minute) || new Set();
|
|
58
|
+
keptByMinute.set(minute, kept);
|
|
59
|
+
if (kept.has(key)) return true;
|
|
60
|
+
const hourCount = keptByHour.get(hour) || 0;
|
|
61
|
+
if (kept.size >= maxKeysPerMinute || hourCount >= maxKeysPerHour) return false;
|
|
62
|
+
kept.add(key);
|
|
63
|
+
keptByHour.set(hour, hourCount + 1);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function schedule() {
|
|
68
|
+
if (timer) return;
|
|
69
|
+
timer = setTimeout(() => {
|
|
70
|
+
timer = null;
|
|
71
|
+
flush();
|
|
72
|
+
}, flushMs);
|
|
73
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Count one request.
|
|
78
|
+
* @param {{ source: string, reason: string, appId?: string, detail?: string, hostname?: string }} rejection
|
|
79
|
+
*/
|
|
80
|
+
function count({ source, reason, appId, detail, hostname }) {
|
|
81
|
+
const minute = Math.floor(now() / TRACKING.REJECTION_BUCKET_MS) * TRACKING.REJECTION_BUCKET_MS;
|
|
82
|
+
let entry = { source, reason, appId: cleanAppId(appId), detail: cleanDetail(detail), hostname: cleanHostname(hostname) };
|
|
83
|
+
let key = [minute, entry.source, entry.reason, entry.appId, entry.detail, entry.hostname].join('\u0000');
|
|
84
|
+
const plain = !entry.appId && !entry.detail && !entry.hostname;
|
|
85
|
+
if (!plain && !keepWhole(minute, key)) {
|
|
86
|
+
entry = { source, reason, appId: '', detail: '', hostname: '' };
|
|
87
|
+
key = [minute, source, reason, '', '', ''].join('\u0000');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const row = pending.get(key) || { minute: new Date(minute), ...entry, requests: 0 };
|
|
91
|
+
row.requests += 1;
|
|
92
|
+
pending.set(key, row);
|
|
93
|
+
schedule();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write everything pending now; also used on shutdown. */
|
|
97
|
+
async function flush() {
|
|
98
|
+
if (timer) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
timer = null;
|
|
101
|
+
}
|
|
102
|
+
if (pending.size === 0) return;
|
|
103
|
+
const rows = [...pending.values()];
|
|
104
|
+
pending.clear();
|
|
105
|
+
await write(rows);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
count,
|
|
110
|
+
flush,
|
|
111
|
+
get pendingKeys() {
|
|
112
|
+
return pending.size;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { createRejectionCounter };
|