@litemetrics/node 0.1.2 → 0.1.3

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(),
@@ -250,6 +262,7 @@ var ClickHouseAdapter = class {
250
262
  async init() {
251
263
  await this.client.command({ query: CREATE_EVENTS_TABLE });
252
264
  await this.client.command({ query: CREATE_SITES_TABLE });
265
+ await this.client.command({ query: CREATE_IDENTITY_MAP_TABLE });
253
266
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
254
267
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
255
268
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
@@ -507,8 +520,8 @@ var ClickHouseAdapter = class {
507
520
  }
508
521
  case "top_exit_pages": {
509
522
  const rows = await this.queryRows(
510
- `SELECT url AS key, count() AS value FROM (
511
- SELECT session_id, argMax(url, timestamp) AS url
523
+ `SELECT exit_url AS key, count() AS value FROM (
524
+ SELECT session_id, argMax(url, timestamp) AS exit_url
512
525
  FROM ${EVENTS_TABLE}
513
526
  WHERE site_id = {siteId:String}
514
527
  AND timestamp >= {from:String}
@@ -517,7 +530,7 @@ var ClickHouseAdapter = class {
517
530
  AND url IS NOT NULL${filterSql}
518
531
  GROUP BY session_id
519
532
  )
520
- GROUP BY url
533
+ GROUP BY exit_url
521
534
  ORDER BY value DESC
522
535
  LIMIT {limit:UInt32}`,
523
536
  { ...params, ...filter.params }
@@ -528,9 +541,9 @@ var ClickHouseAdapter = class {
528
541
  }
529
542
  case "top_transitions": {
530
543
  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
544
+ `SELECT concat(prev_url, ' \u2192 ', curr_url) AS key, count() AS value FROM (
545
+ SELECT session_id, url AS curr_url,
546
+ lagInFrame(url, 1) OVER (PARTITION BY session_id ORDER BY timestamp ASC) AS prev_url
534
547
  FROM ${EVENTS_TABLE}
535
548
  WHERE site_id = {siteId:String}
536
549
  AND timestamp >= {from:String}
@@ -538,7 +551,7 @@ var ClickHouseAdapter = class {
538
551
  AND type = 'pageview'
539
552
  AND url IS NOT NULL${filterSql}
540
553
  )
541
- WHERE prev_url IS NOT NULL
554
+ WHERE prev_url IS NOT NULL AND prev_url != ''
542
555
  GROUP BY key
543
556
  ORDER BY value DESC
544
557
  LIMIT {limit:UInt32}`,
@@ -653,6 +666,57 @@ var ClickHouseAdapter = class {
653
666
  total = data.reduce((sum, d) => sum + d.value, 0);
654
667
  break;
655
668
  }
669
+ case "top_utm_sources": {
670
+ const rows = await this.queryRows(
671
+ `SELECT utm_source AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
672
+ WHERE site_id = {siteId:String}
673
+ AND timestamp >= {from:String}
674
+ AND timestamp <= {to:String}
675
+ AND utm_source IS NOT NULL AND utm_source != ''
676
+ ${filterSql}
677
+ GROUP BY utm_source
678
+ ORDER BY value DESC
679
+ LIMIT {limit:UInt32}`,
680
+ { ...params, ...filter.params }
681
+ );
682
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
683
+ total = data.reduce((sum, d) => sum + d.value, 0);
684
+ break;
685
+ }
686
+ case "top_utm_mediums": {
687
+ const rows = await this.queryRows(
688
+ `SELECT utm_medium AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
689
+ WHERE site_id = {siteId:String}
690
+ AND timestamp >= {from:String}
691
+ AND timestamp <= {to:String}
692
+ AND utm_medium IS NOT NULL AND utm_medium != ''
693
+ ${filterSql}
694
+ GROUP BY utm_medium
695
+ ORDER BY value DESC
696
+ LIMIT {limit:UInt32}`,
697
+ { ...params, ...filter.params }
698
+ );
699
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
700
+ total = data.reduce((sum, d) => sum + d.value, 0);
701
+ break;
702
+ }
703
+ case "top_utm_campaigns": {
704
+ const rows = await this.queryRows(
705
+ `SELECT utm_campaign AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
706
+ WHERE site_id = {siteId:String}
707
+ AND timestamp >= {from:String}
708
+ AND timestamp <= {to:String}
709
+ AND utm_campaign IS NOT NULL AND utm_campaign != ''
710
+ ${filterSql}
711
+ GROUP BY utm_campaign
712
+ ORDER BY value DESC
713
+ LIMIT {limit:UInt32}`,
714
+ { ...params, ...filter.params }
715
+ );
716
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
717
+ total = data.reduce((sum, d) => sum + d.value, 0);
718
+ break;
719
+ }
656
720
  }
657
721
  const result = { metric: q.metric, period, data, total };
658
722
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -906,45 +970,59 @@ var ClickHouseAdapter = class {
906
970
  const where = conditions.join(" AND ");
907
971
  const [userRows, countRows] = await Promise.all([
908
972
  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,
973
+ `WITH identity AS (
974
+ SELECT visitor_id, user_id
975
+ FROM ${IDENTITY_MAP_TABLE} FINAL
976
+ WHERE site_id = {siteId:String}
977
+ )
978
+ SELECT
979
+ if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key,
980
+ anyLast(e.visitor_id) AS visitor_id,
981
+ anyLast(i.user_id) AS userId,
982
+ anyLast(e.traits) AS traits,
983
+ min(e.timestamp) AS firstSeen,
984
+ max(e.timestamp) AS lastSeen,
915
985
  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
986
+ countIf(e.type = 'pageview') AS totalPageviews,
987
+ uniq(e.session_id) AS totalSessions,
988
+ anyLast(e.url) AS lastUrl,
989
+ anyLast(e.referrer) AS referrer,
990
+ anyLast(e.device_type) AS device_type,
991
+ anyLast(e.browser) AS browser,
992
+ anyLast(e.os) AS os,
993
+ anyLast(e.country) AS country,
994
+ anyLast(e.city) AS city,
995
+ anyLast(e.region) AS region,
996
+ anyLast(e.language) AS language,
997
+ anyLast(e.timezone) AS timezone,
998
+ anyLast(e.screen_width) AS screen_width,
999
+ anyLast(e.screen_height) AS screen_height,
1000
+ anyLast(e.utm_source) AS utm_source,
1001
+ anyLast(e.utm_medium) AS utm_medium,
1002
+ anyLast(e.utm_campaign) AS utm_campaign,
1003
+ anyLast(e.utm_term) AS utm_term,
1004
+ anyLast(e.utm_content) AS utm_content
1005
+ FROM ${EVENTS_TABLE} e
1006
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1007
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1008
+ GROUP BY group_key
938
1009
  ORDER BY lastSeen DESC
939
1010
  LIMIT {limit:UInt32}
940
1011
  OFFSET {offset:UInt32}`,
941
1012
  queryParams
942
1013
  ),
943
1014
  this.queryRows(
944
- `SELECT count() AS total FROM (
945
- SELECT visitor_id FROM ${EVENTS_TABLE}
946
- WHERE ${where}
947
- GROUP BY visitor_id
1015
+ `WITH identity AS (
1016
+ SELECT visitor_id, user_id
1017
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1018
+ WHERE site_id = {siteId:String}
1019
+ )
1020
+ SELECT count() AS total FROM (
1021
+ SELECT if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key
1022
+ FROM ${EVENTS_TABLE} e
1023
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1024
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1025
+ GROUP BY group_key
948
1026
  )`,
949
1027
  queryParams
950
1028
  )
@@ -980,13 +1058,178 @@ var ClickHouseAdapter = class {
980
1058
  offset
981
1059
  };
982
1060
  }
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);
1061
+ async getUserDetail(siteId, identifier) {
1062
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1063
+ if (visitorIds.length > 0) {
1064
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
1065
+ }
1066
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1067
+ if (userId) {
1068
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1069
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds.length > 0 ? allVisitorIds : [identifier]);
1070
+ }
1071
+ const result = await this.listUsers({ siteId, search: identifier, limit: 1 });
1072
+ const user = result.users.find((u) => u.visitorId === identifier);
986
1073
  return user ?? null;
987
1074
  }
988
- async getUserEvents(siteId, visitorId, params) {
989
- return this.listEvents({ ...params, siteId, visitorId });
1075
+ async getUserEvents(siteId, identifier, params) {
1076
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1077
+ if (visitorIds.length > 0) {
1078
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
1079
+ }
1080
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1081
+ if (userId) {
1082
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1083
+ if (allVisitorIds.length > 0) {
1084
+ return this.listEventsForVisitorIds(siteId, allVisitorIds, params);
1085
+ }
1086
+ }
1087
+ return this.listEvents({ ...params, siteId, visitorId: identifier });
1088
+ }
1089
+ // ─── Identity Mapping ──────────────────────────────────────
1090
+ async upsertIdentity(siteId, visitorId, userId) {
1091
+ await this.client.insert({
1092
+ table: IDENTITY_MAP_TABLE,
1093
+ values: [{
1094
+ site_id: siteId,
1095
+ visitor_id: visitorId,
1096
+ user_id: userId,
1097
+ identified_at: toCHDateTime(/* @__PURE__ */ new Date())
1098
+ }],
1099
+ format: "JSONEachRow"
1100
+ });
1101
+ }
1102
+ async getVisitorIdsForUser(siteId, userId) {
1103
+ const rows = await this.queryRows(
1104
+ `SELECT visitor_id FROM ${IDENTITY_MAP_TABLE} FINAL
1105
+ WHERE site_id = {siteId:String} AND user_id = {userId:String}`,
1106
+ { siteId, userId }
1107
+ );
1108
+ return rows.map((r) => r.visitor_id);
1109
+ }
1110
+ async getUserIdForVisitor(siteId, visitorId) {
1111
+ const rows = await this.queryRows(
1112
+ `SELECT user_id FROM ${IDENTITY_MAP_TABLE} FINAL
1113
+ WHERE site_id = {siteId:String} AND visitor_id = {visitorId:String}
1114
+ LIMIT 1`,
1115
+ { siteId, visitorId }
1116
+ );
1117
+ return rows.length > 0 ? rows[0].user_id : null;
1118
+ }
1119
+ async getMergedUserDetail(siteId, userId, visitorIds) {
1120
+ const rows = await this.queryRows(
1121
+ `SELECT
1122
+ anyLast(visitor_id) AS visitor_id,
1123
+ anyLast(traits) AS traits,
1124
+ min(timestamp) AS firstSeen,
1125
+ max(timestamp) AS lastSeen,
1126
+ count() AS totalEvents,
1127
+ countIf(type = 'pageview') AS totalPageviews,
1128
+ uniq(session_id) AS totalSessions,
1129
+ anyLast(url) AS lastUrl,
1130
+ anyLast(referrer) AS referrer,
1131
+ anyLast(device_type) AS device_type,
1132
+ anyLast(browser) AS browser,
1133
+ anyLast(os) AS os,
1134
+ anyLast(country) AS country,
1135
+ anyLast(city) AS city,
1136
+ anyLast(region) AS region,
1137
+ anyLast(language) AS language,
1138
+ anyLast(timezone) AS timezone,
1139
+ anyLast(screen_width) AS screen_width,
1140
+ anyLast(screen_height) AS screen_height,
1141
+ anyLast(utm_source) AS utm_source,
1142
+ anyLast(utm_medium) AS utm_medium,
1143
+ anyLast(utm_campaign) AS utm_campaign,
1144
+ anyLast(utm_term) AS utm_term,
1145
+ anyLast(utm_content) AS utm_content
1146
+ FROM ${EVENTS_TABLE}
1147
+ WHERE site_id = {siteId:String}
1148
+ AND visitor_id IN {visitorIds:Array(String)}`,
1149
+ { siteId, visitorIds }
1150
+ );
1151
+ if (rows.length === 0) return null;
1152
+ const u = rows[0];
1153
+ return {
1154
+ visitorId: String(u.visitor_id),
1155
+ visitorIds,
1156
+ userId,
1157
+ traits: this.parseJSON(u.traits),
1158
+ firstSeen: new Date(String(u.firstSeen)).toISOString(),
1159
+ lastSeen: new Date(String(u.lastSeen)).toISOString(),
1160
+ totalEvents: Number(u.totalEvents),
1161
+ totalPageviews: Number(u.totalPageviews),
1162
+ totalSessions: Number(u.totalSessions),
1163
+ lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
1164
+ referrer: u.referrer ? String(u.referrer) : void 0,
1165
+ device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
1166
+ 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,
1167
+ language: u.language ? String(u.language) : void 0,
1168
+ timezone: u.timezone ? String(u.timezone) : void 0,
1169
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
1170
+ utm: u.utm_source ? {
1171
+ source: String(u.utm_source),
1172
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
1173
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
1174
+ term: u.utm_term ? String(u.utm_term) : void 0,
1175
+ content: u.utm_content ? String(u.utm_content) : void 0
1176
+ } : void 0
1177
+ };
1178
+ }
1179
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
1180
+ const limit = Math.min(params.limit ?? 50, 200);
1181
+ const offset = params.offset ?? 0;
1182
+ const conditions = [`site_id = {siteId:String}`, `visitor_id IN {visitorIds:Array(String)}`];
1183
+ const queryParams = { siteId, visitorIds, limit, offset };
1184
+ if (params.type) {
1185
+ conditions.push(`type = {type:String}`);
1186
+ queryParams.type = params.type;
1187
+ }
1188
+ if (params.eventName) {
1189
+ conditions.push(`event_name = {eventName:String}`);
1190
+ queryParams.eventName = params.eventName;
1191
+ }
1192
+ if (params.eventNames && params.eventNames.length > 0) {
1193
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
1194
+ queryParams.eventNames = params.eventNames;
1195
+ }
1196
+ if (params.period || params.dateFrom) {
1197
+ const { dateRange } = resolvePeriod({
1198
+ period: params.period,
1199
+ dateFrom: params.dateFrom,
1200
+ dateTo: params.dateTo
1201
+ });
1202
+ conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
1203
+ queryParams.from = toCHDateTime(dateRange.from);
1204
+ queryParams.to = toCHDateTime(dateRange.to);
1205
+ }
1206
+ const where = conditions.join(" AND ");
1207
+ const [events, countRows] = await Promise.all([
1208
+ this.queryRows(
1209
+ `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
1210
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
1211
+ element_selector, element_text, scroll_depth_pct,
1212
+ user_id, traits, country, city, region,
1213
+ device_type, browser, os, language,
1214
+ utm_source, utm_medium, utm_campaign, utm_term, utm_content
1215
+ FROM ${EVENTS_TABLE}
1216
+ WHERE ${where}
1217
+ ORDER BY timestamp DESC
1218
+ LIMIT {limit:UInt32}
1219
+ OFFSET {offset:UInt32}`,
1220
+ queryParams
1221
+ ),
1222
+ this.queryRows(
1223
+ `SELECT count() AS total FROM ${EVENTS_TABLE} WHERE ${where}`,
1224
+ queryParams
1225
+ )
1226
+ ]);
1227
+ return {
1228
+ events: events.map((e) => this.toEventListItem(e)),
1229
+ total: Number(countRows[0]?.total ?? 0),
1230
+ limit,
1231
+ offset
1232
+ };
990
1233
  }
991
1234
  // ─── Site Management ──────────────────────────────────────
992
1235
  async createSite(data) {
@@ -1237,6 +1480,7 @@ var ClickHouseAdapter = class {
1237
1480
  import { MongoClient } from "mongodb";
1238
1481
  var EVENTS_COLLECTION = "litemetrics_events";
1239
1482
  var SITES_COLLECTION = "litemetrics_sites";
1483
+ var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1240
1484
  function buildFilterMatch(filters) {
1241
1485
  if (!filters) return {};
1242
1486
  const map = {
@@ -1272,6 +1516,7 @@ var MongoDBAdapter = class {
1272
1516
  db;
1273
1517
  collection;
1274
1518
  sites;
1519
+ identityMap;
1275
1520
  constructor(url) {
1276
1521
  this.client = new MongoClient(url);
1277
1522
  }
@@ -1280,13 +1525,16 @@ var MongoDBAdapter = class {
1280
1525
  this.db = this.client.db();
1281
1526
  this.collection = this.db.collection(EVENTS_COLLECTION);
1282
1527
  this.sites = this.db.collection(SITES_COLLECTION);
1528
+ this.identityMap = this.db.collection(IDENTITY_MAP_COLLECTION);
1283
1529
  await Promise.all([
1284
1530
  this.collection.createIndex({ site_id: 1, timestamp: -1 }),
1285
1531
  this.collection.createIndex({ site_id: 1, type: 1 }),
1286
1532
  this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1287
1533
  this.collection.createIndex({ site_id: 1, session_id: 1 }),
1288
1534
  this.sites.createIndex({ site_id: 1 }, { unique: true }),
1289
- this.sites.createIndex({ secret_key: 1 })
1535
+ this.sites.createIndex({ secret_key: 1 }),
1536
+ this.identityMap.createIndex({ site_id: 1, visitor_id: 1 }, { unique: true }),
1537
+ this.identityMap.createIndex({ site_id: 1, user_id: 1 })
1290
1538
  ]);
1291
1539
  }
1292
1540
  async insertEvents(events) {
@@ -1589,6 +1837,42 @@ var MongoDBAdapter = class {
1589
1837
  total = data.reduce((sum, d) => sum + d.value, 0);
1590
1838
  break;
1591
1839
  }
1840
+ case "top_utm_sources": {
1841
+ const rows = await this.collection.aggregate([
1842
+ { $match: { ...baseMatch, ...filterMatch, utm_source: { $nin: [null, ""] } } },
1843
+ { $group: { _id: "$utm_source", value: { $addToSet: "$visitor_id" } } },
1844
+ { $project: { _id: 1, value: { $size: "$value" } } },
1845
+ { $sort: { value: -1 } },
1846
+ { $limit: limit }
1847
+ ]).toArray();
1848
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1849
+ total = data.reduce((sum, d) => sum + d.value, 0);
1850
+ break;
1851
+ }
1852
+ case "top_utm_mediums": {
1853
+ const rows = await this.collection.aggregate([
1854
+ { $match: { ...baseMatch, ...filterMatch, utm_medium: { $nin: [null, ""] } } },
1855
+ { $group: { _id: "$utm_medium", value: { $addToSet: "$visitor_id" } } },
1856
+ { $project: { _id: 1, value: { $size: "$value" } } },
1857
+ { $sort: { value: -1 } },
1858
+ { $limit: limit }
1859
+ ]).toArray();
1860
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1861
+ total = data.reduce((sum, d) => sum + d.value, 0);
1862
+ break;
1863
+ }
1864
+ case "top_utm_campaigns": {
1865
+ const rows = await this.collection.aggregate([
1866
+ { $match: { ...baseMatch, ...filterMatch, utm_campaign: { $nin: [null, ""] } } },
1867
+ { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
1868
+ { $project: { _id: 1, value: { $size: "$value" } } },
1869
+ { $sort: { value: -1 } },
1870
+ { $limit: limit }
1871
+ ]).toArray();
1872
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1873
+ total = data.reduce((sum, d) => sum + d.value, 0);
1874
+ break;
1875
+ }
1592
1876
  }
1593
1877
  const result = { metric: q.metric, period, data, total };
1594
1878
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -1777,24 +2061,72 @@ var MongoDBAdapter = class {
1777
2061
  offset
1778
2062
  };
1779
2063
  }
2064
+ // ─── Identity Mapping ──────────────────────────────────────
2065
+ async upsertIdentity(siteId, visitorId, userId) {
2066
+ await this.identityMap.updateOne(
2067
+ { site_id: siteId, visitor_id: visitorId },
2068
+ {
2069
+ $set: { user_id: userId, identified_at: /* @__PURE__ */ new Date() },
2070
+ $setOnInsert: { site_id: siteId, visitor_id: visitorId, created_at: /* @__PURE__ */ new Date() }
2071
+ },
2072
+ { upsert: true }
2073
+ );
2074
+ }
2075
+ async getVisitorIdsForUser(siteId, userId) {
2076
+ const docs = await this.identityMap.find({ site_id: siteId, user_id: userId }).toArray();
2077
+ return docs.map((d) => d.visitor_id);
2078
+ }
2079
+ async getUserIdForVisitor(siteId, visitorId) {
2080
+ const doc = await this.identityMap.findOne({ site_id: siteId, visitor_id: visitorId });
2081
+ return doc?.user_id ?? null;
2082
+ }
1780
2083
  // ─── User Listing ──────────────────────────────────────
1781
2084
  async listUsers(params) {
1782
2085
  const limit = Math.min(params.limit ?? 50, 200);
1783
2086
  const offset = params.offset ?? 0;
1784
2087
  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
2088
  const pipeline = [
1792
2089
  { $match: match },
2090
+ // Join with identity map to resolve visitor → user
2091
+ {
2092
+ $lookup: {
2093
+ from: IDENTITY_MAP_COLLECTION,
2094
+ let: { vid: "$visitor_id", sid: "$site_id" },
2095
+ pipeline: [
2096
+ { $match: { $expr: { $and: [{ $eq: ["$visitor_id", "$$vid"] }, { $eq: ["$site_id", "$$sid"] }] } } }
2097
+ ],
2098
+ as: "_identity"
2099
+ }
2100
+ },
2101
+ {
2102
+ $addFields: {
2103
+ _resolved_id: {
2104
+ $ifNull: [{ $arrayElemAt: ["$_identity.user_id", 0] }, "$visitor_id"]
2105
+ },
2106
+ _resolved_user_id: {
2107
+ $arrayElemAt: ["$_identity.user_id", 0]
2108
+ }
2109
+ }
2110
+ }
2111
+ ];
2112
+ if (params.search) {
2113
+ pipeline.push({
2114
+ $match: {
2115
+ $or: [
2116
+ { visitor_id: { $regex: params.search, $options: "i" } },
2117
+ { user_id: { $regex: params.search, $options: "i" } },
2118
+ { _resolved_user_id: { $regex: params.search, $options: "i" } }
2119
+ ]
2120
+ }
2121
+ });
2122
+ }
2123
+ pipeline.push(
1793
2124
  { $sort: { timestamp: 1 } },
1794
2125
  {
1795
2126
  $group: {
1796
- _id: "$visitor_id",
1797
- userId: { $last: "$user_id" },
2127
+ _id: "$_resolved_id",
2128
+ visitorIds: { $addToSet: "$visitor_id" },
2129
+ userId: { $last: { $ifNull: ["$_resolved_user_id", "$user_id"] } },
1798
2130
  traits: { $last: "$traits" },
1799
2131
  firstSeen: { $min: "$timestamp" },
1800
2132
  lastSeen: { $max: "$timestamp" },
@@ -1827,10 +2159,11 @@ var MongoDBAdapter = class {
1827
2159
  count: [{ $count: "total" }]
1828
2160
  }
1829
2161
  }
1830
- ];
2162
+ );
1831
2163
  const [result] = await this.collection.aggregate(pipeline).toArray();
1832
2164
  const users = (result?.data ?? []).map((u) => ({
1833
- visitorId: u._id,
2165
+ visitorId: u.visitorIds[0] ?? u._id,
2166
+ visitorIds: u.visitorIds.length > 1 ? u.visitorIds : void 0,
1834
2167
  userId: u.userId ?? void 0,
1835
2168
  traits: u.traits ?? void 0,
1836
2169
  firstSeen: u.firstSeen.toISOString(),
@@ -1860,13 +2193,125 @@ var MongoDBAdapter = class {
1860
2193
  offset
1861
2194
  };
1862
2195
  }
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;
2196
+ async getUserDetail(siteId, identifier) {
2197
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2198
+ if (visitorIds.length > 0) {
2199
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
2200
+ }
2201
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2202
+ if (userId) {
2203
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
2204
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds);
2205
+ }
2206
+ return this.getMergedUserDetail(siteId, void 0, [identifier]);
2207
+ }
2208
+ async getUserEvents(siteId, identifier, params) {
2209
+ let visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2210
+ if (visitorIds.length === 0) {
2211
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2212
+ if (userId) {
2213
+ visitorIds = await this.getVisitorIdsForUser(siteId, userId);
2214
+ }
2215
+ }
2216
+ if (visitorIds.length === 0) {
2217
+ visitorIds = [identifier];
2218
+ }
2219
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
2220
+ }
2221
+ async getMergedUserDetail(siteId, userId, visitorIds) {
2222
+ const pipeline = [
2223
+ { $match: { site_id: siteId, visitor_id: { $in: visitorIds } } },
2224
+ { $sort: { timestamp: 1 } },
2225
+ {
2226
+ $group: {
2227
+ _id: null,
2228
+ visitorIds: { $addToSet: "$visitor_id" },
2229
+ traits: { $last: "$traits" },
2230
+ firstSeen: { $min: "$timestamp" },
2231
+ lastSeen: { $max: "$timestamp" },
2232
+ totalEvents: { $sum: 1 },
2233
+ totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
2234
+ sessions: { $addToSet: "$session_id" },
2235
+ lastUrl: { $last: "$url" },
2236
+ referrer: { $last: "$referrer" },
2237
+ device_type: { $last: "$device_type" },
2238
+ browser: { $last: "$browser" },
2239
+ os: { $last: "$os" },
2240
+ country: { $last: "$country" },
2241
+ city: { $last: "$city" },
2242
+ region: { $last: "$region" },
2243
+ language: { $last: "$language" },
2244
+ timezone: { $last: "$timezone" },
2245
+ screen_width: { $last: "$screen_width" },
2246
+ screen_height: { $last: "$screen_height" },
2247
+ utm_source: { $last: "$utm_source" },
2248
+ utm_medium: { $last: "$utm_medium" },
2249
+ utm_campaign: { $last: "$utm_campaign" },
2250
+ utm_term: { $last: "$utm_term" },
2251
+ utm_content: { $last: "$utm_content" }
2252
+ }
2253
+ }
2254
+ ];
2255
+ const [row] = await this.collection.aggregate(pipeline).toArray();
2256
+ if (!row) return null;
2257
+ return {
2258
+ visitorId: visitorIds[0],
2259
+ visitorIds: row.visitorIds.length > 1 ? row.visitorIds : void 0,
2260
+ userId: userId ?? void 0,
2261
+ traits: row.traits ?? void 0,
2262
+ firstSeen: row.firstSeen.toISOString(),
2263
+ lastSeen: row.lastSeen.toISOString(),
2264
+ totalEvents: row.totalEvents,
2265
+ totalPageviews: row.totalPageviews,
2266
+ totalSessions: row.sessions.length,
2267
+ lastUrl: row.lastUrl ?? void 0,
2268
+ referrer: row.referrer ?? void 0,
2269
+ device: row.device_type ? { type: row.device_type, browser: row.browser ?? "", os: row.os ?? "" } : void 0,
2270
+ geo: row.country ? { country: row.country, city: row.city ?? void 0, region: row.region ?? void 0 } : void 0,
2271
+ language: row.language ?? void 0,
2272
+ timezone: row.timezone ?? void 0,
2273
+ screen: row.screen_width || row.screen_height ? { width: row.screen_width ?? 0, height: row.screen_height ?? 0 } : void 0,
2274
+ utm: row.utm_source ? {
2275
+ source: row.utm_source ?? void 0,
2276
+ medium: row.utm_medium ?? void 0,
2277
+ campaign: row.utm_campaign ?? void 0,
2278
+ term: row.utm_term ?? void 0,
2279
+ content: row.utm_content ?? void 0
2280
+ } : void 0
2281
+ };
1867
2282
  }
1868
- async getUserEvents(siteId, visitorId, params) {
1869
- return this.listEvents({ ...params, siteId, visitorId });
2283
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
2284
+ const limit = Math.min(params.limit ?? 50, 200);
2285
+ const offset = params.offset ?? 0;
2286
+ const match = {
2287
+ site_id: siteId,
2288
+ visitor_id: { $in: visitorIds }
2289
+ };
2290
+ if (params.type) match.type = params.type;
2291
+ if (params.eventName) {
2292
+ match.event_name = params.eventName;
2293
+ } else if (params.eventNames && params.eventNames.length > 0) {
2294
+ match.event_name = { $in: params.eventNames };
2295
+ }
2296
+ if (params.eventSource) match.event_source = params.eventSource;
2297
+ if (params.period || params.dateFrom) {
2298
+ const { dateRange } = resolvePeriod({
2299
+ period: params.period,
2300
+ dateFrom: params.dateFrom,
2301
+ dateTo: params.dateTo
2302
+ });
2303
+ match.timestamp = { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) };
2304
+ }
2305
+ const [events, countResult] = await Promise.all([
2306
+ this.collection.find(match).sort({ timestamp: -1 }).skip(offset).limit(limit).toArray(),
2307
+ this.collection.countDocuments(match)
2308
+ ]);
2309
+ return {
2310
+ events: events.map((e) => this.toEventListItem(e)),
2311
+ total: countResult,
2312
+ limit,
2313
+ offset
2314
+ };
1870
2315
  }
1871
2316
  toEventListItem(doc) {
1872
2317
  return {
@@ -2237,6 +2682,51 @@ async function createCollector(config) {
2237
2682
  return { ...event, ip, geo, device };
2238
2683
  });
2239
2684
  }
2685
+ const identityCache = /* @__PURE__ */ new Map();
2686
+ const IDENTITY_CACHE_TTL = 5 * 60 * 1e3;
2687
+ function getCachedUserId(siteId, visitorId) {
2688
+ const key = `${siteId}:${visitorId}`;
2689
+ const entry = identityCache.get(key);
2690
+ if (!entry) return void 0;
2691
+ if (Date.now() > entry.expires) {
2692
+ identityCache.delete(key);
2693
+ return void 0;
2694
+ }
2695
+ return entry.userId;
2696
+ }
2697
+ function setCachedUserId(siteId, visitorId, userId) {
2698
+ const key = `${siteId}:${visitorId}`;
2699
+ identityCache.set(key, { userId, expires: Date.now() + IDENTITY_CACHE_TTL });
2700
+ if (identityCache.size > 1e4) {
2701
+ const now = Date.now();
2702
+ for (const [k, v] of identityCache) {
2703
+ if (now > v.expires) identityCache.delete(k);
2704
+ }
2705
+ }
2706
+ }
2707
+ async function processIdentity(events) {
2708
+ for (const event of events) {
2709
+ if (!event.visitorId || event.visitorId === "server") continue;
2710
+ if (event.type === "identify" && event.userId) {
2711
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
2712
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
2713
+ } else if (!event.userId) {
2714
+ const cached = getCachedUserId(event.siteId, event.visitorId);
2715
+ if (cached) {
2716
+ event.userId = cached;
2717
+ } else {
2718
+ const resolved = await db.getUserIdForVisitor(event.siteId, event.visitorId);
2719
+ if (resolved) {
2720
+ event.userId = resolved;
2721
+ setCachedUserId(event.siteId, event.visitorId, resolved);
2722
+ }
2723
+ }
2724
+ } else if (event.userId) {
2725
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
2726
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
2727
+ }
2728
+ }
2729
+ }
2240
2730
  function extractIp(req) {
2241
2731
  if (config.trustProxy ?? true) {
2242
2732
  const forwarded = req.headers?.["x-forwarded-for"];
@@ -2298,11 +2788,13 @@ async function createCollector(config) {
2298
2788
  sendJson(res, 200, { ok: true });
2299
2789
  return;
2300
2790
  }
2791
+ await processIdentity(filtered);
2301
2792
  await db.insertEvents(filtered);
2302
2793
  sendJson(res, 200, { ok: true });
2303
2794
  return;
2304
2795
  }
2305
2796
  }
2797
+ await processIdentity(enriched);
2306
2798
  await db.insertEvents(enriched);
2307
2799
  sendJson(res, 200, { ok: true });
2308
2800
  } catch (err) {
@@ -2563,11 +3055,11 @@ async function createCollector(config) {
2563
3055
  async listUsers(params) {
2564
3056
  return db.listUsers(params);
2565
3057
  },
2566
- async getUserDetail(siteId, visitorId) {
2567
- return db.getUserDetail(siteId, visitorId);
3058
+ async getUserDetail(siteId, identifier) {
3059
+ return db.getUserDetail(siteId, identifier);
2568
3060
  },
2569
- async getUserEvents(siteId, visitorId, params) {
2570
- return db.getUserEvents(siteId, visitorId, params);
3061
+ async getUserEvents(siteId, identifier, params) {
3062
+ return db.getUserEvents(siteId, identifier, params);
2571
3063
  },
2572
3064
  async track(siteId, name, properties, options) {
2573
3065
  const event = {