@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.
Files changed (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. 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 };