@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.cjs CHANGED
@@ -179,6 +179,18 @@ function generateSecretKey() {
179
179
  // src/adapters/clickhouse.ts
180
180
  var EVENTS_TABLE = "litemetrics_events";
181
181
  var SITES_TABLE = "litemetrics_sites";
182
+ var IDENTITY_MAP_TABLE = "litemetrics_identity_map";
183
+ var CREATE_IDENTITY_MAP_TABLE = `
184
+ CREATE TABLE IF NOT EXISTS ${IDENTITY_MAP_TABLE} (
185
+ site_id LowCardinality(String),
186
+ visitor_id String,
187
+ user_id String,
188
+ identified_at DateTime64(3),
189
+ created_at DateTime64(3) DEFAULT now64(3)
190
+ ) ENGINE = ReplacingMergeTree(created_at)
191
+ ORDER BY (site_id, visitor_id)
192
+ SETTINGS index_granularity = 8192
193
+ `;
182
194
  var CREATE_EVENTS_TABLE = `
183
195
  CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
184
196
  event_id UUID DEFAULT generateUUIDv4(),
@@ -289,6 +301,7 @@ var ClickHouseAdapter = class {
289
301
  async init() {
290
302
  await this.client.command({ query: CREATE_EVENTS_TABLE });
291
303
  await this.client.command({ query: CREATE_SITES_TABLE });
304
+ await this.client.command({ query: CREATE_IDENTITY_MAP_TABLE });
292
305
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
293
306
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
294
307
  await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
@@ -546,8 +559,8 @@ var ClickHouseAdapter = class {
546
559
  }
547
560
  case "top_exit_pages": {
548
561
  const rows = await this.queryRows(
549
- `SELECT url AS key, count() AS value FROM (
550
- SELECT session_id, argMax(url, timestamp) AS url
562
+ `SELECT exit_url AS key, count() AS value FROM (
563
+ SELECT session_id, argMax(url, timestamp) AS exit_url
551
564
  FROM ${EVENTS_TABLE}
552
565
  WHERE site_id = {siteId:String}
553
566
  AND timestamp >= {from:String}
@@ -556,7 +569,7 @@ var ClickHouseAdapter = class {
556
569
  AND url IS NOT NULL${filterSql}
557
570
  GROUP BY session_id
558
571
  )
559
- GROUP BY url
572
+ GROUP BY exit_url
560
573
  ORDER BY value DESC
561
574
  LIMIT {limit:UInt32}`,
562
575
  { ...params, ...filter.params }
@@ -567,9 +580,9 @@ var ClickHouseAdapter = class {
567
580
  }
568
581
  case "top_transitions": {
569
582
  const rows = await this.queryRows(
570
- `SELECT concat(prev_url, ' \u2192 ', url) AS key, count() AS value FROM (
571
- SELECT session_id, url,
572
- lag(url) OVER (PARTITION BY session_id ORDER BY timestamp) AS prev_url
583
+ `SELECT concat(prev_url, ' \u2192 ', curr_url) AS key, count() AS value FROM (
584
+ SELECT session_id, url AS curr_url,
585
+ lagInFrame(url, 1) OVER (PARTITION BY session_id ORDER BY timestamp ASC) AS prev_url
573
586
  FROM ${EVENTS_TABLE}
574
587
  WHERE site_id = {siteId:String}
575
588
  AND timestamp >= {from:String}
@@ -577,7 +590,7 @@ var ClickHouseAdapter = class {
577
590
  AND type = 'pageview'
578
591
  AND url IS NOT NULL${filterSql}
579
592
  )
580
- WHERE prev_url IS NOT NULL
593
+ WHERE prev_url IS NOT NULL AND prev_url != ''
581
594
  GROUP BY key
582
595
  ORDER BY value DESC
583
596
  LIMIT {limit:UInt32}`,
@@ -692,6 +705,57 @@ var ClickHouseAdapter = class {
692
705
  total = data.reduce((sum, d) => sum + d.value, 0);
693
706
  break;
694
707
  }
708
+ case "top_utm_sources": {
709
+ const rows = await this.queryRows(
710
+ `SELECT utm_source AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
711
+ WHERE site_id = {siteId:String}
712
+ AND timestamp >= {from:String}
713
+ AND timestamp <= {to:String}
714
+ AND utm_source IS NOT NULL AND utm_source != ''
715
+ ${filterSql}
716
+ GROUP BY utm_source
717
+ ORDER BY value DESC
718
+ LIMIT {limit:UInt32}`,
719
+ { ...params, ...filter.params }
720
+ );
721
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
722
+ total = data.reduce((sum, d) => sum + d.value, 0);
723
+ break;
724
+ }
725
+ case "top_utm_mediums": {
726
+ const rows = await this.queryRows(
727
+ `SELECT utm_medium AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
728
+ WHERE site_id = {siteId:String}
729
+ AND timestamp >= {from:String}
730
+ AND timestamp <= {to:String}
731
+ AND utm_medium IS NOT NULL AND utm_medium != ''
732
+ ${filterSql}
733
+ GROUP BY utm_medium
734
+ ORDER BY value DESC
735
+ LIMIT {limit:UInt32}`,
736
+ { ...params, ...filter.params }
737
+ );
738
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
739
+ total = data.reduce((sum, d) => sum + d.value, 0);
740
+ break;
741
+ }
742
+ case "top_utm_campaigns": {
743
+ const rows = await this.queryRows(
744
+ `SELECT utm_campaign AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
745
+ WHERE site_id = {siteId:String}
746
+ AND timestamp >= {from:String}
747
+ AND timestamp <= {to:String}
748
+ AND utm_campaign IS NOT NULL AND utm_campaign != ''
749
+ ${filterSql}
750
+ GROUP BY utm_campaign
751
+ ORDER BY value DESC
752
+ LIMIT {limit:UInt32}`,
753
+ { ...params, ...filter.params }
754
+ );
755
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
756
+ total = data.reduce((sum, d) => sum + d.value, 0);
757
+ break;
758
+ }
695
759
  }
696
760
  const result = { metric: q.metric, period, data, total };
697
761
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -945,45 +1009,59 @@ var ClickHouseAdapter = class {
945
1009
  const where = conditions.join(" AND ");
946
1010
  const [userRows, countRows] = await Promise.all([
947
1011
  this.queryRows(
948
- `SELECT
949
- visitor_id,
950
- anyLast(user_id) AS userId,
951
- anyLast(traits) AS traits,
952
- min(timestamp) AS firstSeen,
953
- max(timestamp) AS lastSeen,
1012
+ `WITH identity AS (
1013
+ SELECT visitor_id, user_id
1014
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1015
+ WHERE site_id = {siteId:String}
1016
+ )
1017
+ SELECT
1018
+ if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key,
1019
+ anyLast(e.visitor_id) AS visitor_id,
1020
+ anyLast(i.user_id) AS userId,
1021
+ anyLast(e.traits) AS traits,
1022
+ min(e.timestamp) AS firstSeen,
1023
+ max(e.timestamp) AS lastSeen,
954
1024
  count() AS totalEvents,
955
- countIf(type = 'pageview') AS totalPageviews,
956
- uniq(session_id) AS totalSessions,
957
- anyLast(url) AS lastUrl,
958
- anyLast(referrer) AS referrer,
959
- anyLast(device_type) AS device_type,
960
- anyLast(browser) AS browser,
961
- anyLast(os) AS os,
962
- anyLast(country) AS country,
963
- anyLast(city) AS city,
964
- anyLast(region) AS region,
965
- anyLast(language) AS language,
966
- anyLast(timezone) AS timezone,
967
- anyLast(screen_width) AS screen_width,
968
- anyLast(screen_height) AS screen_height,
969
- anyLast(utm_source) AS utm_source,
970
- anyLast(utm_medium) AS utm_medium,
971
- anyLast(utm_campaign) AS utm_campaign,
972
- anyLast(utm_term) AS utm_term,
973
- anyLast(utm_content) AS utm_content
974
- FROM ${EVENTS_TABLE}
975
- WHERE ${where}
976
- GROUP BY visitor_id
1025
+ countIf(e.type = 'pageview') AS totalPageviews,
1026
+ uniq(e.session_id) AS totalSessions,
1027
+ anyLast(e.url) AS lastUrl,
1028
+ anyLast(e.referrer) AS referrer,
1029
+ anyLast(e.device_type) AS device_type,
1030
+ anyLast(e.browser) AS browser,
1031
+ anyLast(e.os) AS os,
1032
+ anyLast(e.country) AS country,
1033
+ anyLast(e.city) AS city,
1034
+ anyLast(e.region) AS region,
1035
+ anyLast(e.language) AS language,
1036
+ anyLast(e.timezone) AS timezone,
1037
+ anyLast(e.screen_width) AS screen_width,
1038
+ anyLast(e.screen_height) AS screen_height,
1039
+ anyLast(e.utm_source) AS utm_source,
1040
+ anyLast(e.utm_medium) AS utm_medium,
1041
+ anyLast(e.utm_campaign) AS utm_campaign,
1042
+ anyLast(e.utm_term) AS utm_term,
1043
+ anyLast(e.utm_content) AS utm_content
1044
+ FROM ${EVENTS_TABLE} e
1045
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1046
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1047
+ GROUP BY group_key
977
1048
  ORDER BY lastSeen DESC
978
1049
  LIMIT {limit:UInt32}
979
1050
  OFFSET {offset:UInt32}`,
980
1051
  queryParams
981
1052
  ),
982
1053
  this.queryRows(
983
- `SELECT count() AS total FROM (
984
- SELECT visitor_id FROM ${EVENTS_TABLE}
985
- WHERE ${where}
986
- GROUP BY visitor_id
1054
+ `WITH identity AS (
1055
+ SELECT visitor_id, user_id
1056
+ FROM ${IDENTITY_MAP_TABLE} FINAL
1057
+ WHERE site_id = {siteId:String}
1058
+ )
1059
+ SELECT count() AS total FROM (
1060
+ SELECT if(i.user_id IS NOT NULL AND i.user_id != '', i.user_id, e.visitor_id) AS group_key
1061
+ FROM ${EVENTS_TABLE} e
1062
+ LEFT JOIN identity i ON e.visitor_id = i.visitor_id
1063
+ WHERE e.site_id = {siteId:String}${where.includes("ILIKE") ? ` AND (e.visitor_id ILIKE {search:String} OR i.user_id ILIKE {search:String})` : ""}
1064
+ GROUP BY group_key
987
1065
  )`,
988
1066
  queryParams
989
1067
  )
@@ -1019,13 +1097,178 @@ var ClickHouseAdapter = class {
1019
1097
  offset
1020
1098
  };
1021
1099
  }
1022
- async getUserDetail(siteId, visitorId) {
1023
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1024
- const user = result.users.find((u) => u.visitorId === visitorId);
1100
+ async getUserDetail(siteId, identifier) {
1101
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1102
+ if (visitorIds.length > 0) {
1103
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
1104
+ }
1105
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1106
+ if (userId) {
1107
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1108
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds.length > 0 ? allVisitorIds : [identifier]);
1109
+ }
1110
+ const result = await this.listUsers({ siteId, search: identifier, limit: 1 });
1111
+ const user = result.users.find((u) => u.visitorId === identifier);
1025
1112
  return user ?? null;
1026
1113
  }
1027
- async getUserEvents(siteId, visitorId, params) {
1028
- return this.listEvents({ ...params, siteId, visitorId });
1114
+ async getUserEvents(siteId, identifier, params) {
1115
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
1116
+ if (visitorIds.length > 0) {
1117
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
1118
+ }
1119
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
1120
+ if (userId) {
1121
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
1122
+ if (allVisitorIds.length > 0) {
1123
+ return this.listEventsForVisitorIds(siteId, allVisitorIds, params);
1124
+ }
1125
+ }
1126
+ return this.listEvents({ ...params, siteId, visitorId: identifier });
1127
+ }
1128
+ // ─── Identity Mapping ──────────────────────────────────────
1129
+ async upsertIdentity(siteId, visitorId, userId) {
1130
+ await this.client.insert({
1131
+ table: IDENTITY_MAP_TABLE,
1132
+ values: [{
1133
+ site_id: siteId,
1134
+ visitor_id: visitorId,
1135
+ user_id: userId,
1136
+ identified_at: toCHDateTime(/* @__PURE__ */ new Date())
1137
+ }],
1138
+ format: "JSONEachRow"
1139
+ });
1140
+ }
1141
+ async getVisitorIdsForUser(siteId, userId) {
1142
+ const rows = await this.queryRows(
1143
+ `SELECT visitor_id FROM ${IDENTITY_MAP_TABLE} FINAL
1144
+ WHERE site_id = {siteId:String} AND user_id = {userId:String}`,
1145
+ { siteId, userId }
1146
+ );
1147
+ return rows.map((r) => r.visitor_id);
1148
+ }
1149
+ async getUserIdForVisitor(siteId, visitorId) {
1150
+ const rows = await this.queryRows(
1151
+ `SELECT user_id FROM ${IDENTITY_MAP_TABLE} FINAL
1152
+ WHERE site_id = {siteId:String} AND visitor_id = {visitorId:String}
1153
+ LIMIT 1`,
1154
+ { siteId, visitorId }
1155
+ );
1156
+ return rows.length > 0 ? rows[0].user_id : null;
1157
+ }
1158
+ async getMergedUserDetail(siteId, userId, visitorIds) {
1159
+ const rows = await this.queryRows(
1160
+ `SELECT
1161
+ anyLast(visitor_id) AS visitor_id,
1162
+ anyLast(traits) AS traits,
1163
+ min(timestamp) AS firstSeen,
1164
+ max(timestamp) AS lastSeen,
1165
+ count() AS totalEvents,
1166
+ countIf(type = 'pageview') AS totalPageviews,
1167
+ uniq(session_id) AS totalSessions,
1168
+ anyLast(url) AS lastUrl,
1169
+ anyLast(referrer) AS referrer,
1170
+ anyLast(device_type) AS device_type,
1171
+ anyLast(browser) AS browser,
1172
+ anyLast(os) AS os,
1173
+ anyLast(country) AS country,
1174
+ anyLast(city) AS city,
1175
+ anyLast(region) AS region,
1176
+ anyLast(language) AS language,
1177
+ anyLast(timezone) AS timezone,
1178
+ anyLast(screen_width) AS screen_width,
1179
+ anyLast(screen_height) AS screen_height,
1180
+ anyLast(utm_source) AS utm_source,
1181
+ anyLast(utm_medium) AS utm_medium,
1182
+ anyLast(utm_campaign) AS utm_campaign,
1183
+ anyLast(utm_term) AS utm_term,
1184
+ anyLast(utm_content) AS utm_content
1185
+ FROM ${EVENTS_TABLE}
1186
+ WHERE site_id = {siteId:String}
1187
+ AND visitor_id IN {visitorIds:Array(String)}`,
1188
+ { siteId, visitorIds }
1189
+ );
1190
+ if (rows.length === 0) return null;
1191
+ const u = rows[0];
1192
+ return {
1193
+ visitorId: String(u.visitor_id),
1194
+ visitorIds,
1195
+ userId,
1196
+ traits: this.parseJSON(u.traits),
1197
+ firstSeen: new Date(String(u.firstSeen)).toISOString(),
1198
+ lastSeen: new Date(String(u.lastSeen)).toISOString(),
1199
+ totalEvents: Number(u.totalEvents),
1200
+ totalPageviews: Number(u.totalPageviews),
1201
+ totalSessions: Number(u.totalSessions),
1202
+ lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
1203
+ referrer: u.referrer ? String(u.referrer) : void 0,
1204
+ device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
1205
+ 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,
1206
+ language: u.language ? String(u.language) : void 0,
1207
+ timezone: u.timezone ? String(u.timezone) : void 0,
1208
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
1209
+ utm: u.utm_source ? {
1210
+ source: String(u.utm_source),
1211
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
1212
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
1213
+ term: u.utm_term ? String(u.utm_term) : void 0,
1214
+ content: u.utm_content ? String(u.utm_content) : void 0
1215
+ } : void 0
1216
+ };
1217
+ }
1218
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
1219
+ const limit = Math.min(params.limit ?? 50, 200);
1220
+ const offset = params.offset ?? 0;
1221
+ const conditions = [`site_id = {siteId:String}`, `visitor_id IN {visitorIds:Array(String)}`];
1222
+ const queryParams = { siteId, visitorIds, limit, offset };
1223
+ if (params.type) {
1224
+ conditions.push(`type = {type:String}`);
1225
+ queryParams.type = params.type;
1226
+ }
1227
+ if (params.eventName) {
1228
+ conditions.push(`event_name = {eventName:String}`);
1229
+ queryParams.eventName = params.eventName;
1230
+ }
1231
+ if (params.eventNames && params.eventNames.length > 0) {
1232
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
1233
+ queryParams.eventNames = params.eventNames;
1234
+ }
1235
+ if (params.period || params.dateFrom) {
1236
+ const { dateRange } = resolvePeriod({
1237
+ period: params.period,
1238
+ dateFrom: params.dateFrom,
1239
+ dateTo: params.dateTo
1240
+ });
1241
+ conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
1242
+ queryParams.from = toCHDateTime(dateRange.from);
1243
+ queryParams.to = toCHDateTime(dateRange.to);
1244
+ }
1245
+ const where = conditions.join(" AND ");
1246
+ const [events, countRows] = await Promise.all([
1247
+ this.queryRows(
1248
+ `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
1249
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
1250
+ element_selector, element_text, scroll_depth_pct,
1251
+ user_id, traits, country, city, region,
1252
+ device_type, browser, os, language,
1253
+ utm_source, utm_medium, utm_campaign, utm_term, utm_content
1254
+ FROM ${EVENTS_TABLE}
1255
+ WHERE ${where}
1256
+ ORDER BY timestamp DESC
1257
+ LIMIT {limit:UInt32}
1258
+ OFFSET {offset:UInt32}`,
1259
+ queryParams
1260
+ ),
1261
+ this.queryRows(
1262
+ `SELECT count() AS total FROM ${EVENTS_TABLE} WHERE ${where}`,
1263
+ queryParams
1264
+ )
1265
+ ]);
1266
+ return {
1267
+ events: events.map((e) => this.toEventListItem(e)),
1268
+ total: Number(countRows[0]?.total ?? 0),
1269
+ limit,
1270
+ offset
1271
+ };
1029
1272
  }
1030
1273
  // ─── Site Management ──────────────────────────────────────
1031
1274
  async createSite(data) {
@@ -1276,6 +1519,7 @@ var ClickHouseAdapter = class {
1276
1519
  var import_mongodb = require("mongodb");
1277
1520
  var EVENTS_COLLECTION = "litemetrics_events";
1278
1521
  var SITES_COLLECTION = "litemetrics_sites";
1522
+ var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1279
1523
  function buildFilterMatch(filters) {
1280
1524
  if (!filters) return {};
1281
1525
  const map = {
@@ -1311,6 +1555,7 @@ var MongoDBAdapter = class {
1311
1555
  db;
1312
1556
  collection;
1313
1557
  sites;
1558
+ identityMap;
1314
1559
  constructor(url) {
1315
1560
  this.client = new import_mongodb.MongoClient(url);
1316
1561
  }
@@ -1319,13 +1564,16 @@ var MongoDBAdapter = class {
1319
1564
  this.db = this.client.db();
1320
1565
  this.collection = this.db.collection(EVENTS_COLLECTION);
1321
1566
  this.sites = this.db.collection(SITES_COLLECTION);
1567
+ this.identityMap = this.db.collection(IDENTITY_MAP_COLLECTION);
1322
1568
  await Promise.all([
1323
1569
  this.collection.createIndex({ site_id: 1, timestamp: -1 }),
1324
1570
  this.collection.createIndex({ site_id: 1, type: 1 }),
1325
1571
  this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1326
1572
  this.collection.createIndex({ site_id: 1, session_id: 1 }),
1327
1573
  this.sites.createIndex({ site_id: 1 }, { unique: true }),
1328
- this.sites.createIndex({ secret_key: 1 })
1574
+ this.sites.createIndex({ secret_key: 1 }),
1575
+ this.identityMap.createIndex({ site_id: 1, visitor_id: 1 }, { unique: true }),
1576
+ this.identityMap.createIndex({ site_id: 1, user_id: 1 })
1329
1577
  ]);
1330
1578
  }
1331
1579
  async insertEvents(events) {
@@ -1628,6 +1876,42 @@ var MongoDBAdapter = class {
1628
1876
  total = data.reduce((sum, d) => sum + d.value, 0);
1629
1877
  break;
1630
1878
  }
1879
+ case "top_utm_sources": {
1880
+ const rows = await this.collection.aggregate([
1881
+ { $match: { ...baseMatch, ...filterMatch, utm_source: { $nin: [null, ""] } } },
1882
+ { $group: { _id: "$utm_source", value: { $addToSet: "$visitor_id" } } },
1883
+ { $project: { _id: 1, value: { $size: "$value" } } },
1884
+ { $sort: { value: -1 } },
1885
+ { $limit: limit }
1886
+ ]).toArray();
1887
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1888
+ total = data.reduce((sum, d) => sum + d.value, 0);
1889
+ break;
1890
+ }
1891
+ case "top_utm_mediums": {
1892
+ const rows = await this.collection.aggregate([
1893
+ { $match: { ...baseMatch, ...filterMatch, utm_medium: { $nin: [null, ""] } } },
1894
+ { $group: { _id: "$utm_medium", value: { $addToSet: "$visitor_id" } } },
1895
+ { $project: { _id: 1, value: { $size: "$value" } } },
1896
+ { $sort: { value: -1 } },
1897
+ { $limit: limit }
1898
+ ]).toArray();
1899
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1900
+ total = data.reduce((sum, d) => sum + d.value, 0);
1901
+ break;
1902
+ }
1903
+ case "top_utm_campaigns": {
1904
+ const rows = await this.collection.aggregate([
1905
+ { $match: { ...baseMatch, ...filterMatch, utm_campaign: { $nin: [null, ""] } } },
1906
+ { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
1907
+ { $project: { _id: 1, value: { $size: "$value" } } },
1908
+ { $sort: { value: -1 } },
1909
+ { $limit: limit }
1910
+ ]).toArray();
1911
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1912
+ total = data.reduce((sum, d) => sum + d.value, 0);
1913
+ break;
1914
+ }
1631
1915
  }
1632
1916
  const result = { metric: q.metric, period, data, total };
1633
1917
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -1816,24 +2100,72 @@ var MongoDBAdapter = class {
1816
2100
  offset
1817
2101
  };
1818
2102
  }
2103
+ // ─── Identity Mapping ──────────────────────────────────────
2104
+ async upsertIdentity(siteId, visitorId, userId) {
2105
+ await this.identityMap.updateOne(
2106
+ { site_id: siteId, visitor_id: visitorId },
2107
+ {
2108
+ $set: { user_id: userId, identified_at: /* @__PURE__ */ new Date() },
2109
+ $setOnInsert: { site_id: siteId, visitor_id: visitorId, created_at: /* @__PURE__ */ new Date() }
2110
+ },
2111
+ { upsert: true }
2112
+ );
2113
+ }
2114
+ async getVisitorIdsForUser(siteId, userId) {
2115
+ const docs = await this.identityMap.find({ site_id: siteId, user_id: userId }).toArray();
2116
+ return docs.map((d) => d.visitor_id);
2117
+ }
2118
+ async getUserIdForVisitor(siteId, visitorId) {
2119
+ const doc = await this.identityMap.findOne({ site_id: siteId, visitor_id: visitorId });
2120
+ return doc?.user_id ?? null;
2121
+ }
1819
2122
  // ─── User Listing ──────────────────────────────────────
1820
2123
  async listUsers(params) {
1821
2124
  const limit = Math.min(params.limit ?? 50, 200);
1822
2125
  const offset = params.offset ?? 0;
1823
2126
  const match = { site_id: params.siteId };
1824
- if (params.search) {
1825
- match.$or = [
1826
- { visitor_id: { $regex: params.search, $options: "i" } },
1827
- { user_id: { $regex: params.search, $options: "i" } }
1828
- ];
1829
- }
1830
2127
  const pipeline = [
1831
2128
  { $match: match },
2129
+ // Join with identity map to resolve visitor → user
2130
+ {
2131
+ $lookup: {
2132
+ from: IDENTITY_MAP_COLLECTION,
2133
+ let: { vid: "$visitor_id", sid: "$site_id" },
2134
+ pipeline: [
2135
+ { $match: { $expr: { $and: [{ $eq: ["$visitor_id", "$$vid"] }, { $eq: ["$site_id", "$$sid"] }] } } }
2136
+ ],
2137
+ as: "_identity"
2138
+ }
2139
+ },
2140
+ {
2141
+ $addFields: {
2142
+ _resolved_id: {
2143
+ $ifNull: [{ $arrayElemAt: ["$_identity.user_id", 0] }, "$visitor_id"]
2144
+ },
2145
+ _resolved_user_id: {
2146
+ $arrayElemAt: ["$_identity.user_id", 0]
2147
+ }
2148
+ }
2149
+ }
2150
+ ];
2151
+ if (params.search) {
2152
+ pipeline.push({
2153
+ $match: {
2154
+ $or: [
2155
+ { visitor_id: { $regex: params.search, $options: "i" } },
2156
+ { user_id: { $regex: params.search, $options: "i" } },
2157
+ { _resolved_user_id: { $regex: params.search, $options: "i" } }
2158
+ ]
2159
+ }
2160
+ });
2161
+ }
2162
+ pipeline.push(
1832
2163
  { $sort: { timestamp: 1 } },
1833
2164
  {
1834
2165
  $group: {
1835
- _id: "$visitor_id",
1836
- userId: { $last: "$user_id" },
2166
+ _id: "$_resolved_id",
2167
+ visitorIds: { $addToSet: "$visitor_id" },
2168
+ userId: { $last: { $ifNull: ["$_resolved_user_id", "$user_id"] } },
1837
2169
  traits: { $last: "$traits" },
1838
2170
  firstSeen: { $min: "$timestamp" },
1839
2171
  lastSeen: { $max: "$timestamp" },
@@ -1866,10 +2198,11 @@ var MongoDBAdapter = class {
1866
2198
  count: [{ $count: "total" }]
1867
2199
  }
1868
2200
  }
1869
- ];
2201
+ );
1870
2202
  const [result] = await this.collection.aggregate(pipeline).toArray();
1871
2203
  const users = (result?.data ?? []).map((u) => ({
1872
- visitorId: u._id,
2204
+ visitorId: u.visitorIds[0] ?? u._id,
2205
+ visitorIds: u.visitorIds.length > 1 ? u.visitorIds : void 0,
1873
2206
  userId: u.userId ?? void 0,
1874
2207
  traits: u.traits ?? void 0,
1875
2208
  firstSeen: u.firstSeen.toISOString(),
@@ -1899,13 +2232,125 @@ var MongoDBAdapter = class {
1899
2232
  offset
1900
2233
  };
1901
2234
  }
1902
- async getUserDetail(siteId, visitorId) {
1903
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1904
- const user = result.users.find((u) => u.visitorId === visitorId);
1905
- return user ?? null;
2235
+ async getUserDetail(siteId, identifier) {
2236
+ const visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2237
+ if (visitorIds.length > 0) {
2238
+ return this.getMergedUserDetail(siteId, identifier, visitorIds);
2239
+ }
2240
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2241
+ if (userId) {
2242
+ const allVisitorIds = await this.getVisitorIdsForUser(siteId, userId);
2243
+ return this.getMergedUserDetail(siteId, userId, allVisitorIds);
2244
+ }
2245
+ return this.getMergedUserDetail(siteId, void 0, [identifier]);
2246
+ }
2247
+ async getUserEvents(siteId, identifier, params) {
2248
+ let visitorIds = await this.getVisitorIdsForUser(siteId, identifier);
2249
+ if (visitorIds.length === 0) {
2250
+ const userId = await this.getUserIdForVisitor(siteId, identifier);
2251
+ if (userId) {
2252
+ visitorIds = await this.getVisitorIdsForUser(siteId, userId);
2253
+ }
2254
+ }
2255
+ if (visitorIds.length === 0) {
2256
+ visitorIds = [identifier];
2257
+ }
2258
+ return this.listEventsForVisitorIds(siteId, visitorIds, params);
2259
+ }
2260
+ async getMergedUserDetail(siteId, userId, visitorIds) {
2261
+ const pipeline = [
2262
+ { $match: { site_id: siteId, visitor_id: { $in: visitorIds } } },
2263
+ { $sort: { timestamp: 1 } },
2264
+ {
2265
+ $group: {
2266
+ _id: null,
2267
+ visitorIds: { $addToSet: "$visitor_id" },
2268
+ traits: { $last: "$traits" },
2269
+ firstSeen: { $min: "$timestamp" },
2270
+ lastSeen: { $max: "$timestamp" },
2271
+ totalEvents: { $sum: 1 },
2272
+ totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
2273
+ sessions: { $addToSet: "$session_id" },
2274
+ lastUrl: { $last: "$url" },
2275
+ referrer: { $last: "$referrer" },
2276
+ device_type: { $last: "$device_type" },
2277
+ browser: { $last: "$browser" },
2278
+ os: { $last: "$os" },
2279
+ country: { $last: "$country" },
2280
+ city: { $last: "$city" },
2281
+ region: { $last: "$region" },
2282
+ language: { $last: "$language" },
2283
+ timezone: { $last: "$timezone" },
2284
+ screen_width: { $last: "$screen_width" },
2285
+ screen_height: { $last: "$screen_height" },
2286
+ utm_source: { $last: "$utm_source" },
2287
+ utm_medium: { $last: "$utm_medium" },
2288
+ utm_campaign: { $last: "$utm_campaign" },
2289
+ utm_term: { $last: "$utm_term" },
2290
+ utm_content: { $last: "$utm_content" }
2291
+ }
2292
+ }
2293
+ ];
2294
+ const [row] = await this.collection.aggregate(pipeline).toArray();
2295
+ if (!row) return null;
2296
+ return {
2297
+ visitorId: visitorIds[0],
2298
+ visitorIds: row.visitorIds.length > 1 ? row.visitorIds : void 0,
2299
+ userId: userId ?? void 0,
2300
+ traits: row.traits ?? void 0,
2301
+ firstSeen: row.firstSeen.toISOString(),
2302
+ lastSeen: row.lastSeen.toISOString(),
2303
+ totalEvents: row.totalEvents,
2304
+ totalPageviews: row.totalPageviews,
2305
+ totalSessions: row.sessions.length,
2306
+ lastUrl: row.lastUrl ?? void 0,
2307
+ referrer: row.referrer ?? void 0,
2308
+ device: row.device_type ? { type: row.device_type, browser: row.browser ?? "", os: row.os ?? "" } : void 0,
2309
+ geo: row.country ? { country: row.country, city: row.city ?? void 0, region: row.region ?? void 0 } : void 0,
2310
+ language: row.language ?? void 0,
2311
+ timezone: row.timezone ?? void 0,
2312
+ screen: row.screen_width || row.screen_height ? { width: row.screen_width ?? 0, height: row.screen_height ?? 0 } : void 0,
2313
+ utm: row.utm_source ? {
2314
+ source: row.utm_source ?? void 0,
2315
+ medium: row.utm_medium ?? void 0,
2316
+ campaign: row.utm_campaign ?? void 0,
2317
+ term: row.utm_term ?? void 0,
2318
+ content: row.utm_content ?? void 0
2319
+ } : void 0
2320
+ };
1906
2321
  }
1907
- async getUserEvents(siteId, visitorId, params) {
1908
- return this.listEvents({ ...params, siteId, visitorId });
2322
+ async listEventsForVisitorIds(siteId, visitorIds, params) {
2323
+ const limit = Math.min(params.limit ?? 50, 200);
2324
+ const offset = params.offset ?? 0;
2325
+ const match = {
2326
+ site_id: siteId,
2327
+ visitor_id: { $in: visitorIds }
2328
+ };
2329
+ if (params.type) match.type = params.type;
2330
+ if (params.eventName) {
2331
+ match.event_name = params.eventName;
2332
+ } else if (params.eventNames && params.eventNames.length > 0) {
2333
+ match.event_name = { $in: params.eventNames };
2334
+ }
2335
+ if (params.eventSource) match.event_source = params.eventSource;
2336
+ if (params.period || params.dateFrom) {
2337
+ const { dateRange } = resolvePeriod({
2338
+ period: params.period,
2339
+ dateFrom: params.dateFrom,
2340
+ dateTo: params.dateTo
2341
+ });
2342
+ match.timestamp = { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) };
2343
+ }
2344
+ const [events, countResult] = await Promise.all([
2345
+ this.collection.find(match).sort({ timestamp: -1 }).skip(offset).limit(limit).toArray(),
2346
+ this.collection.countDocuments(match)
2347
+ ]);
2348
+ return {
2349
+ events: events.map((e) => this.toEventListItem(e)),
2350
+ total: countResult,
2351
+ limit,
2352
+ offset
2353
+ };
1909
2354
  }
1910
2355
  toEventListItem(doc) {
1911
2356
  return {
@@ -2276,6 +2721,51 @@ async function createCollector(config) {
2276
2721
  return { ...event, ip, geo, device };
2277
2722
  });
2278
2723
  }
2724
+ const identityCache = /* @__PURE__ */ new Map();
2725
+ const IDENTITY_CACHE_TTL = 5 * 60 * 1e3;
2726
+ function getCachedUserId(siteId, visitorId) {
2727
+ const key = `${siteId}:${visitorId}`;
2728
+ const entry = identityCache.get(key);
2729
+ if (!entry) return void 0;
2730
+ if (Date.now() > entry.expires) {
2731
+ identityCache.delete(key);
2732
+ return void 0;
2733
+ }
2734
+ return entry.userId;
2735
+ }
2736
+ function setCachedUserId(siteId, visitorId, userId) {
2737
+ const key = `${siteId}:${visitorId}`;
2738
+ identityCache.set(key, { userId, expires: Date.now() + IDENTITY_CACHE_TTL });
2739
+ if (identityCache.size > 1e4) {
2740
+ const now = Date.now();
2741
+ for (const [k, v] of identityCache) {
2742
+ if (now > v.expires) identityCache.delete(k);
2743
+ }
2744
+ }
2745
+ }
2746
+ async function processIdentity(events) {
2747
+ for (const event of events) {
2748
+ if (!event.visitorId || event.visitorId === "server") continue;
2749
+ if (event.type === "identify" && event.userId) {
2750
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
2751
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
2752
+ } else if (!event.userId) {
2753
+ const cached = getCachedUserId(event.siteId, event.visitorId);
2754
+ if (cached) {
2755
+ event.userId = cached;
2756
+ } else {
2757
+ const resolved = await db.getUserIdForVisitor(event.siteId, event.visitorId);
2758
+ if (resolved) {
2759
+ event.userId = resolved;
2760
+ setCachedUserId(event.siteId, event.visitorId, resolved);
2761
+ }
2762
+ }
2763
+ } else if (event.userId) {
2764
+ setCachedUserId(event.siteId, event.visitorId, event.userId);
2765
+ await db.upsertIdentity(event.siteId, event.visitorId, event.userId);
2766
+ }
2767
+ }
2768
+ }
2279
2769
  function extractIp(req) {
2280
2770
  if (config.trustProxy ?? true) {
2281
2771
  const forwarded = req.headers?.["x-forwarded-for"];
@@ -2337,11 +2827,13 @@ async function createCollector(config) {
2337
2827
  sendJson(res, 200, { ok: true });
2338
2828
  return;
2339
2829
  }
2830
+ await processIdentity(filtered);
2340
2831
  await db.insertEvents(filtered);
2341
2832
  sendJson(res, 200, { ok: true });
2342
2833
  return;
2343
2834
  }
2344
2835
  }
2836
+ await processIdentity(enriched);
2345
2837
  await db.insertEvents(enriched);
2346
2838
  sendJson(res, 200, { ok: true });
2347
2839
  } catch (err) {
@@ -2602,11 +3094,11 @@ async function createCollector(config) {
2602
3094
  async listUsers(params) {
2603
3095
  return db.listUsers(params);
2604
3096
  },
2605
- async getUserDetail(siteId, visitorId) {
2606
- return db.getUserDetail(siteId, visitorId);
3097
+ async getUserDetail(siteId, identifier) {
3098
+ return db.getUserDetail(siteId, identifier);
2607
3099
  },
2608
- async getUserEvents(siteId, visitorId, params) {
2609
- return db.getUserEvents(siteId, visitorId, params);
3100
+ async getUserEvents(siteId, identifier, params) {
3101
+ return db.getUserEvents(siteId, identifier, params);
2610
3102
  },
2611
3103
  async track(siteId, name, properties, options) {
2612
3104
  const event = {