@litemetrics/node 0.1.2 → 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
@@ -179,6 +179,18 @@ function generateSecretKey() {
179
179
  // src/adapters/clickhouse.ts
180
180
  var EVENTS_TABLE = "litemetrics_events";
181
181
  var SITES_TABLE = "litemetrics_sites";
182
+ var IDENTITY_MAP_TABLE = "litemetrics_identity_map";
183
+ var CREATE_IDENTITY_MAP_TABLE = `
184
+ CREATE TABLE IF NOT EXISTS ${IDENTITY_MAP_TABLE} (
185
+ site_id LowCardinality(String),
186
+ visitor_id String,
187
+ user_id String,
188
+ identified_at DateTime64(3),
189
+ created_at DateTime64(3) DEFAULT now64(3)
190
+ ) ENGINE = ReplacingMergeTree(created_at)
191
+ ORDER BY (site_id, visitor_id)
192
+ SETTINGS index_granularity = 8192
193
+ `;
182
194
  var CREATE_EVENTS_TABLE = `
183
195
  CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
184
196
  event_id UUID DEFAULT generateUUIDv4(),
@@ -217,6 +229,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
217
229
  utm_term Nullable(String),
218
230
  utm_content Nullable(String),
219
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)),
220
239
  created_at DateTime64(3) DEFAULT now64(3)
221
240
  ) ENGINE = MergeTree()
222
241
  PARTITION BY toYYYYMM(timestamp)
@@ -228,6 +247,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
228
247
  site_id String,
229
248
  secret_key String,
230
249
  name String,
250
+ type LowCardinality(Nullable(String)) DEFAULT 'web',
231
251
  domain Nullable(String),
232
252
  allowed_origins Nullable(String),
233
253
  conversion_events Nullable(String),
@@ -243,6 +263,65 @@ function toCHDateTime(d) {
243
263
  const iso = typeof d === "string" ? d : d.toISOString();
244
264
  return iso.replace("T", " ").replace("Z", "");
245
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
+ }
246
325
  function buildFilterConditions(filters) {
247
326
  if (!filters) return { conditions: [], params: {} };
248
327
  const map = {
@@ -253,6 +332,10 @@ function buildFilterConditions(filters) {
253
332
  "device.type": "device_type",
254
333
  "device.browser": "browser",
255
334
  "device.os": "os",
335
+ "device.osVersion": "os_version",
336
+ "device.deviceModel": "device_model",
337
+ "device.deviceBrand": "device_brand",
338
+ "device.appVersion": "app_version",
256
339
  "utm.source": "utm_source",
257
340
  "utm.medium": "utm_medium",
258
341
  "utm.campaign": "utm_campaign",
@@ -269,7 +352,14 @@ function buildFilterConditions(filters) {
269
352
  const conditions = [];
270
353
  const params = {};
271
354
  for (const [key, value] of Object.entries(filters)) {
272
- 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;
273
363
  const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
274
364
  conditions.push(`${map[key]} = {${paramKey}:String}`);
275
365
  params[paramKey] = value;
@@ -289,6 +379,7 @@ var ClickHouseAdapter = class {
289
379
  async init() {
290
380
  await this.client.command({ query: CREATE_EVENTS_TABLE });
291
381
  await this.client.command({ query: CREATE_SITES_TABLE });
382
+ await this.client.command({ query: CREATE_IDENTITY_MAP_TABLE });
292
383
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
293
384
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
294
385
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
@@ -299,6 +390,14 @@ var ClickHouseAdapter = class {
299
390
  await this.client.command({
300
391
  query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
301
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'` });
302
401
  }
303
402
  async close() {
304
403
  await this.client.close();
@@ -341,7 +440,14 @@ var ClickHouseAdapter = class {
341
440
  utm_campaign: e.utm?.campaign ?? null,
342
441
  utm_term: e.utm?.term ?? null,
343
442
  utm_content: e.utm?.content ?? null,
344
- 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
345
451
  }));
346
452
  await this.client.insert({
347
453
  table: EVENTS_TABLE,
@@ -546,8 +652,8 @@ var ClickHouseAdapter = class {
546
652
  }
547
653
  case "top_exit_pages": {
548
654
  const rows = await this.queryRows(
549
- `SELECT url AS key, count() AS value FROM (
550
- SELECT session_id, argMax(url, timestamp) AS url
655
+ `SELECT exit_url AS key, count() AS value FROM (
656
+ SELECT session_id, argMax(url, timestamp) AS exit_url
551
657
  FROM ${EVENTS_TABLE}
552
658
  WHERE site_id = {siteId:String}
553
659
  AND timestamp >= {from:String}
@@ -556,7 +662,7 @@ var ClickHouseAdapter = class {
556
662
  AND url IS NOT NULL${filterSql}
557
663
  GROUP BY session_id
558
664
  )
559
- GROUP BY url
665
+ GROUP BY exit_url
560
666
  ORDER BY value DESC
561
667
  LIMIT {limit:UInt32}`,
562
668
  { ...params, ...filter.params }
@@ -567,9 +673,9 @@ var ClickHouseAdapter = class {
567
673
  }
568
674
  case "top_transitions": {
569
675
  const rows = await this.queryRows(
570
- `SELECT concat(prev_url, ' \u2192 ', url) AS key, count() AS value FROM (
571
- SELECT session_id, url,
572
- lag(url) OVER (PARTITION BY session_id ORDER BY timestamp) AS prev_url
676
+ `SELECT concat(prev_url, ' \u2192 ', curr_url) AS key, count() AS value FROM (
677
+ SELECT session_id, url AS curr_url,
678
+ lagInFrame(url, 1) OVER (PARTITION BY session_id ORDER BY timestamp ASC) AS prev_url
573
679
  FROM ${EVENTS_TABLE}
574
680
  WHERE site_id = {siteId:String}
575
681
  AND timestamp >= {from:String}
@@ -577,7 +683,7 @@ var ClickHouseAdapter = class {
577
683
  AND type = 'pageview'
578
684
  AND url IS NOT NULL${filterSql}
579
685
  )
580
- WHERE prev_url IS NOT NULL
686
+ WHERE prev_url IS NOT NULL AND prev_url != ''
581
687
  GROUP BY key
582
688
  ORDER BY value DESC
583
689
  LIMIT {limit:UInt32}`,
@@ -692,6 +798,159 @@ var ClickHouseAdapter = class {
692
798
  total = data.reduce((sum, d) => sum + d.value, 0);
693
799
  break;
694
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
+ }
853
+ case "top_utm_sources": {
854
+ const rows = await this.queryRows(
855
+ `SELECT ${normalizedUtmSourceExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
856
+ WHERE site_id = {siteId:String}
857
+ AND timestamp >= {from:String}
858
+ AND timestamp <= {to:String}
859
+ AND utm_source IS NOT NULL AND utm_source != ''
860
+ ${filterSql}
861
+ GROUP BY key
862
+ ORDER BY value DESC
863
+ LIMIT {limit:UInt32}`,
864
+ { ...params, ...filter.params }
865
+ );
866
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
867
+ total = data.reduce((sum, d) => sum + d.value, 0);
868
+ break;
869
+ }
870
+ case "top_utm_mediums": {
871
+ const rows = await this.queryRows(
872
+ `SELECT ${normalizedUtmMediumExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
873
+ WHERE site_id = {siteId:String}
874
+ AND timestamp >= {from:String}
875
+ AND timestamp <= {to:String}
876
+ AND utm_medium IS NOT NULL AND utm_medium != ''
877
+ ${filterSql}
878
+ GROUP BY key
879
+ ORDER BY value DESC
880
+ LIMIT {limit:UInt32}`,
881
+ { ...params, ...filter.params }
882
+ );
883
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
884
+ total = data.reduce((sum, d) => sum + d.value, 0);
885
+ break;
886
+ }
887
+ case "top_utm_campaigns": {
888
+ const rows = await this.queryRows(
889
+ `SELECT utm_campaign AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
890
+ WHERE site_id = {siteId:String}
891
+ AND timestamp >= {from:String}
892
+ AND timestamp <= {to:String}
893
+ AND utm_campaign IS NOT NULL AND utm_campaign != ''
894
+ ${filterSql}
895
+ GROUP BY utm_campaign
896
+ ORDER BY value DESC
897
+ LIMIT {limit:UInt32}`,
898
+ { ...params, ...filter.params }
899
+ );
900
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
901
+ total = data.reduce((sum, d) => sum + d.value, 0);
902
+ break;
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
+ }
695
954
  }
696
955
  const result = { metric: q.metric, period, data, total };
697
956
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -945,45 +1204,59 @@ var ClickHouseAdapter = class {
945
1204
  const where = conditions.join(" AND ");
946
1205
  const [userRows, countRows] = await Promise.all([
947
1206
  this.queryRows(
948
- `SELECT
949
- visitor_id,
950
- anyLast(user_id) AS userId,
951
- anyLast(traits) AS traits,
952
- min(timestamp) AS firstSeen,
953
- max(timestamp) AS lastSeen,
1207
+ `WITH identity AS (
1208
+ SELECT visitor_id, user_id
1209
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1210
+ WHERE site_id = {siteId:String}
1211
+ )
1212
+ SELECT
1213
+ if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key,
1214
+ anyLast(e.visitor_id) AS visitor_id,
1215
+ anyLast(i.user_id) AS userId,
1216
+ anyLast(e.traits) AS traits,
1217
+ min(e.timestamp) AS firstSeen,
1218
+ max(e.timestamp) AS lastSeen,
954
1219
  count() AS totalEvents,
955
- countIf(type = 'pageview') AS totalPageviews,
956
- uniq(session_id) AS totalSessions,
957
- anyLast(url) AS lastUrl,
958
- anyLast(referrer) AS referrer,
959
- anyLast(device_type) AS device_type,
960
- anyLast(browser) AS browser,
961
- anyLast(os) AS os,
962
- anyLast(country) AS country,
963
- anyLast(city) AS city,
964
- anyLast(region) AS region,
965
- anyLast(language) AS language,
966
- anyLast(timezone) AS timezone,
967
- anyLast(screen_width) AS screen_width,
968
- anyLast(screen_height) AS screen_height,
969
- anyLast(utm_source) AS utm_source,
970
- anyLast(utm_medium) AS utm_medium,
971
- anyLast(utm_campaign) AS utm_campaign,
972
- anyLast(utm_term) AS utm_term,
973
- anyLast(utm_content) AS utm_content
974
- FROM ${EVENTS_TABLE}
975
- WHERE ${where}
976
- GROUP BY visitor_id
1220
+ countIf(e.type = 'pageview') AS totalPageviews,
1221
+ uniq(e.session_id) AS totalSessions,
1222
+ anyLast(e.url) AS lastUrl,
1223
+ anyLast(e.referrer) AS referrer,
1224
+ anyLast(e.device_type) AS device_type,
1225
+ anyLast(e.browser) AS browser,
1226
+ anyLast(e.os) AS os,
1227
+ anyLast(e.country) AS country,
1228
+ anyLast(e.city) AS city,
1229
+ anyLast(e.region) AS region,
1230
+ anyLast(e.language) AS language,
1231
+ anyLast(e.timezone) AS timezone,
1232
+ anyLast(e.screen_width) AS screen_width,
1233
+ anyLast(e.screen_height) AS screen_height,
1234
+ anyLast(e.utm_source) AS utm_source,
1235
+ anyLast(e.utm_medium) AS utm_medium,
1236
+ anyLast(e.utm_campaign) AS utm_campaign,
1237
+ anyLast(e.utm_term) AS utm_term,
1238
+ anyLast(e.utm_content) AS utm_content
1239
+ FROM ${EVENTS_TABLE} e
1240
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1241
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1242
+ GROUP BY group_key
977
1243
  ORDER BY lastSeen DESC
978
1244
  LIMIT {limit:UInt32}
979
1245
  OFFSET {offset:UInt32}`,
980
1246
  queryParams
981
1247
  ),
982
1248
  this.queryRows(
983
- `SELECT count() AS total FROM (
984
- SELECT visitor_id FROM ${EVENTS_TABLE}
985
- WHERE ${where}
986
- GROUP BY visitor_id
1249
+ `WITH identity AS (
1250
+ SELECT visitor_id, user_id
1251
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1252
+ WHERE site_id = {siteId:String}
1253
+ )
1254
+ SELECT count() AS total FROM (
1255
+ SELECT if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key
1256
+ FROM ${EVENTS_TABLE} e
1257
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1258
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1259
+ GROUP BY group_key
987
1260
  )`,
988
1261
  queryParams
989
1262
  )
@@ -1019,13 +1292,178 @@ var ClickHouseAdapter = class {
1019
1292
  offset
1020
1293
  };
1021
1294
  }
1022
- async getUserDetail(siteId, visitorId) {
1023
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1024
- const user = result.users.find((u) => u.visitorId === visitorId);
1295
+ async getUserDetail(siteId, identifier) {
1296
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1297
+ if (visitorIds.length > 0) {
1298
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
1299
+ }
1300
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1301
+ if (userId) {
1302
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1303
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds.length > 0 ? allVisitorIds : [identifier]);
1304
+ }
1305
+ const result = await this.listUsers({ siteId, search: identifier, limit: 1 });
1306
+ const user = result.users.find((u) => u.visitorId === identifier);
1025
1307
  return user ?? null;
1026
1308
  }
1027
- async getUserEvents(siteId, visitorId, params) {
1028
- return this.listEvents({ ...params, siteId, visitorId });
1309
+ async getUserEvents(siteId, identifier, params) {
1310
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1311
+ if (visitorIds.length > 0) {
1312
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
1313
+ }
1314
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1315
+ if (userId) {
1316
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1317
+ if (allVisitorIds.length > 0) {
1318
+ return this.listEventsForVisitorIds(siteId, allVisitorIds, params);
1319
+ }
1320
+ }
1321
+ return this.listEvents({ ...params, siteId, visitorId: identifier });
1322
+ }
1323
+ // ─── Identity Mapping ──────────────────────────────────────
1324
+ async upsertIdentity(siteId, visitorId, userId) {
1325
+ await this.client.insert({
1326
+ table: IDENTITY_MAP_TABLE,
1327
+ values: [{
1328
+ site_id: siteId,
1329
+ visitor_id: visitorId,
1330
+ user_id: userId,
1331
+ identified_at: toCHDateTime(/* @__PURE__ */ new Date())
1332
+ }],
1333
+ format: "JSONEachRow"
1334
+ });
1335
+ }
1336
+ async getVisitorIdsForUser(siteId, userId) {
1337
+ const rows = await this.queryRows(
1338
+ `SELECT visitor_id FROM ${IDENTITY_MAP_TABLE} FINAL
1339
+ WHERE site_id = {siteId:String} AND user_id = {userId:String}`,
1340
+ { siteId, userId }
1341
+ );
1342
+ return rows.map((r) => r.visitor_id);
1343
+ }
1344
+ async getUserIdForVisitor(siteId, visitorId) {
1345
+ const rows = await this.queryRows(
1346
+ `SELECT user_id FROM ${IDENTITY_MAP_TABLE} FINAL
1347
+ WHERE site_id = {siteId:String} AND visitor_id = {visitorId:String}
1348
+ LIMIT 1`,
1349
+ { siteId, visitorId }
1350
+ );
1351
+ return rows.length > 0 ? rows[0].user_id : null;
1352
+ }
1353
+ async getMergedUserDetail(siteId, userId, visitorIds) {
1354
+ const rows = await this.queryRows(
1355
+ `SELECT
1356
+ anyLast(visitor_id) AS last_visitor_id,
1357
+ anyLast(traits) AS traits,
1358
+ min(timestamp) AS firstSeen,
1359
+ max(timestamp) AS lastSeen,
1360
+ count() AS totalEvents,
1361
+ countIf(type = 'pageview') AS totalPageviews,
1362
+ uniq(session_id) AS totalSessions,
1363
+ anyLast(url) AS lastUrl,
1364
+ anyLast(referrer) AS referrer,
1365
+ anyLast(device_type) AS device_type,
1366
+ anyLast(browser) AS browser,
1367
+ anyLast(os) AS os,
1368
+ anyLast(country) AS country,
1369
+ anyLast(city) AS city,
1370
+ anyLast(region) AS region,
1371
+ anyLast(language) AS language,
1372
+ anyLast(timezone) AS timezone,
1373
+ anyLast(screen_width) AS screen_width,
1374
+ anyLast(screen_height) AS screen_height,
1375
+ anyLast(utm_source) AS utm_source,
1376
+ anyLast(utm_medium) AS utm_medium,
1377
+ anyLast(utm_campaign) AS utm_campaign,
1378
+ anyLast(utm_term) AS utm_term,
1379
+ anyLast(utm_content) AS utm_content
1380
+ FROM ${EVENTS_TABLE}
1381
+ WHERE site_id = {siteId:String}
1382
+ AND visitor_id IN {visitorIds:Array(String)}`,
1383
+ { siteId, visitorIds }
1384
+ );
1385
+ if (rows.length === 0) return null;
1386
+ const u = rows[0];
1387
+ return {
1388
+ visitorId: String(u.last_visitor_id),
1389
+ visitorIds,
1390
+ userId,
1391
+ traits: this.parseJSON(u.traits),
1392
+ firstSeen: new Date(String(u.firstSeen)).toISOString(),
1393
+ lastSeen: new Date(String(u.lastSeen)).toISOString(),
1394
+ totalEvents: Number(u.totalEvents),
1395
+ totalPageviews: Number(u.totalPageviews),
1396
+ totalSessions: Number(u.totalSessions),
1397
+ lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
1398
+ referrer: u.referrer ? String(u.referrer) : void 0,
1399
+ device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
1400
+ geo: u.country ? { country: String(u.country), city: u.city ? String(u.city) : void 0, region: u.region ? String(u.region) : void 0 } : void 0,
1401
+ language: u.language ? String(u.language) : void 0,
1402
+ timezone: u.timezone ? String(u.timezone) : void 0,
1403
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
1404
+ utm: u.utm_source ? {
1405
+ source: String(u.utm_source),
1406
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
1407
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
1408
+ term: u.utm_term ? String(u.utm_term) : void 0,
1409
+ content: u.utm_content ? String(u.utm_content) : void 0
1410
+ } : void 0
1411
+ };
1412
+ }
1413
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
1414
+ const limit = Math.min(params.limit ?? 50, 200);
1415
+ const offset = params.offset ?? 0;
1416
+ const conditions = [`site_id = {siteId:String}`, `visitor_id IN {visitorIds:Array(String)}`];
1417
+ const queryParams = { siteId, visitorIds, limit, offset };
1418
+ if (params.type) {
1419
+ conditions.push(`type = {type:String}`);
1420
+ queryParams.type = params.type;
1421
+ }
1422
+ if (params.eventName) {
1423
+ conditions.push(`event_name = {eventName:String}`);
1424
+ queryParams.eventName = params.eventName;
1425
+ }
1426
+ if (params.eventNames && params.eventNames.length > 0) {
1427
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
1428
+ queryParams.eventNames = params.eventNames;
1429
+ }
1430
+ if (params.period || params.dateFrom) {
1431
+ const { dateRange } = resolvePeriod({
1432
+ period: params.period,
1433
+ dateFrom: params.dateFrom,
1434
+ dateTo: params.dateTo
1435
+ });
1436
+ conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
1437
+ queryParams.from = toCHDateTime(dateRange.from);
1438
+ queryParams.to = toCHDateTime(dateRange.to);
1439
+ }
1440
+ const where = conditions.join(" AND ");
1441
+ const [events, countRows] = await Promise.all([
1442
+ this.queryRows(
1443
+ `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
1444
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
1445
+ element_selector, element_text, scroll_depth_pct,
1446
+ user_id, traits, country, city, region,
1447
+ device_type, browser, os, language,
1448
+ utm_source, utm_medium, utm_campaign, utm_term, utm_content
1449
+ FROM ${EVENTS_TABLE}
1450
+ WHERE ${where}
1451
+ ORDER BY timestamp DESC
1452
+ LIMIT {limit:UInt32}
1453
+ OFFSET {offset:UInt32}`,
1454
+ queryParams
1455
+ ),
1456
+ this.queryRows(
1457
+ `SELECT count() AS total FROM ${EVENTS_TABLE} WHERE ${where}`,
1458
+ queryParams
1459
+ )
1460
+ ]);
1461
+ return {
1462
+ events: events.map((e) => this.toEventListItem(e)),
1463
+ total: Number(countRows[0]?.total ?? 0),
1464
+ limit,
1465
+ offset
1466
+ };
1029
1467
  }
1030
1468
  // ─── Site Management ──────────────────────────────────────
1031
1469
  async createSite(data) {
@@ -1036,6 +1474,7 @@ var ClickHouseAdapter = class {
1036
1474
  siteId: generateSiteId(),
1037
1475
  secretKey: generateSecretKey(),
1038
1476
  name: data.name,
1477
+ type: data.type ?? "web",
1039
1478
  domain: data.domain,
1040
1479
  allowedOrigins: data.allowedOrigins,
1041
1480
  conversionEvents: data.conversionEvents,
@@ -1048,6 +1487,7 @@ var ClickHouseAdapter = class {
1048
1487
  site_id: site.siteId,
1049
1488
  secret_key: site.secretKey,
1050
1489
  name: site.name,
1490
+ type: site.type ?? "web",
1051
1491
  domain: site.domain ?? null,
1052
1492
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1053
1493
  conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
@@ -1062,7 +1502,7 @@ var ClickHouseAdapter = class {
1062
1502
  }
1063
1503
  async getSite(siteId) {
1064
1504
  const rows = await this.queryRows(
1065
- `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
1066
1506
  FROM ${SITES_TABLE} FINAL
1067
1507
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1068
1508
  { siteId }
@@ -1071,7 +1511,7 @@ var ClickHouseAdapter = class {
1071
1511
  }
1072
1512
  async getSiteBySecret(secretKey) {
1073
1513
  const rows = await this.queryRows(
1074
- `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
1075
1515
  FROM ${SITES_TABLE} FINAL
1076
1516
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
1077
1517
  { secretKey }
@@ -1080,7 +1520,7 @@ var ClickHouseAdapter = class {
1080
1520
  }
1081
1521
  async listSites() {
1082
1522
  const rows = await this.queryRows(
1083
- `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
1084
1524
  FROM ${SITES_TABLE} FINAL
1085
1525
  WHERE is_deleted = 0
1086
1526
  ORDER BY created_at DESC`,
@@ -1090,7 +1530,7 @@ var ClickHouseAdapter = class {
1090
1530
  }
1091
1531
  async updateSite(siteId, data) {
1092
1532
  const currentRows = await this.queryRows(
1093
- `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
1094
1534
  FROM ${SITES_TABLE} FINAL
1095
1535
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1096
1536
  { siteId }
@@ -1102,6 +1542,7 @@ var ClickHouseAdapter = class {
1102
1542
  const nowCH = toCHDateTime(now);
1103
1543
  const newVersion = Number(current.version) + 1;
1104
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";
1105
1546
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
1106
1547
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1107
1548
  const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
@@ -1111,6 +1552,7 @@ var ClickHouseAdapter = class {
1111
1552
  site_id: String(current.site_id),
1112
1553
  secret_key: String(current.secret_key),
1113
1554
  name: newName,
1555
+ type: newType,
1114
1556
  domain: newDomain,
1115
1557
  allowed_origins: newOrigins,
1116
1558
  conversion_events: newConversions,
@@ -1125,6 +1567,7 @@ var ClickHouseAdapter = class {
1125
1567
  siteId: String(current.site_id),
1126
1568
  secretKey: String(current.secret_key),
1127
1569
  name: newName,
1570
+ type: newType,
1128
1571
  domain: newDomain ?? void 0,
1129
1572
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1130
1573
  conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
@@ -1134,7 +1577,7 @@ var ClickHouseAdapter = class {
1134
1577
  }
1135
1578
  async deleteSite(siteId) {
1136
1579
  const currentRows = await this.queryRows(
1137
- `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
1138
1581
  FROM ${SITES_TABLE} FINAL
1139
1582
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1140
1583
  { siteId }
@@ -1148,6 +1591,7 @@ var ClickHouseAdapter = class {
1148
1591
  site_id: String(current.site_id),
1149
1592
  secret_key: String(current.secret_key),
1150
1593
  name: String(current.name),
1594
+ type: current.type ? String(current.type) : "web",
1151
1595
  domain: current.domain ? String(current.domain) : null,
1152
1596
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1153
1597
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1162,7 +1606,7 @@ var ClickHouseAdapter = class {
1162
1606
  }
1163
1607
  async regenerateSecret(siteId) {
1164
1608
  const currentRows = await this.queryRows(
1165
- `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
1166
1610
  FROM ${SITES_TABLE} FINAL
1167
1611
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1168
1612
  { siteId }
@@ -1179,6 +1623,7 @@ var ClickHouseAdapter = class {
1179
1623
  site_id: String(current.site_id),
1180
1624
  secret_key: newSecret,
1181
1625
  name: String(current.name),
1626
+ type: current.type ? String(current.type) : "web",
1182
1627
  domain: current.domain ? String(current.domain) : null,
1183
1628
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1184
1629
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1193,6 +1638,7 @@ var ClickHouseAdapter = class {
1193
1638
  siteId: String(current.site_id),
1194
1639
  secretKey: newSecret,
1195
1640
  name: String(current.name),
1641
+ type: current.type ? String(current.type) : "web",
1196
1642
  domain: current.domain ? String(current.domain) : void 0,
1197
1643
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1198
1644
  conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
@@ -1214,6 +1660,7 @@ var ClickHouseAdapter = class {
1214
1660
  siteId: String(row.site_id),
1215
1661
  secretKey: String(row.secret_key),
1216
1662
  name: String(row.name),
1663
+ type: row.type ? String(row.type) : "web",
1217
1664
  domain: row.domain ? String(row.domain) : void 0,
1218
1665
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1219
1666
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
@@ -1276,6 +1723,131 @@ var ClickHouseAdapter = class {
1276
1723
  var import_mongodb = require("mongodb");
1277
1724
  var EVENTS_COLLECTION = "litemetrics_events";
1278
1725
  var SITES_COLLECTION = "litemetrics_sites";
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
+ }
1279
1851
  function buildFilterMatch(filters) {
1280
1852
  if (!filters) return {};
1281
1853
  const map = {
@@ -1286,6 +1858,10 @@ function buildFilterMatch(filters) {
1286
1858
  "device.type": "device_type",
1287
1859
  "device.browser": "browser",
1288
1860
  "device.os": "os",
1861
+ "device.osVersion": "os_version",
1862
+ "device.deviceModel": "device_model",
1863
+ "device.deviceBrand": "device_brand",
1864
+ "device.appVersion": "app_version",
1289
1865
  "utm.source": "utm_source",
1290
1866
  "utm.medium": "utm_medium",
1291
1867
  "utm.campaign": "utm_campaign",
@@ -1301,7 +1877,9 @@ function buildFilterMatch(filters) {
1301
1877
  };
1302
1878
  const match = {};
1303
1879
  for (const [key, value] of Object.entries(filters)) {
1304
- if (!value || !map[key]) continue;
1880
+ if (!value) continue;
1881
+ if (key === "channel") continue;
1882
+ if (!map[key]) continue;
1305
1883
  match[map[key]] = value;
1306
1884
  }
1307
1885
  return match;
@@ -1311,6 +1889,7 @@ var MongoDBAdapter = class {
1311
1889
  db;
1312
1890
  collection;
1313
1891
  sites;
1892
+ identityMap;
1314
1893
  constructor(url) {
1315
1894
  this.client = new import_mongodb.MongoClient(url);
1316
1895
  }
@@ -1319,13 +1898,16 @@ var MongoDBAdapter = class {
1319
1898
  this.db = this.client.db();
1320
1899
  this.collection = this.db.collection(EVENTS_COLLECTION);
1321
1900
  this.sites = this.db.collection(SITES_COLLECTION);
1901
+ this.identityMap = this.db.collection(IDENTITY_MAP_COLLECTION);
1322
1902
  await Promise.all([
1323
1903
  this.collection.createIndex({ site_id: 1, timestamp: -1 }),
1324
1904
  this.collection.createIndex({ site_id: 1, type: 1 }),
1325
1905
  this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1326
1906
  this.collection.createIndex({ site_id: 1, session_id: 1 }),
1327
1907
  this.sites.createIndex({ site_id: 1 }, { unique: true }),
1328
- this.sites.createIndex({ secret_key: 1 })
1908
+ this.sites.createIndex({ secret_key: 1 }),
1909
+ this.identityMap.createIndex({ site_id: 1, visitor_id: 1 }, { unique: true }),
1910
+ this.identityMap.createIndex({ site_id: 1, user_id: 1 })
1329
1911
  ]);
1330
1912
  }
1331
1913
  async insertEvents(events) {
@@ -1366,6 +1948,13 @@ var MongoDBAdapter = class {
1366
1948
  utm_term: e.utm?.term ?? null,
1367
1949
  utm_content: e.utm?.content ?? null,
1368
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,
1369
1958
  created_at: /* @__PURE__ */ new Date()
1370
1959
  }));
1371
1960
  await this.collection.insertMany(docs);
@@ -1379,12 +1968,22 @@ var MongoDBAdapter = class {
1379
1968
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1380
1969
  };
1381
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
+ };
1382
1981
  let data = [];
1383
1982
  let total = 0;
1384
1983
  switch (q.metric) {
1385
1984
  case "pageviews": {
1386
1985
  const [result2] = await this.collection.aggregate([
1387
- { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1986
+ ...matchStages({ type: "pageview" }),
1388
1987
  { $count: "count" }
1389
1988
  ]).toArray();
1390
1989
  total = result2?.count ?? 0;
@@ -1393,7 +1992,7 @@ var MongoDBAdapter = class {
1393
1992
  }
1394
1993
  case "visitors": {
1395
1994
  const [result2] = await this.collection.aggregate([
1396
- { $match: { ...baseMatch, ...filterMatch } },
1995
+ ...matchStages(),
1397
1996
  { $group: { _id: "$visitor_id" } },
1398
1997
  { $count: "count" }
1399
1998
  ]).toArray();
@@ -1403,7 +2002,7 @@ var MongoDBAdapter = class {
1403
2002
  }
1404
2003
  case "sessions": {
1405
2004
  const [result2] = await this.collection.aggregate([
1406
- { $match: { ...baseMatch, ...filterMatch } },
2005
+ ...matchStages(),
1407
2006
  { $group: { _id: "$session_id" } },
1408
2007
  { $count: "count" }
1409
2008
  ]).toArray();
@@ -1413,7 +2012,7 @@ var MongoDBAdapter = class {
1413
2012
  }
1414
2013
  case "events": {
1415
2014
  const [result2] = await this.collection.aggregate([
1416
- { $match: { ...baseMatch, ...filterMatch, type: "event" } },
2015
+ ...matchStages({ type: "event" }),
1417
2016
  { $count: "count" }
1418
2017
  ]).toArray();
1419
2018
  total = result2?.count ?? 0;
@@ -1428,7 +2027,7 @@ var MongoDBAdapter = class {
1428
2027
  break;
1429
2028
  }
1430
2029
  const [result2] = await this.collection.aggregate([
1431
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2030
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1432
2031
  { $count: "count" }
1433
2032
  ]).toArray();
1434
2033
  total = result2?.count ?? 0;
@@ -1437,7 +2036,7 @@ var MongoDBAdapter = class {
1437
2036
  }
1438
2037
  case "top_pages": {
1439
2038
  const rows = await this.collection.aggregate([
1440
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2039
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1441
2040
  { $group: { _id: "$url", value: { $sum: 1 } } },
1442
2041
  { $sort: { value: -1 } },
1443
2042
  { $limit: limit }
@@ -1448,7 +2047,7 @@ var MongoDBAdapter = class {
1448
2047
  }
1449
2048
  case "top_referrers": {
1450
2049
  const rows = await this.collection.aggregate([
1451
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
2050
+ ...matchStages({ type: "pageview", referrer: { $nin: [null, ""] } }),
1452
2051
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1453
2052
  { $sort: { value: -1 } },
1454
2053
  { $limit: limit }
@@ -1459,7 +2058,7 @@ var MongoDBAdapter = class {
1459
2058
  }
1460
2059
  case "top_countries": {
1461
2060
  const rows = await this.collection.aggregate([
1462
- { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
2061
+ ...matchStages({ country: { $ne: null } }),
1463
2062
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1464
2063
  { $project: { _id: 1, value: { $size: "$value" } } },
1465
2064
  { $sort: { value: -1 } },
@@ -1471,7 +2070,7 @@ var MongoDBAdapter = class {
1471
2070
  }
1472
2071
  case "top_cities": {
1473
2072
  const rows = await this.collection.aggregate([
1474
- { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
2073
+ ...matchStages({ city: { $ne: null } }),
1475
2074
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1476
2075
  { $project: { _id: 1, value: { $size: "$value" } } },
1477
2076
  { $sort: { value: -1 } },
@@ -1483,7 +2082,7 @@ var MongoDBAdapter = class {
1483
2082
  }
1484
2083
  case "top_events": {
1485
2084
  const rows = await this.collection.aggregate([
1486
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
2085
+ ...matchStages({ type: "event", event_name: { $ne: null } }),
1487
2086
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1488
2087
  { $sort: { value: -1 } },
1489
2088
  { $limit: limit }
@@ -1500,7 +2099,7 @@ var MongoDBAdapter = class {
1500
2099
  break;
1501
2100
  }
1502
2101
  const rows = await this.collection.aggregate([
1503
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2102
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1504
2103
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1505
2104
  { $sort: { value: -1 } },
1506
2105
  { $limit: limit }
@@ -1511,7 +2110,7 @@ var MongoDBAdapter = class {
1511
2110
  }
1512
2111
  case "top_exit_pages": {
1513
2112
  const rows = await this.collection.aggregate([
1514
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2113
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1515
2114
  { $sort: { timestamp: 1 } },
1516
2115
  { $group: { _id: "$session_id", url: { $last: "$url" } } },
1517
2116
  { $group: { _id: "$url", value: { $sum: 1 } } },
@@ -1524,7 +2123,7 @@ var MongoDBAdapter = class {
1524
2123
  }
1525
2124
  case "top_transitions": {
1526
2125
  const rows = await this.collection.aggregate([
1527
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2126
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1528
2127
  {
1529
2128
  $setWindowFields: {
1530
2129
  partitionBy: "$session_id",
@@ -1545,7 +2144,7 @@ var MongoDBAdapter = class {
1545
2144
  }
1546
2145
  case "top_scroll_pages": {
1547
2146
  const rows = await this.collection.aggregate([
1548
- { $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 } }),
1549
2148
  { $group: { _id: "$page_path", value: { $sum: 1 } } },
1550
2149
  { $sort: { value: -1 } },
1551
2150
  { $limit: limit }
@@ -1556,15 +2155,11 @@ var MongoDBAdapter = class {
1556
2155
  }
1557
2156
  case "top_button_clicks": {
1558
2157
  const rows = await this.collection.aggregate([
1559
- {
1560
- $match: {
1561
- ...baseMatch,
1562
- ...filterMatch,
1563
- type: "event",
1564
- event_subtype: "button_click",
1565
- $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1566
- }
1567
- },
2158
+ ...matchStages({
2159
+ type: "event",
2160
+ event_subtype: "button_click",
2161
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
2162
+ }),
1568
2163
  { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1569
2164
  { $sort: { value: -1 } },
1570
2165
  { $limit: limit }
@@ -1575,15 +2170,11 @@ var MongoDBAdapter = class {
1575
2170
  }
1576
2171
  case "top_link_targets": {
1577
2172
  const rows = await this.collection.aggregate([
1578
- {
1579
- $match: {
1580
- ...baseMatch,
1581
- ...filterMatch,
1582
- type: "event",
1583
- event_subtype: { $in: ["link_click", "outbound_click"] },
1584
- target_url_path: { $ne: null }
1585
- }
1586
- },
2173
+ ...matchStages({
2174
+ type: "event",
2175
+ event_subtype: { $in: ["link_click", "outbound_click"] },
2176
+ target_url_path: { $ne: null }
2177
+ }),
1587
2178
  { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1588
2179
  { $sort: { value: -1 } },
1589
2180
  { $limit: limit }
@@ -1594,7 +2185,7 @@ var MongoDBAdapter = class {
1594
2185
  }
1595
2186
  case "top_devices": {
1596
2187
  const rows = await this.collection.aggregate([
1597
- { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
2188
+ ...matchStages({ device_type: { $ne: null } }),
1598
2189
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1599
2190
  { $project: { _id: 1, value: { $size: "$value" } } },
1600
2191
  { $sort: { value: -1 } },
@@ -1606,7 +2197,7 @@ var MongoDBAdapter = class {
1606
2197
  }
1607
2198
  case "top_browsers": {
1608
2199
  const rows = await this.collection.aggregate([
1609
- { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
2200
+ ...matchStages({ browser: { $ne: null } }),
1610
2201
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1611
2202
  { $project: { _id: 1, value: { $size: "$value" } } },
1612
2203
  { $sort: { value: -1 } },
@@ -1618,7 +2209,7 @@ var MongoDBAdapter = class {
1618
2209
  }
1619
2210
  case "top_os": {
1620
2211
  const rows = await this.collection.aggregate([
1621
- { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
2212
+ ...matchStages({ os: { $ne: null } }),
1622
2213
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1623
2214
  { $project: { _id: 1, value: { $size: "$value" } } },
1624
2215
  { $sort: { value: -1 } },
@@ -1628,6 +2219,117 @@ var MongoDBAdapter = class {
1628
2219
  total = data.reduce((sum, d) => sum + d.value, 0);
1629
2220
  break;
1630
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
+ }
2258
+ case "top_utm_sources": {
2259
+ const rows = await this.collection.aggregate([
2260
+ ...matchStages({ utm_source: { $nin: [null, ""] } }),
2261
+ { $addFields: { _normalized_source: normalizedUtmSourceSwitch() } },
2262
+ { $group: { _id: "$_normalized_source", value: { $addToSet: "$visitor_id" } } },
2263
+ { $project: { _id: 1, value: { $size: "$value" } } },
2264
+ { $sort: { value: -1 } },
2265
+ { $limit: limit }
2266
+ ]).toArray();
2267
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2268
+ total = data.reduce((sum, d) => sum + d.value, 0);
2269
+ break;
2270
+ }
2271
+ case "top_utm_mediums": {
2272
+ const rows = await this.collection.aggregate([
2273
+ ...matchStages({ utm_medium: { $nin: [null, ""] } }),
2274
+ { $addFields: { _normalized_medium: normalizedUtmMediumSwitch() } },
2275
+ { $group: { _id: "$_normalized_medium", value: { $addToSet: "$visitor_id" } } },
2276
+ { $project: { _id: 1, value: { $size: "$value" } } },
2277
+ { $sort: { value: -1 } },
2278
+ { $limit: limit }
2279
+ ]).toArray();
2280
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2281
+ total = data.reduce((sum, d) => sum + d.value, 0);
2282
+ break;
2283
+ }
2284
+ case "top_utm_campaigns": {
2285
+ const rows = await this.collection.aggregate([
2286
+ ...matchStages({ utm_campaign: { $nin: [null, ""] } }),
2287
+ { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
2288
+ { $project: { _id: 1, value: { $size: "$value" } } },
2289
+ { $sort: { value: -1 } },
2290
+ { $limit: limit }
2291
+ ]).toArray();
2292
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2293
+ total = data.reduce((sum, d) => sum + d.value, 0);
2294
+ break;
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
+ }
1631
2333
  }
1632
2334
  const result = { metric: q.metric, period, data, total };
1633
2335
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -1816,24 +2518,72 @@ var MongoDBAdapter = class {
1816
2518
  offset
1817
2519
  };
1818
2520
  }
2521
+ // ─── Identity Mapping ──────────────────────────────────────
2522
+ async upsertIdentity(siteId, visitorId, userId) {
2523
+ await this.identityMap.updateOne(
2524
+ { site_id: siteId, visitor_id: visitorId },
2525
+ {
2526
+ $set: { user_id: userId, identified_at: /* @__PURE__ */ new Date() },
2527
+ $setOnInsert: { site_id: siteId, visitor_id: visitorId, created_at: /* @__PURE__ */ new Date() }
2528
+ },
2529
+ { upsert: true }
2530
+ );
2531
+ }
2532
+ async getVisitorIdsForUser(siteId, userId) {
2533
+ const docs = await this.identityMap.find({ site_id: siteId, user_id: userId }).toArray();
2534
+ return docs.map((d) => d.visitor_id);
2535
+ }
2536
+ async getUserIdForVisitor(siteId, visitorId) {
2537
+ const doc = await this.identityMap.findOne({ site_id: siteId, visitor_id: visitorId });
2538
+ return doc?.user_id ?? null;
2539
+ }
1819
2540
  // ─── User Listing ──────────────────────────────────────
1820
2541
  async listUsers(params) {
1821
2542
  const limit = Math.min(params.limit ?? 50, 200);
1822
2543
  const offset = params.offset ?? 0;
1823
2544
  const match = { site_id: params.siteId };
1824
- if (params.search) {
1825
- match.$or = [
1826
- { visitor_id: { $regex: params.search, $options: "i" } },
1827
- { user_id: { $regex: params.search, $options: "i" } }
1828
- ];
1829
- }
1830
2545
  const pipeline = [
1831
2546
  { $match: match },
2547
+ // Join with identity map to resolve visitor → user
2548
+ {
2549
+ $lookup: {
2550
+ from: IDENTITY_MAP_COLLECTION,
2551
+ let: { vid: "$visitor_id", sid: "$site_id" },
2552
+ pipeline: [
2553
+ { $match: { $expr: { $and: [{ $eq: ["$visitor_id", "$$vid"] }, { $eq: ["$site_id", "$$sid"] }] } } }
2554
+ ],
2555
+ as: "_identity"
2556
+ }
2557
+ },
2558
+ {
2559
+ $addFields: {
2560
+ _resolved_id: {
2561
+ $ifNull: [{ $arrayElemAt: ["$_identity.user_id", 0] }, "$visitor_id"]
2562
+ },
2563
+ _resolved_user_id: {
2564
+ $arrayElemAt: ["$_identity.user_id", 0]
2565
+ }
2566
+ }
2567
+ }
2568
+ ];
2569
+ if (params.search) {
2570
+ pipeline.push({
2571
+ $match: {
2572
+ $or: [
2573
+ { visitor_id: { $regex: params.search, $options: "i" } },
2574
+ { user_id: { $regex: params.search, $options: "i" } },
2575
+ { _resolved_user_id: { $regex: params.search, $options: "i" } }
2576
+ ]
2577
+ }
2578
+ });
2579
+ }
2580
+ pipeline.push(
1832
2581
  { $sort: { timestamp: 1 } },
1833
2582
  {
1834
2583
  $group: {
1835
- _id: "$visitor_id",
1836
- userId: { $last: "$user_id" },
2584
+ _id: "$_resolved_id",
2585
+ visitorIds: { $addToSet: "$visitor_id" },
2586
+ userId: { $last: { $ifNull: ["$_resolved_user_id", "$user_id"] } },
1837
2587
  traits: { $last: "$traits" },
1838
2588
  firstSeen: { $min: "$timestamp" },
1839
2589
  lastSeen: { $max: "$timestamp" },
@@ -1866,10 +2616,11 @@ var MongoDBAdapter = class {
1866
2616
  count: [{ $count: "total" }]
1867
2617
  }
1868
2618
  }
1869
- ];
2619
+ );
1870
2620
  const [result] = await this.collection.aggregate(pipeline).toArray();
1871
2621
  const users = (result?.data ?? []).map((u) => ({
1872
- visitorId: u._id,
2622
+ visitorId: u.visitorIds[0] ?? u._id,
2623
+ visitorIds: u.visitorIds.length > 1 ? u.visitorIds : void 0,
1873
2624
  userId: u.userId ?? void 0,
1874
2625
  traits: u.traits ?? void 0,
1875
2626
  firstSeen: u.firstSeen.toISOString(),
@@ -1899,13 +2650,125 @@ var MongoDBAdapter = class {
1899
2650
  offset
1900
2651
  };
1901
2652
  }
1902
- async getUserDetail(siteId, visitorId) {
1903
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1904
- const user = result.users.find((u) => u.visitorId === visitorId);
1905
- return user ?? null;
2653
+ async getUserDetail(siteId, identifier) {
2654
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2655
+ if (visitorIds.length > 0) {
2656
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
2657
+ }
2658
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2659
+ if (userId) {
2660
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
2661
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds);
2662
+ }
2663
+ return this.getMergedUserDetail(siteId, void 0, [identifier]);
2664
+ }
2665
+ async getUserEvents(siteId, identifier, params) {
2666
+ let visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2667
+ if (visitorIds.length === 0) {
2668
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2669
+ if (userId) {
2670
+ visitorIds = await this.getVisitorIdsForUser(siteId, userId);
2671
+ }
2672
+ }
2673
+ if (visitorIds.length === 0) {
2674
+ visitorIds = [identifier];
2675
+ }
2676
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
2677
+ }
2678
+ async getMergedUserDetail(siteId, userId, visitorIds) {
2679
+ const pipeline = [
2680
+ { $match: { site_id: siteId, visitor_id: { $in: visitorIds } } },
2681
+ { $sort: { timestamp: 1 } },
2682
+ {
2683
+ $group: {
2684
+ _id: null,
2685
+ visitorIds: { $addToSet: "$visitor_id" },
2686
+ traits: { $last: "$traits" },
2687
+ firstSeen: { $min: "$timestamp" },
2688
+ lastSeen: { $max: "$timestamp" },
2689
+ totalEvents: { $sum: 1 },
2690
+ totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
2691
+ sessions: { $addToSet: "$session_id" },
2692
+ lastUrl: { $last: "$url" },
2693
+ referrer: { $last: "$referrer" },
2694
+ device_type: { $last: "$device_type" },
2695
+ browser: { $last: "$browser" },
2696
+ os: { $last: "$os" },
2697
+ country: { $last: "$country" },
2698
+ city: { $last: "$city" },
2699
+ region: { $last: "$region" },
2700
+ language: { $last: "$language" },
2701
+ timezone: { $last: "$timezone" },
2702
+ screen_width: { $last: "$screen_width" },
2703
+ screen_height: { $last: "$screen_height" },
2704
+ utm_source: { $last: "$utm_source" },
2705
+ utm_medium: { $last: "$utm_medium" },
2706
+ utm_campaign: { $last: "$utm_campaign" },
2707
+ utm_term: { $last: "$utm_term" },
2708
+ utm_content: { $last: "$utm_content" }
2709
+ }
2710
+ }
2711
+ ];
2712
+ const [row] = await this.collection.aggregate(pipeline).toArray();
2713
+ if (!row) return null;
2714
+ return {
2715
+ visitorId: visitorIds[0],
2716
+ visitorIds: row.visitorIds.length > 1 ? row.visitorIds : void 0,
2717
+ userId: userId ?? void 0,
2718
+ traits: row.traits ?? void 0,
2719
+ firstSeen: row.firstSeen.toISOString(),
2720
+ lastSeen: row.lastSeen.toISOString(),
2721
+ totalEvents: row.totalEvents,
2722
+ totalPageviews: row.totalPageviews,
2723
+ totalSessions: row.sessions.length,
2724
+ lastUrl: row.lastUrl ?? void 0,
2725
+ referrer: row.referrer ?? void 0,
2726
+ device: row.device_type ? { type: row.device_type, browser: row.browser ?? "", os: row.os ?? "" } : void 0,
2727
+ geo: row.country ? { country: row.country, city: row.city ?? void 0, region: row.region ?? void 0 } : void 0,
2728
+ language: row.language ?? void 0,
2729
+ timezone: row.timezone ?? void 0,
2730
+ screen: row.screen_width || row.screen_height ? { width: row.screen_width ?? 0, height: row.screen_height ?? 0 } : void 0,
2731
+ utm: row.utm_source ? {
2732
+ source: row.utm_source ?? void 0,
2733
+ medium: row.utm_medium ?? void 0,
2734
+ campaign: row.utm_campaign ?? void 0,
2735
+ term: row.utm_term ?? void 0,
2736
+ content: row.utm_content ?? void 0
2737
+ } : void 0
2738
+ };
1906
2739
  }
1907
- async getUserEvents(siteId, visitorId, params) {
1908
- return this.listEvents({ ...params, siteId, visitorId });
2740
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
2741
+ const limit = Math.min(params.limit ?? 50, 200);
2742
+ const offset = params.offset ?? 0;
2743
+ const match = {
2744
+ site_id: siteId,
2745
+ visitor_id: { $in: visitorIds }
2746
+ };
2747
+ if (params.type) match.type = params.type;
2748
+ if (params.eventName) {
2749
+ match.event_name = params.eventName;
2750
+ } else if (params.eventNames && params.eventNames.length > 0) {
2751
+ match.event_name = { $in: params.eventNames };
2752
+ }
2753
+ if (params.eventSource) match.event_source = params.eventSource;
2754
+ if (params.period || params.dateFrom) {
2755
+ const { dateRange } = resolvePeriod({
2756
+ period: params.period,
2757
+ dateFrom: params.dateFrom,
2758
+ dateTo: params.dateTo
2759
+ });
2760
+ match.timestamp = { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) };
2761
+ }
2762
+ const [events, countResult] = await Promise.all([
2763
+ this.collection.find(match).sort({ timestamp: -1 }).skip(offset).limit(limit).toArray(),
2764
+ this.collection.countDocuments(match)
2765
+ ]);
2766
+ return {
2767
+ events: events.map((e) => this.toEventListItem(e)),
2768
+ total: countResult,
2769
+ limit,
2770
+ offset
2771
+ };
1909
2772
  }
1910
2773
  toEventListItem(doc) {
1911
2774
  return {
@@ -1929,7 +2792,18 @@ var MongoDBAdapter = class {
1929
2792
  userId: doc.user_id ?? void 0,
1930
2793
  traits: doc.traits ?? void 0,
1931
2794
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
1932
- 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,
1933
2807
  language: doc.language ?? void 0,
1934
2808
  utm: doc.utm_source ? {
1935
2809
  source: doc.utm_source ?? void 0,
@@ -1947,6 +2821,7 @@ var MongoDBAdapter = class {
1947
2821
  site_id: generateSiteId(),
1948
2822
  secret_key: generateSecretKey(),
1949
2823
  name: data.name,
2824
+ type: data.type ?? "web",
1950
2825
  domain: data.domain ?? null,
1951
2826
  allowed_origins: data.allowedOrigins ?? null,
1952
2827
  conversion_events: data.conversionEvents ?? null,
@@ -1971,6 +2846,7 @@ var MongoDBAdapter = class {
1971
2846
  async updateSite(siteId, data) {
1972
2847
  const updates = { updated_at: /* @__PURE__ */ new Date() };
1973
2848
  if (data.name !== void 0) updates.name = data.name;
2849
+ if (data.type !== void 0) updates.type = data.type;
1974
2850
  if (data.domain !== void 0) updates.domain = data.domain || null;
1975
2851
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
1976
2852
  if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
@@ -2002,6 +2878,7 @@ var MongoDBAdapter = class {
2002
2878
  siteId: doc.site_id,
2003
2879
  secretKey: doc.secret_key,
2004
2880
  name: doc.name,
2881
+ type: doc.type ?? "web",
2005
2882
  domain: doc.domain ?? void 0,
2006
2883
  allowedOrigins: doc.allowed_origins ?? void 0,
2007
2884
  conversionEvents: doc.conversion_events ?? void 0,
@@ -2270,12 +3147,74 @@ async function createCollector(config) {
2270
3147
  return false;
2271
3148
  }
2272
3149
  function enrichEvents(events, ip, userAgent) {
2273
- const device = parseUserAgent(userAgent);
3150
+ const uaDevice = parseUserAgent(userAgent);
2274
3151
  return events.map((event) => {
2275
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
+ }
2276
3170
  return { ...event, ip, geo, device };
2277
3171
  });
2278
3172
  }
3173
+ const identityCache = /* @__PURE__ */ new Map();
3174
+ const IDENTITY_CACHE_TTL = 5 * 60 * 1e3;
3175
+ function getCachedUserId(siteId, visitorId) {
3176
+ const key = `${siteId}:${visitorId}`;
3177
+ const entry = identityCache.get(key);
3178
+ if (!entry) return void 0;
3179
+ if (Date.now() > entry.expires) {
3180
+ identityCache.delete(key);
3181
+ return void 0;
3182
+ }
3183
+ return entry.userId;
3184
+ }
3185
+ function setCachedUserId(siteId, visitorId, userId) {
3186
+ const key = `${siteId}:${visitorId}`;
3187
+ identityCache.set(key, { userId, expires: Date.now() + IDENTITY_CACHE_TTL });
3188
+ if (identityCache.size > 1e4) {
3189
+ const now = Date.now();
3190
+ for (const [k, v] of identityCache) {
3191
+ if (now > v.expires) identityCache.delete(k);
3192
+ }
3193
+ }
3194
+ }
3195
+ async function processIdentity(events) {
3196
+ for (const event of events) {
3197
+ if (!event.visitorId || event.visitorId === "server") continue;
3198
+ if (event.type === "identify" && event.userId) {
3199
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
3200
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
3201
+ } else if (!event.userId) {
3202
+ const cached = getCachedUserId(event.siteId, event.visitorId);
3203
+ if (cached) {
3204
+ event.userId = cached;
3205
+ } else {
3206
+ const resolved = await db.getUserIdForVisitor(event.siteId, event.visitorId);
3207
+ if (resolved) {
3208
+ event.userId = resolved;
3209
+ setCachedUserId(event.siteId, event.visitorId, resolved);
3210
+ }
3211
+ }
3212
+ } else if (event.userId) {
3213
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
3214
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
3215
+ }
3216
+ }
3217
+ }
2279
3218
  function extractIp(req) {
2280
3219
  if (config.trustProxy ?? true) {
2281
3220
  const forwarded = req.headers?.["x-forwarded-for"];
@@ -2287,6 +3226,22 @@ async function createCollector(config) {
2287
3226
  }
2288
3227
  return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
2289
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
+ }
2290
3245
  function handler() {
2291
3246
  return async (req, res) => {
2292
3247
  res.setHeader?.("Access-Control-Allow-Origin", "*");
@@ -2312,6 +3267,12 @@ async function createCollector(config) {
2312
3267
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
2313
3268
  return;
2314
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];
2315
3276
  const userAgent = req.headers?.["user-agent"] || "";
2316
3277
  if (isBot(userAgent)) {
2317
3278
  sendJson(res, 200, { ok: true });
@@ -2319,29 +3280,20 @@ async function createCollector(config) {
2319
3280
  }
2320
3281
  const ip = extractIp(req);
2321
3282
  const enriched = enrichEvents(payload.events, ip, userAgent);
2322
- const siteId = enriched[0]?.siteId;
2323
- if (siteId) {
2324
- const site = await db.getSite(siteId);
2325
- if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2326
- const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2327
- const filtered = enriched.filter((event) => {
2328
- if (!event.url) return true;
2329
- try {
2330
- const hostname = new URL(event.url).hostname.toLowerCase();
2331
- return allowed.has(hostname);
2332
- } catch {
2333
- return true;
2334
- }
2335
- });
2336
- if (filtered.length === 0) {
2337
- sendJson(res, 200, { ok: true });
2338
- return;
2339
- }
2340
- 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)) {
2341
3292
  sendJson(res, 200, { ok: true });
2342
3293
  return;
2343
3294
  }
2344
3295
  }
3296
+ await processIdentity(enriched);
2345
3297
  await db.insertEvents(enriched);
2346
3298
  sendJson(res, 200, { ok: true });
2347
3299
  } catch (err) {
@@ -2602,11 +3554,11 @@ async function createCollector(config) {
2602
3554
  async listUsers(params) {
2603
3555
  return db.listUsers(params);
2604
3556
  },
2605
- async getUserDetail(siteId, visitorId) {
2606
- return db.getUserDetail(siteId, visitorId);
3557
+ async getUserDetail(siteId, identifier) {
3558
+ return db.getUserDetail(siteId, identifier);
2607
3559
  },
2608
- async getUserEvents(siteId, visitorId, params) {
2609
- return db.getUserEvents(siteId, visitorId, params);
3560
+ async getUserEvents(siteId, identifier, params) {
3561
+ return db.getUserEvents(siteId, identifier, params);
2610
3562
  },
2611
3563
  async track(siteId, name, properties, options) {
2612
3564
  const event = {