@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.js CHANGED
@@ -140,6 +140,18 @@ function generateSecretKey() {
140
140
  // src/adapters/clickhouse.ts
141
141
  var EVENTS_TABLE = "litemetrics_events";
142
142
  var SITES_TABLE = "litemetrics_sites";
143
+ var IDENTITY_MAP_TABLE = "litemetrics_identity_map";
144
+ var CREATE_IDENTITY_MAP_TABLE = `
145
+ CREATE TABLE IF NOT EXISTS ${IDENTITY_MAP_TABLE} (
146
+ site_id LowCardinality(String),
147
+ visitor_id String,
148
+ user_id String,
149
+ identified_at DateTime64(3),
150
+ created_at DateTime64(3) DEFAULT now64(3)
151
+ ) ENGINE = ReplacingMergeTree(created_at)
152
+ ORDER BY (site_id, visitor_id)
153
+ SETTINGS index_granularity = 8192
154
+ `;
143
155
  var CREATE_EVENTS_TABLE = `
144
156
  CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
145
157
  event_id UUID DEFAULT generateUUIDv4(),
@@ -178,6 +190,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
178
190
  utm_term Nullable(String),
179
191
  utm_content Nullable(String),
180
192
  ip Nullable(String),
193
+ os_version LowCardinality(Nullable(String)),
194
+ device_model LowCardinality(Nullable(String)),
195
+ device_brand LowCardinality(Nullable(String)),
196
+ app_version LowCardinality(Nullable(String)),
197
+ app_build Nullable(String),
198
+ sdk_name LowCardinality(Nullable(String)),
199
+ sdk_version LowCardinality(Nullable(String)),
181
200
  created_at DateTime64(3) DEFAULT now64(3)
182
201
  ) ENGINE = MergeTree()
183
202
  PARTITION BY toYYYYMM(timestamp)
@@ -189,6 +208,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
189
208
  site_id String,
190
209
  secret_key String,
191
210
  name String,
211
+ type LowCardinality(Nullable(String)) DEFAULT 'web',
192
212
  domain Nullable(String),
193
213
  allowed_origins Nullable(String),
194
214
  conversion_events Nullable(String),
@@ -204,6 +224,65 @@ function toCHDateTime(d) {
204
224
  const iso = typeof d === "string" ? d : d.toISOString();
205
225
  return iso.replace("T", " ").replace("Z", "");
206
226
  }
227
+ function normalizedUtmSourceExpr() {
228
+ return `multiIf(
229
+ lower(utm_source) IN ('ig','instagram','instagram.com'), 'Instagram',
230
+ lower(utm_source) IN ('fb','facebook','facebook.com','fb.com'), 'Facebook',
231
+ lower(utm_source) IN ('tw','twitter','twitter.com','x','x.com','t.co'), 'X (Twitter)',
232
+ lower(utm_source) IN ('li','linkedin','linkedin.com'), 'LinkedIn',
233
+ lower(utm_source) IN ('yt','youtube','youtube.com'), 'YouTube',
234
+ lower(utm_source) IN ('goog','google','google.com'), 'Google',
235
+ lower(utm_source) IN ('gh','github','github.com'), 'GitHub',
236
+ lower(utm_source) IN ('reddit','reddit.com'), 'Reddit',
237
+ lower(utm_source) IN ('pinterest','pinterest.com'), 'Pinterest',
238
+ lower(utm_source) IN ('tiktok','tiktok.com'), 'TikTok',
239
+ lower(utm_source) IN ('openai','chatgpt','chat.openai.com'), 'OpenAI',
240
+ lower(utm_source) IN ('perplexity','perplexity.ai'), 'Perplexity',
241
+ utm_source
242
+ )`;
243
+ }
244
+ function normalizedUtmMediumExpr() {
245
+ return `multiIf(
246
+ lower(utm_medium) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid'), 'Paid',
247
+ lower(utm_medium) IN ('organic'), 'Organic',
248
+ lower(utm_medium) IN ('social','social-media','social_media'), 'Social',
249
+ lower(utm_medium) IN ('email','e-mail','e_mail'), 'Email',
250
+ lower(utm_medium) IN ('display','banner','cpm'), 'Display',
251
+ lower(utm_medium) IN ('affiliate'), 'Affiliate',
252
+ lower(utm_medium) IN ('referral'), 'Referral',
253
+ utm_medium
254
+ )`;
255
+ }
256
+ function channelClassificationExpr() {
257
+ return `multiIf(
258
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
259
+ AND (lower(ifNull(utm_source,'')) IN ('google','goog','bing','yahoo','duckduckgo','ecosia','baidu','yandex')
260
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0),
261
+ 'Paid Search',
262
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
263
+ AND (lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat')
264
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat']) > 0),
265
+ 'Paid Social',
266
+ lower(ifNull(utm_medium,'')) IN ('email','e-mail','e_mail'),
267
+ 'Email',
268
+ lower(ifNull(utm_medium,'')) IN ('display','banner','cpm'),
269
+ 'Display',
270
+ lower(ifNull(utm_medium,'')) IN ('affiliate'),
271
+ 'Affiliate',
272
+ multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0
273
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
274
+ 'Organic Search',
275
+ (multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat','mastodon','tumblr']) > 0
276
+ OR lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat'))
277
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
278
+ 'Organic Social',
279
+ ifNull(referrer,'') != '' AND length(ifNull(referrer,'')) > 0,
280
+ 'Referral',
281
+ (ifNull(utm_source,'') != '' OR ifNull(utm_medium,'') != '' OR ifNull(utm_campaign,'') != ''),
282
+ 'Other',
283
+ 'Direct'
284
+ )`;
285
+ }
207
286
  function buildFilterConditions(filters) {
208
287
  if (!filters) return { conditions: [], params: {} };
209
288
  const map = {
@@ -214,6 +293,10 @@ function buildFilterConditions(filters) {
214
293
  "device.type": "device_type",
215
294
  "device.browser": "browser",
216
295
  "device.os": "os",
296
+ "device.osVersion": "os_version",
297
+ "device.deviceModel": "device_model",
298
+ "device.deviceBrand": "device_brand",
299
+ "device.appVersion": "app_version",
217
300
  "utm.source": "utm_source",
218
301
  "utm.medium": "utm_medium",
219
302
  "utm.campaign": "utm_campaign",
@@ -230,7 +313,14 @@ function buildFilterConditions(filters) {
230
313
  const conditions = [];
231
314
  const params = {};
232
315
  for (const [key, value] of Object.entries(filters)) {
233
- if (!value || !map[key]) continue;
316
+ if (!value) continue;
317
+ if (key === "channel") {
318
+ const paramKey2 = "f_channel";
319
+ conditions.push(`${channelClassificationExpr()} = {${paramKey2}:String}`);
320
+ params[paramKey2] = value;
321
+ continue;
322
+ }
323
+ if (!map[key]) continue;
234
324
  const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
235
325
  conditions.push(`${map[key]} = {${paramKey}:String}`);
236
326
  params[paramKey] = value;
@@ -250,6 +340,7 @@ var ClickHouseAdapter = class {
250
340
  async init() {
251
341
  await this.client.command({ query: CREATE_EVENTS_TABLE });
252
342
  await this.client.command({ query: CREATE_SITES_TABLE });
343
+ await this.client.command({ query: CREATE_IDENTITY_MAP_TABLE });
253
344
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
254
345
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
255
346
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
@@ -260,6 +351,14 @@ var ClickHouseAdapter = class {
260
351
  await this.client.command({
261
352
  query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
262
353
  });
354
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS os_version LowCardinality(Nullable(String))` });
355
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_model LowCardinality(Nullable(String))` });
356
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_brand LowCardinality(Nullable(String))` });
357
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_version LowCardinality(Nullable(String))` });
358
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_build Nullable(String)` });
359
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_name LowCardinality(Nullable(String))` });
360
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_version LowCardinality(Nullable(String))` });
361
+ await this.client.command({ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS type LowCardinality(Nullable(String)) DEFAULT 'web'` });
263
362
  }
264
363
  async close() {
265
364
  await this.client.close();
@@ -302,7 +401,14 @@ var ClickHouseAdapter = class {
302
401
  utm_campaign: e.utm?.campaign ?? null,
303
402
  utm_term: e.utm?.term ?? null,
304
403
  utm_content: e.utm?.content ?? null,
305
- ip: e.ip ?? null
404
+ ip: e.ip ?? null,
405
+ os_version: e.device?.osVersion ?? null,
406
+ device_model: e.device?.deviceModel ?? null,
407
+ device_brand: e.device?.deviceBrand ?? null,
408
+ app_version: e.device?.appVersion ?? null,
409
+ app_build: e.device?.appBuild ?? null,
410
+ sdk_name: e.device?.sdkName ?? null,
411
+ sdk_version: e.device?.sdkVersion ?? null
306
412
  }));
307
413
  await this.client.insert({
308
414
  table: EVENTS_TABLE,
@@ -507,8 +613,8 @@ var ClickHouseAdapter = class {
507
613
  }
508
614
  case "top_exit_pages": {
509
615
  const rows = await this.queryRows(
510
- `SELECT url AS key, count() AS value FROM (
511
- SELECT session_id, argMax(url, timestamp) AS url
616
+ `SELECT exit_url AS key, count() AS value FROM (
617
+ SELECT session_id, argMax(url, timestamp) AS exit_url
512
618
  FROM ${EVENTS_TABLE}
513
619
  WHERE site_id = {siteId:String}
514
620
  AND timestamp >= {from:String}
@@ -517,7 +623,7 @@ var ClickHouseAdapter = class {
517
623
  AND url IS NOT NULL${filterSql}
518
624
  GROUP BY session_id
519
625
  )
520
- GROUP BY url
626
+ GROUP BY exit_url
521
627
  ORDER BY value DESC
522
628
  LIMIT {limit:UInt32}`,
523
629
  { ...params, ...filter.params }
@@ -528,9 +634,9 @@ var ClickHouseAdapter = class {
528
634
  }
529
635
  case "top_transitions": {
530
636
  const rows = await this.queryRows(
531
- `SELECT concat(prev_url, ' \u2192 ', url) AS key, count() AS value FROM (
532
- SELECT session_id, url,
533
- lag(url) OVER (PARTITION BY session_id ORDER BY timestamp) AS prev_url
637
+ `SELECT concat(prev_url, ' \u2192 ', curr_url) AS key, count() AS value FROM (
638
+ SELECT session_id, url AS curr_url,
639
+ lagInFrame(url, 1) OVER (PARTITION BY session_id ORDER BY timestamp ASC) AS prev_url
534
640
  FROM ${EVENTS_TABLE}
535
641
  WHERE site_id = {siteId:String}
536
642
  AND timestamp >= {from:String}
@@ -538,7 +644,7 @@ var ClickHouseAdapter = class {
538
644
  AND type = 'pageview'
539
645
  AND url IS NOT NULL${filterSql}
540
646
  )
541
- WHERE prev_url IS NOT NULL
647
+ WHERE prev_url IS NOT NULL AND prev_url != ''
542
648
  GROUP BY key
543
649
  ORDER BY value DESC
544
650
  LIMIT {limit:UInt32}`,
@@ -653,6 +759,159 @@ var ClickHouseAdapter = class {
653
759
  total = data.reduce((sum, d) => sum + d.value, 0);
654
760
  break;
655
761
  }
762
+ case "top_os_versions": {
763
+ const rows = await this.queryRows(
764
+ `SELECT concat(os, ' ', ifNull(os_version, '')) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
765
+ WHERE site_id = {siteId:String}
766
+ AND timestamp >= {from:String}
767
+ AND timestamp <= {to:String}
768
+ AND os IS NOT NULL
769
+ AND os_version IS NOT NULL
770
+ ${filterSql}
771
+ GROUP BY key
772
+ ORDER BY value DESC
773
+ LIMIT {limit:UInt32}`,
774
+ { ...params, ...filter.params }
775
+ );
776
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
777
+ total = data.reduce((sum, d) => sum + d.value, 0);
778
+ break;
779
+ }
780
+ case "top_device_models": {
781
+ const rows = await this.queryRows(
782
+ `SELECT trim(concat(ifNull(device_brand, ''), ' ', device_model)) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
783
+ WHERE site_id = {siteId:String}
784
+ AND timestamp >= {from:String}
785
+ AND timestamp <= {to:String}
786
+ AND device_model IS NOT NULL
787
+ ${filterSql}
788
+ GROUP BY key
789
+ ORDER BY value DESC
790
+ LIMIT {limit:UInt32}`,
791
+ { ...params, ...filter.params }
792
+ );
793
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
794
+ total = data.reduce((sum, d) => sum + d.value, 0);
795
+ break;
796
+ }
797
+ case "top_app_versions": {
798
+ const rows = await this.queryRows(
799
+ `SELECT app_version AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
800
+ WHERE site_id = {siteId:String}
801
+ AND timestamp >= {from:String}
802
+ AND timestamp <= {to:String}
803
+ AND app_version IS NOT NULL
804
+ ${filterSql}
805
+ GROUP BY app_version
806
+ ORDER BY value DESC
807
+ LIMIT {limit:UInt32}`,
808
+ { ...params, ...filter.params }
809
+ );
810
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
811
+ total = data.reduce((sum, d) => sum + d.value, 0);
812
+ break;
813
+ }
814
+ case "top_utm_sources": {
815
+ const rows = await this.queryRows(
816
+ `SELECT ${normalizedUtmSourceExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
817
+ WHERE site_id = {siteId:String}
818
+ AND timestamp >= {from:String}
819
+ AND timestamp <= {to:String}
820
+ AND utm_source IS NOT NULL AND utm_source != ''
821
+ ${filterSql}
822
+ GROUP BY key
823
+ ORDER BY value DESC
824
+ LIMIT {limit:UInt32}`,
825
+ { ...params, ...filter.params }
826
+ );
827
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
828
+ total = data.reduce((sum, d) => sum + d.value, 0);
829
+ break;
830
+ }
831
+ case "top_utm_mediums": {
832
+ const rows = await this.queryRows(
833
+ `SELECT ${normalizedUtmMediumExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
834
+ WHERE site_id = {siteId:String}
835
+ AND timestamp >= {from:String}
836
+ AND timestamp <= {to:String}
837
+ AND utm_medium IS NOT NULL AND utm_medium != ''
838
+ ${filterSql}
839
+ GROUP BY key
840
+ ORDER BY value DESC
841
+ LIMIT {limit:UInt32}`,
842
+ { ...params, ...filter.params }
843
+ );
844
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
845
+ total = data.reduce((sum, d) => sum + d.value, 0);
846
+ break;
847
+ }
848
+ case "top_utm_campaigns": {
849
+ const rows = await this.queryRows(
850
+ `SELECT utm_campaign AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
851
+ WHERE site_id = {siteId:String}
852
+ AND timestamp >= {from:String}
853
+ AND timestamp <= {to:String}
854
+ AND utm_campaign IS NOT NULL AND utm_campaign != ''
855
+ ${filterSql}
856
+ GROUP BY utm_campaign
857
+ ORDER BY value DESC
858
+ LIMIT {limit:UInt32}`,
859
+ { ...params, ...filter.params }
860
+ );
861
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
862
+ total = data.reduce((sum, d) => sum + d.value, 0);
863
+ break;
864
+ }
865
+ case "top_utm_terms": {
866
+ const rows = await this.queryRows(
867
+ `SELECT utm_term AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
868
+ WHERE site_id = {siteId:String}
869
+ AND timestamp >= {from:String}
870
+ AND timestamp <= {to:String}
871
+ AND utm_term IS NOT NULL AND utm_term != ''
872
+ ${filterSql}
873
+ GROUP BY utm_term
874
+ ORDER BY value DESC
875
+ LIMIT {limit:UInt32}`,
876
+ { ...params, ...filter.params }
877
+ );
878
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
879
+ total = data.reduce((sum, d) => sum + d.value, 0);
880
+ break;
881
+ }
882
+ case "top_utm_contents": {
883
+ const rows = await this.queryRows(
884
+ `SELECT utm_content AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
885
+ WHERE site_id = {siteId:String}
886
+ AND timestamp >= {from:String}
887
+ AND timestamp <= {to:String}
888
+ AND utm_content IS NOT NULL AND utm_content != ''
889
+ ${filterSql}
890
+ GROUP BY utm_content
891
+ ORDER BY value DESC
892
+ LIMIT {limit:UInt32}`,
893
+ { ...params, ...filter.params }
894
+ );
895
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
896
+ total = data.reduce((sum, d) => sum + d.value, 0);
897
+ break;
898
+ }
899
+ case "top_channels": {
900
+ const rows = await this.queryRows(
901
+ `SELECT ${channelClassificationExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
902
+ WHERE site_id = {siteId:String}
903
+ AND timestamp >= {from:String}
904
+ AND timestamp <= {to:String}
905
+ ${filterSql}
906
+ GROUP BY key
907
+ ORDER BY value DESC
908
+ LIMIT {limit:UInt32}`,
909
+ { ...params, ...filter.params }
910
+ );
911
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
912
+ total = data.reduce((sum, d) => sum + d.value, 0);
913
+ break;
914
+ }
656
915
  }
657
916
  const result = { metric: q.metric, period, data, total };
658
917
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -906,45 +1165,59 @@ var ClickHouseAdapter = class {
906
1165
  const where = conditions.join(" AND ");
907
1166
  const [userRows, countRows] = await Promise.all([
908
1167
  this.queryRows(
909
- `SELECT
910
- visitor_id,
911
- anyLast(user_id) AS userId,
912
- anyLast(traits) AS traits,
913
- min(timestamp) AS firstSeen,
914
- max(timestamp) AS lastSeen,
1168
+ `WITH identity AS (
1169
+ SELECT visitor_id, user_id
1170
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1171
+ WHERE site_id = {siteId:String}
1172
+ )
1173
+ SELECT
1174
+ if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key,
1175
+ anyLast(e.visitor_id) AS visitor_id,
1176
+ anyLast(i.user_id) AS userId,
1177
+ anyLast(e.traits) AS traits,
1178
+ min(e.timestamp) AS firstSeen,
1179
+ max(e.timestamp) AS lastSeen,
915
1180
  count() AS totalEvents,
916
- countIf(type = 'pageview') AS totalPageviews,
917
- uniq(session_id) AS totalSessions,
918
- anyLast(url) AS lastUrl,
919
- anyLast(referrer) AS referrer,
920
- anyLast(device_type) AS device_type,
921
- anyLast(browser) AS browser,
922
- anyLast(os) AS os,
923
- anyLast(country) AS country,
924
- anyLast(city) AS city,
925
- anyLast(region) AS region,
926
- anyLast(language) AS language,
927
- anyLast(timezone) AS timezone,
928
- anyLast(screen_width) AS screen_width,
929
- anyLast(screen_height) AS screen_height,
930
- anyLast(utm_source) AS utm_source,
931
- anyLast(utm_medium) AS utm_medium,
932
- anyLast(utm_campaign) AS utm_campaign,
933
- anyLast(utm_term) AS utm_term,
934
- anyLast(utm_content) AS utm_content
935
- FROM ${EVENTS_TABLE}
936
- WHERE ${where}
937
- GROUP BY visitor_id
1181
+ countIf(e.type = 'pageview') AS totalPageviews,
1182
+ uniq(e.session_id) AS totalSessions,
1183
+ anyLast(e.url) AS lastUrl,
1184
+ anyLast(e.referrer) AS referrer,
1185
+ anyLast(e.device_type) AS device_type,
1186
+ anyLast(e.browser) AS browser,
1187
+ anyLast(e.os) AS os,
1188
+ anyLast(e.country) AS country,
1189
+ anyLast(e.city) AS city,
1190
+ anyLast(e.region) AS region,
1191
+ anyLast(e.language) AS language,
1192
+ anyLast(e.timezone) AS timezone,
1193
+ anyLast(e.screen_width) AS screen_width,
1194
+ anyLast(e.screen_height) AS screen_height,
1195
+ anyLast(e.utm_source) AS utm_source,
1196
+ anyLast(e.utm_medium) AS utm_medium,
1197
+ anyLast(e.utm_campaign) AS utm_campaign,
1198
+ anyLast(e.utm_term) AS utm_term,
1199
+ anyLast(e.utm_content) AS utm_content
1200
+ FROM ${EVENTS_TABLE} e
1201
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1202
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1203
+ GROUP BY group_key
938
1204
  ORDER BY lastSeen DESC
939
1205
  LIMIT {limit:UInt32}
940
1206
  OFFSET {offset:UInt32}`,
941
1207
  queryParams
942
1208
  ),
943
1209
  this.queryRows(
944
- `SELECT count() AS total FROM (
945
- SELECT visitor_id FROM ${EVENTS_TABLE}
946
- WHERE ${where}
947
- GROUP BY visitor_id
1210
+ `WITH identity AS (
1211
+ SELECT visitor_id, user_id
1212
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1213
+ WHERE site_id = {siteId:String}
1214
+ )
1215
+ SELECT count() AS total FROM (
1216
+ SELECT if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key
1217
+ FROM ${EVENTS_TABLE} e
1218
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1219
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1220
+ GROUP BY group_key
948
1221
  )`,
949
1222
  queryParams
950
1223
  )
@@ -980,13 +1253,178 @@ var ClickHouseAdapter = class {
980
1253
  offset
981
1254
  };
982
1255
  }
983
- async getUserDetail(siteId, visitorId) {
984
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
985
- const user = result.users.find((u) => u.visitorId === visitorId);
1256
+ async getUserDetail(siteId, identifier) {
1257
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1258
+ if (visitorIds.length > 0) {
1259
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
1260
+ }
1261
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1262
+ if (userId) {
1263
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1264
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds.length > 0 ? allVisitorIds : [identifier]);
1265
+ }
1266
+ const result = await this.listUsers({ siteId, search: identifier, limit: 1 });
1267
+ const user = result.users.find((u) => u.visitorId === identifier);
986
1268
  return user ?? null;
987
1269
  }
988
- async getUserEvents(siteId, visitorId, params) {
989
- return this.listEvents({ ...params, siteId, visitorId });
1270
+ async getUserEvents(siteId, identifier, params) {
1271
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1272
+ if (visitorIds.length > 0) {
1273
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
1274
+ }
1275
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1276
+ if (userId) {
1277
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1278
+ if (allVisitorIds.length > 0) {
1279
+ return this.listEventsForVisitorIds(siteId, allVisitorIds, params);
1280
+ }
1281
+ }
1282
+ return this.listEvents({ ...params, siteId, visitorId: identifier });
1283
+ }
1284
+ // ─── Identity Mapping ──────────────────────────────────────
1285
+ async upsertIdentity(siteId, visitorId, userId) {
1286
+ await this.client.insert({
1287
+ table: IDENTITY_MAP_TABLE,
1288
+ values: [{
1289
+ site_id: siteId,
1290
+ visitor_id: visitorId,
1291
+ user_id: userId,
1292
+ identified_at: toCHDateTime(/* @__PURE__ */ new Date())
1293
+ }],
1294
+ format: "JSONEachRow"
1295
+ });
1296
+ }
1297
+ async getVisitorIdsForUser(siteId, userId) {
1298
+ const rows = await this.queryRows(
1299
+ `SELECT visitor_id FROM ${IDENTITY_MAP_TABLE} FINAL
1300
+ WHERE site_id = {siteId:String} AND user_id = {userId:String}`,
1301
+ { siteId, userId }
1302
+ );
1303
+ return rows.map((r) => r.visitor_id);
1304
+ }
1305
+ async getUserIdForVisitor(siteId, visitorId) {
1306
+ const rows = await this.queryRows(
1307
+ `SELECT user_id FROM ${IDENTITY_MAP_TABLE} FINAL
1308
+ WHERE site_id = {siteId:String} AND visitor_id = {visitorId:String}
1309
+ LIMIT 1`,
1310
+ { siteId, visitorId }
1311
+ );
1312
+ return rows.length > 0 ? rows[0].user_id : null;
1313
+ }
1314
+ async getMergedUserDetail(siteId, userId, visitorIds) {
1315
+ const rows = await this.queryRows(
1316
+ `SELECT
1317
+ anyLast(visitor_id) AS last_visitor_id,
1318
+ anyLast(traits) AS traits,
1319
+ min(timestamp) AS firstSeen,
1320
+ max(timestamp) AS lastSeen,
1321
+ count() AS totalEvents,
1322
+ countIf(type = 'pageview') AS totalPageviews,
1323
+ uniq(session_id) AS totalSessions,
1324
+ anyLast(url) AS lastUrl,
1325
+ anyLast(referrer) AS referrer,
1326
+ anyLast(device_type) AS device_type,
1327
+ anyLast(browser) AS browser,
1328
+ anyLast(os) AS os,
1329
+ anyLast(country) AS country,
1330
+ anyLast(city) AS city,
1331
+ anyLast(region) AS region,
1332
+ anyLast(language) AS language,
1333
+ anyLast(timezone) AS timezone,
1334
+ anyLast(screen_width) AS screen_width,
1335
+ anyLast(screen_height) AS screen_height,
1336
+ anyLast(utm_source) AS utm_source,
1337
+ anyLast(utm_medium) AS utm_medium,
1338
+ anyLast(utm_campaign) AS utm_campaign,
1339
+ anyLast(utm_term) AS utm_term,
1340
+ anyLast(utm_content) AS utm_content
1341
+ FROM ${EVENTS_TABLE}
1342
+ WHERE site_id = {siteId:String}
1343
+ AND visitor_id IN {visitorIds:Array(String)}`,
1344
+ { siteId, visitorIds }
1345
+ );
1346
+ if (rows.length === 0) return null;
1347
+ const u = rows[0];
1348
+ return {
1349
+ visitorId: String(u.last_visitor_id),
1350
+ visitorIds,
1351
+ userId,
1352
+ traits: this.parseJSON(u.traits),
1353
+ firstSeen: new Date(String(u.firstSeen)).toISOString(),
1354
+ lastSeen: new Date(String(u.lastSeen)).toISOString(),
1355
+ totalEvents: Number(u.totalEvents),
1356
+ totalPageviews: Number(u.totalPageviews),
1357
+ totalSessions: Number(u.totalSessions),
1358
+ lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
1359
+ referrer: u.referrer ? String(u.referrer) : void 0,
1360
+ device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
1361
+ 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,
1362
+ language: u.language ? String(u.language) : void 0,
1363
+ timezone: u.timezone ? String(u.timezone) : void 0,
1364
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
1365
+ utm: u.utm_source ? {
1366
+ source: String(u.utm_source),
1367
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
1368
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
1369
+ term: u.utm_term ? String(u.utm_term) : void 0,
1370
+ content: u.utm_content ? String(u.utm_content) : void 0
1371
+ } : void 0
1372
+ };
1373
+ }
1374
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
1375
+ const limit = Math.min(params.limit ?? 50, 200);
1376
+ const offset = params.offset ?? 0;
1377
+ const conditions = [`site_id = {siteId:String}`, `visitor_id IN {visitorIds:Array(String)}`];
1378
+ const queryParams = { siteId, visitorIds, limit, offset };
1379
+ if (params.type) {
1380
+ conditions.push(`type = {type:String}`);
1381
+ queryParams.type = params.type;
1382
+ }
1383
+ if (params.eventName) {
1384
+ conditions.push(`event_name = {eventName:String}`);
1385
+ queryParams.eventName = params.eventName;
1386
+ }
1387
+ if (params.eventNames && params.eventNames.length > 0) {
1388
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
1389
+ queryParams.eventNames = params.eventNames;
1390
+ }
1391
+ if (params.period || params.dateFrom) {
1392
+ const { dateRange } = resolvePeriod({
1393
+ period: params.period,
1394
+ dateFrom: params.dateFrom,
1395
+ dateTo: params.dateTo
1396
+ });
1397
+ conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
1398
+ queryParams.from = toCHDateTime(dateRange.from);
1399
+ queryParams.to = toCHDateTime(dateRange.to);
1400
+ }
1401
+ const where = conditions.join(" AND ");
1402
+ const [events, countRows] = await Promise.all([
1403
+ this.queryRows(
1404
+ `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
1405
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
1406
+ element_selector, element_text, scroll_depth_pct,
1407
+ user_id, traits, country, city, region,
1408
+ device_type, browser, os, language,
1409
+ utm_source, utm_medium, utm_campaign, utm_term, utm_content
1410
+ FROM ${EVENTS_TABLE}
1411
+ WHERE ${where}
1412
+ ORDER BY timestamp DESC
1413
+ LIMIT {limit:UInt32}
1414
+ OFFSET {offset:UInt32}`,
1415
+ queryParams
1416
+ ),
1417
+ this.queryRows(
1418
+ `SELECT count() AS total FROM ${EVENTS_TABLE} WHERE ${where}`,
1419
+ queryParams
1420
+ )
1421
+ ]);
1422
+ return {
1423
+ events: events.map((e) => this.toEventListItem(e)),
1424
+ total: Number(countRows[0]?.total ?? 0),
1425
+ limit,
1426
+ offset
1427
+ };
990
1428
  }
991
1429
  // ─── Site Management ──────────────────────────────────────
992
1430
  async createSite(data) {
@@ -997,6 +1435,7 @@ var ClickHouseAdapter = class {
997
1435
  siteId: generateSiteId(),
998
1436
  secretKey: generateSecretKey(),
999
1437
  name: data.name,
1438
+ type: data.type ?? "web",
1000
1439
  domain: data.domain,
1001
1440
  allowedOrigins: data.allowedOrigins,
1002
1441
  conversionEvents: data.conversionEvents,
@@ -1009,6 +1448,7 @@ var ClickHouseAdapter = class {
1009
1448
  site_id: site.siteId,
1010
1449
  secret_key: site.secretKey,
1011
1450
  name: site.name,
1451
+ type: site.type ?? "web",
1012
1452
  domain: site.domain ?? null,
1013
1453
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1014
1454
  conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
@@ -1023,7 +1463,7 @@ var ClickHouseAdapter = class {
1023
1463
  }
1024
1464
  async getSite(siteId) {
1025
1465
  const rows = await this.queryRows(
1026
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1466
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1027
1467
  FROM ${SITES_TABLE} FINAL
1028
1468
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1029
1469
  { siteId }
@@ -1032,7 +1472,7 @@ var ClickHouseAdapter = class {
1032
1472
  }
1033
1473
  async getSiteBySecret(secretKey) {
1034
1474
  const rows = await this.queryRows(
1035
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1475
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1036
1476
  FROM ${SITES_TABLE} FINAL
1037
1477
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
1038
1478
  { secretKey }
@@ -1041,7 +1481,7 @@ var ClickHouseAdapter = class {
1041
1481
  }
1042
1482
  async listSites() {
1043
1483
  const rows = await this.queryRows(
1044
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1484
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1045
1485
  FROM ${SITES_TABLE} FINAL
1046
1486
  WHERE is_deleted = 0
1047
1487
  ORDER BY created_at DESC`,
@@ -1051,7 +1491,7 @@ var ClickHouseAdapter = class {
1051
1491
  }
1052
1492
  async updateSite(siteId, data) {
1053
1493
  const currentRows = await this.queryRows(
1054
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
1494
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at, version
1055
1495
  FROM ${SITES_TABLE} FINAL
1056
1496
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1057
1497
  { siteId }
@@ -1063,6 +1503,7 @@ var ClickHouseAdapter = class {
1063
1503
  const nowCH = toCHDateTime(now);
1064
1504
  const newVersion = Number(current.version) + 1;
1065
1505
  const newName = data.name !== void 0 ? data.name : String(current.name);
1506
+ const newType = data.type !== void 0 ? data.type : current.type ? String(current.type) : "web";
1066
1507
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
1067
1508
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1068
1509
  const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
@@ -1072,6 +1513,7 @@ var ClickHouseAdapter = class {
1072
1513
  site_id: String(current.site_id),
1073
1514
  secret_key: String(current.secret_key),
1074
1515
  name: newName,
1516
+ type: newType,
1075
1517
  domain: newDomain,
1076
1518
  allowed_origins: newOrigins,
1077
1519
  conversion_events: newConversions,
@@ -1086,6 +1528,7 @@ var ClickHouseAdapter = class {
1086
1528
  siteId: String(current.site_id),
1087
1529
  secretKey: String(current.secret_key),
1088
1530
  name: newName,
1531
+ type: newType,
1089
1532
  domain: newDomain ?? void 0,
1090
1533
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1091
1534
  conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
@@ -1095,7 +1538,7 @@ var ClickHouseAdapter = class {
1095
1538
  }
1096
1539
  async deleteSite(siteId) {
1097
1540
  const currentRows = await this.queryRows(
1098
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1541
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1099
1542
  FROM ${SITES_TABLE} FINAL
1100
1543
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1101
1544
  { siteId }
@@ -1109,6 +1552,7 @@ var ClickHouseAdapter = class {
1109
1552
  site_id: String(current.site_id),
1110
1553
  secret_key: String(current.secret_key),
1111
1554
  name: String(current.name),
1555
+ type: current.type ? String(current.type) : "web",
1112
1556
  domain: current.domain ? String(current.domain) : null,
1113
1557
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1114
1558
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1123,7 +1567,7 @@ var ClickHouseAdapter = class {
1123
1567
  }
1124
1568
  async regenerateSecret(siteId) {
1125
1569
  const currentRows = await this.queryRows(
1126
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1570
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1127
1571
  FROM ${SITES_TABLE} FINAL
1128
1572
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1129
1573
  { siteId }
@@ -1140,6 +1584,7 @@ var ClickHouseAdapter = class {
1140
1584
  site_id: String(current.site_id),
1141
1585
  secret_key: newSecret,
1142
1586
  name: String(current.name),
1587
+ type: current.type ? String(current.type) : "web",
1143
1588
  domain: current.domain ? String(current.domain) : null,
1144
1589
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1145
1590
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1154,6 +1599,7 @@ var ClickHouseAdapter = class {
1154
1599
  siteId: String(current.site_id),
1155
1600
  secretKey: newSecret,
1156
1601
  name: String(current.name),
1602
+ type: current.type ? String(current.type) : "web",
1157
1603
  domain: current.domain ? String(current.domain) : void 0,
1158
1604
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1159
1605
  conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
@@ -1175,6 +1621,7 @@ var ClickHouseAdapter = class {
1175
1621
  siteId: String(row.site_id),
1176
1622
  secretKey: String(row.secret_key),
1177
1623
  name: String(row.name),
1624
+ type: row.type ? String(row.type) : "web",
1178
1625
  domain: row.domain ? String(row.domain) : void 0,
1179
1626
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1180
1627
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
@@ -1237,6 +1684,131 @@ var ClickHouseAdapter = class {
1237
1684
  import { MongoClient } from "mongodb";
1238
1685
  var EVENTS_COLLECTION = "litemetrics_events";
1239
1686
  var SITES_COLLECTION = "litemetrics_sites";
1687
+ var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1688
+ function normalizedUtmSourceSwitch() {
1689
+ return {
1690
+ $switch: {
1691
+ branches: [
1692
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["ig", "instagram", "instagram.com"]] }, then: "Instagram" },
1693
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["fb", "facebook", "facebook.com", "fb.com"]] }, then: "Facebook" },
1694
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tw", "twitter", "twitter.com", "x", "x.com", "t.co"]] }, then: "X (Twitter)" },
1695
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["li", "linkedin", "linkedin.com"]] }, then: "LinkedIn" },
1696
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["yt", "youtube", "youtube.com"]] }, then: "YouTube" },
1697
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["goog", "google", "google.com"]] }, then: "Google" },
1698
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["gh", "github", "github.com"]] }, then: "GitHub" },
1699
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["reddit", "reddit.com"]] }, then: "Reddit" },
1700
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["pinterest", "pinterest.com"]] }, then: "Pinterest" },
1701
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tiktok", "tiktok.com"]] }, then: "TikTok" },
1702
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["openai", "chatgpt", "chat.openai.com"]] }, then: "OpenAI" },
1703
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["perplexity", "perplexity.ai"]] }, then: "Perplexity" }
1704
+ ],
1705
+ default: "$utm_source"
1706
+ }
1707
+ };
1708
+ }
1709
+ function normalizedUtmMediumSwitch() {
1710
+ return {
1711
+ $switch: {
1712
+ branches: [
1713
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"]] }, then: "Paid" },
1714
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["organic"]] }, then: "Organic" },
1715
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["social", "social-media", "social_media"]] }, then: "Social" },
1716
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1717
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["display", "banner", "cpm"]] }, then: "Display" },
1718
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["affiliate"]] }, then: "Affiliate" },
1719
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["referral"]] }, then: "Referral" }
1720
+ ],
1721
+ default: "$utm_medium"
1722
+ }
1723
+ };
1724
+ }
1725
+ var SEARCH_ENGINES = /google|bing|yahoo|duckduckgo|ecosia|baidu|yandex|search\.brave/i;
1726
+ var SOCIAL_NETWORKS = /instagram|facebook|twitter|x\.com|t\.co|linkedin|youtube|tiktok|pinterest|reddit|snapchat|mastodon|tumblr/i;
1727
+ var PAID_MEDIUMS = ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"];
1728
+ var SOCIAL_SOURCES = ["instagram", "ig", "facebook", "fb", "twitter", "tw", "x", "linkedin", "li", "youtube", "yt", "tiktok", "pinterest", "reddit", "snapchat"];
1729
+ function channelClassificationSwitch() {
1730
+ const lMedium = { $toLower: { $ifNull: ["$utm_medium", ""] } };
1731
+ const lSource = { $toLower: { $ifNull: ["$utm_source", ""] } };
1732
+ const refStr = { $ifNull: ["$referrer", ""] };
1733
+ return {
1734
+ $switch: {
1735
+ branches: [
1736
+ // Paid Search
1737
+ {
1738
+ case: {
1739
+ $and: [
1740
+ { $in: [lMedium, PAID_MEDIUMS] },
1741
+ { $or: [
1742
+ { $in: [lSource, ["google", "goog", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex"]] },
1743
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } }
1744
+ ] }
1745
+ ]
1746
+ },
1747
+ then: "Paid Search"
1748
+ },
1749
+ // Paid Social
1750
+ {
1751
+ case: {
1752
+ $and: [
1753
+ { $in: [lMedium, PAID_MEDIUMS] },
1754
+ { $or: [
1755
+ { $in: [lSource, SOCIAL_SOURCES] },
1756
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } }
1757
+ ] }
1758
+ ]
1759
+ },
1760
+ then: "Paid Social"
1761
+ },
1762
+ // Email
1763
+ { case: { $in: [lMedium, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1764
+ // Display
1765
+ { case: { $in: [lMedium, ["display", "banner", "cpm"]] }, then: "Display" },
1766
+ // Affiliate
1767
+ { case: { $in: [lMedium, ["affiliate"]] }, then: "Affiliate" },
1768
+ // Organic Search
1769
+ {
1770
+ case: {
1771
+ $and: [
1772
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } },
1773
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1774
+ ]
1775
+ },
1776
+ then: "Organic Search"
1777
+ },
1778
+ // Organic Social
1779
+ {
1780
+ case: {
1781
+ $and: [
1782
+ { $or: [
1783
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } },
1784
+ { $in: [lSource, SOCIAL_SOURCES] }
1785
+ ] },
1786
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1787
+ ]
1788
+ },
1789
+ then: "Organic Social"
1790
+ },
1791
+ // Referral
1792
+ {
1793
+ case: { $and: [{ $ne: [refStr, ""] }, { $gt: [{ $strLenCP: refStr }, 0] }] },
1794
+ then: "Referral"
1795
+ },
1796
+ // Other (has UTM but no referrer)
1797
+ {
1798
+ case: {
1799
+ $or: [
1800
+ { $and: [{ $ne: [{ $ifNull: ["$utm_source", ""] }, ""] }] },
1801
+ { $and: [{ $ne: [{ $ifNull: ["$utm_medium", ""] }, ""] }] },
1802
+ { $and: [{ $ne: [{ $ifNull: ["$utm_campaign", ""] }, ""] }] }
1803
+ ]
1804
+ },
1805
+ then: "Other"
1806
+ }
1807
+ ],
1808
+ default: "Direct"
1809
+ }
1810
+ };
1811
+ }
1240
1812
  function buildFilterMatch(filters) {
1241
1813
  if (!filters) return {};
1242
1814
  const map = {
@@ -1247,6 +1819,10 @@ function buildFilterMatch(filters) {
1247
1819
  "device.type": "device_type",
1248
1820
  "device.browser": "browser",
1249
1821
  "device.os": "os",
1822
+ "device.osVersion": "os_version",
1823
+ "device.deviceModel": "device_model",
1824
+ "device.deviceBrand": "device_brand",
1825
+ "device.appVersion": "app_version",
1250
1826
  "utm.source": "utm_source",
1251
1827
  "utm.medium": "utm_medium",
1252
1828
  "utm.campaign": "utm_campaign",
@@ -1262,7 +1838,9 @@ function buildFilterMatch(filters) {
1262
1838
  };
1263
1839
  const match = {};
1264
1840
  for (const [key, value] of Object.entries(filters)) {
1265
- if (!value || !map[key]) continue;
1841
+ if (!value) continue;
1842
+ if (key === "channel") continue;
1843
+ if (!map[key]) continue;
1266
1844
  match[map[key]] = value;
1267
1845
  }
1268
1846
  return match;
@@ -1272,6 +1850,7 @@ var MongoDBAdapter = class {
1272
1850
  db;
1273
1851
  collection;
1274
1852
  sites;
1853
+ identityMap;
1275
1854
  constructor(url) {
1276
1855
  this.client = new MongoClient(url);
1277
1856
  }
@@ -1280,13 +1859,16 @@ var MongoDBAdapter = class {
1280
1859
  this.db = this.client.db();
1281
1860
  this.collection = this.db.collection(EVENTS_COLLECTION);
1282
1861
  this.sites = this.db.collection(SITES_COLLECTION);
1862
+ this.identityMap = this.db.collection(IDENTITY_MAP_COLLECTION);
1283
1863
  await Promise.all([
1284
1864
  this.collection.createIndex({ site_id: 1, timestamp: -1 }),
1285
1865
  this.collection.createIndex({ site_id: 1, type: 1 }),
1286
1866
  this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1287
1867
  this.collection.createIndex({ site_id: 1, session_id: 1 }),
1288
1868
  this.sites.createIndex({ site_id: 1 }, { unique: true }),
1289
- this.sites.createIndex({ secret_key: 1 })
1869
+ this.sites.createIndex({ secret_key: 1 }),
1870
+ this.identityMap.createIndex({ site_id: 1, visitor_id: 1 }, { unique: true }),
1871
+ this.identityMap.createIndex({ site_id: 1, user_id: 1 })
1290
1872
  ]);
1291
1873
  }
1292
1874
  async insertEvents(events) {
@@ -1327,6 +1909,13 @@ var MongoDBAdapter = class {
1327
1909
  utm_term: e.utm?.term ?? null,
1328
1910
  utm_content: e.utm?.content ?? null,
1329
1911
  ip: e.ip ?? null,
1912
+ os_version: e.device?.osVersion ?? null,
1913
+ device_model: e.device?.deviceModel ?? null,
1914
+ device_brand: e.device?.deviceBrand ?? null,
1915
+ app_version: e.device?.appVersion ?? null,
1916
+ app_build: e.device?.appBuild ?? null,
1917
+ sdk_name: e.device?.sdkName ?? null,
1918
+ sdk_version: e.device?.sdkVersion ?? null,
1330
1919
  created_at: /* @__PURE__ */ new Date()
1331
1920
  }));
1332
1921
  await this.collection.insertMany(docs);
@@ -1340,12 +1929,22 @@ var MongoDBAdapter = class {
1340
1929
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1341
1930
  };
1342
1931
  const filterMatch = buildFilterMatch(q.filters);
1932
+ const matchStages = (extra) => {
1933
+ const stages = [
1934
+ { $match: { ...baseMatch, ...filterMatch, ...extra } }
1935
+ ];
1936
+ if (q.filters?.channel) {
1937
+ stages.push({ $addFields: { _channel: channelClassificationSwitch() } });
1938
+ stages.push({ $match: { _channel: q.filters.channel } });
1939
+ }
1940
+ return stages;
1941
+ };
1343
1942
  let data = [];
1344
1943
  let total = 0;
1345
1944
  switch (q.metric) {
1346
1945
  case "pageviews": {
1347
1946
  const [result2] = await this.collection.aggregate([
1348
- { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1947
+ ...matchStages({ type: "pageview" }),
1349
1948
  { $count: "count" }
1350
1949
  ]).toArray();
1351
1950
  total = result2?.count ?? 0;
@@ -1354,7 +1953,7 @@ var MongoDBAdapter = class {
1354
1953
  }
1355
1954
  case "visitors": {
1356
1955
  const [result2] = await this.collection.aggregate([
1357
- { $match: { ...baseMatch, ...filterMatch } },
1956
+ ...matchStages(),
1358
1957
  { $group: { _id: "$visitor_id" } },
1359
1958
  { $count: "count" }
1360
1959
  ]).toArray();
@@ -1364,7 +1963,7 @@ var MongoDBAdapter = class {
1364
1963
  }
1365
1964
  case "sessions": {
1366
1965
  const [result2] = await this.collection.aggregate([
1367
- { $match: { ...baseMatch, ...filterMatch } },
1966
+ ...matchStages(),
1368
1967
  { $group: { _id: "$session_id" } },
1369
1968
  { $count: "count" }
1370
1969
  ]).toArray();
@@ -1374,7 +1973,7 @@ var MongoDBAdapter = class {
1374
1973
  }
1375
1974
  case "events": {
1376
1975
  const [result2] = await this.collection.aggregate([
1377
- { $match: { ...baseMatch, ...filterMatch, type: "event" } },
1976
+ ...matchStages({ type: "event" }),
1378
1977
  { $count: "count" }
1379
1978
  ]).toArray();
1380
1979
  total = result2?.count ?? 0;
@@ -1389,7 +1988,7 @@ var MongoDBAdapter = class {
1389
1988
  break;
1390
1989
  }
1391
1990
  const [result2] = await this.collection.aggregate([
1392
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1991
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1393
1992
  { $count: "count" }
1394
1993
  ]).toArray();
1395
1994
  total = result2?.count ?? 0;
@@ -1398,7 +1997,7 @@ var MongoDBAdapter = class {
1398
1997
  }
1399
1998
  case "top_pages": {
1400
1999
  const rows = await this.collection.aggregate([
1401
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2000
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1402
2001
  { $group: { _id: "$url", value: { $sum: 1 } } },
1403
2002
  { $sort: { value: -1 } },
1404
2003
  { $limit: limit }
@@ -1409,7 +2008,7 @@ var MongoDBAdapter = class {
1409
2008
  }
1410
2009
  case "top_referrers": {
1411
2010
  const rows = await this.collection.aggregate([
1412
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
2011
+ ...matchStages({ type: "pageview", referrer: { $nin: [null, ""] } }),
1413
2012
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1414
2013
  { $sort: { value: -1 } },
1415
2014
  { $limit: limit }
@@ -1420,7 +2019,7 @@ var MongoDBAdapter = class {
1420
2019
  }
1421
2020
  case "top_countries": {
1422
2021
  const rows = await this.collection.aggregate([
1423
- { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
2022
+ ...matchStages({ country: { $ne: null } }),
1424
2023
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1425
2024
  { $project: { _id: 1, value: { $size: "$value" } } },
1426
2025
  { $sort: { value: -1 } },
@@ -1432,7 +2031,7 @@ var MongoDBAdapter = class {
1432
2031
  }
1433
2032
  case "top_cities": {
1434
2033
  const rows = await this.collection.aggregate([
1435
- { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
2034
+ ...matchStages({ city: { $ne: null } }),
1436
2035
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1437
2036
  { $project: { _id: 1, value: { $size: "$value" } } },
1438
2037
  { $sort: { value: -1 } },
@@ -1444,7 +2043,7 @@ var MongoDBAdapter = class {
1444
2043
  }
1445
2044
  case "top_events": {
1446
2045
  const rows = await this.collection.aggregate([
1447
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
2046
+ ...matchStages({ type: "event", event_name: { $ne: null } }),
1448
2047
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1449
2048
  { $sort: { value: -1 } },
1450
2049
  { $limit: limit }
@@ -1461,7 +2060,7 @@ var MongoDBAdapter = class {
1461
2060
  break;
1462
2061
  }
1463
2062
  const rows = await this.collection.aggregate([
1464
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2063
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1465
2064
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1466
2065
  { $sort: { value: -1 } },
1467
2066
  { $limit: limit }
@@ -1472,7 +2071,7 @@ var MongoDBAdapter = class {
1472
2071
  }
1473
2072
  case "top_exit_pages": {
1474
2073
  const rows = await this.collection.aggregate([
1475
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2074
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1476
2075
  { $sort: { timestamp: 1 } },
1477
2076
  { $group: { _id: "$session_id", url: { $last: "$url" } } },
1478
2077
  { $group: { _id: "$url", value: { $sum: 1 } } },
@@ -1485,7 +2084,7 @@ var MongoDBAdapter = class {
1485
2084
  }
1486
2085
  case "top_transitions": {
1487
2086
  const rows = await this.collection.aggregate([
1488
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2087
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1489
2088
  {
1490
2089
  $setWindowFields: {
1491
2090
  partitionBy: "$session_id",
@@ -1506,7 +2105,7 @@ var MongoDBAdapter = class {
1506
2105
  }
1507
2106
  case "top_scroll_pages": {
1508
2107
  const rows = await this.collection.aggregate([
1509
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
2108
+ ...matchStages({ type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } }),
1510
2109
  { $group: { _id: "$page_path", value: { $sum: 1 } } },
1511
2110
  { $sort: { value: -1 } },
1512
2111
  { $limit: limit }
@@ -1517,15 +2116,11 @@ var MongoDBAdapter = class {
1517
2116
  }
1518
2117
  case "top_button_clicks": {
1519
2118
  const rows = await this.collection.aggregate([
1520
- {
1521
- $match: {
1522
- ...baseMatch,
1523
- ...filterMatch,
1524
- type: "event",
1525
- event_subtype: "button_click",
1526
- $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1527
- }
1528
- },
2119
+ ...matchStages({
2120
+ type: "event",
2121
+ event_subtype: "button_click",
2122
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
2123
+ }),
1529
2124
  { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1530
2125
  { $sort: { value: -1 } },
1531
2126
  { $limit: limit }
@@ -1536,15 +2131,11 @@ var MongoDBAdapter = class {
1536
2131
  }
1537
2132
  case "top_link_targets": {
1538
2133
  const rows = await this.collection.aggregate([
1539
- {
1540
- $match: {
1541
- ...baseMatch,
1542
- ...filterMatch,
1543
- type: "event",
1544
- event_subtype: { $in: ["link_click", "outbound_click"] },
1545
- target_url_path: { $ne: null }
1546
- }
1547
- },
2134
+ ...matchStages({
2135
+ type: "event",
2136
+ event_subtype: { $in: ["link_click", "outbound_click"] },
2137
+ target_url_path: { $ne: null }
2138
+ }),
1548
2139
  { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1549
2140
  { $sort: { value: -1 } },
1550
2141
  { $limit: limit }
@@ -1555,7 +2146,7 @@ var MongoDBAdapter = class {
1555
2146
  }
1556
2147
  case "top_devices": {
1557
2148
  const rows = await this.collection.aggregate([
1558
- { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
2149
+ ...matchStages({ device_type: { $ne: null } }),
1559
2150
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1560
2151
  { $project: { _id: 1, value: { $size: "$value" } } },
1561
2152
  { $sort: { value: -1 } },
@@ -1567,7 +2158,7 @@ var MongoDBAdapter = class {
1567
2158
  }
1568
2159
  case "top_browsers": {
1569
2160
  const rows = await this.collection.aggregate([
1570
- { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
2161
+ ...matchStages({ browser: { $ne: null } }),
1571
2162
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1572
2163
  { $project: { _id: 1, value: { $size: "$value" } } },
1573
2164
  { $sort: { value: -1 } },
@@ -1579,7 +2170,7 @@ var MongoDBAdapter = class {
1579
2170
  }
1580
2171
  case "top_os": {
1581
2172
  const rows = await this.collection.aggregate([
1582
- { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
2173
+ ...matchStages({ os: { $ne: null } }),
1583
2174
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1584
2175
  { $project: { _id: 1, value: { $size: "$value" } } },
1585
2176
  { $sort: { value: -1 } },
@@ -1589,6 +2180,117 @@ var MongoDBAdapter = class {
1589
2180
  total = data.reduce((sum, d) => sum + d.value, 0);
1590
2181
  break;
1591
2182
  }
2183
+ case "top_os_versions": {
2184
+ const rows = await this.collection.aggregate([
2185
+ ...matchStages({ os: { $ne: null }, os_version: { $ne: null } }),
2186
+ { $group: { _id: { $concat: ["$os", " ", "$os_version"] }, value: { $addToSet: "$visitor_id" } } },
2187
+ { $project: { _id: 1, value: { $size: "$value" } } },
2188
+ { $sort: { value: -1 } },
2189
+ { $limit: limit }
2190
+ ]).toArray();
2191
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2192
+ total = data.reduce((sum, d) => sum + d.value, 0);
2193
+ break;
2194
+ }
2195
+ case "top_device_models": {
2196
+ const rows = await this.collection.aggregate([
2197
+ ...matchStages({ device_model: { $ne: null } }),
2198
+ { $group: { _id: { $trim: { input: { $concat: [{ $ifNull: ["$device_brand", ""] }, " ", "$device_model"] } } }, value: { $addToSet: "$visitor_id" } } },
2199
+ { $project: { _id: 1, value: { $size: "$value" } } },
2200
+ { $sort: { value: -1 } },
2201
+ { $limit: limit }
2202
+ ]).toArray();
2203
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2204
+ total = data.reduce((sum, d) => sum + d.value, 0);
2205
+ break;
2206
+ }
2207
+ case "top_app_versions": {
2208
+ const rows = await this.collection.aggregate([
2209
+ ...matchStages({ app_version: { $ne: null } }),
2210
+ { $group: { _id: "$app_version", value: { $addToSet: "$visitor_id" } } },
2211
+ { $project: { _id: 1, value: { $size: "$value" } } },
2212
+ { $sort: { value: -1 } },
2213
+ { $limit: limit }
2214
+ ]).toArray();
2215
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2216
+ total = data.reduce((sum, d) => sum + d.value, 0);
2217
+ break;
2218
+ }
2219
+ case "top_utm_sources": {
2220
+ const rows = await this.collection.aggregate([
2221
+ ...matchStages({ utm_source: { $nin: [null, ""] } }),
2222
+ { $addFields: { _normalized_source: normalizedUtmSourceSwitch() } },
2223
+ { $group: { _id: "$_normalized_source", value: { $addToSet: "$visitor_id" } } },
2224
+ { $project: { _id: 1, value: { $size: "$value" } } },
2225
+ { $sort: { value: -1 } },
2226
+ { $limit: limit }
2227
+ ]).toArray();
2228
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2229
+ total = data.reduce((sum, d) => sum + d.value, 0);
2230
+ break;
2231
+ }
2232
+ case "top_utm_mediums": {
2233
+ const rows = await this.collection.aggregate([
2234
+ ...matchStages({ utm_medium: { $nin: [null, ""] } }),
2235
+ { $addFields: { _normalized_medium: normalizedUtmMediumSwitch() } },
2236
+ { $group: { _id: "$_normalized_medium", value: { $addToSet: "$visitor_id" } } },
2237
+ { $project: { _id: 1, value: { $size: "$value" } } },
2238
+ { $sort: { value: -1 } },
2239
+ { $limit: limit }
2240
+ ]).toArray();
2241
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2242
+ total = data.reduce((sum, d) => sum + d.value, 0);
2243
+ break;
2244
+ }
2245
+ case "top_utm_campaigns": {
2246
+ const rows = await this.collection.aggregate([
2247
+ ...matchStages({ utm_campaign: { $nin: [null, ""] } }),
2248
+ { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
2249
+ { $project: { _id: 1, value: { $size: "$value" } } },
2250
+ { $sort: { value: -1 } },
2251
+ { $limit: limit }
2252
+ ]).toArray();
2253
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2254
+ total = data.reduce((sum, d) => sum + d.value, 0);
2255
+ break;
2256
+ }
2257
+ case "top_utm_terms": {
2258
+ const rows = await this.collection.aggregate([
2259
+ ...matchStages({ utm_term: { $nin: [null, ""] } }),
2260
+ { $group: { _id: "$utm_term", value: { $addToSet: "$visitor_id" } } },
2261
+ { $project: { _id: 1, value: { $size: "$value" } } },
2262
+ { $sort: { value: -1 } },
2263
+ { $limit: limit }
2264
+ ]).toArray();
2265
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2266
+ total = data.reduce((sum, d) => sum + d.value, 0);
2267
+ break;
2268
+ }
2269
+ case "top_utm_contents": {
2270
+ const rows = await this.collection.aggregate([
2271
+ ...matchStages({ utm_content: { $nin: [null, ""] } }),
2272
+ { $group: { _id: "$utm_content", value: { $addToSet: "$visitor_id" } } },
2273
+ { $project: { _id: 1, value: { $size: "$value" } } },
2274
+ { $sort: { value: -1 } },
2275
+ { $limit: limit }
2276
+ ]).toArray();
2277
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2278
+ total = data.reduce((sum, d) => sum + d.value, 0);
2279
+ break;
2280
+ }
2281
+ case "top_channels": {
2282
+ const rows = await this.collection.aggregate([
2283
+ ...matchStages(),
2284
+ { $addFields: { _channel: channelClassificationSwitch() } },
2285
+ { $group: { _id: "$_channel", value: { $addToSet: "$visitor_id" } } },
2286
+ { $project: { _id: 1, value: { $size: "$value" } } },
2287
+ { $sort: { value: -1 } },
2288
+ { $limit: limit }
2289
+ ]).toArray();
2290
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2291
+ total = data.reduce((sum, d) => sum + d.value, 0);
2292
+ break;
2293
+ }
1592
2294
  }
1593
2295
  const result = { metric: q.metric, period, data, total };
1594
2296
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -1777,24 +2479,72 @@ var MongoDBAdapter = class {
1777
2479
  offset
1778
2480
  };
1779
2481
  }
2482
+ // ─── Identity Mapping ──────────────────────────────────────
2483
+ async upsertIdentity(siteId, visitorId, userId) {
2484
+ await this.identityMap.updateOne(
2485
+ { site_id: siteId, visitor_id: visitorId },
2486
+ {
2487
+ $set: { user_id: userId, identified_at: /* @__PURE__ */ new Date() },
2488
+ $setOnInsert: { site_id: siteId, visitor_id: visitorId, created_at: /* @__PURE__ */ new Date() }
2489
+ },
2490
+ { upsert: true }
2491
+ );
2492
+ }
2493
+ async getVisitorIdsForUser(siteId, userId) {
2494
+ const docs = await this.identityMap.find({ site_id: siteId, user_id: userId }).toArray();
2495
+ return docs.map((d) => d.visitor_id);
2496
+ }
2497
+ async getUserIdForVisitor(siteId, visitorId) {
2498
+ const doc = await this.identityMap.findOne({ site_id: siteId, visitor_id: visitorId });
2499
+ return doc?.user_id ?? null;
2500
+ }
1780
2501
  // ─── User Listing ──────────────────────────────────────
1781
2502
  async listUsers(params) {
1782
2503
  const limit = Math.min(params.limit ?? 50, 200);
1783
2504
  const offset = params.offset ?? 0;
1784
2505
  const match = { site_id: params.siteId };
1785
- if (params.search) {
1786
- match.$or = [
1787
- { visitor_id: { $regex: params.search, $options: "i" } },
1788
- { user_id: { $regex: params.search, $options: "i" } }
1789
- ];
1790
- }
1791
2506
  const pipeline = [
1792
2507
  { $match: match },
2508
+ // Join with identity map to resolve visitor → user
2509
+ {
2510
+ $lookup: {
2511
+ from: IDENTITY_MAP_COLLECTION,
2512
+ let: { vid: "$visitor_id", sid: "$site_id" },
2513
+ pipeline: [
2514
+ { $match: { $expr: { $and: [{ $eq: ["$visitor_id", "$$vid"] }, { $eq: ["$site_id", "$$sid"] }] } } }
2515
+ ],
2516
+ as: "_identity"
2517
+ }
2518
+ },
2519
+ {
2520
+ $addFields: {
2521
+ _resolved_id: {
2522
+ $ifNull: [{ $arrayElemAt: ["$_identity.user_id", 0] }, "$visitor_id"]
2523
+ },
2524
+ _resolved_user_id: {
2525
+ $arrayElemAt: ["$_identity.user_id", 0]
2526
+ }
2527
+ }
2528
+ }
2529
+ ];
2530
+ if (params.search) {
2531
+ pipeline.push({
2532
+ $match: {
2533
+ $or: [
2534
+ { visitor_id: { $regex: params.search, $options: "i" } },
2535
+ { user_id: { $regex: params.search, $options: "i" } },
2536
+ { _resolved_user_id: { $regex: params.search, $options: "i" } }
2537
+ ]
2538
+ }
2539
+ });
2540
+ }
2541
+ pipeline.push(
1793
2542
  { $sort: { timestamp: 1 } },
1794
2543
  {
1795
2544
  $group: {
1796
- _id: "$visitor_id",
1797
- userId: { $last: "$user_id" },
2545
+ _id: "$_resolved_id",
2546
+ visitorIds: { $addToSet: "$visitor_id" },
2547
+ userId: { $last: { $ifNull: ["$_resolved_user_id", "$user_id"] } },
1798
2548
  traits: { $last: "$traits" },
1799
2549
  firstSeen: { $min: "$timestamp" },
1800
2550
  lastSeen: { $max: "$timestamp" },
@@ -1827,10 +2577,11 @@ var MongoDBAdapter = class {
1827
2577
  count: [{ $count: "total" }]
1828
2578
  }
1829
2579
  }
1830
- ];
2580
+ );
1831
2581
  const [result] = await this.collection.aggregate(pipeline).toArray();
1832
2582
  const users = (result?.data ?? []).map((u) => ({
1833
- visitorId: u._id,
2583
+ visitorId: u.visitorIds[0] ?? u._id,
2584
+ visitorIds: u.visitorIds.length > 1 ? u.visitorIds : void 0,
1834
2585
  userId: u.userId ?? void 0,
1835
2586
  traits: u.traits ?? void 0,
1836
2587
  firstSeen: u.firstSeen.toISOString(),
@@ -1860,13 +2611,125 @@ var MongoDBAdapter = class {
1860
2611
  offset
1861
2612
  };
1862
2613
  }
1863
- async getUserDetail(siteId, visitorId) {
1864
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1865
- const user = result.users.find((u) => u.visitorId === visitorId);
1866
- return user ?? null;
2614
+ async getUserDetail(siteId, identifier) {
2615
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2616
+ if (visitorIds.length > 0) {
2617
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
2618
+ }
2619
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2620
+ if (userId) {
2621
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
2622
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds);
2623
+ }
2624
+ return this.getMergedUserDetail(siteId, void 0, [identifier]);
2625
+ }
2626
+ async getUserEvents(siteId, identifier, params) {
2627
+ let visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2628
+ if (visitorIds.length === 0) {
2629
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2630
+ if (userId) {
2631
+ visitorIds = await this.getVisitorIdsForUser(siteId, userId);
2632
+ }
2633
+ }
2634
+ if (visitorIds.length === 0) {
2635
+ visitorIds = [identifier];
2636
+ }
2637
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
2638
+ }
2639
+ async getMergedUserDetail(siteId, userId, visitorIds) {
2640
+ const pipeline = [
2641
+ { $match: { site_id: siteId, visitor_id: { $in: visitorIds } } },
2642
+ { $sort: { timestamp: 1 } },
2643
+ {
2644
+ $group: {
2645
+ _id: null,
2646
+ visitorIds: { $addToSet: "$visitor_id" },
2647
+ traits: { $last: "$traits" },
2648
+ firstSeen: { $min: "$timestamp" },
2649
+ lastSeen: { $max: "$timestamp" },
2650
+ totalEvents: { $sum: 1 },
2651
+ totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
2652
+ sessions: { $addToSet: "$session_id" },
2653
+ lastUrl: { $last: "$url" },
2654
+ referrer: { $last: "$referrer" },
2655
+ device_type: { $last: "$device_type" },
2656
+ browser: { $last: "$browser" },
2657
+ os: { $last: "$os" },
2658
+ country: { $last: "$country" },
2659
+ city: { $last: "$city" },
2660
+ region: { $last: "$region" },
2661
+ language: { $last: "$language" },
2662
+ timezone: { $last: "$timezone" },
2663
+ screen_width: { $last: "$screen_width" },
2664
+ screen_height: { $last: "$screen_height" },
2665
+ utm_source: { $last: "$utm_source" },
2666
+ utm_medium: { $last: "$utm_medium" },
2667
+ utm_campaign: { $last: "$utm_campaign" },
2668
+ utm_term: { $last: "$utm_term" },
2669
+ utm_content: { $last: "$utm_content" }
2670
+ }
2671
+ }
2672
+ ];
2673
+ const [row] = await this.collection.aggregate(pipeline).toArray();
2674
+ if (!row) return null;
2675
+ return {
2676
+ visitorId: visitorIds[0],
2677
+ visitorIds: row.visitorIds.length > 1 ? row.visitorIds : void 0,
2678
+ userId: userId ?? void 0,
2679
+ traits: row.traits ?? void 0,
2680
+ firstSeen: row.firstSeen.toISOString(),
2681
+ lastSeen: row.lastSeen.toISOString(),
2682
+ totalEvents: row.totalEvents,
2683
+ totalPageviews: row.totalPageviews,
2684
+ totalSessions: row.sessions.length,
2685
+ lastUrl: row.lastUrl ?? void 0,
2686
+ referrer: row.referrer ?? void 0,
2687
+ device: row.device_type ? { type: row.device_type, browser: row.browser ?? "", os: row.os ?? "" } : void 0,
2688
+ geo: row.country ? { country: row.country, city: row.city ?? void 0, region: row.region ?? void 0 } : void 0,
2689
+ language: row.language ?? void 0,
2690
+ timezone: row.timezone ?? void 0,
2691
+ screen: row.screen_width || row.screen_height ? { width: row.screen_width ?? 0, height: row.screen_height ?? 0 } : void 0,
2692
+ utm: row.utm_source ? {
2693
+ source: row.utm_source ?? void 0,
2694
+ medium: row.utm_medium ?? void 0,
2695
+ campaign: row.utm_campaign ?? void 0,
2696
+ term: row.utm_term ?? void 0,
2697
+ content: row.utm_content ?? void 0
2698
+ } : void 0
2699
+ };
1867
2700
  }
1868
- async getUserEvents(siteId, visitorId, params) {
1869
- return this.listEvents({ ...params, siteId, visitorId });
2701
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
2702
+ const limit = Math.min(params.limit ?? 50, 200);
2703
+ const offset = params.offset ?? 0;
2704
+ const match = {
2705
+ site_id: siteId,
2706
+ visitor_id: { $in: visitorIds }
2707
+ };
2708
+ if (params.type) match.type = params.type;
2709
+ if (params.eventName) {
2710
+ match.event_name = params.eventName;
2711
+ } else if (params.eventNames && params.eventNames.length > 0) {
2712
+ match.event_name = { $in: params.eventNames };
2713
+ }
2714
+ if (params.eventSource) match.event_source = params.eventSource;
2715
+ if (params.period || params.dateFrom) {
2716
+ const { dateRange } = resolvePeriod({
2717
+ period: params.period,
2718
+ dateFrom: params.dateFrom,
2719
+ dateTo: params.dateTo
2720
+ });
2721
+ match.timestamp = { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) };
2722
+ }
2723
+ const [events, countResult] = await Promise.all([
2724
+ this.collection.find(match).sort({ timestamp: -1 }).skip(offset).limit(limit).toArray(),
2725
+ this.collection.countDocuments(match)
2726
+ ]);
2727
+ return {
2728
+ events: events.map((e) => this.toEventListItem(e)),
2729
+ total: countResult,
2730
+ limit,
2731
+ offset
2732
+ };
1870
2733
  }
1871
2734
  toEventListItem(doc) {
1872
2735
  return {
@@ -1890,7 +2753,18 @@ var MongoDBAdapter = class {
1890
2753
  userId: doc.user_id ?? void 0,
1891
2754
  traits: doc.traits ?? void 0,
1892
2755
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
1893
- device: doc.device_type ? { type: doc.device_type, browser: doc.browser ?? "", os: doc.os ?? "" } : void 0,
2756
+ device: doc.device_type ? {
2757
+ type: doc.device_type,
2758
+ browser: doc.browser ?? "",
2759
+ os: doc.os ?? "",
2760
+ osVersion: doc.os_version ?? void 0,
2761
+ deviceModel: doc.device_model ?? void 0,
2762
+ deviceBrand: doc.device_brand ?? void 0,
2763
+ appVersion: doc.app_version ?? void 0,
2764
+ appBuild: doc.app_build ?? void 0,
2765
+ sdkName: doc.sdk_name ?? void 0,
2766
+ sdkVersion: doc.sdk_version ?? void 0
2767
+ } : void 0,
1894
2768
  language: doc.language ?? void 0,
1895
2769
  utm: doc.utm_source ? {
1896
2770
  source: doc.utm_source ?? void 0,
@@ -1908,6 +2782,7 @@ var MongoDBAdapter = class {
1908
2782
  site_id: generateSiteId(),
1909
2783
  secret_key: generateSecretKey(),
1910
2784
  name: data.name,
2785
+ type: data.type ?? "web",
1911
2786
  domain: data.domain ?? null,
1912
2787
  allowed_origins: data.allowedOrigins ?? null,
1913
2788
  conversion_events: data.conversionEvents ?? null,
@@ -1932,6 +2807,7 @@ var MongoDBAdapter = class {
1932
2807
  async updateSite(siteId, data) {
1933
2808
  const updates = { updated_at: /* @__PURE__ */ new Date() };
1934
2809
  if (data.name !== void 0) updates.name = data.name;
2810
+ if (data.type !== void 0) updates.type = data.type;
1935
2811
  if (data.domain !== void 0) updates.domain = data.domain || null;
1936
2812
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
1937
2813
  if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
@@ -1963,6 +2839,7 @@ var MongoDBAdapter = class {
1963
2839
  siteId: doc.site_id,
1964
2840
  secretKey: doc.secret_key,
1965
2841
  name: doc.name,
2842
+ type: doc.type ?? "web",
1966
2843
  domain: doc.domain ?? void 0,
1967
2844
  allowedOrigins: doc.allowed_origins ?? void 0,
1968
2845
  conversionEvents: doc.conversion_events ?? void 0,
@@ -2231,12 +3108,74 @@ async function createCollector(config) {
2231
3108
  return false;
2232
3109
  }
2233
3110
  function enrichEvents(events, ip, userAgent) {
2234
- const device = parseUserAgent(userAgent);
3111
+ const uaDevice = parseUserAgent(userAgent);
2235
3112
  return events.map((event) => {
2236
3113
  const geo = resolveGeo(ip, event.timezone);
3114
+ let device;
3115
+ if (event.mobile?.platform) {
3116
+ device = {
3117
+ type: "mobile",
3118
+ browser: "App",
3119
+ os: event.mobile.platform === "ios" ? "iOS" : "Android",
3120
+ osVersion: event.mobile.osVersion,
3121
+ deviceModel: event.mobile.deviceModel,
3122
+ deviceBrand: event.mobile.deviceBrand,
3123
+ appVersion: event.mobile.appVersion,
3124
+ appBuild: event.mobile.appBuild,
3125
+ sdkName: event.mobile.sdkName,
3126
+ sdkVersion: event.mobile.sdkVersion
3127
+ };
3128
+ } else {
3129
+ device = uaDevice;
3130
+ }
2237
3131
  return { ...event, ip, geo, device };
2238
3132
  });
2239
3133
  }
3134
+ const identityCache = /* @__PURE__ */ new Map();
3135
+ const IDENTITY_CACHE_TTL = 5 * 60 * 1e3;
3136
+ function getCachedUserId(siteId, visitorId) {
3137
+ const key = `${siteId}:${visitorId}`;
3138
+ const entry = identityCache.get(key);
3139
+ if (!entry) return void 0;
3140
+ if (Date.now() > entry.expires) {
3141
+ identityCache.delete(key);
3142
+ return void 0;
3143
+ }
3144
+ return entry.userId;
3145
+ }
3146
+ function setCachedUserId(siteId, visitorId, userId) {
3147
+ const key = `${siteId}:${visitorId}`;
3148
+ identityCache.set(key, { userId, expires: Date.now() + IDENTITY_CACHE_TTL });
3149
+ if (identityCache.size > 1e4) {
3150
+ const now = Date.now();
3151
+ for (const [k, v] of identityCache) {
3152
+ if (now > v.expires) identityCache.delete(k);
3153
+ }
3154
+ }
3155
+ }
3156
+ async function processIdentity(events) {
3157
+ for (const event of events) {
3158
+ if (!event.visitorId || event.visitorId === "server") continue;
3159
+ if (event.type === "identify" && event.userId) {
3160
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
3161
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
3162
+ } else if (!event.userId) {
3163
+ const cached = getCachedUserId(event.siteId, event.visitorId);
3164
+ if (cached) {
3165
+ event.userId = cached;
3166
+ } else {
3167
+ const resolved = await db.getUserIdForVisitor(event.siteId, event.visitorId);
3168
+ if (resolved) {
3169
+ event.userId = resolved;
3170
+ setCachedUserId(event.siteId, event.visitorId, resolved);
3171
+ }
3172
+ }
3173
+ } else if (event.userId) {
3174
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
3175
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
3176
+ }
3177
+ }
3178
+ }
2240
3179
  function extractIp(req) {
2241
3180
  if (config.trustProxy ?? true) {
2242
3181
  const forwarded = req.headers?.["x-forwarded-for"];
@@ -2248,6 +3187,22 @@ async function createCollector(config) {
2248
3187
  }
2249
3188
  return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
2250
3189
  }
3190
+ function extractRequestHostname(req) {
3191
+ const headerValue = (value) => {
3192
+ if (Array.isArray(value)) return value[0];
3193
+ if (typeof value === "string") return value;
3194
+ return void 0;
3195
+ };
3196
+ const origin = headerValue(req.headers?.origin);
3197
+ const referer = headerValue(req.headers?.referer) || headerValue(req.headers?.referrer);
3198
+ const raw = origin ?? referer;
3199
+ if (!raw || raw === "null") return void 0;
3200
+ try {
3201
+ return new URL(raw).hostname.toLowerCase();
3202
+ } catch {
3203
+ return void 0;
3204
+ }
3205
+ }
2251
3206
  function handler() {
2252
3207
  return async (req, res) => {
2253
3208
  res.setHeader?.("Access-Control-Allow-Origin", "*");
@@ -2273,6 +3228,12 @@ async function createCollector(config) {
2273
3228
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
2274
3229
  return;
2275
3230
  }
3231
+ const siteIds = new Set(payload.events.map((event) => event.siteId).filter(Boolean));
3232
+ if (siteIds.size !== 1) {
3233
+ sendJson(res, 200, { ok: true });
3234
+ return;
3235
+ }
3236
+ const siteId = Array.from(siteIds)[0];
2276
3237
  const userAgent = req.headers?.["user-agent"] || "";
2277
3238
  if (isBot(userAgent)) {
2278
3239
  sendJson(res, 200, { ok: true });
@@ -2280,29 +3241,20 @@ async function createCollector(config) {
2280
3241
  }
2281
3242
  const ip = extractIp(req);
2282
3243
  const enriched = enrichEvents(payload.events, ip, userAgent);
2283
- const siteId = enriched[0]?.siteId;
2284
- if (siteId) {
2285
- const site = await db.getSite(siteId);
2286
- if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2287
- const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2288
- const filtered = enriched.filter((event) => {
2289
- if (!event.url) return true;
2290
- try {
2291
- const hostname = new URL(event.url).hostname.toLowerCase();
2292
- return allowed.has(hostname);
2293
- } catch {
2294
- return true;
2295
- }
2296
- });
2297
- if (filtered.length === 0) {
2298
- sendJson(res, 200, { ok: true });
2299
- return;
2300
- }
2301
- await db.insertEvents(filtered);
3244
+ const site = await db.getSite(siteId);
3245
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
3246
+ const requestHostname = extractRequestHostname(req);
3247
+ if (!requestHostname) {
3248
+ sendJson(res, 200, { ok: true });
3249
+ return;
3250
+ }
3251
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
3252
+ if (!allowed.has(requestHostname)) {
2302
3253
  sendJson(res, 200, { ok: true });
2303
3254
  return;
2304
3255
  }
2305
3256
  }
3257
+ await processIdentity(enriched);
2306
3258
  await db.insertEvents(enriched);
2307
3259
  sendJson(res, 200, { ok: true });
2308
3260
  } catch (err) {
@@ -2563,11 +3515,11 @@ async function createCollector(config) {
2563
3515
  async listUsers(params) {
2564
3516
  return db.listUsers(params);
2565
3517
  },
2566
- async getUserDetail(siteId, visitorId) {
2567
- return db.getUserDetail(siteId, visitorId);
3518
+ async getUserDetail(siteId, identifier) {
3519
+ return db.getUserDetail(siteId, identifier);
2568
3520
  },
2569
- async getUserEvents(siteId, visitorId, params) {
2570
- return db.getUserEvents(siteId, visitorId, params);
3521
+ async getUserEvents(siteId, identifier, params) {
3522
+ return db.getUserEvents(siteId, identifier, params);
2571
3523
  },
2572
3524
  async track(siteId, name, properties, options) {
2573
3525
  const event = {