@litemetrics/node 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -190,6 +190,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
190
190
  utm_term Nullable(String),
191
191
  utm_content Nullable(String),
192
192
  ip Nullable(String),
193
+ os_version LowCardinality(Nullable(String)),
194
+ device_model LowCardinality(Nullable(String)),
195
+ device_brand LowCardinality(Nullable(String)),
196
+ app_version LowCardinality(Nullable(String)),
197
+ app_build Nullable(String),
198
+ sdk_name LowCardinality(Nullable(String)),
199
+ sdk_version LowCardinality(Nullable(String)),
193
200
  created_at DateTime64(3) DEFAULT now64(3)
194
201
  ) ENGINE = MergeTree()
195
202
  PARTITION BY toYYYYMM(timestamp)
@@ -201,6 +208,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
201
208
  site_id String,
202
209
  secret_key String,
203
210
  name String,
211
+ type LowCardinality(Nullable(String)) DEFAULT 'web',
204
212
  domain Nullable(String),
205
213
  allowed_origins Nullable(String),
206
214
  conversion_events Nullable(String),
@@ -216,6 +224,65 @@ function toCHDateTime(d) {
216
224
  const iso = typeof d === "string" ? d : d.toISOString();
217
225
  return iso.replace("T", " ").replace("Z", "");
218
226
  }
227
+ function normalizedUtmSourceExpr() {
228
+ return `multiIf(
229
+ lower(utm_source) IN ('ig','instagram','instagram.com'), 'Instagram',
230
+ lower(utm_source) IN ('fb','facebook','facebook.com','fb.com'), 'Facebook',
231
+ lower(utm_source) IN ('tw','twitter','twitter.com','x','x.com','t.co'), 'X (Twitter)',
232
+ lower(utm_source) IN ('li','linkedin','linkedin.com'), 'LinkedIn',
233
+ lower(utm_source) IN ('yt','youtube','youtube.com'), 'YouTube',
234
+ lower(utm_source) IN ('goog','google','google.com'), 'Google',
235
+ lower(utm_source) IN ('gh','github','github.com'), 'GitHub',
236
+ lower(utm_source) IN ('reddit','reddit.com'), 'Reddit',
237
+ lower(utm_source) IN ('pinterest','pinterest.com'), 'Pinterest',
238
+ lower(utm_source) IN ('tiktok','tiktok.com'), 'TikTok',
239
+ lower(utm_source) IN ('openai','chatgpt','chat.openai.com'), 'OpenAI',
240
+ lower(utm_source) IN ('perplexity','perplexity.ai'), 'Perplexity',
241
+ utm_source
242
+ )`;
243
+ }
244
+ function normalizedUtmMediumExpr() {
245
+ return `multiIf(
246
+ lower(utm_medium) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid'), 'Paid',
247
+ lower(utm_medium) IN ('organic'), 'Organic',
248
+ lower(utm_medium) IN ('social','social-media','social_media'), 'Social',
249
+ lower(utm_medium) IN ('email','e-mail','e_mail'), 'Email',
250
+ lower(utm_medium) IN ('display','banner','cpm'), 'Display',
251
+ lower(utm_medium) IN ('affiliate'), 'Affiliate',
252
+ lower(utm_medium) IN ('referral'), 'Referral',
253
+ utm_medium
254
+ )`;
255
+ }
256
+ function channelClassificationExpr() {
257
+ return `multiIf(
258
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
259
+ AND (lower(ifNull(utm_source,'')) IN ('google','goog','bing','yahoo','duckduckgo','ecosia','baidu','yandex')
260
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0),
261
+ 'Paid Search',
262
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
263
+ AND (lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat')
264
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat']) > 0),
265
+ 'Paid Social',
266
+ lower(ifNull(utm_medium,'')) IN ('email','e-mail','e_mail'),
267
+ 'Email',
268
+ lower(ifNull(utm_medium,'')) IN ('display','banner','cpm'),
269
+ 'Display',
270
+ lower(ifNull(utm_medium,'')) IN ('affiliate'),
271
+ 'Affiliate',
272
+ multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0
273
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
274
+ 'Organic Search',
275
+ (multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat','mastodon','tumblr']) > 0
276
+ OR lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat'))
277
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
278
+ 'Organic Social',
279
+ ifNull(referrer,'') != '' AND length(ifNull(referrer,'')) > 0,
280
+ 'Referral',
281
+ (ifNull(utm_source,'') != '' OR ifNull(utm_medium,'') != '' OR ifNull(utm_campaign,'') != ''),
282
+ 'Other',
283
+ 'Direct'
284
+ )`;
285
+ }
219
286
  function buildFilterConditions(filters) {
220
287
  if (!filters) return { conditions: [], params: {} };
221
288
  const map = {
@@ -226,6 +293,10 @@ function buildFilterConditions(filters) {
226
293
  "device.type": "device_type",
227
294
  "device.browser": "browser",
228
295
  "device.os": "os",
296
+ "device.osVersion": "os_version",
297
+ "device.deviceModel": "device_model",
298
+ "device.deviceBrand": "device_brand",
299
+ "device.appVersion": "app_version",
229
300
  "utm.source": "utm_source",
230
301
  "utm.medium": "utm_medium",
231
302
  "utm.campaign": "utm_campaign",
@@ -242,7 +313,14 @@ function buildFilterConditions(filters) {
242
313
  const conditions = [];
243
314
  const params = {};
244
315
  for (const [key, value] of Object.entries(filters)) {
245
- if (!value || !map[key]) continue;
316
+ if (!value) continue;
317
+ if (key === "channel") {
318
+ const paramKey2 = "f_channel";
319
+ conditions.push(`${channelClassificationExpr()} = {${paramKey2}:String}`);
320
+ params[paramKey2] = value;
321
+ continue;
322
+ }
323
+ if (!map[key]) continue;
246
324
  const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
247
325
  conditions.push(`${map[key]} = {${paramKey}:String}`);
248
326
  params[paramKey] = value;
@@ -273,6 +351,14 @@ var ClickHouseAdapter = class {
273
351
  await this.client.command({
274
352
  query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
275
353
  });
354
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS os_version LowCardinality(Nullable(String))` });
355
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_model LowCardinality(Nullable(String))` });
356
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_brand LowCardinality(Nullable(String))` });
357
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_version LowCardinality(Nullable(String))` });
358
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_build Nullable(String)` });
359
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_name LowCardinality(Nullable(String))` });
360
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_version LowCardinality(Nullable(String))` });
361
+ await this.client.command({ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS type LowCardinality(Nullable(String)) DEFAULT 'web'` });
276
362
  }
277
363
  async close() {
278
364
  await this.client.close();
@@ -315,7 +401,14 @@ var ClickHouseAdapter = class {
315
401
  utm_campaign: e.utm?.campaign ?? null,
316
402
  utm_term: e.utm?.term ?? null,
317
403
  utm_content: e.utm?.content ?? null,
318
- ip: e.ip ?? null
404
+ ip: e.ip ?? null,
405
+ os_version: e.device?.osVersion ?? null,
406
+ device_model: e.device?.deviceModel ?? null,
407
+ device_brand: e.device?.deviceBrand ?? null,
408
+ app_version: e.device?.appVersion ?? null,
409
+ app_build: e.device?.appBuild ?? null,
410
+ sdk_name: e.device?.sdkName ?? null,
411
+ sdk_version: e.device?.sdkVersion ?? null
319
412
  }));
320
413
  await this.client.insert({
321
414
  table: EVENTS_TABLE,
@@ -666,15 +759,67 @@ var ClickHouseAdapter = class {
666
759
  total = data.reduce((sum, d) => sum + d.value, 0);
667
760
  break;
668
761
  }
762
+ case "top_os_versions": {
763
+ const rows = await this.queryRows(
764
+ `SELECT concat(os, ' ', ifNull(os_version, '')) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
765
+ WHERE site_id = {siteId:String}
766
+ AND timestamp >= {from:String}
767
+ AND timestamp <= {to:String}
768
+ AND os IS NOT NULL
769
+ AND os_version IS NOT NULL
770
+ ${filterSql}
771
+ GROUP BY key
772
+ ORDER BY value DESC
773
+ LIMIT {limit:UInt32}`,
774
+ { ...params, ...filter.params }
775
+ );
776
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
777
+ total = data.reduce((sum, d) => sum + d.value, 0);
778
+ break;
779
+ }
780
+ case "top_device_models": {
781
+ const rows = await this.queryRows(
782
+ `SELECT trim(concat(ifNull(device_brand, ''), ' ', device_model)) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
783
+ WHERE site_id = {siteId:String}
784
+ AND timestamp >= {from:String}
785
+ AND timestamp <= {to:String}
786
+ AND device_model IS NOT NULL
787
+ ${filterSql}
788
+ GROUP BY key
789
+ ORDER BY value DESC
790
+ LIMIT {limit:UInt32}`,
791
+ { ...params, ...filter.params }
792
+ );
793
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
794
+ total = data.reduce((sum, d) => sum + d.value, 0);
795
+ break;
796
+ }
797
+ case "top_app_versions": {
798
+ const rows = await this.queryRows(
799
+ `SELECT app_version AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
800
+ WHERE site_id = {siteId:String}
801
+ AND timestamp >= {from:String}
802
+ AND timestamp <= {to:String}
803
+ AND app_version IS NOT NULL
804
+ ${filterSql}
805
+ GROUP BY app_version
806
+ ORDER BY value DESC
807
+ LIMIT {limit:UInt32}`,
808
+ { ...params, ...filter.params }
809
+ );
810
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
811
+ total = data.reduce((sum, d) => sum + d.value, 0);
812
+ break;
813
+ }
669
814
  case "top_utm_sources": {
670
815
  const rows = await this.queryRows(
671
- `SELECT utm_source AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
816
+ `SELECT ${normalizedUtmSourceExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
672
817
  WHERE site_id = {siteId:String}
673
818
  AND timestamp >= {from:String}
674
819
  AND timestamp <= {to:String}
675
820
  AND utm_source IS NOT NULL AND utm_source != ''
676
821
  ${filterSql}
677
- GROUP BY utm_source
822
+ GROUP BY key
678
823
  ORDER BY value DESC
679
824
  LIMIT {limit:UInt32}`,
680
825
  { ...params, ...filter.params }
@@ -685,13 +830,13 @@ var ClickHouseAdapter = class {
685
830
  }
686
831
  case "top_utm_mediums": {
687
832
  const rows = await this.queryRows(
688
- `SELECT utm_medium AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
833
+ `SELECT ${normalizedUtmMediumExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
689
834
  WHERE site_id = {siteId:String}
690
835
  AND timestamp >= {from:String}
691
836
  AND timestamp <= {to:String}
692
837
  AND utm_medium IS NOT NULL AND utm_medium != ''
693
838
  ${filterSql}
694
- GROUP BY utm_medium
839
+ GROUP BY key
695
840
  ORDER BY value DESC
696
841
  LIMIT {limit:UInt32}`,
697
842
  { ...params, ...filter.params }
@@ -717,6 +862,56 @@ var ClickHouseAdapter = class {
717
862
  total = data.reduce((sum, d) => sum + d.value, 0);
718
863
  break;
719
864
  }
865
+ case "top_utm_terms": {
866
+ const rows = await this.queryRows(
867
+ `SELECT utm_term AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
868
+ WHERE site_id = {siteId:String}
869
+ AND timestamp >= {from:String}
870
+ AND timestamp <= {to:String}
871
+ AND utm_term IS NOT NULL AND utm_term != ''
872
+ ${filterSql}
873
+ GROUP BY utm_term
874
+ ORDER BY value DESC
875
+ LIMIT {limit:UInt32}`,
876
+ { ...params, ...filter.params }
877
+ );
878
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
879
+ total = data.reduce((sum, d) => sum + d.value, 0);
880
+ break;
881
+ }
882
+ case "top_utm_contents": {
883
+ const rows = await this.queryRows(
884
+ `SELECT utm_content AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
885
+ WHERE site_id = {siteId:String}
886
+ AND timestamp >= {from:String}
887
+ AND timestamp <= {to:String}
888
+ AND utm_content IS NOT NULL AND utm_content != ''
889
+ ${filterSql}
890
+ GROUP BY utm_content
891
+ ORDER BY value DESC
892
+ LIMIT {limit:UInt32}`,
893
+ { ...params, ...filter.params }
894
+ );
895
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
896
+ total = data.reduce((sum, d) => sum + d.value, 0);
897
+ break;
898
+ }
899
+ case "top_channels": {
900
+ const rows = await this.queryRows(
901
+ `SELECT ${channelClassificationExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
902
+ WHERE site_id = {siteId:String}
903
+ AND timestamp >= {from:String}
904
+ AND timestamp <= {to:String}
905
+ ${filterSql}
906
+ GROUP BY key
907
+ ORDER BY value DESC
908
+ LIMIT {limit:UInt32}`,
909
+ { ...params, ...filter.params }
910
+ );
911
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
912
+ total = data.reduce((sum, d) => sum + d.value, 0);
913
+ break;
914
+ }
720
915
  }
721
916
  const result = { metric: q.metric, period, data, total };
722
917
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -1119,7 +1314,7 @@ var ClickHouseAdapter = class {
1119
1314
  async getMergedUserDetail(siteId, userId, visitorIds) {
1120
1315
  const rows = await this.queryRows(
1121
1316
  `SELECT
1122
- anyLast(visitor_id) AS visitor_id,
1317
+ anyLast(visitor_id) AS last_visitor_id,
1123
1318
  anyLast(traits) AS traits,
1124
1319
  min(timestamp) AS firstSeen,
1125
1320
  max(timestamp) AS lastSeen,
@@ -1151,7 +1346,7 @@ var ClickHouseAdapter = class {
1151
1346
  if (rows.length === 0) return null;
1152
1347
  const u = rows[0];
1153
1348
  return {
1154
- visitorId: String(u.visitor_id),
1349
+ visitorId: String(u.last_visitor_id),
1155
1350
  visitorIds,
1156
1351
  userId,
1157
1352
  traits: this.parseJSON(u.traits),
@@ -1240,6 +1435,7 @@ var ClickHouseAdapter = class {
1240
1435
  siteId: generateSiteId(),
1241
1436
  secretKey: generateSecretKey(),
1242
1437
  name: data.name,
1438
+ type: data.type ?? "web",
1243
1439
  domain: data.domain,
1244
1440
  allowedOrigins: data.allowedOrigins,
1245
1441
  conversionEvents: data.conversionEvents,
@@ -1252,6 +1448,7 @@ var ClickHouseAdapter = class {
1252
1448
  site_id: site.siteId,
1253
1449
  secret_key: site.secretKey,
1254
1450
  name: site.name,
1451
+ type: site.type ?? "web",
1255
1452
  domain: site.domain ?? null,
1256
1453
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1257
1454
  conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
@@ -1266,7 +1463,7 @@ var ClickHouseAdapter = class {
1266
1463
  }
1267
1464
  async getSite(siteId) {
1268
1465
  const rows = await this.queryRows(
1269
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1466
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1270
1467
  FROM ${SITES_TABLE} FINAL
1271
1468
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1272
1469
  { siteId }
@@ -1275,7 +1472,7 @@ var ClickHouseAdapter = class {
1275
1472
  }
1276
1473
  async getSiteBySecret(secretKey) {
1277
1474
  const rows = await this.queryRows(
1278
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1475
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1279
1476
  FROM ${SITES_TABLE} FINAL
1280
1477
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
1281
1478
  { secretKey }
@@ -1284,7 +1481,7 @@ var ClickHouseAdapter = class {
1284
1481
  }
1285
1482
  async listSites() {
1286
1483
  const rows = await this.queryRows(
1287
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1484
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1288
1485
  FROM ${SITES_TABLE} FINAL
1289
1486
  WHERE is_deleted = 0
1290
1487
  ORDER BY created_at DESC`,
@@ -1294,7 +1491,7 @@ var ClickHouseAdapter = class {
1294
1491
  }
1295
1492
  async updateSite(siteId, data) {
1296
1493
  const currentRows = await this.queryRows(
1297
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
1494
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at, version
1298
1495
  FROM ${SITES_TABLE} FINAL
1299
1496
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1300
1497
  { siteId }
@@ -1306,6 +1503,7 @@ var ClickHouseAdapter = class {
1306
1503
  const nowCH = toCHDateTime(now);
1307
1504
  const newVersion = Number(current.version) + 1;
1308
1505
  const newName = data.name !== void 0 ? data.name : String(current.name);
1506
+ const newType = data.type !== void 0 ? data.type : current.type ? String(current.type) : "web";
1309
1507
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
1310
1508
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1311
1509
  const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
@@ -1315,6 +1513,7 @@ var ClickHouseAdapter = class {
1315
1513
  site_id: String(current.site_id),
1316
1514
  secret_key: String(current.secret_key),
1317
1515
  name: newName,
1516
+ type: newType,
1318
1517
  domain: newDomain,
1319
1518
  allowed_origins: newOrigins,
1320
1519
  conversion_events: newConversions,
@@ -1329,6 +1528,7 @@ var ClickHouseAdapter = class {
1329
1528
  siteId: String(current.site_id),
1330
1529
  secretKey: String(current.secret_key),
1331
1530
  name: newName,
1531
+ type: newType,
1332
1532
  domain: newDomain ?? void 0,
1333
1533
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1334
1534
  conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
@@ -1338,7 +1538,7 @@ var ClickHouseAdapter = class {
1338
1538
  }
1339
1539
  async deleteSite(siteId) {
1340
1540
  const currentRows = await this.queryRows(
1341
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1541
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1342
1542
  FROM ${SITES_TABLE} FINAL
1343
1543
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1344
1544
  { siteId }
@@ -1352,6 +1552,7 @@ var ClickHouseAdapter = class {
1352
1552
  site_id: String(current.site_id),
1353
1553
  secret_key: String(current.secret_key),
1354
1554
  name: String(current.name),
1555
+ type: current.type ? String(current.type) : "web",
1355
1556
  domain: current.domain ? String(current.domain) : null,
1356
1557
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1357
1558
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1366,7 +1567,7 @@ var ClickHouseAdapter = class {
1366
1567
  }
1367
1568
  async regenerateSecret(siteId) {
1368
1569
  const currentRows = await this.queryRows(
1369
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1570
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1370
1571
  FROM ${SITES_TABLE} FINAL
1371
1572
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1372
1573
  { siteId }
@@ -1383,6 +1584,7 @@ var ClickHouseAdapter = class {
1383
1584
  site_id: String(current.site_id),
1384
1585
  secret_key: newSecret,
1385
1586
  name: String(current.name),
1587
+ type: current.type ? String(current.type) : "web",
1386
1588
  domain: current.domain ? String(current.domain) : null,
1387
1589
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1388
1590
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1397,6 +1599,7 @@ var ClickHouseAdapter = class {
1397
1599
  siteId: String(current.site_id),
1398
1600
  secretKey: newSecret,
1399
1601
  name: String(current.name),
1602
+ type: current.type ? String(current.type) : "web",
1400
1603
  domain: current.domain ? String(current.domain) : void 0,
1401
1604
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1402
1605
  conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
@@ -1418,6 +1621,7 @@ var ClickHouseAdapter = class {
1418
1621
  siteId: String(row.site_id),
1419
1622
  secretKey: String(row.secret_key),
1420
1623
  name: String(row.name),
1624
+ type: row.type ? String(row.type) : "web",
1421
1625
  domain: row.domain ? String(row.domain) : void 0,
1422
1626
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1423
1627
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
@@ -1481,6 +1685,130 @@ import { MongoClient } from "mongodb";
1481
1685
  var EVENTS_COLLECTION = "litemetrics_events";
1482
1686
  var SITES_COLLECTION = "litemetrics_sites";
1483
1687
  var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1688
+ function normalizedUtmSourceSwitch() {
1689
+ return {
1690
+ $switch: {
1691
+ branches: [
1692
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["ig", "instagram", "instagram.com"]] }, then: "Instagram" },
1693
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["fb", "facebook", "facebook.com", "fb.com"]] }, then: "Facebook" },
1694
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tw", "twitter", "twitter.com", "x", "x.com", "t.co"]] }, then: "X (Twitter)" },
1695
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["li", "linkedin", "linkedin.com"]] }, then: "LinkedIn" },
1696
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["yt", "youtube", "youtube.com"]] }, then: "YouTube" },
1697
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["goog", "google", "google.com"]] }, then: "Google" },
1698
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["gh", "github", "github.com"]] }, then: "GitHub" },
1699
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["reddit", "reddit.com"]] }, then: "Reddit" },
1700
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["pinterest", "pinterest.com"]] }, then: "Pinterest" },
1701
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tiktok", "tiktok.com"]] }, then: "TikTok" },
1702
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["openai", "chatgpt", "chat.openai.com"]] }, then: "OpenAI" },
1703
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["perplexity", "perplexity.ai"]] }, then: "Perplexity" }
1704
+ ],
1705
+ default: "$utm_source"
1706
+ }
1707
+ };
1708
+ }
1709
+ function normalizedUtmMediumSwitch() {
1710
+ return {
1711
+ $switch: {
1712
+ branches: [
1713
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"]] }, then: "Paid" },
1714
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["organic"]] }, then: "Organic" },
1715
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["social", "social-media", "social_media"]] }, then: "Social" },
1716
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1717
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["display", "banner", "cpm"]] }, then: "Display" },
1718
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["affiliate"]] }, then: "Affiliate" },
1719
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["referral"]] }, then: "Referral" }
1720
+ ],
1721
+ default: "$utm_medium"
1722
+ }
1723
+ };
1724
+ }
1725
+ var SEARCH_ENGINES = /google|bing|yahoo|duckduckgo|ecosia|baidu|yandex|search\.brave/i;
1726
+ var SOCIAL_NETWORKS = /instagram|facebook|twitter|x\.com|t\.co|linkedin|youtube|tiktok|pinterest|reddit|snapchat|mastodon|tumblr/i;
1727
+ var PAID_MEDIUMS = ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"];
1728
+ var SOCIAL_SOURCES = ["instagram", "ig", "facebook", "fb", "twitter", "tw", "x", "linkedin", "li", "youtube", "yt", "tiktok", "pinterest", "reddit", "snapchat"];
1729
+ function channelClassificationSwitch() {
1730
+ const lMedium = { $toLower: { $ifNull: ["$utm_medium", ""] } };
1731
+ const lSource = { $toLower: { $ifNull: ["$utm_source", ""] } };
1732
+ const refStr = { $ifNull: ["$referrer", ""] };
1733
+ return {
1734
+ $switch: {
1735
+ branches: [
1736
+ // Paid Search
1737
+ {
1738
+ case: {
1739
+ $and: [
1740
+ { $in: [lMedium, PAID_MEDIUMS] },
1741
+ { $or: [
1742
+ { $in: [lSource, ["google", "goog", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex"]] },
1743
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } }
1744
+ ] }
1745
+ ]
1746
+ },
1747
+ then: "Paid Search"
1748
+ },
1749
+ // Paid Social
1750
+ {
1751
+ case: {
1752
+ $and: [
1753
+ { $in: [lMedium, PAID_MEDIUMS] },
1754
+ { $or: [
1755
+ { $in: [lSource, SOCIAL_SOURCES] },
1756
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } }
1757
+ ] }
1758
+ ]
1759
+ },
1760
+ then: "Paid Social"
1761
+ },
1762
+ // Email
1763
+ { case: { $in: [lMedium, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1764
+ // Display
1765
+ { case: { $in: [lMedium, ["display", "banner", "cpm"]] }, then: "Display" },
1766
+ // Affiliate
1767
+ { case: { $in: [lMedium, ["affiliate"]] }, then: "Affiliate" },
1768
+ // Organic Search
1769
+ {
1770
+ case: {
1771
+ $and: [
1772
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } },
1773
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1774
+ ]
1775
+ },
1776
+ then: "Organic Search"
1777
+ },
1778
+ // Organic Social
1779
+ {
1780
+ case: {
1781
+ $and: [
1782
+ { $or: [
1783
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } },
1784
+ { $in: [lSource, SOCIAL_SOURCES] }
1785
+ ] },
1786
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1787
+ ]
1788
+ },
1789
+ then: "Organic Social"
1790
+ },
1791
+ // Referral
1792
+ {
1793
+ case: { $and: [{ $ne: [refStr, ""] }, { $gt: [{ $strLenCP: refStr }, 0] }] },
1794
+ then: "Referral"
1795
+ },
1796
+ // Other (has UTM but no referrer)
1797
+ {
1798
+ case: {
1799
+ $or: [
1800
+ { $and: [{ $ne: [{ $ifNull: ["$utm_source", ""] }, ""] }] },
1801
+ { $and: [{ $ne: [{ $ifNull: ["$utm_medium", ""] }, ""] }] },
1802
+ { $and: [{ $ne: [{ $ifNull: ["$utm_campaign", ""] }, ""] }] }
1803
+ ]
1804
+ },
1805
+ then: "Other"
1806
+ }
1807
+ ],
1808
+ default: "Direct"
1809
+ }
1810
+ };
1811
+ }
1484
1812
  function buildFilterMatch(filters) {
1485
1813
  if (!filters) return {};
1486
1814
  const map = {
@@ -1491,6 +1819,10 @@ function buildFilterMatch(filters) {
1491
1819
  "device.type": "device_type",
1492
1820
  "device.browser": "browser",
1493
1821
  "device.os": "os",
1822
+ "device.osVersion": "os_version",
1823
+ "device.deviceModel": "device_model",
1824
+ "device.deviceBrand": "device_brand",
1825
+ "device.appVersion": "app_version",
1494
1826
  "utm.source": "utm_source",
1495
1827
  "utm.medium": "utm_medium",
1496
1828
  "utm.campaign": "utm_campaign",
@@ -1506,7 +1838,9 @@ function buildFilterMatch(filters) {
1506
1838
  };
1507
1839
  const match = {};
1508
1840
  for (const [key, value] of Object.entries(filters)) {
1509
- if (!value || !map[key]) continue;
1841
+ if (!value) continue;
1842
+ if (key === "channel") continue;
1843
+ if (!map[key]) continue;
1510
1844
  match[map[key]] = value;
1511
1845
  }
1512
1846
  return match;
@@ -1575,6 +1909,13 @@ var MongoDBAdapter = class {
1575
1909
  utm_term: e.utm?.term ?? null,
1576
1910
  utm_content: e.utm?.content ?? null,
1577
1911
  ip: e.ip ?? null,
1912
+ os_version: e.device?.osVersion ?? null,
1913
+ device_model: e.device?.deviceModel ?? null,
1914
+ device_brand: e.device?.deviceBrand ?? null,
1915
+ app_version: e.device?.appVersion ?? null,
1916
+ app_build: e.device?.appBuild ?? null,
1917
+ sdk_name: e.device?.sdkName ?? null,
1918
+ sdk_version: e.device?.sdkVersion ?? null,
1578
1919
  created_at: /* @__PURE__ */ new Date()
1579
1920
  }));
1580
1921
  await this.collection.insertMany(docs);
@@ -1588,12 +1929,22 @@ var MongoDBAdapter = class {
1588
1929
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1589
1930
  };
1590
1931
  const filterMatch = buildFilterMatch(q.filters);
1932
+ const matchStages = (extra) => {
1933
+ const stages = [
1934
+ { $match: { ...baseMatch, ...filterMatch, ...extra } }
1935
+ ];
1936
+ if (q.filters?.channel) {
1937
+ stages.push({ $addFields: { _channel: channelClassificationSwitch() } });
1938
+ stages.push({ $match: { _channel: q.filters.channel } });
1939
+ }
1940
+ return stages;
1941
+ };
1591
1942
  let data = [];
1592
1943
  let total = 0;
1593
1944
  switch (q.metric) {
1594
1945
  case "pageviews": {
1595
1946
  const [result2] = await this.collection.aggregate([
1596
- { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1947
+ ...matchStages({ type: "pageview" }),
1597
1948
  { $count: "count" }
1598
1949
  ]).toArray();
1599
1950
  total = result2?.count ?? 0;
@@ -1602,7 +1953,7 @@ var MongoDBAdapter = class {
1602
1953
  }
1603
1954
  case "visitors": {
1604
1955
  const [result2] = await this.collection.aggregate([
1605
- { $match: { ...baseMatch, ...filterMatch } },
1956
+ ...matchStages(),
1606
1957
  { $group: { _id: "$visitor_id" } },
1607
1958
  { $count: "count" }
1608
1959
  ]).toArray();
@@ -1612,7 +1963,7 @@ var MongoDBAdapter = class {
1612
1963
  }
1613
1964
  case "sessions": {
1614
1965
  const [result2] = await this.collection.aggregate([
1615
- { $match: { ...baseMatch, ...filterMatch } },
1966
+ ...matchStages(),
1616
1967
  { $group: { _id: "$session_id" } },
1617
1968
  { $count: "count" }
1618
1969
  ]).toArray();
@@ -1622,7 +1973,7 @@ var MongoDBAdapter = class {
1622
1973
  }
1623
1974
  case "events": {
1624
1975
  const [result2] = await this.collection.aggregate([
1625
- { $match: { ...baseMatch, ...filterMatch, type: "event" } },
1976
+ ...matchStages({ type: "event" }),
1626
1977
  { $count: "count" }
1627
1978
  ]).toArray();
1628
1979
  total = result2?.count ?? 0;
@@ -1637,7 +1988,7 @@ var MongoDBAdapter = class {
1637
1988
  break;
1638
1989
  }
1639
1990
  const [result2] = await this.collection.aggregate([
1640
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1991
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1641
1992
  { $count: "count" }
1642
1993
  ]).toArray();
1643
1994
  total = result2?.count ?? 0;
@@ -1646,7 +1997,7 @@ var MongoDBAdapter = class {
1646
1997
  }
1647
1998
  case "top_pages": {
1648
1999
  const rows = await this.collection.aggregate([
1649
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2000
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1650
2001
  { $group: { _id: "$url", value: { $sum: 1 } } },
1651
2002
  { $sort: { value: -1 } },
1652
2003
  { $limit: limit }
@@ -1657,7 +2008,7 @@ var MongoDBAdapter = class {
1657
2008
  }
1658
2009
  case "top_referrers": {
1659
2010
  const rows = await this.collection.aggregate([
1660
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
2011
+ ...matchStages({ type: "pageview", referrer: { $nin: [null, ""] } }),
1661
2012
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1662
2013
  { $sort: { value: -1 } },
1663
2014
  { $limit: limit }
@@ -1668,7 +2019,7 @@ var MongoDBAdapter = class {
1668
2019
  }
1669
2020
  case "top_countries": {
1670
2021
  const rows = await this.collection.aggregate([
1671
- { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
2022
+ ...matchStages({ country: { $ne: null } }),
1672
2023
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1673
2024
  { $project: { _id: 1, value: { $size: "$value" } } },
1674
2025
  { $sort: { value: -1 } },
@@ -1680,7 +2031,7 @@ var MongoDBAdapter = class {
1680
2031
  }
1681
2032
  case "top_cities": {
1682
2033
  const rows = await this.collection.aggregate([
1683
- { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
2034
+ ...matchStages({ city: { $ne: null } }),
1684
2035
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1685
2036
  { $project: { _id: 1, value: { $size: "$value" } } },
1686
2037
  { $sort: { value: -1 } },
@@ -1692,7 +2043,7 @@ var MongoDBAdapter = class {
1692
2043
  }
1693
2044
  case "top_events": {
1694
2045
  const rows = await this.collection.aggregate([
1695
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
2046
+ ...matchStages({ type: "event", event_name: { $ne: null } }),
1696
2047
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1697
2048
  { $sort: { value: -1 } },
1698
2049
  { $limit: limit }
@@ -1709,7 +2060,7 @@ var MongoDBAdapter = class {
1709
2060
  break;
1710
2061
  }
1711
2062
  const rows = await this.collection.aggregate([
1712
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2063
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1713
2064
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1714
2065
  { $sort: { value: -1 } },
1715
2066
  { $limit: limit }
@@ -1720,7 +2071,7 @@ var MongoDBAdapter = class {
1720
2071
  }
1721
2072
  case "top_exit_pages": {
1722
2073
  const rows = await this.collection.aggregate([
1723
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2074
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1724
2075
  { $sort: { timestamp: 1 } },
1725
2076
  { $group: { _id: "$session_id", url: { $last: "$url" } } },
1726
2077
  { $group: { _id: "$url", value: { $sum: 1 } } },
@@ -1733,7 +2084,7 @@ var MongoDBAdapter = class {
1733
2084
  }
1734
2085
  case "top_transitions": {
1735
2086
  const rows = await this.collection.aggregate([
1736
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2087
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1737
2088
  {
1738
2089
  $setWindowFields: {
1739
2090
  partitionBy: "$session_id",
@@ -1754,7 +2105,7 @@ var MongoDBAdapter = class {
1754
2105
  }
1755
2106
  case "top_scroll_pages": {
1756
2107
  const rows = await this.collection.aggregate([
1757
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
2108
+ ...matchStages({ type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } }),
1758
2109
  { $group: { _id: "$page_path", value: { $sum: 1 } } },
1759
2110
  { $sort: { value: -1 } },
1760
2111
  { $limit: limit }
@@ -1765,15 +2116,11 @@ var MongoDBAdapter = class {
1765
2116
  }
1766
2117
  case "top_button_clicks": {
1767
2118
  const rows = await this.collection.aggregate([
1768
- {
1769
- $match: {
1770
- ...baseMatch,
1771
- ...filterMatch,
1772
- type: "event",
1773
- event_subtype: "button_click",
1774
- $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1775
- }
1776
- },
2119
+ ...matchStages({
2120
+ type: "event",
2121
+ event_subtype: "button_click",
2122
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
2123
+ }),
1777
2124
  { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1778
2125
  { $sort: { value: -1 } },
1779
2126
  { $limit: limit }
@@ -1784,15 +2131,11 @@ var MongoDBAdapter = class {
1784
2131
  }
1785
2132
  case "top_link_targets": {
1786
2133
  const rows = await this.collection.aggregate([
1787
- {
1788
- $match: {
1789
- ...baseMatch,
1790
- ...filterMatch,
1791
- type: "event",
1792
- event_subtype: { $in: ["link_click", "outbound_click"] },
1793
- target_url_path: { $ne: null }
1794
- }
1795
- },
2134
+ ...matchStages({
2135
+ type: "event",
2136
+ event_subtype: { $in: ["link_click", "outbound_click"] },
2137
+ target_url_path: { $ne: null }
2138
+ }),
1796
2139
  { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1797
2140
  { $sort: { value: -1 } },
1798
2141
  { $limit: limit }
@@ -1803,7 +2146,7 @@ var MongoDBAdapter = class {
1803
2146
  }
1804
2147
  case "top_devices": {
1805
2148
  const rows = await this.collection.aggregate([
1806
- { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
2149
+ ...matchStages({ device_type: { $ne: null } }),
1807
2150
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1808
2151
  { $project: { _id: 1, value: { $size: "$value" } } },
1809
2152
  { $sort: { value: -1 } },
@@ -1815,7 +2158,7 @@ var MongoDBAdapter = class {
1815
2158
  }
1816
2159
  case "top_browsers": {
1817
2160
  const rows = await this.collection.aggregate([
1818
- { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
2161
+ ...matchStages({ browser: { $ne: null } }),
1819
2162
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1820
2163
  { $project: { _id: 1, value: { $size: "$value" } } },
1821
2164
  { $sort: { value: -1 } },
@@ -1827,7 +2170,7 @@ var MongoDBAdapter = class {
1827
2170
  }
1828
2171
  case "top_os": {
1829
2172
  const rows = await this.collection.aggregate([
1830
- { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
2173
+ ...matchStages({ os: { $ne: null } }),
1831
2174
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1832
2175
  { $project: { _id: 1, value: { $size: "$value" } } },
1833
2176
  { $sort: { value: -1 } },
@@ -1837,10 +2180,47 @@ var MongoDBAdapter = class {
1837
2180
  total = data.reduce((sum, d) => sum + d.value, 0);
1838
2181
  break;
1839
2182
  }
2183
+ case "top_os_versions": {
2184
+ const rows = await this.collection.aggregate([
2185
+ ...matchStages({ os: { $ne: null }, os_version: { $ne: null } }),
2186
+ { $group: { _id: { $concat: ["$os", " ", "$os_version"] }, value: { $addToSet: "$visitor_id" } } },
2187
+ { $project: { _id: 1, value: { $size: "$value" } } },
2188
+ { $sort: { value: -1 } },
2189
+ { $limit: limit }
2190
+ ]).toArray();
2191
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2192
+ total = data.reduce((sum, d) => sum + d.value, 0);
2193
+ break;
2194
+ }
2195
+ case "top_device_models": {
2196
+ const rows = await this.collection.aggregate([
2197
+ ...matchStages({ device_model: { $ne: null } }),
2198
+ { $group: { _id: { $trim: { input: { $concat: [{ $ifNull: ["$device_brand", ""] }, " ", "$device_model"] } } }, value: { $addToSet: "$visitor_id" } } },
2199
+ { $project: { _id: 1, value: { $size: "$value" } } },
2200
+ { $sort: { value: -1 } },
2201
+ { $limit: limit }
2202
+ ]).toArray();
2203
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2204
+ total = data.reduce((sum, d) => sum + d.value, 0);
2205
+ break;
2206
+ }
2207
+ case "top_app_versions": {
2208
+ const rows = await this.collection.aggregate([
2209
+ ...matchStages({ app_version: { $ne: null } }),
2210
+ { $group: { _id: "$app_version", value: { $addToSet: "$visitor_id" } } },
2211
+ { $project: { _id: 1, value: { $size: "$value" } } },
2212
+ { $sort: { value: -1 } },
2213
+ { $limit: limit }
2214
+ ]).toArray();
2215
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2216
+ total = data.reduce((sum, d) => sum + d.value, 0);
2217
+ break;
2218
+ }
1840
2219
  case "top_utm_sources": {
1841
2220
  const rows = await this.collection.aggregate([
1842
- { $match: { ...baseMatch, ...filterMatch, utm_source: { $nin: [null, ""] } } },
1843
- { $group: { _id: "$utm_source", value: { $addToSet: "$visitor_id" } } },
2221
+ ...matchStages({ utm_source: { $nin: [null, ""] } }),
2222
+ { $addFields: { _normalized_source: normalizedUtmSourceSwitch() } },
2223
+ { $group: { _id: "$_normalized_source", value: { $addToSet: "$visitor_id" } } },
1844
2224
  { $project: { _id: 1, value: { $size: "$value" } } },
1845
2225
  { $sort: { value: -1 } },
1846
2226
  { $limit: limit }
@@ -1851,8 +2231,9 @@ var MongoDBAdapter = class {
1851
2231
  }
1852
2232
  case "top_utm_mediums": {
1853
2233
  const rows = await this.collection.aggregate([
1854
- { $match: { ...baseMatch, ...filterMatch, utm_medium: { $nin: [null, ""] } } },
1855
- { $group: { _id: "$utm_medium", value: { $addToSet: "$visitor_id" } } },
2234
+ ...matchStages({ utm_medium: { $nin: [null, ""] } }),
2235
+ { $addFields: { _normalized_medium: normalizedUtmMediumSwitch() } },
2236
+ { $group: { _id: "$_normalized_medium", value: { $addToSet: "$visitor_id" } } },
1856
2237
  { $project: { _id: 1, value: { $size: "$value" } } },
1857
2238
  { $sort: { value: -1 } },
1858
2239
  { $limit: limit }
@@ -1863,7 +2244,7 @@ var MongoDBAdapter = class {
1863
2244
  }
1864
2245
  case "top_utm_campaigns": {
1865
2246
  const rows = await this.collection.aggregate([
1866
- { $match: { ...baseMatch, ...filterMatch, utm_campaign: { $nin: [null, ""] } } },
2247
+ ...matchStages({ utm_campaign: { $nin: [null, ""] } }),
1867
2248
  { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
1868
2249
  { $project: { _id: 1, value: { $size: "$value" } } },
1869
2250
  { $sort: { value: -1 } },
@@ -1873,6 +2254,43 @@ var MongoDBAdapter = class {
1873
2254
  total = data.reduce((sum, d) => sum + d.value, 0);
1874
2255
  break;
1875
2256
  }
2257
+ case "top_utm_terms": {
2258
+ const rows = await this.collection.aggregate([
2259
+ ...matchStages({ utm_term: { $nin: [null, ""] } }),
2260
+ { $group: { _id: "$utm_term", value: { $addToSet: "$visitor_id" } } },
2261
+ { $project: { _id: 1, value: { $size: "$value" } } },
2262
+ { $sort: { value: -1 } },
2263
+ { $limit: limit }
2264
+ ]).toArray();
2265
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2266
+ total = data.reduce((sum, d) => sum + d.value, 0);
2267
+ break;
2268
+ }
2269
+ case "top_utm_contents": {
2270
+ const rows = await this.collection.aggregate([
2271
+ ...matchStages({ utm_content: { $nin: [null, ""] } }),
2272
+ { $group: { _id: "$utm_content", value: { $addToSet: "$visitor_id" } } },
2273
+ { $project: { _id: 1, value: { $size: "$value" } } },
2274
+ { $sort: { value: -1 } },
2275
+ { $limit: limit }
2276
+ ]).toArray();
2277
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2278
+ total = data.reduce((sum, d) => sum + d.value, 0);
2279
+ break;
2280
+ }
2281
+ case "top_channels": {
2282
+ const rows = await this.collection.aggregate([
2283
+ ...matchStages(),
2284
+ { $addFields: { _channel: channelClassificationSwitch() } },
2285
+ { $group: { _id: "$_channel", value: { $addToSet: "$visitor_id" } } },
2286
+ { $project: { _id: 1, value: { $size: "$value" } } },
2287
+ { $sort: { value: -1 } },
2288
+ { $limit: limit }
2289
+ ]).toArray();
2290
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2291
+ total = data.reduce((sum, d) => sum + d.value, 0);
2292
+ break;
2293
+ }
1876
2294
  }
1877
2295
  const result = { metric: q.metric, period, data, total };
1878
2296
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -2335,7 +2753,18 @@ var MongoDBAdapter = class {
2335
2753
  userId: doc.user_id ?? void 0,
2336
2754
  traits: doc.traits ?? void 0,
2337
2755
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
2338
- device: doc.device_type ? { type: doc.device_type, browser: doc.browser ?? "", os: doc.os ?? "" } : void 0,
2756
+ device: doc.device_type ? {
2757
+ type: doc.device_type,
2758
+ browser: doc.browser ?? "",
2759
+ os: doc.os ?? "",
2760
+ osVersion: doc.os_version ?? void 0,
2761
+ deviceModel: doc.device_model ?? void 0,
2762
+ deviceBrand: doc.device_brand ?? void 0,
2763
+ appVersion: doc.app_version ?? void 0,
2764
+ appBuild: doc.app_build ?? void 0,
2765
+ sdkName: doc.sdk_name ?? void 0,
2766
+ sdkVersion: doc.sdk_version ?? void 0
2767
+ } : void 0,
2339
2768
  language: doc.language ?? void 0,
2340
2769
  utm: doc.utm_source ? {
2341
2770
  source: doc.utm_source ?? void 0,
@@ -2353,6 +2782,7 @@ var MongoDBAdapter = class {
2353
2782
  site_id: generateSiteId(),
2354
2783
  secret_key: generateSecretKey(),
2355
2784
  name: data.name,
2785
+ type: data.type ?? "web",
2356
2786
  domain: data.domain ?? null,
2357
2787
  allowed_origins: data.allowedOrigins ?? null,
2358
2788
  conversion_events: data.conversionEvents ?? null,
@@ -2377,6 +2807,7 @@ var MongoDBAdapter = class {
2377
2807
  async updateSite(siteId, data) {
2378
2808
  const updates = { updated_at: /* @__PURE__ */ new Date() };
2379
2809
  if (data.name !== void 0) updates.name = data.name;
2810
+ if (data.type !== void 0) updates.type = data.type;
2380
2811
  if (data.domain !== void 0) updates.domain = data.domain || null;
2381
2812
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
2382
2813
  if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
@@ -2408,6 +2839,7 @@ var MongoDBAdapter = class {
2408
2839
  siteId: doc.site_id,
2409
2840
  secretKey: doc.secret_key,
2410
2841
  name: doc.name,
2842
+ type: doc.type ?? "web",
2411
2843
  domain: doc.domain ?? void 0,
2412
2844
  allowedOrigins: doc.allowed_origins ?? void 0,
2413
2845
  conversionEvents: doc.conversion_events ?? void 0,
@@ -2676,9 +3108,26 @@ async function createCollector(config) {
2676
3108
  return false;
2677
3109
  }
2678
3110
  function enrichEvents(events, ip, userAgent) {
2679
- const device = parseUserAgent(userAgent);
3111
+ const uaDevice = parseUserAgent(userAgent);
2680
3112
  return events.map((event) => {
2681
3113
  const geo = resolveGeo(ip, event.timezone);
3114
+ let device;
3115
+ if (event.mobile?.platform) {
3116
+ device = {
3117
+ type: "mobile",
3118
+ browser: "App",
3119
+ os: event.mobile.platform === "ios" ? "iOS" : "Android",
3120
+ osVersion: event.mobile.osVersion,
3121
+ deviceModel: event.mobile.deviceModel,
3122
+ deviceBrand: event.mobile.deviceBrand,
3123
+ appVersion: event.mobile.appVersion,
3124
+ appBuild: event.mobile.appBuild,
3125
+ sdkName: event.mobile.sdkName,
3126
+ sdkVersion: event.mobile.sdkVersion
3127
+ };
3128
+ } else {
3129
+ device = uaDevice;
3130
+ }
2682
3131
  return { ...event, ip, geo, device };
2683
3132
  });
2684
3133
  }
@@ -2738,6 +3187,22 @@ async function createCollector(config) {
2738
3187
  }
2739
3188
  return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
2740
3189
  }
3190
+ function extractRequestHostname(req) {
3191
+ const headerValue = (value) => {
3192
+ if (Array.isArray(value)) return value[0];
3193
+ if (typeof value === "string") return value;
3194
+ return void 0;
3195
+ };
3196
+ const origin = headerValue(req.headers?.origin);
3197
+ const referer = headerValue(req.headers?.referer) || headerValue(req.headers?.referrer);
3198
+ const raw = origin ?? referer;
3199
+ if (!raw || raw === "null") return void 0;
3200
+ try {
3201
+ return new URL(raw).hostname.toLowerCase();
3202
+ } catch {
3203
+ return void 0;
3204
+ }
3205
+ }
2741
3206
  function handler() {
2742
3207
  return async (req, res) => {
2743
3208
  res.setHeader?.("Access-Control-Allow-Origin", "*");
@@ -2763,6 +3228,12 @@ async function createCollector(config) {
2763
3228
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
2764
3229
  return;
2765
3230
  }
3231
+ const siteIds = new Set(payload.events.map((event) => event.siteId).filter(Boolean));
3232
+ if (siteIds.size !== 1) {
3233
+ sendJson(res, 200, { ok: true });
3234
+ return;
3235
+ }
3236
+ const siteId = Array.from(siteIds)[0];
2766
3237
  const userAgent = req.headers?.["user-agent"] || "";
2767
3238
  if (isBot(userAgent)) {
2768
3239
  sendJson(res, 200, { ok: true });
@@ -2770,26 +3241,15 @@ async function createCollector(config) {
2770
3241
  }
2771
3242
  const ip = extractIp(req);
2772
3243
  const enriched = enrichEvents(payload.events, ip, userAgent);
2773
- const siteId = enriched[0]?.siteId;
2774
- if (siteId) {
2775
- const site = await db.getSite(siteId);
2776
- if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2777
- const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2778
- const filtered = enriched.filter((event) => {
2779
- if (!event.url) return true;
2780
- try {
2781
- const hostname = new URL(event.url).hostname.toLowerCase();
2782
- return allowed.has(hostname);
2783
- } catch {
2784
- return true;
2785
- }
2786
- });
2787
- if (filtered.length === 0) {
2788
- sendJson(res, 200, { ok: true });
2789
- return;
2790
- }
2791
- await processIdentity(filtered);
2792
- await db.insertEvents(filtered);
3244
+ const site = await db.getSite(siteId);
3245
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
3246
+ const requestHostname = extractRequestHostname(req);
3247
+ if (!requestHostname) {
3248
+ sendJson(res, 200, { ok: true });
3249
+ return;
3250
+ }
3251
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
3252
+ if (!allowed.has(requestHostname)) {
2793
3253
  sendJson(res, 200, { ok: true });
2794
3254
  return;
2795
3255
  }