@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.cjs CHANGED
@@ -229,6 +229,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
229
229
  utm_term Nullable(String),
230
230
  utm_content Nullable(String),
231
231
  ip Nullable(String),
232
+ os_version LowCardinality(Nullable(String)),
233
+ device_model LowCardinality(Nullable(String)),
234
+ device_brand LowCardinality(Nullable(String)),
235
+ app_version LowCardinality(Nullable(String)),
236
+ app_build Nullable(String),
237
+ sdk_name LowCardinality(Nullable(String)),
238
+ sdk_version LowCardinality(Nullable(String)),
232
239
  created_at DateTime64(3) DEFAULT now64(3)
233
240
  ) ENGINE = MergeTree()
234
241
  PARTITION BY toYYYYMM(timestamp)
@@ -240,6 +247,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
240
247
  site_id String,
241
248
  secret_key String,
242
249
  name String,
250
+ type LowCardinality(Nullable(String)) DEFAULT 'web',
243
251
  domain Nullable(String),
244
252
  allowed_origins Nullable(String),
245
253
  conversion_events Nullable(String),
@@ -255,6 +263,65 @@ function toCHDateTime(d) {
255
263
  const iso = typeof d === "string" ? d : d.toISOString();
256
264
  return iso.replace("T", " ").replace("Z", "");
257
265
  }
266
+ function normalizedUtmSourceExpr() {
267
+ return `multiIf(
268
+ lower(utm_source) IN ('ig','instagram','instagram.com'), 'Instagram',
269
+ lower(utm_source) IN ('fb','facebook','facebook.com','fb.com'), 'Facebook',
270
+ lower(utm_source) IN ('tw','twitter','twitter.com','x','x.com','t.co'), 'X (Twitter)',
271
+ lower(utm_source) IN ('li','linkedin','linkedin.com'), 'LinkedIn',
272
+ lower(utm_source) IN ('yt','youtube','youtube.com'), 'YouTube',
273
+ lower(utm_source) IN ('goog','google','google.com'), 'Google',
274
+ lower(utm_source) IN ('gh','github','github.com'), 'GitHub',
275
+ lower(utm_source) IN ('reddit','reddit.com'), 'Reddit',
276
+ lower(utm_source) IN ('pinterest','pinterest.com'), 'Pinterest',
277
+ lower(utm_source) IN ('tiktok','tiktok.com'), 'TikTok',
278
+ lower(utm_source) IN ('openai','chatgpt','chat.openai.com'), 'OpenAI',
279
+ lower(utm_source) IN ('perplexity','perplexity.ai'), 'Perplexity',
280
+ utm_source
281
+ )`;
282
+ }
283
+ function normalizedUtmMediumExpr() {
284
+ return `multiIf(
285
+ lower(utm_medium) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid'), 'Paid',
286
+ lower(utm_medium) IN ('organic'), 'Organic',
287
+ lower(utm_medium) IN ('social','social-media','social_media'), 'Social',
288
+ lower(utm_medium) IN ('email','e-mail','e_mail'), 'Email',
289
+ lower(utm_medium) IN ('display','banner','cpm'), 'Display',
290
+ lower(utm_medium) IN ('affiliate'), 'Affiliate',
291
+ lower(utm_medium) IN ('referral'), 'Referral',
292
+ utm_medium
293
+ )`;
294
+ }
295
+ function channelClassificationExpr() {
296
+ return `multiIf(
297
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
298
+ AND (lower(ifNull(utm_source,'')) IN ('google','goog','bing','yahoo','duckduckgo','ecosia','baidu','yandex')
299
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0),
300
+ 'Paid Search',
301
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
302
+ AND (lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat')
303
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat']) > 0),
304
+ 'Paid Social',
305
+ lower(ifNull(utm_medium,'')) IN ('email','e-mail','e_mail'),
306
+ 'Email',
307
+ lower(ifNull(utm_medium,'')) IN ('display','banner','cpm'),
308
+ 'Display',
309
+ lower(ifNull(utm_medium,'')) IN ('affiliate'),
310
+ 'Affiliate',
311
+ multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0
312
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
313
+ 'Organic Search',
314
+ (multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat','mastodon','tumblr']) > 0
315
+ OR lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat'))
316
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
317
+ 'Organic Social',
318
+ ifNull(referrer,'') != '' AND length(ifNull(referrer,'')) > 0,
319
+ 'Referral',
320
+ (ifNull(utm_source,'') != '' OR ifNull(utm_medium,'') != '' OR ifNull(utm_campaign,'') != ''),
321
+ 'Other',
322
+ 'Direct'
323
+ )`;
324
+ }
258
325
  function buildFilterConditions(filters) {
259
326
  if (!filters) return { conditions: [], params: {} };
260
327
  const map = {
@@ -265,6 +332,10 @@ function buildFilterConditions(filters) {
265
332
  "device.type": "device_type",
266
333
  "device.browser": "browser",
267
334
  "device.os": "os",
335
+ "device.osVersion": "os_version",
336
+ "device.deviceModel": "device_model",
337
+ "device.deviceBrand": "device_brand",
338
+ "device.appVersion": "app_version",
268
339
  "utm.source": "utm_source",
269
340
  "utm.medium": "utm_medium",
270
341
  "utm.campaign": "utm_campaign",
@@ -281,7 +352,14 @@ function buildFilterConditions(filters) {
281
352
  const conditions = [];
282
353
  const params = {};
283
354
  for (const [key, value] of Object.entries(filters)) {
284
- if (!value || !map[key]) continue;
355
+ if (!value) continue;
356
+ if (key === "channel") {
357
+ const paramKey2 = "f_channel";
358
+ conditions.push(`${channelClassificationExpr()} = {${paramKey2}:String}`);
359
+ params[paramKey2] = value;
360
+ continue;
361
+ }
362
+ if (!map[key]) continue;
285
363
  const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
286
364
  conditions.push(`${map[key]} = {${paramKey}:String}`);
287
365
  params[paramKey] = value;
@@ -312,6 +390,14 @@ var ClickHouseAdapter = class {
312
390
  await this.client.command({
313
391
  query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
314
392
  });
393
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS os_version LowCardinality(Nullable(String))` });
394
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_model LowCardinality(Nullable(String))` });
395
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_brand LowCardinality(Nullable(String))` });
396
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_version LowCardinality(Nullable(String))` });
397
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_build Nullable(String)` });
398
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_name LowCardinality(Nullable(String))` });
399
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_version LowCardinality(Nullable(String))` });
400
+ await this.client.command({ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS type LowCardinality(Nullable(String)) DEFAULT 'web'` });
315
401
  }
316
402
  async close() {
317
403
  await this.client.close();
@@ -354,7 +440,14 @@ var ClickHouseAdapter = class {
354
440
  utm_campaign: e.utm?.campaign ?? null,
355
441
  utm_term: e.utm?.term ?? null,
356
442
  utm_content: e.utm?.content ?? null,
357
- ip: e.ip ?? null
443
+ ip: e.ip ?? null,
444
+ os_version: e.device?.osVersion ?? null,
445
+ device_model: e.device?.deviceModel ?? null,
446
+ device_brand: e.device?.deviceBrand ?? null,
447
+ app_version: e.device?.appVersion ?? null,
448
+ app_build: e.device?.appBuild ?? null,
449
+ sdk_name: e.device?.sdkName ?? null,
450
+ sdk_version: e.device?.sdkVersion ?? null
358
451
  }));
359
452
  await this.client.insert({
360
453
  table: EVENTS_TABLE,
@@ -705,15 +798,67 @@ var ClickHouseAdapter = class {
705
798
  total = data.reduce((sum, d) => sum + d.value, 0);
706
799
  break;
707
800
  }
801
+ case "top_os_versions": {
802
+ const rows = await this.queryRows(
803
+ `SELECT concat(os, ' ', ifNull(os_version, '')) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
804
+ WHERE site_id = {siteId:String}
805
+ AND timestamp >= {from:String}
806
+ AND timestamp <= {to:String}
807
+ AND os IS NOT NULL
808
+ AND os_version IS NOT NULL
809
+ ${filterSql}
810
+ GROUP BY key
811
+ ORDER BY value DESC
812
+ LIMIT {limit:UInt32}`,
813
+ { ...params, ...filter.params }
814
+ );
815
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
816
+ total = data.reduce((sum, d) => sum + d.value, 0);
817
+ break;
818
+ }
819
+ case "top_device_models": {
820
+ const rows = await this.queryRows(
821
+ `SELECT trim(concat(ifNull(device_brand, ''), ' ', device_model)) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
822
+ WHERE site_id = {siteId:String}
823
+ AND timestamp >= {from:String}
824
+ AND timestamp <= {to:String}
825
+ AND device_model IS NOT NULL
826
+ ${filterSql}
827
+ GROUP BY key
828
+ ORDER BY value DESC
829
+ LIMIT {limit:UInt32}`,
830
+ { ...params, ...filter.params }
831
+ );
832
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
833
+ total = data.reduce((sum, d) => sum + d.value, 0);
834
+ break;
835
+ }
836
+ case "top_app_versions": {
837
+ const rows = await this.queryRows(
838
+ `SELECT app_version AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
839
+ WHERE site_id = {siteId:String}
840
+ AND timestamp >= {from:String}
841
+ AND timestamp <= {to:String}
842
+ AND app_version IS NOT NULL
843
+ ${filterSql}
844
+ GROUP BY app_version
845
+ ORDER BY value DESC
846
+ LIMIT {limit:UInt32}`,
847
+ { ...params, ...filter.params }
848
+ );
849
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
850
+ total = data.reduce((sum, d) => sum + d.value, 0);
851
+ break;
852
+ }
708
853
  case "top_utm_sources": {
709
854
  const rows = await this.queryRows(
710
- `SELECT utm_source AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
855
+ `SELECT ${normalizedUtmSourceExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
711
856
  WHERE site_id = {siteId:String}
712
857
  AND timestamp >= {from:String}
713
858
  AND timestamp <= {to:String}
714
859
  AND utm_source IS NOT NULL AND utm_source != ''
715
860
  ${filterSql}
716
- GROUP BY utm_source
861
+ GROUP BY key
717
862
  ORDER BY value DESC
718
863
  LIMIT {limit:UInt32}`,
719
864
  { ...params, ...filter.params }
@@ -724,13 +869,13 @@ var ClickHouseAdapter = class {
724
869
  }
725
870
  case "top_utm_mediums": {
726
871
  const rows = await this.queryRows(
727
- `SELECT utm_medium AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
872
+ `SELECT ${normalizedUtmMediumExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
728
873
  WHERE site_id = {siteId:String}
729
874
  AND timestamp >= {from:String}
730
875
  AND timestamp <= {to:String}
731
876
  AND utm_medium IS NOT NULL AND utm_medium != ''
732
877
  ${filterSql}
733
- GROUP BY utm_medium
878
+ GROUP BY key
734
879
  ORDER BY value DESC
735
880
  LIMIT {limit:UInt32}`,
736
881
  { ...params, ...filter.params }
@@ -756,6 +901,56 @@ var ClickHouseAdapter = class {
756
901
  total = data.reduce((sum, d) => sum + d.value, 0);
757
902
  break;
758
903
  }
904
+ case "top_utm_terms": {
905
+ const rows = await this.queryRows(
906
+ `SELECT utm_term AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
907
+ WHERE site_id = {siteId:String}
908
+ AND timestamp >= {from:String}
909
+ AND timestamp <= {to:String}
910
+ AND utm_term IS NOT NULL AND utm_term != ''
911
+ ${filterSql}
912
+ GROUP BY utm_term
913
+ ORDER BY value DESC
914
+ LIMIT {limit:UInt32}`,
915
+ { ...params, ...filter.params }
916
+ );
917
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
918
+ total = data.reduce((sum, d) => sum + d.value, 0);
919
+ break;
920
+ }
921
+ case "top_utm_contents": {
922
+ const rows = await this.queryRows(
923
+ `SELECT utm_content AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
924
+ WHERE site_id = {siteId:String}
925
+ AND timestamp >= {from:String}
926
+ AND timestamp <= {to:String}
927
+ AND utm_content IS NOT NULL AND utm_content != ''
928
+ ${filterSql}
929
+ GROUP BY utm_content
930
+ ORDER BY value DESC
931
+ LIMIT {limit:UInt32}`,
932
+ { ...params, ...filter.params }
933
+ );
934
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
935
+ total = data.reduce((sum, d) => sum + d.value, 0);
936
+ break;
937
+ }
938
+ case "top_channels": {
939
+ const rows = await this.queryRows(
940
+ `SELECT ${channelClassificationExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
941
+ WHERE site_id = {siteId:String}
942
+ AND timestamp >= {from:String}
943
+ AND timestamp <= {to:String}
944
+ ${filterSql}
945
+ GROUP BY key
946
+ ORDER BY value DESC
947
+ LIMIT {limit:UInt32}`,
948
+ { ...params, ...filter.params }
949
+ );
950
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
951
+ total = data.reduce((sum, d) => sum + d.value, 0);
952
+ break;
953
+ }
759
954
  }
760
955
  const result = { metric: q.metric, period, data, total };
761
956
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -1158,7 +1353,7 @@ var ClickHouseAdapter = class {
1158
1353
  async getMergedUserDetail(siteId, userId, visitorIds) {
1159
1354
  const rows = await this.queryRows(
1160
1355
  `SELECT
1161
- anyLast(visitor_id) AS visitor_id,
1356
+ anyLast(visitor_id) AS last_visitor_id,
1162
1357
  anyLast(traits) AS traits,
1163
1358
  min(timestamp) AS firstSeen,
1164
1359
  max(timestamp) AS lastSeen,
@@ -1190,7 +1385,7 @@ var ClickHouseAdapter = class {
1190
1385
  if (rows.length === 0) return null;
1191
1386
  const u = rows[0];
1192
1387
  return {
1193
- visitorId: String(u.visitor_id),
1388
+ visitorId: String(u.last_visitor_id),
1194
1389
  visitorIds,
1195
1390
  userId,
1196
1391
  traits: this.parseJSON(u.traits),
@@ -1279,6 +1474,7 @@ var ClickHouseAdapter = class {
1279
1474
  siteId: generateSiteId(),
1280
1475
  secretKey: generateSecretKey(),
1281
1476
  name: data.name,
1477
+ type: data.type ?? "web",
1282
1478
  domain: data.domain,
1283
1479
  allowedOrigins: data.allowedOrigins,
1284
1480
  conversionEvents: data.conversionEvents,
@@ -1291,6 +1487,7 @@ var ClickHouseAdapter = class {
1291
1487
  site_id: site.siteId,
1292
1488
  secret_key: site.secretKey,
1293
1489
  name: site.name,
1490
+ type: site.type ?? "web",
1294
1491
  domain: site.domain ?? null,
1295
1492
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1296
1493
  conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
@@ -1305,7 +1502,7 @@ var ClickHouseAdapter = class {
1305
1502
  }
1306
1503
  async getSite(siteId) {
1307
1504
  const rows = await this.queryRows(
1308
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1505
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1309
1506
  FROM ${SITES_TABLE} FINAL
1310
1507
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1311
1508
  { siteId }
@@ -1314,7 +1511,7 @@ var ClickHouseAdapter = class {
1314
1511
  }
1315
1512
  async getSiteBySecret(secretKey) {
1316
1513
  const rows = await this.queryRows(
1317
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1514
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1318
1515
  FROM ${SITES_TABLE} FINAL
1319
1516
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
1320
1517
  { secretKey }
@@ -1323,7 +1520,7 @@ var ClickHouseAdapter = class {
1323
1520
  }
1324
1521
  async listSites() {
1325
1522
  const rows = await this.queryRows(
1326
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1523
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1327
1524
  FROM ${SITES_TABLE} FINAL
1328
1525
  WHERE is_deleted = 0
1329
1526
  ORDER BY created_at DESC`,
@@ -1333,7 +1530,7 @@ var ClickHouseAdapter = class {
1333
1530
  }
1334
1531
  async updateSite(siteId, data) {
1335
1532
  const currentRows = await this.queryRows(
1336
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
1533
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at, version
1337
1534
  FROM ${SITES_TABLE} FINAL
1338
1535
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1339
1536
  { siteId }
@@ -1345,6 +1542,7 @@ var ClickHouseAdapter = class {
1345
1542
  const nowCH = toCHDateTime(now);
1346
1543
  const newVersion = Number(current.version) + 1;
1347
1544
  const newName = data.name !== void 0 ? data.name : String(current.name);
1545
+ const newType = data.type !== void 0 ? data.type : current.type ? String(current.type) : "web";
1348
1546
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
1349
1547
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1350
1548
  const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
@@ -1354,6 +1552,7 @@ var ClickHouseAdapter = class {
1354
1552
  site_id: String(current.site_id),
1355
1553
  secret_key: String(current.secret_key),
1356
1554
  name: newName,
1555
+ type: newType,
1357
1556
  domain: newDomain,
1358
1557
  allowed_origins: newOrigins,
1359
1558
  conversion_events: newConversions,
@@ -1368,6 +1567,7 @@ var ClickHouseAdapter = class {
1368
1567
  siteId: String(current.site_id),
1369
1568
  secretKey: String(current.secret_key),
1370
1569
  name: newName,
1570
+ type: newType,
1371
1571
  domain: newDomain ?? void 0,
1372
1572
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1373
1573
  conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
@@ -1377,7 +1577,7 @@ var ClickHouseAdapter = class {
1377
1577
  }
1378
1578
  async deleteSite(siteId) {
1379
1579
  const currentRows = await this.queryRows(
1380
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1580
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1381
1581
  FROM ${SITES_TABLE} FINAL
1382
1582
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1383
1583
  { siteId }
@@ -1391,6 +1591,7 @@ var ClickHouseAdapter = class {
1391
1591
  site_id: String(current.site_id),
1392
1592
  secret_key: String(current.secret_key),
1393
1593
  name: String(current.name),
1594
+ type: current.type ? String(current.type) : "web",
1394
1595
  domain: current.domain ? String(current.domain) : null,
1395
1596
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1396
1597
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1405,7 +1606,7 @@ var ClickHouseAdapter = class {
1405
1606
  }
1406
1607
  async regenerateSecret(siteId) {
1407
1608
  const currentRows = await this.queryRows(
1408
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1609
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1409
1610
  FROM ${SITES_TABLE} FINAL
1410
1611
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1411
1612
  { siteId }
@@ -1422,6 +1623,7 @@ var ClickHouseAdapter = class {
1422
1623
  site_id: String(current.site_id),
1423
1624
  secret_key: newSecret,
1424
1625
  name: String(current.name),
1626
+ type: current.type ? String(current.type) : "web",
1425
1627
  domain: current.domain ? String(current.domain) : null,
1426
1628
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1427
1629
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1436,6 +1638,7 @@ var ClickHouseAdapter = class {
1436
1638
  siteId: String(current.site_id),
1437
1639
  secretKey: newSecret,
1438
1640
  name: String(current.name),
1641
+ type: current.type ? String(current.type) : "web",
1439
1642
  domain: current.domain ? String(current.domain) : void 0,
1440
1643
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1441
1644
  conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
@@ -1457,6 +1660,7 @@ var ClickHouseAdapter = class {
1457
1660
  siteId: String(row.site_id),
1458
1661
  secretKey: String(row.secret_key),
1459
1662
  name: String(row.name),
1663
+ type: row.type ? String(row.type) : "web",
1460
1664
  domain: row.domain ? String(row.domain) : void 0,
1461
1665
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1462
1666
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
@@ -1520,6 +1724,130 @@ var import_mongodb = require("mongodb");
1520
1724
  var EVENTS_COLLECTION = "litemetrics_events";
1521
1725
  var SITES_COLLECTION = "litemetrics_sites";
1522
1726
  var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1727
+ function normalizedUtmSourceSwitch() {
1728
+ return {
1729
+ $switch: {
1730
+ branches: [
1731
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["ig", "instagram", "instagram.com"]] }, then: "Instagram" },
1732
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["fb", "facebook", "facebook.com", "fb.com"]] }, then: "Facebook" },
1733
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tw", "twitter", "twitter.com", "x", "x.com", "t.co"]] }, then: "X (Twitter)" },
1734
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["li", "linkedin", "linkedin.com"]] }, then: "LinkedIn" },
1735
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["yt", "youtube", "youtube.com"]] }, then: "YouTube" },
1736
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["goog", "google", "google.com"]] }, then: "Google" },
1737
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["gh", "github", "github.com"]] }, then: "GitHub" },
1738
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["reddit", "reddit.com"]] }, then: "Reddit" },
1739
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["pinterest", "pinterest.com"]] }, then: "Pinterest" },
1740
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tiktok", "tiktok.com"]] }, then: "TikTok" },
1741
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["openai", "chatgpt", "chat.openai.com"]] }, then: "OpenAI" },
1742
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["perplexity", "perplexity.ai"]] }, then: "Perplexity" }
1743
+ ],
1744
+ default: "$utm_source"
1745
+ }
1746
+ };
1747
+ }
1748
+ function normalizedUtmMediumSwitch() {
1749
+ return {
1750
+ $switch: {
1751
+ branches: [
1752
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"]] }, then: "Paid" },
1753
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["organic"]] }, then: "Organic" },
1754
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["social", "social-media", "social_media"]] }, then: "Social" },
1755
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1756
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["display", "banner", "cpm"]] }, then: "Display" },
1757
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["affiliate"]] }, then: "Affiliate" },
1758
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["referral"]] }, then: "Referral" }
1759
+ ],
1760
+ default: "$utm_medium"
1761
+ }
1762
+ };
1763
+ }
1764
+ var SEARCH_ENGINES = /google|bing|yahoo|duckduckgo|ecosia|baidu|yandex|search\.brave/i;
1765
+ var SOCIAL_NETWORKS = /instagram|facebook|twitter|x\.com|t\.co|linkedin|youtube|tiktok|pinterest|reddit|snapchat|mastodon|tumblr/i;
1766
+ var PAID_MEDIUMS = ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"];
1767
+ var SOCIAL_SOURCES = ["instagram", "ig", "facebook", "fb", "twitter", "tw", "x", "linkedin", "li", "youtube", "yt", "tiktok", "pinterest", "reddit", "snapchat"];
1768
+ function channelClassificationSwitch() {
1769
+ const lMedium = { $toLower: { $ifNull: ["$utm_medium", ""] } };
1770
+ const lSource = { $toLower: { $ifNull: ["$utm_source", ""] } };
1771
+ const refStr = { $ifNull: ["$referrer", ""] };
1772
+ return {
1773
+ $switch: {
1774
+ branches: [
1775
+ // Paid Search
1776
+ {
1777
+ case: {
1778
+ $and: [
1779
+ { $in: [lMedium, PAID_MEDIUMS] },
1780
+ { $or: [
1781
+ { $in: [lSource, ["google", "goog", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex"]] },
1782
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } }
1783
+ ] }
1784
+ ]
1785
+ },
1786
+ then: "Paid Search"
1787
+ },
1788
+ // Paid Social
1789
+ {
1790
+ case: {
1791
+ $and: [
1792
+ { $in: [lMedium, PAID_MEDIUMS] },
1793
+ { $or: [
1794
+ { $in: [lSource, SOCIAL_SOURCES] },
1795
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } }
1796
+ ] }
1797
+ ]
1798
+ },
1799
+ then: "Paid Social"
1800
+ },
1801
+ // Email
1802
+ { case: { $in: [lMedium, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1803
+ // Display
1804
+ { case: { $in: [lMedium, ["display", "banner", "cpm"]] }, then: "Display" },
1805
+ // Affiliate
1806
+ { case: { $in: [lMedium, ["affiliate"]] }, then: "Affiliate" },
1807
+ // Organic Search
1808
+ {
1809
+ case: {
1810
+ $and: [
1811
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } },
1812
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1813
+ ]
1814
+ },
1815
+ then: "Organic Search"
1816
+ },
1817
+ // Organic Social
1818
+ {
1819
+ case: {
1820
+ $and: [
1821
+ { $or: [
1822
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } },
1823
+ { $in: [lSource, SOCIAL_SOURCES] }
1824
+ ] },
1825
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1826
+ ]
1827
+ },
1828
+ then: "Organic Social"
1829
+ },
1830
+ // Referral
1831
+ {
1832
+ case: { $and: [{ $ne: [refStr, ""] }, { $gt: [{ $strLenCP: refStr }, 0] }] },
1833
+ then: "Referral"
1834
+ },
1835
+ // Other (has UTM but no referrer)
1836
+ {
1837
+ case: {
1838
+ $or: [
1839
+ { $and: [{ $ne: [{ $ifNull: ["$utm_source", ""] }, ""] }] },
1840
+ { $and: [{ $ne: [{ $ifNull: ["$utm_medium", ""] }, ""] }] },
1841
+ { $and: [{ $ne: [{ $ifNull: ["$utm_campaign", ""] }, ""] }] }
1842
+ ]
1843
+ },
1844
+ then: "Other"
1845
+ }
1846
+ ],
1847
+ default: "Direct"
1848
+ }
1849
+ };
1850
+ }
1523
1851
  function buildFilterMatch(filters) {
1524
1852
  if (!filters) return {};
1525
1853
  const map = {
@@ -1530,6 +1858,10 @@ function buildFilterMatch(filters) {
1530
1858
  "device.type": "device_type",
1531
1859
  "device.browser": "browser",
1532
1860
  "device.os": "os",
1861
+ "device.osVersion": "os_version",
1862
+ "device.deviceModel": "device_model",
1863
+ "device.deviceBrand": "device_brand",
1864
+ "device.appVersion": "app_version",
1533
1865
  "utm.source": "utm_source",
1534
1866
  "utm.medium": "utm_medium",
1535
1867
  "utm.campaign": "utm_campaign",
@@ -1545,7 +1877,9 @@ function buildFilterMatch(filters) {
1545
1877
  };
1546
1878
  const match = {};
1547
1879
  for (const [key, value] of Object.entries(filters)) {
1548
- if (!value || !map[key]) continue;
1880
+ if (!value) continue;
1881
+ if (key === "channel") continue;
1882
+ if (!map[key]) continue;
1549
1883
  match[map[key]] = value;
1550
1884
  }
1551
1885
  return match;
@@ -1614,6 +1948,13 @@ var MongoDBAdapter = class {
1614
1948
  utm_term: e.utm?.term ?? null,
1615
1949
  utm_content: e.utm?.content ?? null,
1616
1950
  ip: e.ip ?? null,
1951
+ os_version: e.device?.osVersion ?? null,
1952
+ device_model: e.device?.deviceModel ?? null,
1953
+ device_brand: e.device?.deviceBrand ?? null,
1954
+ app_version: e.device?.appVersion ?? null,
1955
+ app_build: e.device?.appBuild ?? null,
1956
+ sdk_name: e.device?.sdkName ?? null,
1957
+ sdk_version: e.device?.sdkVersion ?? null,
1617
1958
  created_at: /* @__PURE__ */ new Date()
1618
1959
  }));
1619
1960
  await this.collection.insertMany(docs);
@@ -1627,12 +1968,22 @@ var MongoDBAdapter = class {
1627
1968
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1628
1969
  };
1629
1970
  const filterMatch = buildFilterMatch(q.filters);
1971
+ const matchStages = (extra) => {
1972
+ const stages = [
1973
+ { $match: { ...baseMatch, ...filterMatch, ...extra } }
1974
+ ];
1975
+ if (q.filters?.channel) {
1976
+ stages.push({ $addFields: { _channel: channelClassificationSwitch() } });
1977
+ stages.push({ $match: { _channel: q.filters.channel } });
1978
+ }
1979
+ return stages;
1980
+ };
1630
1981
  let data = [];
1631
1982
  let total = 0;
1632
1983
  switch (q.metric) {
1633
1984
  case "pageviews": {
1634
1985
  const [result2] = await this.collection.aggregate([
1635
- { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1986
+ ...matchStages({ type: "pageview" }),
1636
1987
  { $count: "count" }
1637
1988
  ]).toArray();
1638
1989
  total = result2?.count ?? 0;
@@ -1641,7 +1992,7 @@ var MongoDBAdapter = class {
1641
1992
  }
1642
1993
  case "visitors": {
1643
1994
  const [result2] = await this.collection.aggregate([
1644
- { $match: { ...baseMatch, ...filterMatch } },
1995
+ ...matchStages(),
1645
1996
  { $group: { _id: "$visitor_id" } },
1646
1997
  { $count: "count" }
1647
1998
  ]).toArray();
@@ -1651,7 +2002,7 @@ var MongoDBAdapter = class {
1651
2002
  }
1652
2003
  case "sessions": {
1653
2004
  const [result2] = await this.collection.aggregate([
1654
- { $match: { ...baseMatch, ...filterMatch } },
2005
+ ...matchStages(),
1655
2006
  { $group: { _id: "$session_id" } },
1656
2007
  { $count: "count" }
1657
2008
  ]).toArray();
@@ -1661,7 +2012,7 @@ var MongoDBAdapter = class {
1661
2012
  }
1662
2013
  case "events": {
1663
2014
  const [result2] = await this.collection.aggregate([
1664
- { $match: { ...baseMatch, ...filterMatch, type: "event" } },
2015
+ ...matchStages({ type: "event" }),
1665
2016
  { $count: "count" }
1666
2017
  ]).toArray();
1667
2018
  total = result2?.count ?? 0;
@@ -1676,7 +2027,7 @@ var MongoDBAdapter = class {
1676
2027
  break;
1677
2028
  }
1678
2029
  const [result2] = await this.collection.aggregate([
1679
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2030
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1680
2031
  { $count: "count" }
1681
2032
  ]).toArray();
1682
2033
  total = result2?.count ?? 0;
@@ -1685,7 +2036,7 @@ var MongoDBAdapter = class {
1685
2036
  }
1686
2037
  case "top_pages": {
1687
2038
  const rows = await this.collection.aggregate([
1688
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2039
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1689
2040
  { $group: { _id: "$url", value: { $sum: 1 } } },
1690
2041
  { $sort: { value: -1 } },
1691
2042
  { $limit: limit }
@@ -1696,7 +2047,7 @@ var MongoDBAdapter = class {
1696
2047
  }
1697
2048
  case "top_referrers": {
1698
2049
  const rows = await this.collection.aggregate([
1699
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
2050
+ ...matchStages({ type: "pageview", referrer: { $nin: [null, ""] } }),
1700
2051
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1701
2052
  { $sort: { value: -1 } },
1702
2053
  { $limit: limit }
@@ -1707,7 +2058,7 @@ var MongoDBAdapter = class {
1707
2058
  }
1708
2059
  case "top_countries": {
1709
2060
  const rows = await this.collection.aggregate([
1710
- { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
2061
+ ...matchStages({ country: { $ne: null } }),
1711
2062
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1712
2063
  { $project: { _id: 1, value: { $size: "$value" } } },
1713
2064
  { $sort: { value: -1 } },
@@ -1719,7 +2070,7 @@ var MongoDBAdapter = class {
1719
2070
  }
1720
2071
  case "top_cities": {
1721
2072
  const rows = await this.collection.aggregate([
1722
- { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
2073
+ ...matchStages({ city: { $ne: null } }),
1723
2074
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1724
2075
  { $project: { _id: 1, value: { $size: "$value" } } },
1725
2076
  { $sort: { value: -1 } },
@@ -1731,7 +2082,7 @@ var MongoDBAdapter = class {
1731
2082
  }
1732
2083
  case "top_events": {
1733
2084
  const rows = await this.collection.aggregate([
1734
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
2085
+ ...matchStages({ type: "event", event_name: { $ne: null } }),
1735
2086
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1736
2087
  { $sort: { value: -1 } },
1737
2088
  { $limit: limit }
@@ -1748,7 +2099,7 @@ var MongoDBAdapter = class {
1748
2099
  break;
1749
2100
  }
1750
2101
  const rows = await this.collection.aggregate([
1751
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2102
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1752
2103
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1753
2104
  { $sort: { value: -1 } },
1754
2105
  { $limit: limit }
@@ -1759,7 +2110,7 @@ var MongoDBAdapter = class {
1759
2110
  }
1760
2111
  case "top_exit_pages": {
1761
2112
  const rows = await this.collection.aggregate([
1762
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2113
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1763
2114
  { $sort: { timestamp: 1 } },
1764
2115
  { $group: { _id: "$session_id", url: { $last: "$url" } } },
1765
2116
  { $group: { _id: "$url", value: { $sum: 1 } } },
@@ -1772,7 +2123,7 @@ var MongoDBAdapter = class {
1772
2123
  }
1773
2124
  case "top_transitions": {
1774
2125
  const rows = await this.collection.aggregate([
1775
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2126
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1776
2127
  {
1777
2128
  $setWindowFields: {
1778
2129
  partitionBy: "$session_id",
@@ -1793,7 +2144,7 @@ var MongoDBAdapter = class {
1793
2144
  }
1794
2145
  case "top_scroll_pages": {
1795
2146
  const rows = await this.collection.aggregate([
1796
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
2147
+ ...matchStages({ type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } }),
1797
2148
  { $group: { _id: "$page_path", value: { $sum: 1 } } },
1798
2149
  { $sort: { value: -1 } },
1799
2150
  { $limit: limit }
@@ -1804,15 +2155,11 @@ var MongoDBAdapter = class {
1804
2155
  }
1805
2156
  case "top_button_clicks": {
1806
2157
  const rows = await this.collection.aggregate([
1807
- {
1808
- $match: {
1809
- ...baseMatch,
1810
- ...filterMatch,
1811
- type: "event",
1812
- event_subtype: "button_click",
1813
- $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1814
- }
1815
- },
2158
+ ...matchStages({
2159
+ type: "event",
2160
+ event_subtype: "button_click",
2161
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
2162
+ }),
1816
2163
  { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1817
2164
  { $sort: { value: -1 } },
1818
2165
  { $limit: limit }
@@ -1823,15 +2170,11 @@ var MongoDBAdapter = class {
1823
2170
  }
1824
2171
  case "top_link_targets": {
1825
2172
  const rows = await this.collection.aggregate([
1826
- {
1827
- $match: {
1828
- ...baseMatch,
1829
- ...filterMatch,
1830
- type: "event",
1831
- event_subtype: { $in: ["link_click", "outbound_click"] },
1832
- target_url_path: { $ne: null }
1833
- }
1834
- },
2173
+ ...matchStages({
2174
+ type: "event",
2175
+ event_subtype: { $in: ["link_click", "outbound_click"] },
2176
+ target_url_path: { $ne: null }
2177
+ }),
1835
2178
  { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1836
2179
  { $sort: { value: -1 } },
1837
2180
  { $limit: limit }
@@ -1842,7 +2185,7 @@ var MongoDBAdapter = class {
1842
2185
  }
1843
2186
  case "top_devices": {
1844
2187
  const rows = await this.collection.aggregate([
1845
- { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
2188
+ ...matchStages({ device_type: { $ne: null } }),
1846
2189
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1847
2190
  { $project: { _id: 1, value: { $size: "$value" } } },
1848
2191
  { $sort: { value: -1 } },
@@ -1854,7 +2197,7 @@ var MongoDBAdapter = class {
1854
2197
  }
1855
2198
  case "top_browsers": {
1856
2199
  const rows = await this.collection.aggregate([
1857
- { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
2200
+ ...matchStages({ browser: { $ne: null } }),
1858
2201
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1859
2202
  { $project: { _id: 1, value: { $size: "$value" } } },
1860
2203
  { $sort: { value: -1 } },
@@ -1866,7 +2209,7 @@ var MongoDBAdapter = class {
1866
2209
  }
1867
2210
  case "top_os": {
1868
2211
  const rows = await this.collection.aggregate([
1869
- { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
2212
+ ...matchStages({ os: { $ne: null } }),
1870
2213
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1871
2214
  { $project: { _id: 1, value: { $size: "$value" } } },
1872
2215
  { $sort: { value: -1 } },
@@ -1876,10 +2219,47 @@ var MongoDBAdapter = class {
1876
2219
  total = data.reduce((sum, d) => sum + d.value, 0);
1877
2220
  break;
1878
2221
  }
2222
+ case "top_os_versions": {
2223
+ const rows = await this.collection.aggregate([
2224
+ ...matchStages({ os: { $ne: null }, os_version: { $ne: null } }),
2225
+ { $group: { _id: { $concat: ["$os", " ", "$os_version"] }, value: { $addToSet: "$visitor_id" } } },
2226
+ { $project: { _id: 1, value: { $size: "$value" } } },
2227
+ { $sort: { value: -1 } },
2228
+ { $limit: limit }
2229
+ ]).toArray();
2230
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2231
+ total = data.reduce((sum, d) => sum + d.value, 0);
2232
+ break;
2233
+ }
2234
+ case "top_device_models": {
2235
+ const rows = await this.collection.aggregate([
2236
+ ...matchStages({ device_model: { $ne: null } }),
2237
+ { $group: { _id: { $trim: { input: { $concat: [{ $ifNull: ["$device_brand", ""] }, " ", "$device_model"] } } }, value: { $addToSet: "$visitor_id" } } },
2238
+ { $project: { _id: 1, value: { $size: "$value" } } },
2239
+ { $sort: { value: -1 } },
2240
+ { $limit: limit }
2241
+ ]).toArray();
2242
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2243
+ total = data.reduce((sum, d) => sum + d.value, 0);
2244
+ break;
2245
+ }
2246
+ case "top_app_versions": {
2247
+ const rows = await this.collection.aggregate([
2248
+ ...matchStages({ app_version: { $ne: null } }),
2249
+ { $group: { _id: "$app_version", value: { $addToSet: "$visitor_id" } } },
2250
+ { $project: { _id: 1, value: { $size: "$value" } } },
2251
+ { $sort: { value: -1 } },
2252
+ { $limit: limit }
2253
+ ]).toArray();
2254
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2255
+ total = data.reduce((sum, d) => sum + d.value, 0);
2256
+ break;
2257
+ }
1879
2258
  case "top_utm_sources": {
1880
2259
  const rows = await this.collection.aggregate([
1881
- { $match: { ...baseMatch, ...filterMatch, utm_source: { $nin: [null, ""] } } },
1882
- { $group: { _id: "$utm_source", value: { $addToSet: "$visitor_id" } } },
2260
+ ...matchStages({ utm_source: { $nin: [null, ""] } }),
2261
+ { $addFields: { _normalized_source: normalizedUtmSourceSwitch() } },
2262
+ { $group: { _id: "$_normalized_source", value: { $addToSet: "$visitor_id" } } },
1883
2263
  { $project: { _id: 1, value: { $size: "$value" } } },
1884
2264
  { $sort: { value: -1 } },
1885
2265
  { $limit: limit }
@@ -1890,8 +2270,9 @@ var MongoDBAdapter = class {
1890
2270
  }
1891
2271
  case "top_utm_mediums": {
1892
2272
  const rows = await this.collection.aggregate([
1893
- { $match: { ...baseMatch, ...filterMatch, utm_medium: { $nin: [null, ""] } } },
1894
- { $group: { _id: "$utm_medium", value: { $addToSet: "$visitor_id" } } },
2273
+ ...matchStages({ utm_medium: { $nin: [null, ""] } }),
2274
+ { $addFields: { _normalized_medium: normalizedUtmMediumSwitch() } },
2275
+ { $group: { _id: "$_normalized_medium", value: { $addToSet: "$visitor_id" } } },
1895
2276
  { $project: { _id: 1, value: { $size: "$value" } } },
1896
2277
  { $sort: { value: -1 } },
1897
2278
  { $limit: limit }
@@ -1902,7 +2283,7 @@ var MongoDBAdapter = class {
1902
2283
  }
1903
2284
  case "top_utm_campaigns": {
1904
2285
  const rows = await this.collection.aggregate([
1905
- { $match: { ...baseMatch, ...filterMatch, utm_campaign: { $nin: [null, ""] } } },
2286
+ ...matchStages({ utm_campaign: { $nin: [null, ""] } }),
1906
2287
  { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
1907
2288
  { $project: { _id: 1, value: { $size: "$value" } } },
1908
2289
  { $sort: { value: -1 } },
@@ -1912,6 +2293,43 @@ var MongoDBAdapter = class {
1912
2293
  total = data.reduce((sum, d) => sum + d.value, 0);
1913
2294
  break;
1914
2295
  }
2296
+ case "top_utm_terms": {
2297
+ const rows = await this.collection.aggregate([
2298
+ ...matchStages({ utm_term: { $nin: [null, ""] } }),
2299
+ { $group: { _id: "$utm_term", value: { $addToSet: "$visitor_id" } } },
2300
+ { $project: { _id: 1, value: { $size: "$value" } } },
2301
+ { $sort: { value: -1 } },
2302
+ { $limit: limit }
2303
+ ]).toArray();
2304
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2305
+ total = data.reduce((sum, d) => sum + d.value, 0);
2306
+ break;
2307
+ }
2308
+ case "top_utm_contents": {
2309
+ const rows = await this.collection.aggregate([
2310
+ ...matchStages({ utm_content: { $nin: [null, ""] } }),
2311
+ { $group: { _id: "$utm_content", value: { $addToSet: "$visitor_id" } } },
2312
+ { $project: { _id: 1, value: { $size: "$value" } } },
2313
+ { $sort: { value: -1 } },
2314
+ { $limit: limit }
2315
+ ]).toArray();
2316
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2317
+ total = data.reduce((sum, d) => sum + d.value, 0);
2318
+ break;
2319
+ }
2320
+ case "top_channels": {
2321
+ const rows = await this.collection.aggregate([
2322
+ ...matchStages(),
2323
+ { $addFields: { _channel: channelClassificationSwitch() } },
2324
+ { $group: { _id: "$_channel", value: { $addToSet: "$visitor_id" } } },
2325
+ { $project: { _id: 1, value: { $size: "$value" } } },
2326
+ { $sort: { value: -1 } },
2327
+ { $limit: limit }
2328
+ ]).toArray();
2329
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2330
+ total = data.reduce((sum, d) => sum + d.value, 0);
2331
+ break;
2332
+ }
1915
2333
  }
1916
2334
  const result = { metric: q.metric, period, data, total };
1917
2335
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -2374,7 +2792,18 @@ var MongoDBAdapter = class {
2374
2792
  userId: doc.user_id ?? void 0,
2375
2793
  traits: doc.traits ?? void 0,
2376
2794
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
2377
- device: doc.device_type ? { type: doc.device_type, browser: doc.browser ?? "", os: doc.os ?? "" } : void 0,
2795
+ device: doc.device_type ? {
2796
+ type: doc.device_type,
2797
+ browser: doc.browser ?? "",
2798
+ os: doc.os ?? "",
2799
+ osVersion: doc.os_version ?? void 0,
2800
+ deviceModel: doc.device_model ?? void 0,
2801
+ deviceBrand: doc.device_brand ?? void 0,
2802
+ appVersion: doc.app_version ?? void 0,
2803
+ appBuild: doc.app_build ?? void 0,
2804
+ sdkName: doc.sdk_name ?? void 0,
2805
+ sdkVersion: doc.sdk_version ?? void 0
2806
+ } : void 0,
2378
2807
  language: doc.language ?? void 0,
2379
2808
  utm: doc.utm_source ? {
2380
2809
  source: doc.utm_source ?? void 0,
@@ -2392,6 +2821,7 @@ var MongoDBAdapter = class {
2392
2821
  site_id: generateSiteId(),
2393
2822
  secret_key: generateSecretKey(),
2394
2823
  name: data.name,
2824
+ type: data.type ?? "web",
2395
2825
  domain: data.domain ?? null,
2396
2826
  allowed_origins: data.allowedOrigins ?? null,
2397
2827
  conversion_events: data.conversionEvents ?? null,
@@ -2416,6 +2846,7 @@ var MongoDBAdapter = class {
2416
2846
  async updateSite(siteId, data) {
2417
2847
  const updates = { updated_at: /* @__PURE__ */ new Date() };
2418
2848
  if (data.name !== void 0) updates.name = data.name;
2849
+ if (data.type !== void 0) updates.type = data.type;
2419
2850
  if (data.domain !== void 0) updates.domain = data.domain || null;
2420
2851
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
2421
2852
  if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
@@ -2447,6 +2878,7 @@ var MongoDBAdapter = class {
2447
2878
  siteId: doc.site_id,
2448
2879
  secretKey: doc.secret_key,
2449
2880
  name: doc.name,
2881
+ type: doc.type ?? "web",
2450
2882
  domain: doc.domain ?? void 0,
2451
2883
  allowedOrigins: doc.allowed_origins ?? void 0,
2452
2884
  conversionEvents: doc.conversion_events ?? void 0,
@@ -2715,9 +3147,26 @@ async function createCollector(config) {
2715
3147
  return false;
2716
3148
  }
2717
3149
  function enrichEvents(events, ip, userAgent) {
2718
- const device = parseUserAgent(userAgent);
3150
+ const uaDevice = parseUserAgent(userAgent);
2719
3151
  return events.map((event) => {
2720
3152
  const geo = resolveGeo(ip, event.timezone);
3153
+ let device;
3154
+ if (event.mobile?.platform) {
3155
+ device = {
3156
+ type: "mobile",
3157
+ browser: "App",
3158
+ os: event.mobile.platform === "ios" ? "iOS" : "Android",
3159
+ osVersion: event.mobile.osVersion,
3160
+ deviceModel: event.mobile.deviceModel,
3161
+ deviceBrand: event.mobile.deviceBrand,
3162
+ appVersion: event.mobile.appVersion,
3163
+ appBuild: event.mobile.appBuild,
3164
+ sdkName: event.mobile.sdkName,
3165
+ sdkVersion: event.mobile.sdkVersion
3166
+ };
3167
+ } else {
3168
+ device = uaDevice;
3169
+ }
2721
3170
  return { ...event, ip, geo, device };
2722
3171
  });
2723
3172
  }
@@ -2777,6 +3226,22 @@ async function createCollector(config) {
2777
3226
  }
2778
3227
  return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
2779
3228
  }
3229
+ function extractRequestHostname(req) {
3230
+ const headerValue = (value) => {
3231
+ if (Array.isArray(value)) return value[0];
3232
+ if (typeof value === "string") return value;
3233
+ return void 0;
3234
+ };
3235
+ const origin = headerValue(req.headers?.origin);
3236
+ const referer = headerValue(req.headers?.referer) || headerValue(req.headers?.referrer);
3237
+ const raw = origin ?? referer;
3238
+ if (!raw || raw === "null") return void 0;
3239
+ try {
3240
+ return new URL(raw).hostname.toLowerCase();
3241
+ } catch {
3242
+ return void 0;
3243
+ }
3244
+ }
2780
3245
  function handler() {
2781
3246
  return async (req, res) => {
2782
3247
  res.setHeader?.("Access-Control-Allow-Origin", "*");
@@ -2802,6 +3267,12 @@ async function createCollector(config) {
2802
3267
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
2803
3268
  return;
2804
3269
  }
3270
+ const siteIds = new Set(payload.events.map((event) => event.siteId).filter(Boolean));
3271
+ if (siteIds.size !== 1) {
3272
+ sendJson(res, 200, { ok: true });
3273
+ return;
3274
+ }
3275
+ const siteId = Array.from(siteIds)[0];
2805
3276
  const userAgent = req.headers?.["user-agent"] || "";
2806
3277
  if (isBot(userAgent)) {
2807
3278
  sendJson(res, 200, { ok: true });
@@ -2809,26 +3280,15 @@ async function createCollector(config) {
2809
3280
  }
2810
3281
  const ip = extractIp(req);
2811
3282
  const enriched = enrichEvents(payload.events, ip, userAgent);
2812
- const siteId = enriched[0]?.siteId;
2813
- if (siteId) {
2814
- const site = await db.getSite(siteId);
2815
- if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2816
- const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2817
- const filtered = enriched.filter((event) => {
2818
- if (!event.url) return true;
2819
- try {
2820
- const hostname = new URL(event.url).hostname.toLowerCase();
2821
- return allowed.has(hostname);
2822
- } catch {
2823
- return true;
2824
- }
2825
- });
2826
- if (filtered.length === 0) {
2827
- sendJson(res, 200, { ok: true });
2828
- return;
2829
- }
2830
- await processIdentity(filtered);
2831
- await db.insertEvents(filtered);
3283
+ const site = await db.getSite(siteId);
3284
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
3285
+ const requestHostname = extractRequestHostname(req);
3286
+ if (!requestHostname) {
3287
+ sendJson(res, 200, { ok: true });
3288
+ return;
3289
+ }
3290
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
3291
+ if (!allowed.has(requestHostname)) {
2832
3292
  sendJson(res, 200, { ok: true });
2833
3293
  return;
2834
3294
  }