@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/constants.js CHANGED
@@ -52,6 +52,14 @@ const FIELD_MAX_LENGTH = {
52
52
  NOTE: 1000,
53
53
  /** CHAR(36): the canonical textual form of a UUID. */
54
54
  UUID: 36,
55
+ /** The longest a DNS name can be. */
56
+ HOSTNAME: 253,
57
+ /** A primary language subtag: two or three letters, rarely up to eight. */
58
+ LANGUAGE: 8,
59
+ /** Each of utm_source, utm_medium, utm_campaign, utm_term, utm_content. */
60
+ UTM: 100,
61
+ REGION: 100,
62
+ CITY: 100,
55
63
  };
56
64
 
57
65
  /** Bounds for user-supplied pagination and range parameters. */
@@ -89,7 +97,7 @@ const DATABASE = {
89
97
  QUERY_TIMEOUT_MS: 5_000,
90
98
  CONNECT_TIMEOUT_MS: 10_000,
91
99
  DEFAULT_PORT: 3306,
92
- SCHEMA_VERSION: 'admin_schema_v4',
100
+ SCHEMA_VERSION: 'schema_v6',
93
101
  /** Rows given a public_id per statement when backfilling an old table. */
94
102
  BACKFILL_BATCH_SIZE: 500,
95
103
  };
@@ -145,10 +153,21 @@ const ADMIN = {
145
153
  MAX_PASSWORD_INPUT_LENGTH: 1024,
146
154
  SESSION_TOKEN_BYTES: 32,
147
155
  CSRF_TOKEN_BYTES: 32,
148
- /** Signed out after this long without a request. */
149
- SESSION_IDLE_TIMEOUT_MS: 30 * 60 * 1000,
150
- /** Signed out after this long regardless of activity. */
151
- SESSION_ABSOLUTE_TIMEOUT_MS: 12 * 60 * 60 * 1000,
156
+ /** Default for ADMIN_SESSION_IDLE_TIMEOUT: signed out after this long unused. */
157
+ SESSION_IDLE_TIMEOUT_MS: 7 * 24 * 60 * 60 * 1000,
158
+ /** Default for ADMIN_SESSION_MAX_AGE: signed out after this long regardless. */
159
+ SESSION_ABSOLUTE_TIMEOUT_MS: 30 * 24 * 60 * 60 * 1000,
160
+ SESSION_IDLE_MIN_MS: 5 * 60 * 1000,
161
+ SESSION_IDLE_MAX_MS: 90 * 24 * 60 * 60 * 1000,
162
+ SESSION_MAX_AGE_MIN_MS: 60 * 60 * 1000,
163
+ SESSION_MAX_AGE_MAX_MS: 365 * 24 * 60 * 60 * 1000,
164
+ /**
165
+ * A session's last-seen time is written at most this often, so an active
166
+ * admin costs one database write a minute rather than one per request.
167
+ */
168
+ SESSION_TOUCH_INTERVAL_MS: 60 * 1000,
169
+ /** Permanent erasure needs the password to have been entered this recently. */
170
+ REAUTH_WINDOW_MS: 15 * 60 * 1000,
152
171
  /** Oldest sessions are evicted beyond this, bounding memory. */
153
172
  MAX_SESSIONS: 50,
154
173
  LOGIN_RATE_LIMIT_WINDOW_MS: 15 * 60 * 1000,
@@ -176,6 +195,8 @@ const ADMIN = {
176
195
  VIEW_LOG_PRUNE_BATCH_SIZE: 5000,
177
196
  /** How often expired trash and old view-log entries are checked for. */
178
197
  RETENTION_INTERVAL_MS: 60 * 60 * 1000,
198
+ /** The tracking log's summary covers this many recent hours. */
199
+ TRACKING_SUMMARY_HOURS: 24,
179
200
  };
180
201
 
181
202
  /**
@@ -183,6 +204,7 @@ const ADMIN = {
183
204
  * a number of days. `all` has no lower bound. Only these values reach SQL.
184
205
  */
185
206
  const ADMIN_RANGE = {
207
+ DAY: '24h',
186
208
  WEEK: '7d',
187
209
  MONTH: '30d',
188
210
  QUARTER: '90d',
@@ -191,6 +213,7 @@ const ADMIN_RANGE = {
191
213
  };
192
214
 
193
215
  const ADMIN_RANGE_DAYS = {
216
+ [ADMIN_RANGE.DAY]: 1,
194
217
  [ADMIN_RANGE.WEEK]: 7,
195
218
  [ADMIN_RANGE.MONTH]: 30,
196
219
  [ADMIN_RANGE.QUARTER]: 90,
@@ -203,6 +226,7 @@ const ADMIN_RANGE_DAYS = {
203
226
  * actually in the filtered set, so a chart never has thousands of points.
204
227
  */
205
228
  const TREND_BUCKET = {
229
+ HOUR: 'hour',
206
230
  DAY: 'day',
207
231
  WEEK: 'week',
208
232
  MONTH: 'month',
@@ -210,12 +234,34 @@ const TREND_BUCKET = {
210
234
 
211
235
  /** Largest span, in days, charted per day and per week. */
212
236
  const TREND_BUCKET_MAX_DAYS = {
237
+ [TREND_BUCKET.HOUR]: 2,
213
238
  [TREND_BUCKET.DAY]: 92,
214
239
  [TREND_BUCKET.WEEK]: 731,
215
240
  };
216
241
 
217
242
  /** Rows per breakdown returned by the admin analysis; the rest is "other". */
218
- const ANALYSIS_TOP_N = 8;
243
+ const ANALYSIS_TOP_N = 10;
244
+
245
+ /** Bounds of the admin analysis beyond the breakdowns. */
246
+ const ANALYSIS = {
247
+ /**
248
+ * A visit ends after this long without a page view, the common 30-minute
249
+ * convention. Visits are read from the rotating visitor hash, so they
250
+ * never link a visitor across days.
251
+ */
252
+ VISIT_GAP_SECONDS: 30 * 60,
253
+ /** Page-to-page steps listed in the page flow. */
254
+ TRANSITIONS_TOP_N: 15,
255
+ /** The time-of-day heatmap reads at most the last year of the range. */
256
+ HEATMAP_MAX_DAYS: 365,
257
+ /** Most recent custom events whose properties are tallied. */
258
+ EVENT_PROPERTIES_SAMPLE: 5000,
259
+ /** Property values listed across all custom events. */
260
+ EVENT_PROPERTIES_TOP_N: 30,
261
+ /** "Right now" means the last few minutes; the live chart covers half an hour. */
262
+ REALTIME_VISITOR_MINUTES: 5,
263
+ REALTIME_CHART_MINUTES: 30,
264
+ };
219
265
 
220
266
  /** Which rows an admin listing returns. */
221
267
  const VIEW_STATUS = {
@@ -248,6 +294,13 @@ const ADMIN_SORT_COLUMNS = {
248
294
  eventType: 'event_type',
249
295
  source: 'source_type',
250
296
  browser: 'browser',
297
+ os: 'os',
298
+ title: 'page_title',
299
+ hostname: 'hostname',
300
+ referrer: 'referrer_domain',
301
+ campaign: 'utm_campaign',
302
+ language: 'language',
303
+ engagedMs: 'engaged_ms',
251
304
  modifiedAt: 'admin_modified_at',
252
305
  deletedAt: 'deleted_at',
253
306
  };
@@ -283,6 +336,8 @@ const ADMIN_ACTION = {
283
336
  VIEWS_PURGED: 'views_purged',
284
337
  TRASH_AUTO_PURGED: 'trash_auto_purged',
285
338
  VIEW_LOG_PRUNED: 'view_log_pruned',
339
+ /** The password entered again to allow a permanent erasure. */
340
+ PASSWORD_CONFIRMED: 'password_confirmed',
286
341
  };
287
342
 
288
343
  /**
@@ -295,20 +350,75 @@ const ADMIN_ERROR_CODE = {
295
350
  TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
296
351
  RATE_LIMITED: 'RATE_LIMITED',
297
352
  CSRF_REJECTED: 'CSRF_REJECTED',
353
+ /** The action needs the password entered again (POST /reauth), then a retry. */
354
+ REAUTH_REQUIRED: 'REAUTH_REQUIRED',
298
355
  VALIDATION_FAILED: 'VALIDATION_FAILED',
299
356
  NOT_FOUND: 'NOT_FOUND',
300
357
  SERVER_ERROR: 'SERVER_ERROR',
301
358
  };
302
359
 
303
- /** Which write endpoint a view-register-log entry came through. */
360
+ /** Which tracking endpoint a request came through. */
304
361
  const VIEW_LOG_SOURCE = {
305
362
  REGISTER_VIEW: 'registerView',
306
363
  EVENT: 'event',
364
+ /** Time on page and scroll depth for a view already recorded. */
365
+ ENGAGE: 'engage',
307
366
  };
308
367
 
309
368
  /** Service-owned tables. All carry the reserved `_` prefix. */
310
369
  const ADMIN_LOG_TABLE = '_admin_log';
370
+ const ADMIN_SESSIONS_TABLE = '_admin_sessions';
311
371
  const VIEW_LOG_TABLE = '_view_log';
372
+ /** Tracking requests that were not stored, counted per minute. */
373
+ const TRACKING_REJECTIONS_TABLE = '_tracking_rejections';
374
+
375
+ /** The tracking pipeline's own bounds. */
376
+ const TRACKING = {
377
+ /** Longest visible time one page view may report. */
378
+ MAX_ENGAGED_MS: 6 * 60 * 60 * 1000,
379
+ /** Engagement is only accepted for a view recorded this recently. */
380
+ ENGAGE_WINDOW_HOURS: 24,
381
+ /** A beacon is a few dozen bytes; this bounds what is even parsed. */
382
+ ENGAGE_BODY_BYTES: 1024,
383
+ /** How often counted rejections are written to the database. */
384
+ REJECTION_FLUSH_MS: 15_000,
385
+ /**
386
+ * Distinct rejection keys kept with their app, detail, and hostname, per
387
+ * minute and per hour, however often the counts are written. Beyond these,
388
+ * a request is still counted, by minute, endpoint, and reason alone, so a
389
+ * flood of made-up values cannot grow memory or the table: at most a few
390
+ * thousand rows an hour, whatever arrives.
391
+ */
392
+ REJECTION_MAX_KEYS_PER_MINUTE: 100,
393
+ REJECTION_MAX_KEYS_PER_HOUR: 1000,
394
+ /** Rejections are only ever stored as per-minute counts. */
395
+ REJECTION_BUCKET_MS: 60 * 1000,
396
+ };
397
+
398
+ /** What happened to one tracking request, as the tracking log reports it. */
399
+ const TRACKING_OUTCOME = {
400
+ /** Stored, and the first view of the page by this visitor in the window. */
401
+ RECORDED: 'recorded',
402
+ /** Stored, but the same visitor viewed it again within the window. */
403
+ REPEAT: 'repeat',
404
+ /** A crawler, preview fetcher, or headless browser; counted, not stored. */
405
+ BOT: 'bot',
406
+ /** Refused; the reason says why. */
407
+ REJECTED: 'rejected',
408
+ };
409
+
410
+ /** Why a tracking request was not stored. */
411
+ const REJECTION_REASON = {
412
+ BOT: 'bot',
413
+ UNKNOWN_APP: 'unknown_app',
414
+ ORIGIN_NOT_ALLOWED: 'origin_not_allowed',
415
+ INVALID_REQUEST: 'invalid_request',
416
+ INVALID_IP: 'invalid_ip',
417
+ RATE_LIMITED: 'rate_limited',
418
+ /** Engagement for a view that does not exist, is trashed, or is too old. */
419
+ UNKNOWN_VIEW: 'unknown_view',
420
+ SERVER_ERROR: 'server_error',
421
+ };
312
422
 
313
423
  /** Canonical UUID text form, any version. Admin row IDs are validated with it. */
314
424
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -335,6 +445,8 @@ const SOURCE_TYPE = {
335
445
  EMAIL: 'email',
336
446
  CAMPAIGN: 'campaign',
337
447
  REFERRAL: 'referral',
448
+ /** From another page of the same site: a click within it, not a way in. */
449
+ INTERNAL: 'internal',
338
450
  UNKNOWN: 'unknown',
339
451
  };
340
452
 
@@ -367,7 +479,7 @@ const SCOPE_ALL = '*';
367
479
  * identifiers cannot be bound as parameters. Until now app IDs only ever came
368
480
  * from trusted local config; they can now arrive over HTTP from the admin API,
369
481
  * so the character set is restricted to what is unambiguously safe as an
370
- * identifier. This is the gate — not a nicety.
482
+ * identifier. This is the gate, not a nicety.
371
483
  *
372
484
  * Letters, digits, underscore, and hyphen only. A backtick is the sole
373
485
  * character that can terminate a quoted identifier, and none of these can;
@@ -416,6 +528,7 @@ module.exports = {
416
528
  TREND_BUCKET,
417
529
  TREND_BUCKET_MAX_DAYS,
418
530
  ANALYSIS_TOP_N,
531
+ ANALYSIS,
419
532
  MODIFIED_FILTER,
420
533
  SORT_ORDER,
421
534
  ADMIN_SORT_COLUMNS,
@@ -424,7 +537,12 @@ module.exports = {
424
537
  ADMIN_ERROR_CODE,
425
538
  VIEW_LOG_SOURCE,
426
539
  ADMIN_LOG_TABLE,
540
+ ADMIN_SESSIONS_TABLE,
427
541
  VIEW_LOG_TABLE,
542
+ TRACKING_REJECTIONS_TABLE,
543
+ TRACKING,
544
+ TRACKING_OUTCOME,
545
+ REJECTION_REASON,
428
546
  UUID_PATTERN,
429
547
  EVENT_TYPE,
430
548
  TREND_PERIOD,
@@ -10,14 +10,13 @@
10
10
  const {
11
11
  ADMIN_RANGE_DAYS,
12
12
  ADMIN_SORT_COLUMNS,
13
- ANALYSIS_TOP_N,
14
13
  EDITABLE_FIELDS,
15
14
  MODIFIED_FILTER,
16
15
  SORT_ORDER,
17
- TREND_BUCKET,
18
- TREND_BUCKET_MAX_DAYS,
16
+ SOURCE_TYPE,
19
17
  VIEW_STATUS,
20
18
  } = require('../constants');
19
+ const analysis = require('./analysis');
21
20
  const { getError, ErrorType } = require('../utils/errorUtils');
22
21
  const { isValidAppId } = require('../utils/appIdUtils');
23
22
  const { parseJson } = require('./LogRepository');
@@ -32,6 +31,8 @@ const ADMIN_VIEW_COLUMNS = [
32
31
  'page_path', 'page_title', 'referrer', 'referrer_domain', 'source_type',
33
32
  'browser', 'browser_version', 'os', 'os_version', 'device_type',
34
33
  'session_id', 'event_type', 'event_data', 'is_unique',
34
+ 'hostname', 'language', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
35
+ 'region', 'city', 'engaged_ms', 'scroll_depth',
35
36
  'note', 'admin_modified_at', 'deleted_at',
36
37
  ].join(', ');
37
38
 
@@ -75,81 +76,58 @@ function escapeLike(text) {
75
76
  return String(text).replace(/[\\%_]/g, (char) => `\\${char}`);
76
77
  }
77
78
 
78
- /**
79
- * Columns the analysis reads. `visitor_hash` is here only so the database can
80
- * count distinct visitors; it is never selected into a result set.
81
- */
82
- const ANALYSIS_COLUMNS = [
83
- 'country', 'event_type', 'source_type', 'devicesize', 'browser', 'os',
84
- 'visitor_hash', 'is_unique', 'timestamp', 'admin_modified_at',
85
- ].join(', ');
86
-
87
- /**
88
- * Time-series bucket expressions, keyed by TREND_BUCKET. Fixed literals: the
89
- * bucket is chosen in code from the data's span, never from caller input.
90
- * Every bucket is labelled by the date it starts on (YYYY-MM-DD).
91
- */
92
- const BUCKET_EXPRESSION = {
93
- [TREND_BUCKET.DAY]: "DATE_FORMAT(timestamp, '%Y-%m-%d')",
94
- [TREND_BUCKET.WEEK]: "DATE_FORMAT(DATE_SUB(DATE(timestamp), INTERVAL WEEKDAY(timestamp) DAY), '%Y-%m-%d')",
95
- [TREND_BUCKET.MONTH]: "DATE_FORMAT(timestamp, '%Y-%m-01')",
96
- };
97
-
98
- /** Breakdown dimensions of the analysis: API name -> column. Fixed literals. */
99
- const BREAKDOWN_COLUMNS = {
100
- source: 'source_type',
101
- deviceSize: 'devicesize',
102
- browser: 'browser',
103
- os: 'os',
104
- eventType: 'event_type',
105
- app: 'app_id',
106
- };
107
-
108
- const DAY_MS = 24 * 60 * 60 * 1000;
109
-
110
- /** The coarsest bucket that keeps a chart of this span readable. */
111
- function chooseBucket(firstAt, lastAt) {
112
- if (!firstAt || !lastAt) return TREND_BUCKET.DAY;
113
- const days = (new Date(lastAt).getTime() - new Date(firstAt).getTime()) / DAY_MS;
114
- if (days <= TREND_BUCKET_MAX_DAYS[TREND_BUCKET.DAY]) return TREND_BUCKET.DAY;
115
- if (days <= TREND_BUCKET_MAX_DAYS[TREND_BUCKET.WEEK]) return TREND_BUCKET.WEEK;
116
- return TREND_BUCKET.MONTH;
117
- }
118
-
119
79
  /**
120
80
  * The WHERE clause shared by the listing and the analysis, so the table and
121
81
  * the charts above it always describe the same rows.
122
82
  *
83
+ * With `window` 'previous', the range moves back by its own length, for
84
+ * comparing with the period before: last 7 days against the 7 days before.
85
+ *
86
+ * `where` narrows to breakdown values, such as one country or one page, as
87
+ * the analysis groups them; null matches the rows with no value ("Unknown").
88
+ *
123
89
  * @param {{ status?: string, modified?: string, search?: string, range?: string,
124
- * eventType?: string }} query
90
+ * eventType?: string, where?: Record<string, string|null> }} query
91
+ * @param {'previous'} [window]
125
92
  * @returns {{ clause: string, params: unknown[] }}
126
93
  */
127
- function buildFilter({ status, modified, search, range, eventType } = {}) {
128
- const where = [STATUS_CONDITION[status] || STATE.ACTIVE];
94
+ function buildFilter({ status, modified, search, range, eventType, where = {} } = {}, window) {
95
+ const conditions = [STATUS_CONDITION[status] || STATE.ACTIVE];
129
96
  const params = [];
130
97
 
131
98
  const modifiedCondition = MODIFIED_CONDITION[modified];
132
- if (modifiedCondition) where.push(modifiedCondition);
99
+ if (modifiedCondition) conditions.push(modifiedCondition);
133
100
 
134
101
  if (eventType) {
135
- where.push('event_type = ?');
102
+ conditions.push('event_type = ?');
136
103
  params.push(eventType);
137
104
  }
138
105
 
139
106
  const days = ADMIN_RANGE_DAYS[range];
140
- if (days) {
141
- where.push('timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)');
107
+ if (days && window === 'previous') {
108
+ conditions.push('timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) AND timestamp < DATE_SUB(NOW(), INTERVAL ? DAY)');
109
+ params.push(days * 2, days);
110
+ } else if (days) {
111
+ conditions.push('timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)');
142
112
  params.push(days);
143
113
  }
144
114
 
115
+ for (const [dim, value] of Object.entries(where)) {
116
+ // The expression is a fixed literal, looked up by a validated name;
117
+ // <=> is equality that also matches NULL to NULL.
118
+ if (!Object.hasOwn(analysis.FILTER_COLUMNS, dim)) continue;
119
+ conditions.push(`(${analysis.FILTER_COLUMNS[dim]}) <=> ?`);
120
+ params.push(value);
121
+ }
122
+
145
123
  if (search) {
146
124
  const pattern = `%${escapeLike(search)}%`;
147
- where.push(`(public_id = ? OR page_path LIKE ? OR page_title LIKE ? OR referrer_domain LIKE ?
148
- OR note LIKE ? OR event_type LIKE ? OR session_id LIKE ?)`);
149
- params.push(search, pattern, pattern, pattern, pattern, pattern, pattern);
125
+ conditions.push(`(public_id = ? OR page_path LIKE ? OR page_title LIKE ? OR referrer_domain LIKE ?
126
+ OR hostname LIKE ? OR utm_campaign LIKE ? OR note LIKE ? OR event_type LIKE ? OR session_id LIKE ?)`);
127
+ params.push(search, pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern);
150
128
  }
151
129
 
152
- return { clause: where.join(' AND '), params };
130
+ return { clause: conditions.join(' AND '), params };
153
131
  }
154
132
 
155
133
  /** Shape one row for the API: camelCase, public ID, parsed JSON. */
@@ -175,6 +153,17 @@ function toApiRow(row, appId = row.app_id) {
175
153
  eventType: row.event_type,
176
154
  eventData: parseJson(row.event_data),
177
155
  isUnique: row.is_unique === 1 || row.is_unique === true,
156
+ hostname: row.hostname ?? null,
157
+ language: row.language ?? null,
158
+ utmSource: row.utm_source ?? null,
159
+ utmMedium: row.utm_medium ?? null,
160
+ utmCampaign: row.utm_campaign ?? null,
161
+ utmTerm: row.utm_term ?? null,
162
+ utmContent: row.utm_content ?? null,
163
+ region: row.region ?? null,
164
+ city: row.city ?? null,
165
+ engagedMs: row.engaged_ms ?? null,
166
+ scrollDepth: row.scroll_depth ?? null,
178
167
  note: row.note,
179
168
  adminModifiedAt: row.admin_modified_at,
180
169
  deletedAt: row.deleted_at,
@@ -306,110 +295,33 @@ class AdminRepository {
306
295
  }
307
296
 
308
297
  /**
309
- * Aggregates over the same rows a listing with this query would show.
298
+ * Aggregates over the same rows a listing with this query would show: see
299
+ * db/analysis.js. A bounded range is compared with the period before it.
310
300
  *
311
301
  * @param {string[]} appIds
312
- * @param {{ status: string, modified: string, search?: string, range?: string }} query
313
- * @returns {Promise<object>} totals, trend, breakdowns, and per-country counts
302
+ * @param {{ status: string, modified: string, search?: string, range?: string, eventType?: string }} query
303
+ * @returns {Promise<object>}
314
304
  */
315
305
  async analyze(appIds, query) {
316
- const empty = {
317
- totals: { views: 0, uniqueViews: 0, visitors: 0, countries: 0, modified: 0, firstAt: null, lastAt: null },
318
- bucket: TREND_BUCKET.DAY,
319
- trend: [],
320
- breakdowns: Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) => [dim, []])),
321
- countries: [],
322
- eventTypes: [],
323
- };
324
- if (appIds.length === 0) return empty;
325
-
326
- const { clause, params } = buildFilter(query);
327
- const branches = appIds.map((appId) =>
328
- `SELECT ? AS app_id, ${ANALYSIS_COLUMNS} FROM ${this.table(appId)} WHERE ${clause}`);
329
- const cte = `WITH v AS (${branches.join(' UNION ALL ')})`;
330
- const cteParams = appIds.flatMap((appId) => [appId, ...params]);
331
-
332
- const [totalRows] = await this.pool.query(
333
- `${cte} SELECT
334
- COUNT(*) AS views,
335
- COALESCE(SUM(is_unique), 0) AS unique_views,
336
- COUNT(DISTINCT visitor_hash) AS visitors,
337
- COUNT(DISTINCT country) AS countries,
338
- COALESCE(SUM(admin_modified_at IS NOT NULL), 0) AS modified,
339
- MIN(timestamp) AS first_at,
340
- MAX(timestamp) AS last_at
341
- FROM v`,
342
- cteParams
343
- );
344
- const totalsRow = totalRows[0] || {};
345
- const totals = {
346
- views: Number(totalsRow.views || 0),
347
- uniqueViews: Number(totalsRow.unique_views || 0),
348
- visitors: Number(totalsRow.visitors || 0),
349
- countries: Number(totalsRow.countries || 0),
350
- modified: Number(totalsRow.modified || 0),
351
- firstAt: totalsRow.first_at ?? null,
352
- lastAt: totalsRow.last_at ?? null,
353
- };
354
- // Before the early return: when the filters match nothing is exactly
355
- // when the admin needs every type on offer to pick another.
356
- const eventTypes = await this.eventTypes(appIds, query.status);
357
- if (totals.views === 0) return { ...empty, totals, eventTypes };
358
-
359
- const bucket = chooseBucket(totals.firstAt, totals.lastAt);
360
- const [trendRows] = await this.pool.query(
361
- `${cte} SELECT ${BUCKET_EXPRESSION[bucket]} AS period, COUNT(*) AS views,
362
- COALESCE(SUM(is_unique), 0) AS unique_views
363
- FROM v GROUP BY period ORDER BY period`,
364
- cteParams
365
- );
366
-
367
- const groups = Object.entries(BREAKDOWN_COLUMNS).map(([dim, column]) =>
368
- `SELECT '${dim}' AS dim, ${column} AS value, COUNT(*) AS views FROM v GROUP BY ${column}`);
369
- const [breakdownRows] = await this.pool.query(
370
- `${cte} SELECT dim, value, views FROM (
371
- SELECT dim, value, views,
372
- ROW_NUMBER() OVER (PARTITION BY dim ORDER BY views DESC, value) AS rank_in_dim
373
- FROM (${groups.join(' UNION ALL ')}) AS g
374
- ) AS ranked
375
- WHERE rank_in_dim <= ?
376
- ORDER BY dim, views DESC, value`,
377
- [...cteParams, ANALYSIS_TOP_N]
378
- );
379
- const breakdowns = Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) => [dim, []]));
380
- for (const row of breakdownRows) {
381
- breakdowns[row.dim]?.push({ value: row.value ?? null, views: Number(row.views) });
306
+ if (appIds.length === 0) {
307
+ return { totals: null, previous: null, window: null, ...analysis.emptySections(), eventTypes: [] };
382
308
  }
309
+ const result = await analysis.runAnalysis(this.pool, (appId) => this.table(appId), appIds,
310
+ (window) => buildFilter(query, window),
311
+ { hasPrevious: Boolean(ADMIN_RANGE_DAYS[query.range]), spanDays: ADMIN_RANGE_DAYS[query.range] });
312
+ // After the analysis: when the filters match nothing is exactly when
313
+ // the admin needs every type on offer to pick another.
314
+ return { ...result, eventTypes: await this.eventTypes(appIds, query.status) };
315
+ }
383
316
 
384
- // Per-country counts, split by the leading event types so the map can
385
- // show where each type comes from; the rest are grouped as null.
386
- const types = breakdowns.eventType.map((entry) => entry.value).filter((value) => value !== null);
387
- const [countryRows] = await this.pool.query(
388
- `${cte} SELECT country,
389
- CASE WHEN event_type IN (?) THEN event_type ELSE NULL END AS event_type,
390
- COUNT(*) AS views
391
- FROM v WHERE country IS NOT NULL
392
- GROUP BY country, CASE WHEN event_type IN (?) THEN event_type ELSE NULL END
393
- ORDER BY country`,
394
- [...cteParams, types.length ? types : [''], types.length ? types : ['']]
395
- );
396
-
397
- return {
398
- totals,
399
- bucket,
400
- trend: trendRows.map((row) => ({
401
- period: String(row.period),
402
- views: Number(row.views),
403
- uniqueViews: Number(row.unique_views),
404
- })),
405
- breakdowns,
406
- countries: countryRows.map((row) => ({
407
- country: row.country,
408
- eventType: row.event_type ?? null,
409
- views: Number(row.views),
410
- })),
411
- eventTypes,
412
- };
317
+ /**
318
+ * Who is on the sites right now, and views per minute over the last half
319
+ * hour. Live views of these apps only.
320
+ * @param {string[]} appIds
321
+ */
322
+ async realtime(appIds) {
323
+ if (appIds.length === 0) return { visitors: 0, nowMinute: null, minutes: [], pages: [] };
324
+ return analysis.runRealtime(this.pool, (appId) => this.table(appId), appIds);
413
325
  }
414
326
 
415
327
  /**
@@ -466,11 +378,24 @@ class AdminRepository {
466
378
  const matched = await this.matchIds(appId, ids, STATE.ACTIVE);
467
379
  if (matched.length === 0 || columns.length === 0) return [];
468
380
 
469
- const assignments = columns.map((column) => `\`${column}\` = ?`).join(', ');
381
+ const assignments = [];
382
+ const params = [];
383
+ for (const column of columns) {
384
+ if (column === 'source_type' && columnValues.referrer_domain) {
385
+ // A referrer on the row's own site is internal, as on the write
386
+ // path; each row is judged by its own site, in the same statement.
387
+ const site = String(columnValues.referrer_domain).toLowerCase().replace(/^www\./, '');
388
+ assignments.push('`source_type` = CASE WHEN LOWER(hostname) IN (?, ?) THEN ? ELSE ? END');
389
+ params.push(site, `www.${site}`, SOURCE_TYPE.INTERNAL, columnValues.source_type);
390
+ } else {
391
+ assignments.push(`\`${column}\` = ?`);
392
+ params.push(columnValues[column]);
393
+ }
394
+ }
470
395
  await this.pool.query(
471
- `UPDATE ${this.table(appId)} SET ${assignments}, admin_modified_at = NOW()
396
+ `UPDATE ${this.table(appId)} SET ${assignments.join(', ')}, admin_modified_at = NOW()
472
397
  WHERE public_id IN (?) AND ${STATE.ACTIVE}`,
473
- [...columns.map((column) => columnValues[column]), matched]
398
+ [...params, matched]
474
399
  );
475
400
  return matched;
476
401
  }
@@ -557,6 +482,7 @@ module.exports.WRITABLE_COLUMNS = WRITABLE_COLUMNS;
557
482
  module.exports.escapeLike = escapeLike;
558
483
  module.exports.toApiRow = toApiRow;
559
484
  module.exports.buildFilter = buildFilter;
560
- module.exports.chooseBucket = chooseBucket;
561
- module.exports.BREAKDOWN_COLUMNS = BREAKDOWN_COLUMNS;
562
- module.exports.BUCKET_EXPRESSION = BUCKET_EXPRESSION;
485
+ module.exports.chooseBucket = analysis.chooseBucket;
486
+ module.exports.BREAKDOWN_COLUMNS = analysis.BREAKDOWN_COLUMNS;
487
+ module.exports.FILTER_COLUMNS = analysis.FILTER_COLUMNS;
488
+ module.exports.BUCKET_EXPRESSION = analysis.BUCKET_EXPRESSION;