@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/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: '
|
|
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
|
-
/**
|
|
149
|
-
SESSION_IDLE_TIMEOUT_MS:
|
|
150
|
-
/**
|
|
151
|
-
SESSION_ABSOLUTE_TIMEOUT_MS:
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
package/db/AdminRepository.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
99
|
+
if (modifiedCondition) conditions.push(modifiedCondition);
|
|
133
100
|
|
|
134
101
|
if (eventType) {
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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>}
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 =
|
|
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
|
-
[...
|
|
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.
|
|
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;
|