@litemetrics/node 0.1.1 → 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(),
@@ -153,6 +165,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
153
165
  title Nullable(String),
154
166
  event_name Nullable(String),
155
167
  properties Nullable(String),
168
+ event_source LowCardinality(Nullable(String)),
169
+ event_subtype LowCardinality(Nullable(String)),
170
+ page_path Nullable(String),
171
+ target_url_path Nullable(String),
172
+ element_selector Nullable(String),
173
+ element_text Nullable(String),
174
+ scroll_depth_pct Nullable(UInt8),
156
175
  user_id Nullable(String),
157
176
  traits Nullable(String),
158
177
  country LowCardinality(Nullable(String)),
@@ -184,6 +203,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
184
203
  name String,
185
204
  domain Nullable(String),
186
205
  allowed_origins Nullable(String),
206
+ conversion_events Nullable(String),
187
207
  created_at DateTime64(3),
188
208
  updated_at DateTime64(3),
189
209
  version UInt64,
@@ -196,6 +216,39 @@ function toCHDateTime(d) {
196
216
  const iso = typeof d === "string" ? d : d.toISOString();
197
217
  return iso.replace("T", " ").replace("Z", "");
198
218
  }
219
+ function buildFilterConditions(filters) {
220
+ if (!filters) return { conditions: [], params: {} };
221
+ const map = {
222
+ "geo.country": "country",
223
+ "geo.city": "city",
224
+ "geo.region": "region",
225
+ "language": "language",
226
+ "device.type": "device_type",
227
+ "device.browser": "browser",
228
+ "device.os": "os",
229
+ "utm.source": "utm_source",
230
+ "utm.medium": "utm_medium",
231
+ "utm.campaign": "utm_campaign",
232
+ "utm.term": "utm_term",
233
+ "utm.content": "utm_content",
234
+ "referrer": "referrer",
235
+ "event_source": "event_source",
236
+ "event_subtype": "event_subtype",
237
+ "page_path": "page_path",
238
+ "target_url_path": "target_url_path",
239
+ "event_name": "event_name",
240
+ "type": "type"
241
+ };
242
+ const conditions = [];
243
+ const params = {};
244
+ for (const [key, value] of Object.entries(filters)) {
245
+ if (!value || !map[key]) continue;
246
+ const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
247
+ conditions.push(`${map[key]} = {${paramKey}:String}`);
248
+ params[paramKey] = value;
249
+ }
250
+ return { conditions, params };
251
+ }
199
252
  var ClickHouseAdapter = class {
200
253
  client;
201
254
  constructor(url) {
@@ -209,6 +262,17 @@ var ClickHouseAdapter = class {
209
262
  async init() {
210
263
  await this.client.command({ query: CREATE_EVENTS_TABLE });
211
264
  await this.client.command({ query: CREATE_SITES_TABLE });
265
+ await this.client.command({ query: CREATE_IDENTITY_MAP_TABLE });
266
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
267
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
268
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
269
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS target_url_path Nullable(String)` });
270
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS element_selector Nullable(String)` });
271
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS element_text Nullable(String)` });
272
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS scroll_depth_pct Nullable(UInt8)` });
273
+ await this.client.command({
274
+ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
275
+ });
212
276
  }
213
277
  async close() {
214
278
  await this.client.close();
@@ -227,6 +291,13 @@ var ClickHouseAdapter = class {
227
291
  title: e.title ?? null,
228
292
  event_name: e.name ?? null,
229
293
  properties: e.properties ? JSON.stringify(e.properties) : null,
294
+ event_source: e.eventSource ?? null,
295
+ event_subtype: e.eventSubtype ?? null,
296
+ page_path: e.pagePath ?? null,
297
+ target_url_path: e.targetUrlPath ?? null,
298
+ element_selector: e.elementSelector ?? null,
299
+ element_text: e.elementText ?? null,
300
+ scroll_depth_pct: e.scrollDepthPct ?? null,
230
301
  user_id: e.userId ?? null,
231
302
  traits: e.traits ? JSON.stringify(e.traits) : null,
232
303
  country: e.geo?.country ?? null,
@@ -263,6 +334,8 @@ var ClickHouseAdapter = class {
263
334
  to: toCHDateTime(dateRange.to),
264
335
  limit
265
336
  };
337
+ const filter = buildFilterConditions(q.filters);
338
+ const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
266
339
  let data = [];
267
340
  let total = 0;
268
341
  switch (q.metric) {
@@ -272,8 +345,8 @@ var ClickHouseAdapter = class {
272
345
  WHERE site_id = {siteId:String}
273
346
  AND timestamp >= {from:String}
274
347
  AND timestamp <= {to:String}
275
- AND type = 'pageview'`,
276
- params
348
+ AND type = 'pageview'${filterSql}`,
349
+ { ...params, ...filter.params }
277
350
  );
278
351
  total = Number(rows[0]?.value ?? 0);
279
352
  data = [{ key: "pageviews", value: total }];
@@ -284,8 +357,8 @@ var ClickHouseAdapter = class {
284
357
  `SELECT uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
285
358
  WHERE site_id = {siteId:String}
286
359
  AND timestamp >= {from:String}
287
- AND timestamp <= {to:String}`,
288
- params
360
+ AND timestamp <= {to:String}${filterSql}`,
361
+ { ...params, ...filter.params }
289
362
  );
290
363
  total = Number(rows[0]?.value ?? 0);
291
364
  data = [{ key: "visitors", value: total }];
@@ -296,8 +369,8 @@ var ClickHouseAdapter = class {
296
369
  `SELECT uniq(session_id) AS value FROM ${EVENTS_TABLE}
297
370
  WHERE site_id = {siteId:String}
298
371
  AND timestamp >= {from:String}
299
- AND timestamp <= {to:String}`,
300
- params
372
+ AND timestamp <= {to:String}${filterSql}`,
373
+ { ...params, ...filter.params }
301
374
  );
302
375
  total = Number(rows[0]?.value ?? 0);
303
376
  data = [{ key: "sessions", value: total }];
@@ -309,13 +382,33 @@ var ClickHouseAdapter = class {
309
382
  WHERE site_id = {siteId:String}
310
383
  AND timestamp >= {from:String}
311
384
  AND timestamp <= {to:String}
312
- AND type = 'event'`,
313
- params
385
+ AND type = 'event'${filterSql}`,
386
+ { ...params, ...filter.params }
314
387
  );
315
388
  total = Number(rows[0]?.value ?? 0);
316
389
  data = [{ key: "events", value: total }];
317
390
  break;
318
391
  }
392
+ case "conversions": {
393
+ const conversionEvents = q.conversionEvents ?? [];
394
+ if (conversionEvents.length === 0) {
395
+ total = 0;
396
+ data = [{ key: "conversions", value: 0 }];
397
+ break;
398
+ }
399
+ const rows = await this.queryRows(
400
+ `SELECT count() AS value FROM ${EVENTS_TABLE}
401
+ WHERE site_id = {siteId:String}
402
+ AND timestamp >= {from:String}
403
+ AND timestamp <= {to:String}
404
+ AND type = 'event'
405
+ AND event_name IN {eventNames:Array(String)}${filterSql}`,
406
+ { ...params, eventNames: conversionEvents, ...filter.params }
407
+ );
408
+ total = Number(rows[0]?.value ?? 0);
409
+ data = [{ key: "conversions", value: total }];
410
+ break;
411
+ }
319
412
  case "top_pages": {
320
413
  const rows = await this.queryRows(
321
414
  `SELECT url AS key, count() AS value FROM ${EVENTS_TABLE}
@@ -323,11 +416,11 @@ var ClickHouseAdapter = class {
323
416
  AND timestamp >= {from:String}
324
417
  AND timestamp <= {to:String}
325
418
  AND type = 'pageview'
326
- AND url IS NOT NULL
419
+ AND url IS NOT NULL${filterSql}
327
420
  GROUP BY url
328
421
  ORDER BY value DESC
329
422
  LIMIT {limit:UInt32}`,
330
- params
423
+ { ...params, ...filter.params }
331
424
  );
332
425
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
333
426
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -341,11 +434,11 @@ var ClickHouseAdapter = class {
341
434
  AND timestamp <= {to:String}
342
435
  AND type = 'pageview'
343
436
  AND referrer IS NOT NULL
344
- AND referrer != ''
437
+ AND referrer != ''${filterSql}
345
438
  GROUP BY referrer
346
439
  ORDER BY value DESC
347
440
  LIMIT {limit:UInt32}`,
348
- params
441
+ { ...params, ...filter.params }
349
442
  );
350
443
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
351
444
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -357,11 +450,11 @@ var ClickHouseAdapter = class {
357
450
  WHERE site_id = {siteId:String}
358
451
  AND timestamp >= {from:String}
359
452
  AND timestamp <= {to:String}
360
- AND country IS NOT NULL
453
+ AND country IS NOT NULL${filterSql}
361
454
  GROUP BY country
362
455
  ORDER BY value DESC
363
456
  LIMIT {limit:UInt32}`,
364
- params
457
+ { ...params, ...filter.params }
365
458
  );
366
459
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
367
460
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -373,11 +466,11 @@ var ClickHouseAdapter = class {
373
466
  WHERE site_id = {siteId:String}
374
467
  AND timestamp >= {from:String}
375
468
  AND timestamp <= {to:String}
376
- AND city IS NOT NULL
469
+ AND city IS NOT NULL${filterSql}
377
470
  GROUP BY city
378
471
  ORDER BY value DESC
379
472
  LIMIT {limit:UInt32}`,
380
- params
473
+ { ...params, ...filter.params }
381
474
  );
382
475
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
383
476
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -391,10 +484,132 @@ var ClickHouseAdapter = class {
391
484
  AND timestamp <= {to:String}
392
485
  AND type = 'event'
393
486
  AND event_name IS NOT NULL
487
+ ${filterSql}
488
+ GROUP BY event_name
489
+ ORDER BY value DESC
490
+ LIMIT {limit:UInt32}`,
491
+ { ...params, ...filter.params }
492
+ );
493
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
494
+ total = data.reduce((sum, d) => sum + d.value, 0);
495
+ break;
496
+ }
497
+ case "top_conversions": {
498
+ const conversionEvents = q.conversionEvents ?? [];
499
+ if (conversionEvents.length === 0) {
500
+ total = 0;
501
+ data = [];
502
+ break;
503
+ }
504
+ const rows = await this.queryRows(
505
+ `SELECT event_name AS key, count() AS value FROM ${EVENTS_TABLE}
506
+ WHERE site_id = {siteId:String}
507
+ AND timestamp >= {from:String}
508
+ AND timestamp <= {to:String}
509
+ AND type = 'event'
510
+ AND event_name IN {eventNames:Array(String)}
511
+ ${filterSql}
394
512
  GROUP BY event_name
395
513
  ORDER BY value DESC
396
514
  LIMIT {limit:UInt32}`,
397
- params
515
+ { ...params, eventNames: conversionEvents, ...filter.params }
516
+ );
517
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
518
+ total = data.reduce((sum, d) => sum + d.value, 0);
519
+ break;
520
+ }
521
+ case "top_exit_pages": {
522
+ const rows = await this.queryRows(
523
+ `SELECT exit_url AS key, count() AS value FROM (
524
+ SELECT session_id, argMax(url, timestamp) AS exit_url
525
+ FROM ${EVENTS_TABLE}
526
+ WHERE site_id = {siteId:String}
527
+ AND timestamp >= {from:String}
528
+ AND timestamp <= {to:String}
529
+ AND type = 'pageview'
530
+ AND url IS NOT NULL${filterSql}
531
+ GROUP BY session_id
532
+ )
533
+ GROUP BY exit_url
534
+ ORDER BY value DESC
535
+ LIMIT {limit:UInt32}`,
536
+ { ...params, ...filter.params }
537
+ );
538
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
539
+ total = data.reduce((sum, d) => sum + d.value, 0);
540
+ break;
541
+ }
542
+ case "top_transitions": {
543
+ const rows = await this.queryRows(
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
547
+ FROM ${EVENTS_TABLE}
548
+ WHERE site_id = {siteId:String}
549
+ AND timestamp >= {from:String}
550
+ AND timestamp <= {to:String}
551
+ AND type = 'pageview'
552
+ AND url IS NOT NULL${filterSql}
553
+ )
554
+ WHERE prev_url IS NOT NULL AND prev_url != ''
555
+ GROUP BY key
556
+ ORDER BY value DESC
557
+ LIMIT {limit:UInt32}`,
558
+ { ...params, ...filter.params }
559
+ );
560
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
561
+ total = data.reduce((sum, d) => sum + d.value, 0);
562
+ break;
563
+ }
564
+ case "top_scroll_pages": {
565
+ const rows = await this.queryRows(
566
+ `SELECT page_path AS key, count() AS value FROM ${EVENTS_TABLE}
567
+ WHERE site_id = {siteId:String}
568
+ AND timestamp >= {from:String}
569
+ AND timestamp <= {to:String}
570
+ AND type = 'event'
571
+ AND event_subtype = 'scroll_depth'
572
+ AND page_path IS NOT NULL${filterSql}
573
+ GROUP BY page_path
574
+ ORDER BY value DESC
575
+ LIMIT {limit:UInt32}`,
576
+ { ...params, ...filter.params }
577
+ );
578
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
579
+ total = data.reduce((sum, d) => sum + d.value, 0);
580
+ break;
581
+ }
582
+ case "top_button_clicks": {
583
+ const rows = await this.queryRows(
584
+ `SELECT ifNull(element_text, element_selector) AS key, count() AS value FROM ${EVENTS_TABLE}
585
+ WHERE site_id = {siteId:String}
586
+ AND timestamp >= {from:String}
587
+ AND timestamp <= {to:String}
588
+ AND type = 'event'
589
+ AND event_subtype = 'button_click'
590
+ AND (element_text IS NOT NULL OR element_selector IS NOT NULL)${filterSql}
591
+ GROUP BY key
592
+ ORDER BY value DESC
593
+ LIMIT {limit:UInt32}`,
594
+ { ...params, ...filter.params }
595
+ );
596
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
597
+ total = data.reduce((sum, d) => sum + d.value, 0);
598
+ break;
599
+ }
600
+ case "top_link_targets": {
601
+ const rows = await this.queryRows(
602
+ `SELECT target_url_path AS key, count() AS value FROM ${EVENTS_TABLE}
603
+ WHERE site_id = {siteId:String}
604
+ AND timestamp >= {from:String}
605
+ AND timestamp <= {to:String}
606
+ AND type = 'event'
607
+ AND event_subtype IN ('link_click','outbound_click')
608
+ AND target_url_path IS NOT NULL${filterSql}
609
+ GROUP BY target_url_path
610
+ ORDER BY value DESC
611
+ LIMIT {limit:UInt32}`,
612
+ { ...params, ...filter.params }
398
613
  );
399
614
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
400
615
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -407,10 +622,11 @@ var ClickHouseAdapter = class {
407
622
  AND timestamp >= {from:String}
408
623
  AND timestamp <= {to:String}
409
624
  AND device_type IS NOT NULL
625
+ ${filterSql}
410
626
  GROUP BY device_type
411
627
  ORDER BY value DESC
412
628
  LIMIT {limit:UInt32}`,
413
- params
629
+ { ...params, ...filter.params }
414
630
  );
415
631
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
416
632
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -423,10 +639,11 @@ var ClickHouseAdapter = class {
423
639
  AND timestamp >= {from:String}
424
640
  AND timestamp <= {to:String}
425
641
  AND browser IS NOT NULL
642
+ ${filterSql}
426
643
  GROUP BY browser
427
644
  ORDER BY value DESC
428
645
  LIMIT {limit:UInt32}`,
429
- params
646
+ { ...params, ...filter.params }
430
647
  );
431
648
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
432
649
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -439,10 +656,62 @@ var ClickHouseAdapter = class {
439
656
  AND timestamp >= {from:String}
440
657
  AND timestamp <= {to:String}
441
658
  AND os IS NOT NULL
659
+ ${filterSql}
442
660
  GROUP BY os
443
661
  ORDER BY value DESC
444
662
  LIMIT {limit:UInt32}`,
445
- params
663
+ { ...params, ...filter.params }
664
+ );
665
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
666
+ total = data.reduce((sum, d) => sum + d.value, 0);
667
+ break;
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 }
446
715
  );
447
716
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
448
717
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -450,7 +719,7 @@ var ClickHouseAdapter = class {
450
719
  }
451
720
  }
452
721
  const result = { metric: q.metric, period, data, total };
453
- if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
722
+ if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
454
723
  const prevRange = previousPeriodRange(dateRange);
455
724
  const prevResult = await this.query({
456
725
  ...q,
@@ -480,7 +749,12 @@ var ClickHouseAdapter = class {
480
749
  const granularity = params.granularity ?? autoGranularity(period);
481
750
  const bucketFn = this.granularityToClickHouseFunc(granularity);
482
751
  const dateFormat = granularityToDateFormat(granularity);
752
+ const filter = buildFilterConditions(params.filters);
753
+ const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
483
754
  const typeFilter = params.metric === "pageviews" ? `AND type = 'pageview'` : "";
755
+ const eventsFilter = params.metric === "events" ? `AND type = 'event'` : "";
756
+ const conversionsFilter = params.metric === "conversions" ? `AND type = 'event' AND event_name IN {eventNames:Array(String)}` : "";
757
+ const extraFilters = [typeFilter, eventsFilter, conversionsFilter, filterSql].filter(Boolean).join(" ");
484
758
  let sql;
485
759
  if (params.metric === "visitors" || params.metric === "sessions") {
486
760
  const field = params.metric === "visitors" ? "visitor_id" : "session_id";
@@ -490,7 +764,7 @@ var ClickHouseAdapter = class {
490
764
  WHERE site_id = {siteId:String}
491
765
  AND timestamp >= {from:String}
492
766
  AND timestamp <= {to:String}
493
- ${typeFilter}
767
+ ${extraFilters}
494
768
  GROUP BY bucket
495
769
  ORDER BY bucket ASC
496
770
  `;
@@ -501,7 +775,7 @@ var ClickHouseAdapter = class {
501
775
  WHERE site_id = {siteId:String}
502
776
  AND timestamp >= {from:String}
503
777
  AND timestamp <= {to:String}
504
- ${typeFilter}
778
+ ${extraFilters}
505
779
  GROUP BY bucket
506
780
  ORDER BY bucket ASC
507
781
  `;
@@ -509,7 +783,9 @@ var ClickHouseAdapter = class {
509
783
  const rows = await this.queryRows(sql, {
510
784
  siteId: params.siteId,
511
785
  from: toCHDateTime(dateRange.from),
512
- to: toCHDateTime(dateRange.to)
786
+ to: toCHDateTime(dateRange.to),
787
+ eventNames: params.conversionEvents ?? [],
788
+ ...filter.params
513
789
  });
514
790
  const mappedRows = rows.map((r) => ({
515
791
  _id: this.convertClickHouseBucket(r.bucket, granularity),
@@ -627,6 +903,14 @@ var ClickHouseAdapter = class {
627
903
  conditions.push(`event_name = {eventName:String}`);
628
904
  queryParams.eventName = params.eventName;
629
905
  }
906
+ if (params.eventSource) {
907
+ conditions.push(`event_source = {eventSource:String}`);
908
+ queryParams.eventSource = params.eventSource;
909
+ }
910
+ if (params.eventNames && params.eventNames.length > 0) {
911
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
912
+ queryParams.eventNames = params.eventNames;
913
+ }
630
914
  if (params.visitorId) {
631
915
  conditions.push(`visitor_id = {visitorId:String}`);
632
916
  queryParams.visitorId = params.visitorId;
@@ -649,7 +933,9 @@ var ClickHouseAdapter = class {
649
933
  const [events, countRows] = await Promise.all([
650
934
  this.queryRows(
651
935
  `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
652
- event_name, properties, user_id, traits, country, city, region,
936
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
937
+ element_selector, element_text, scroll_depth_pct,
938
+ user_id, traits, country, city, region,
653
939
  device_type, browser, os, language,
654
940
  utm_source, utm_medium, utm_campaign, utm_term, utm_content
655
941
  FROM ${EVENTS_TABLE}
@@ -684,36 +970,59 @@ var ClickHouseAdapter = class {
684
970
  const where = conditions.join(" AND ");
685
971
  const [userRows, countRows] = await Promise.all([
686
972
  this.queryRows(
687
- `SELECT
688
- visitor_id,
689
- anyLast(user_id) AS userId,
690
- anyLast(traits) AS traits,
691
- min(timestamp) AS firstSeen,
692
- 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,
693
985
  count() AS totalEvents,
694
- countIf(type = 'pageview') AS totalPageviews,
695
- uniq(session_id) AS totalSessions,
696
- anyLast(url) AS lastUrl,
697
- anyLast(device_type) AS device_type,
698
- anyLast(browser) AS browser,
699
- anyLast(os) AS os,
700
- anyLast(country) AS country,
701
- anyLast(city) AS city,
702
- anyLast(region) AS region,
703
- anyLast(language) AS language
704
- FROM ${EVENTS_TABLE}
705
- WHERE ${where}
706
- 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
707
1009
  ORDER BY lastSeen DESC
708
1010
  LIMIT {limit:UInt32}
709
1011
  OFFSET {offset:UInt32}`,
710
1012
  queryParams
711
1013
  ),
712
1014
  this.queryRows(
713
- `SELECT count() AS total FROM (
714
- SELECT visitor_id FROM ${EVENTS_TABLE}
715
- WHERE ${where}
716
- 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
717
1026
  )`,
718
1027
  queryParams
719
1028
  )
@@ -728,9 +1037,19 @@ var ClickHouseAdapter = class {
728
1037
  totalPageviews: Number(u.totalPageviews),
729
1038
  totalSessions: Number(u.totalSessions),
730
1039
  lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
1040
+ referrer: u.referrer ? String(u.referrer) : void 0,
731
1041
  device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
732
1042
  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,
733
- language: u.language ? String(u.language) : void 0
1043
+ language: u.language ? String(u.language) : void 0,
1044
+ timezone: u.timezone ? String(u.timezone) : void 0,
1045
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
1046
+ utm: u.utm_source ? {
1047
+ source: String(u.utm_source),
1048
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
1049
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
1050
+ term: u.utm_term ? String(u.utm_term) : void 0,
1051
+ content: u.utm_content ? String(u.utm_content) : void 0
1052
+ } : void 0
734
1053
  }));
735
1054
  return {
736
1055
  users,
@@ -739,13 +1058,178 @@ var ClickHouseAdapter = class {
739
1058
  offset
740
1059
  };
741
1060
  }
742
- async getUserDetail(siteId, visitorId) {
743
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
744
- 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);
745
1073
  return user ?? null;
746
1074
  }
747
- async getUserEvents(siteId, visitorId, params) {
748
- 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
+ };
749
1233
  }
750
1234
  // ─── Site Management ──────────────────────────────────────
751
1235
  async createSite(data) {
@@ -758,6 +1242,7 @@ var ClickHouseAdapter = class {
758
1242
  name: data.name,
759
1243
  domain: data.domain,
760
1244
  allowedOrigins: data.allowedOrigins,
1245
+ conversionEvents: data.conversionEvents,
761
1246
  createdAt: nowISO,
762
1247
  updatedAt: nowISO
763
1248
  };
@@ -769,6 +1254,7 @@ var ClickHouseAdapter = class {
769
1254
  name: site.name,
770
1255
  domain: site.domain ?? null,
771
1256
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1257
+ conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
772
1258
  created_at: nowCH,
773
1259
  updated_at: nowCH,
774
1260
  version: 1,
@@ -780,7 +1266,7 @@ var ClickHouseAdapter = class {
780
1266
  }
781
1267
  async getSite(siteId) {
782
1268
  const rows = await this.queryRows(
783
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1269
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
784
1270
  FROM ${SITES_TABLE} FINAL
785
1271
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
786
1272
  { siteId }
@@ -789,7 +1275,7 @@ var ClickHouseAdapter = class {
789
1275
  }
790
1276
  async getSiteBySecret(secretKey) {
791
1277
  const rows = await this.queryRows(
792
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1278
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
793
1279
  FROM ${SITES_TABLE} FINAL
794
1280
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
795
1281
  { secretKey }
@@ -798,7 +1284,7 @@ var ClickHouseAdapter = class {
798
1284
  }
799
1285
  async listSites() {
800
1286
  const rows = await this.queryRows(
801
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1287
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
802
1288
  FROM ${SITES_TABLE} FINAL
803
1289
  WHERE is_deleted = 0
804
1290
  ORDER BY created_at DESC`,
@@ -808,7 +1294,7 @@ var ClickHouseAdapter = class {
808
1294
  }
809
1295
  async updateSite(siteId, data) {
810
1296
  const currentRows = await this.queryRows(
811
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at, version
1297
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
812
1298
  FROM ${SITES_TABLE} FINAL
813
1299
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
814
1300
  { siteId }
@@ -822,6 +1308,7 @@ var ClickHouseAdapter = class {
822
1308
  const newName = data.name !== void 0 ? data.name : String(current.name);
823
1309
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
824
1310
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1311
+ const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
825
1312
  await this.client.insert({
826
1313
  table: SITES_TABLE,
827
1314
  values: [{
@@ -830,6 +1317,7 @@ var ClickHouseAdapter = class {
830
1317
  name: newName,
831
1318
  domain: newDomain,
832
1319
  allowed_origins: newOrigins,
1320
+ conversion_events: newConversions,
833
1321
  created_at: toCHDateTime(String(current.created_at)),
834
1322
  updated_at: nowCH,
835
1323
  version: newVersion,
@@ -843,13 +1331,14 @@ var ClickHouseAdapter = class {
843
1331
  name: newName,
844
1332
  domain: newDomain ?? void 0,
845
1333
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1334
+ conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
846
1335
  createdAt: String(current.created_at),
847
1336
  updatedAt: nowISO
848
1337
  };
849
1338
  }
850
1339
  async deleteSite(siteId) {
851
1340
  const currentRows = await this.queryRows(
852
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
1341
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
853
1342
  FROM ${SITES_TABLE} FINAL
854
1343
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
855
1344
  { siteId }
@@ -865,6 +1354,7 @@ var ClickHouseAdapter = class {
865
1354
  name: String(current.name),
866
1355
  domain: current.domain ? String(current.domain) : null,
867
1356
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1357
+ conversion_events: current.conversion_events ? String(current.conversion_events) : null,
868
1358
  created_at: toCHDateTime(String(current.created_at)),
869
1359
  updated_at: nowCH,
870
1360
  version: Number(current.version) + 1,
@@ -876,7 +1366,7 @@ var ClickHouseAdapter = class {
876
1366
  }
877
1367
  async regenerateSecret(siteId) {
878
1368
  const currentRows = await this.queryRows(
879
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
1369
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
880
1370
  FROM ${SITES_TABLE} FINAL
881
1371
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
882
1372
  { siteId }
@@ -895,6 +1385,7 @@ var ClickHouseAdapter = class {
895
1385
  name: String(current.name),
896
1386
  domain: current.domain ? String(current.domain) : null,
897
1387
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1388
+ conversion_events: current.conversion_events ? String(current.conversion_events) : null,
898
1389
  created_at: toCHDateTime(String(current.created_at)),
899
1390
  updated_at: nowCH,
900
1391
  version: Number(current.version) + 1,
@@ -908,6 +1399,7 @@ var ClickHouseAdapter = class {
908
1399
  name: String(current.name),
909
1400
  domain: current.domain ? String(current.domain) : void 0,
910
1401
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1402
+ conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
911
1403
  createdAt: String(current.created_at),
912
1404
  updatedAt: nowISO
913
1405
  };
@@ -928,6 +1420,7 @@ var ClickHouseAdapter = class {
928
1420
  name: String(row.name),
929
1421
  domain: row.domain ? String(row.domain) : void 0,
930
1422
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1423
+ conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
931
1424
  createdAt: new Date(String(row.created_at)).toISOString(),
932
1425
  updatedAt: new Date(String(row.updated_at)).toISOString()
933
1426
  };
@@ -944,6 +1437,13 @@ var ClickHouseAdapter = class {
944
1437
  title: row.title ? String(row.title) : void 0,
945
1438
  name: row.event_name ? String(row.event_name) : void 0,
946
1439
  properties: this.parseJSON(row.properties),
1440
+ eventSource: row.event_source ? String(row.event_source) : void 0,
1441
+ eventSubtype: row.event_subtype ? String(row.event_subtype) : void 0,
1442
+ pagePath: row.page_path ? String(row.page_path) : void 0,
1443
+ targetUrlPath: row.target_url_path ? String(row.target_url_path) : void 0,
1444
+ elementSelector: row.element_selector ? String(row.element_selector) : void 0,
1445
+ elementText: row.element_text ? String(row.element_text) : void 0,
1446
+ scrollDepthPct: row.scroll_depth_pct !== null && row.scroll_depth_pct !== void 0 ? Number(row.scroll_depth_pct) : void 0,
947
1447
  userId: row.user_id ? String(row.user_id) : void 0,
948
1448
  traits: this.parseJSON(row.traits),
949
1449
  geo: row.country ? {
@@ -980,11 +1480,43 @@ var ClickHouseAdapter = class {
980
1480
  import { MongoClient } from "mongodb";
981
1481
  var EVENTS_COLLECTION = "litemetrics_events";
982
1482
  var SITES_COLLECTION = "litemetrics_sites";
1483
+ var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1484
+ function buildFilterMatch(filters) {
1485
+ if (!filters) return {};
1486
+ const map = {
1487
+ "geo.country": "country",
1488
+ "geo.city": "city",
1489
+ "geo.region": "region",
1490
+ "language": "language",
1491
+ "device.type": "device_type",
1492
+ "device.browser": "browser",
1493
+ "device.os": "os",
1494
+ "utm.source": "utm_source",
1495
+ "utm.medium": "utm_medium",
1496
+ "utm.campaign": "utm_campaign",
1497
+ "utm.term": "utm_term",
1498
+ "utm.content": "utm_content",
1499
+ "referrer": "referrer",
1500
+ "event_source": "event_source",
1501
+ "event_subtype": "event_subtype",
1502
+ "page_path": "page_path",
1503
+ "target_url_path": "target_url_path",
1504
+ "event_name": "event_name",
1505
+ "type": "type"
1506
+ };
1507
+ const match = {};
1508
+ for (const [key, value] of Object.entries(filters)) {
1509
+ if (!value || !map[key]) continue;
1510
+ match[map[key]] = value;
1511
+ }
1512
+ return match;
1513
+ }
983
1514
  var MongoDBAdapter = class {
984
1515
  client;
985
1516
  db;
986
1517
  collection;
987
1518
  sites;
1519
+ identityMap;
988
1520
  constructor(url) {
989
1521
  this.client = new MongoClient(url);
990
1522
  }
@@ -993,13 +1525,16 @@ var MongoDBAdapter = class {
993
1525
  this.db = this.client.db();
994
1526
  this.collection = this.db.collection(EVENTS_COLLECTION);
995
1527
  this.sites = this.db.collection(SITES_COLLECTION);
1528
+ this.identityMap = this.db.collection(IDENTITY_MAP_COLLECTION);
996
1529
  await Promise.all([
997
1530
  this.collection.createIndex({ site_id: 1, timestamp: -1 }),
998
1531
  this.collection.createIndex({ site_id: 1, type: 1 }),
999
1532
  this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1000
1533
  this.collection.createIndex({ site_id: 1, session_id: 1 }),
1001
1534
  this.sites.createIndex({ site_id: 1 }, { unique: true }),
1002
- 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 })
1003
1538
  ]);
1004
1539
  }
1005
1540
  async insertEvents(events) {
@@ -1015,6 +1550,13 @@ var MongoDBAdapter = class {
1015
1550
  title: e.title ?? null,
1016
1551
  event_name: e.name ?? null,
1017
1552
  properties: e.properties ?? null,
1553
+ event_source: e.eventSource ?? null,
1554
+ event_subtype: e.eventSubtype ?? null,
1555
+ page_path: e.pagePath ?? null,
1556
+ target_url_path: e.targetUrlPath ?? null,
1557
+ element_selector: e.elementSelector ?? null,
1558
+ element_text: e.elementText ?? null,
1559
+ scroll_depth_pct: e.scrollDepthPct ?? null,
1018
1560
  user_id: e.userId ?? null,
1019
1561
  traits: e.traits ?? null,
1020
1562
  country: e.geo?.country ?? null,
@@ -1045,12 +1587,13 @@ var MongoDBAdapter = class {
1045
1587
  site_id: siteId,
1046
1588
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1047
1589
  };
1590
+ const filterMatch = buildFilterMatch(q.filters);
1048
1591
  let data = [];
1049
1592
  let total = 0;
1050
1593
  switch (q.metric) {
1051
1594
  case "pageviews": {
1052
1595
  const [result2] = await this.collection.aggregate([
1053
- { $match: { ...baseMatch, type: "pageview" } },
1596
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1054
1597
  { $count: "count" }
1055
1598
  ]).toArray();
1056
1599
  total = result2?.count ?? 0;
@@ -1059,7 +1602,7 @@ var MongoDBAdapter = class {
1059
1602
  }
1060
1603
  case "visitors": {
1061
1604
  const [result2] = await this.collection.aggregate([
1062
- { $match: baseMatch },
1605
+ { $match: { ...baseMatch, ...filterMatch } },
1063
1606
  { $group: { _id: "$visitor_id" } },
1064
1607
  { $count: "count" }
1065
1608
  ]).toArray();
@@ -1069,7 +1612,7 @@ var MongoDBAdapter = class {
1069
1612
  }
1070
1613
  case "sessions": {
1071
1614
  const [result2] = await this.collection.aggregate([
1072
- { $match: baseMatch },
1615
+ { $match: { ...baseMatch, ...filterMatch } },
1073
1616
  { $group: { _id: "$session_id" } },
1074
1617
  { $count: "count" }
1075
1618
  ]).toArray();
@@ -1079,16 +1622,31 @@ var MongoDBAdapter = class {
1079
1622
  }
1080
1623
  case "events": {
1081
1624
  const [result2] = await this.collection.aggregate([
1082
- { $match: { ...baseMatch, type: "event" } },
1625
+ { $match: { ...baseMatch, ...filterMatch, type: "event" } },
1083
1626
  { $count: "count" }
1084
1627
  ]).toArray();
1085
1628
  total = result2?.count ?? 0;
1086
1629
  data = [{ key: "events", value: total }];
1087
1630
  break;
1088
1631
  }
1632
+ case "conversions": {
1633
+ const conversionEvents = q.conversionEvents ?? [];
1634
+ if (conversionEvents.length === 0) {
1635
+ total = 0;
1636
+ data = [{ key: "conversions", value: 0 }];
1637
+ break;
1638
+ }
1639
+ const [result2] = await this.collection.aggregate([
1640
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1641
+ { $count: "count" }
1642
+ ]).toArray();
1643
+ total = result2?.count ?? 0;
1644
+ data = [{ key: "conversions", value: total }];
1645
+ break;
1646
+ }
1089
1647
  case "top_pages": {
1090
1648
  const rows = await this.collection.aggregate([
1091
- { $match: { ...baseMatch, type: "pageview", url: { $ne: null } } },
1649
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1092
1650
  { $group: { _id: "$url", value: { $sum: 1 } } },
1093
1651
  { $sort: { value: -1 } },
1094
1652
  { $limit: limit }
@@ -1099,7 +1657,7 @@ var MongoDBAdapter = class {
1099
1657
  }
1100
1658
  case "top_referrers": {
1101
1659
  const rows = await this.collection.aggregate([
1102
- { $match: { ...baseMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1660
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1103
1661
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1104
1662
  { $sort: { value: -1 } },
1105
1663
  { $limit: limit }
@@ -1110,7 +1668,7 @@ var MongoDBAdapter = class {
1110
1668
  }
1111
1669
  case "top_countries": {
1112
1670
  const rows = await this.collection.aggregate([
1113
- { $match: { ...baseMatch, country: { $ne: null } } },
1671
+ { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
1114
1672
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1115
1673
  { $project: { _id: 1, value: { $size: "$value" } } },
1116
1674
  { $sort: { value: -1 } },
@@ -1122,7 +1680,7 @@ var MongoDBAdapter = class {
1122
1680
  }
1123
1681
  case "top_cities": {
1124
1682
  const rows = await this.collection.aggregate([
1125
- { $match: { ...baseMatch, city: { $ne: null } } },
1683
+ { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
1126
1684
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1127
1685
  { $project: { _id: 1, value: { $size: "$value" } } },
1128
1686
  { $sort: { value: -1 } },
@@ -1134,7 +1692,24 @@ var MongoDBAdapter = class {
1134
1692
  }
1135
1693
  case "top_events": {
1136
1694
  const rows = await this.collection.aggregate([
1137
- { $match: { ...baseMatch, type: "event", event_name: { $ne: null } } },
1695
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
1696
+ { $group: { _id: "$event_name", value: { $sum: 1 } } },
1697
+ { $sort: { value: -1 } },
1698
+ { $limit: limit }
1699
+ ]).toArray();
1700
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1701
+ total = data.reduce((sum, d) => sum + d.value, 0);
1702
+ break;
1703
+ }
1704
+ case "top_conversions": {
1705
+ const conversionEvents = q.conversionEvents ?? [];
1706
+ if (conversionEvents.length === 0) {
1707
+ total = 0;
1708
+ data = [];
1709
+ break;
1710
+ }
1711
+ const rows = await this.collection.aggregate([
1712
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1138
1713
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1139
1714
  { $sort: { value: -1 } },
1140
1715
  { $limit: limit }
@@ -1143,9 +1718,92 @@ var MongoDBAdapter = class {
1143
1718
  total = data.reduce((sum, d) => sum + d.value, 0);
1144
1719
  break;
1145
1720
  }
1721
+ case "top_exit_pages": {
1722
+ const rows = await this.collection.aggregate([
1723
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1724
+ { $sort: { timestamp: 1 } },
1725
+ { $group: { _id: "$session_id", url: { $last: "$url" } } },
1726
+ { $group: { _id: "$url", value: { $sum: 1 } } },
1727
+ { $sort: { value: -1 } },
1728
+ { $limit: limit }
1729
+ ]).toArray();
1730
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1731
+ total = data.reduce((sum, d) => sum + d.value, 0);
1732
+ break;
1733
+ }
1734
+ case "top_transitions": {
1735
+ const rows = await this.collection.aggregate([
1736
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1737
+ {
1738
+ $setWindowFields: {
1739
+ partitionBy: "$session_id",
1740
+ sortBy: { timestamp: 1 },
1741
+ output: {
1742
+ prev_url: { $shift: { output: "$url", by: -1 } }
1743
+ }
1744
+ }
1745
+ },
1746
+ { $match: { prev_url: { $ne: null } } },
1747
+ { $group: { _id: { $concat: ["$prev_url", " \u2192 ", "$url"] }, value: { $sum: 1 } } },
1748
+ { $sort: { value: -1 } },
1749
+ { $limit: limit }
1750
+ ]).toArray();
1751
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1752
+ total = data.reduce((sum, d) => sum + d.value, 0);
1753
+ break;
1754
+ }
1755
+ case "top_scroll_pages": {
1756
+ const rows = await this.collection.aggregate([
1757
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
1758
+ { $group: { _id: "$page_path", value: { $sum: 1 } } },
1759
+ { $sort: { value: -1 } },
1760
+ { $limit: limit }
1761
+ ]).toArray();
1762
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1763
+ total = data.reduce((sum, d) => sum + d.value, 0);
1764
+ break;
1765
+ }
1766
+ case "top_button_clicks": {
1767
+ const rows = await this.collection.aggregate([
1768
+ {
1769
+ $match: {
1770
+ ...baseMatch,
1771
+ ...filterMatch,
1772
+ type: "event",
1773
+ event_subtype: "button_click",
1774
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1775
+ }
1776
+ },
1777
+ { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1778
+ { $sort: { value: -1 } },
1779
+ { $limit: limit }
1780
+ ]).toArray();
1781
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1782
+ total = data.reduce((sum, d) => sum + d.value, 0);
1783
+ break;
1784
+ }
1785
+ case "top_link_targets": {
1786
+ const rows = await this.collection.aggregate([
1787
+ {
1788
+ $match: {
1789
+ ...baseMatch,
1790
+ ...filterMatch,
1791
+ type: "event",
1792
+ event_subtype: { $in: ["link_click", "outbound_click"] },
1793
+ target_url_path: { $ne: null }
1794
+ }
1795
+ },
1796
+ { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1797
+ { $sort: { value: -1 } },
1798
+ { $limit: limit }
1799
+ ]).toArray();
1800
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1801
+ total = data.reduce((sum, d) => sum + d.value, 0);
1802
+ break;
1803
+ }
1146
1804
  case "top_devices": {
1147
1805
  const rows = await this.collection.aggregate([
1148
- { $match: { ...baseMatch, device_type: { $ne: null } } },
1806
+ { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
1149
1807
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1150
1808
  { $project: { _id: 1, value: { $size: "$value" } } },
1151
1809
  { $sort: { value: -1 } },
@@ -1157,7 +1815,7 @@ var MongoDBAdapter = class {
1157
1815
  }
1158
1816
  case "top_browsers": {
1159
1817
  const rows = await this.collection.aggregate([
1160
- { $match: { ...baseMatch, browser: { $ne: null } } },
1818
+ { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
1161
1819
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1162
1820
  { $project: { _id: 1, value: { $size: "$value" } } },
1163
1821
  { $sort: { value: -1 } },
@@ -1169,7 +1827,7 @@ var MongoDBAdapter = class {
1169
1827
  }
1170
1828
  case "top_os": {
1171
1829
  const rows = await this.collection.aggregate([
1172
- { $match: { ...baseMatch, os: { $ne: null } } },
1830
+ { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
1173
1831
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1174
1832
  { $project: { _id: 1, value: { $size: "$value" } } },
1175
1833
  { $sort: { value: -1 } },
@@ -1179,9 +1837,45 @@ var MongoDBAdapter = class {
1179
1837
  total = data.reduce((sum, d) => sum + d.value, 0);
1180
1838
  break;
1181
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
+ }
1182
1876
  }
1183
1877
  const result = { metric: q.metric, period, data, total };
1184
- if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
1878
+ if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
1185
1879
  const prevRange = previousPeriodRange(dateRange);
1186
1880
  const prevResult = await this.query({
1187
1881
  ...q,
@@ -1213,15 +1907,34 @@ var MongoDBAdapter = class {
1213
1907
  site_id: params.siteId,
1214
1908
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1215
1909
  };
1910
+ const filterMatch = buildFilterMatch(params.filters);
1216
1911
  if (params.metric === "pageviews") {
1217
1912
  baseMatch.type = "pageview";
1218
1913
  }
1914
+ if (params.metric === "events") {
1915
+ baseMatch.type = "event";
1916
+ }
1917
+ if (params.metric === "conversions") {
1918
+ baseMatch.type = "event";
1919
+ const conversionEvents = params.conversionEvents ?? [];
1920
+ if (conversionEvents.length === 0) {
1921
+ const data2 = fillBuckets(
1922
+ new Date(dateRange.from),
1923
+ new Date(dateRange.to),
1924
+ granularity,
1925
+ granularityToDateFormat(granularity),
1926
+ []
1927
+ );
1928
+ return { metric: params.metric, granularity, data: data2 };
1929
+ }
1930
+ baseMatch.event_name = { $in: conversionEvents };
1931
+ }
1219
1932
  const dateFormat = granularityToDateFormat(granularity);
1220
1933
  let pipeline;
1221
1934
  if (params.metric === "visitors" || params.metric === "sessions") {
1222
1935
  const groupField = params.metric === "visitors" ? "$visitor_id" : "$session_id";
1223
1936
  pipeline = [
1224
- { $match: baseMatch },
1937
+ { $match: { ...baseMatch, ...filterMatch } },
1225
1938
  {
1226
1939
  $group: {
1227
1940
  _id: {
@@ -1240,7 +1953,7 @@ var MongoDBAdapter = class {
1240
1953
  ];
1241
1954
  } else {
1242
1955
  pipeline = [
1243
- { $match: baseMatch },
1956
+ { $match: { ...baseMatch, ...filterMatch } },
1244
1957
  {
1245
1958
  $group: {
1246
1959
  _id: { $dateToString: { format: dateFormat, date: "$timestamp" } },
@@ -1321,7 +2034,12 @@ var MongoDBAdapter = class {
1321
2034
  const offset = params.offset ?? 0;
1322
2035
  const match = { site_id: params.siteId };
1323
2036
  if (params.type) match.type = params.type;
1324
- if (params.eventName) match.event_name = params.eventName;
2037
+ if (params.eventName) {
2038
+ match.event_name = params.eventName;
2039
+ } else if (params.eventNames && params.eventNames.length > 0) {
2040
+ match.event_name = { $in: params.eventNames };
2041
+ }
2042
+ if (params.eventSource) match.event_source = params.eventSource;
1325
2043
  if (params.visitorId) match.visitor_id = params.visitorId;
1326
2044
  if (params.userId) match.user_id = params.userId;
1327
2045
  if (params.period || params.dateFrom) {
@@ -1343,23 +2061,72 @@ var MongoDBAdapter = class {
1343
2061
  offset
1344
2062
  };
1345
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
+ }
1346
2083
  // ─── User Listing ──────────────────────────────────────
1347
2084
  async listUsers(params) {
1348
2085
  const limit = Math.min(params.limit ?? 50, 200);
1349
2086
  const offset = params.offset ?? 0;
1350
2087
  const match = { site_id: params.siteId };
1351
- if (params.search) {
1352
- match.$or = [
1353
- { visitor_id: { $regex: params.search, $options: "i" } },
1354
- { user_id: { $regex: params.search, $options: "i" } }
1355
- ];
1356
- }
1357
2088
  const pipeline = [
1358
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(
2124
+ { $sort: { timestamp: 1 } },
1359
2125
  {
1360
2126
  $group: {
1361
- _id: "$visitor_id",
1362
- userId: { $last: "$user_id" },
2127
+ _id: "$_resolved_id",
2128
+ visitorIds: { $addToSet: "$visitor_id" },
2129
+ userId: { $last: { $ifNull: ["$_resolved_user_id", "$user_id"] } },
1363
2130
  traits: { $last: "$traits" },
1364
2131
  firstSeen: { $min: "$timestamp" },
1365
2132
  lastSeen: { $max: "$timestamp" },
@@ -1367,13 +2134,22 @@ var MongoDBAdapter = class {
1367
2134
  totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
1368
2135
  sessions: { $addToSet: "$session_id" },
1369
2136
  lastUrl: { $last: "$url" },
2137
+ referrer: { $last: "$referrer" },
1370
2138
  device_type: { $last: "$device_type" },
1371
2139
  browser: { $last: "$browser" },
1372
2140
  os: { $last: "$os" },
1373
2141
  country: { $last: "$country" },
1374
2142
  city: { $last: "$city" },
1375
2143
  region: { $last: "$region" },
1376
- language: { $last: "$language" }
2144
+ language: { $last: "$language" },
2145
+ timezone: { $last: "$timezone" },
2146
+ screen_width: { $last: "$screen_width" },
2147
+ screen_height: { $last: "$screen_height" },
2148
+ utm_source: { $last: "$utm_source" },
2149
+ utm_medium: { $last: "$utm_medium" },
2150
+ utm_campaign: { $last: "$utm_campaign" },
2151
+ utm_term: { $last: "$utm_term" },
2152
+ utm_content: { $last: "$utm_content" }
1377
2153
  }
1378
2154
  },
1379
2155
  { $sort: { lastSeen: -1 } },
@@ -1383,10 +2159,11 @@ var MongoDBAdapter = class {
1383
2159
  count: [{ $count: "total" }]
1384
2160
  }
1385
2161
  }
1386
- ];
2162
+ );
1387
2163
  const [result] = await this.collection.aggregate(pipeline).toArray();
1388
2164
  const users = (result?.data ?? []).map((u) => ({
1389
- visitorId: u._id,
2165
+ visitorId: u.visitorIds[0] ?? u._id,
2166
+ visitorIds: u.visitorIds.length > 1 ? u.visitorIds : void 0,
1390
2167
  userId: u.userId ?? void 0,
1391
2168
  traits: u.traits ?? void 0,
1392
2169
  firstSeen: u.firstSeen.toISOString(),
@@ -1395,9 +2172,19 @@ var MongoDBAdapter = class {
1395
2172
  totalPageviews: u.totalPageviews,
1396
2173
  totalSessions: u.sessions.length,
1397
2174
  lastUrl: u.lastUrl ?? void 0,
2175
+ referrer: u.referrer ?? void 0,
1398
2176
  device: u.device_type ? { type: u.device_type, browser: u.browser ?? "", os: u.os ?? "" } : void 0,
1399
2177
  geo: u.country ? { country: u.country, city: u.city ?? void 0, region: u.region ?? void 0 } : void 0,
1400
- language: u.language ?? void 0
2178
+ language: u.language ?? void 0,
2179
+ timezone: u.timezone ?? void 0,
2180
+ screen: u.screen_width || u.screen_height ? { width: u.screen_width ?? 0, height: u.screen_height ?? 0 } : void 0,
2181
+ utm: u.utm_source ? {
2182
+ source: u.utm_source ?? void 0,
2183
+ medium: u.utm_medium ?? void 0,
2184
+ campaign: u.utm_campaign ?? void 0,
2185
+ term: u.utm_term ?? void 0,
2186
+ content: u.utm_content ?? void 0
2187
+ } : void 0
1401
2188
  }));
1402
2189
  return {
1403
2190
  users,
@@ -1406,13 +2193,125 @@ var MongoDBAdapter = class {
1406
2193
  offset
1407
2194
  };
1408
2195
  }
1409
- async getUserDetail(siteId, visitorId) {
1410
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1411
- const user = result.users.find((u) => u.visitorId === visitorId);
1412
- 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);
1413
2220
  }
1414
- async getUserEvents(siteId, visitorId, params) {
1415
- return this.listEvents({ ...params, siteId, visitorId });
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
+ };
2282
+ }
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
+ };
1416
2315
  }
1417
2316
  toEventListItem(doc) {
1418
2317
  return {
@@ -1426,6 +2325,13 @@ var MongoDBAdapter = class {
1426
2325
  title: doc.title ?? void 0,
1427
2326
  name: doc.event_name ?? void 0,
1428
2327
  properties: doc.properties ?? void 0,
2328
+ eventSource: doc.event_source ? doc.event_source : void 0,
2329
+ eventSubtype: doc.event_subtype ? doc.event_subtype : void 0,
2330
+ pagePath: doc.page_path ?? void 0,
2331
+ targetUrlPath: doc.target_url_path ?? void 0,
2332
+ elementSelector: doc.element_selector ?? void 0,
2333
+ elementText: doc.element_text ?? void 0,
2334
+ scrollDepthPct: doc.scroll_depth_pct ?? void 0,
1429
2335
  userId: doc.user_id ?? void 0,
1430
2336
  traits: doc.traits ?? void 0,
1431
2337
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
@@ -1449,6 +2355,7 @@ var MongoDBAdapter = class {
1449
2355
  name: data.name,
1450
2356
  domain: data.domain ?? null,
1451
2357
  allowed_origins: data.allowedOrigins ?? null,
2358
+ conversion_events: data.conversionEvents ?? null,
1452
2359
  created_at: now,
1453
2360
  updated_at: now
1454
2361
  };
@@ -1472,6 +2379,7 @@ var MongoDBAdapter = class {
1472
2379
  if (data.name !== void 0) updates.name = data.name;
1473
2380
  if (data.domain !== void 0) updates.domain = data.domain || null;
1474
2381
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
2382
+ if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
1475
2383
  const result = await this.sites.findOneAndUpdate(
1476
2384
  { site_id: siteId },
1477
2385
  { $set: updates },
@@ -1502,6 +2410,7 @@ var MongoDBAdapter = class {
1502
2410
  name: doc.name,
1503
2411
  domain: doc.domain ?? void 0,
1504
2412
  allowedOrigins: doc.allowed_origins ?? void 0,
2413
+ conversionEvents: doc.conversion_events ?? void 0,
1505
2414
  createdAt: doc.created_at.toISOString(),
1506
2415
  updatedAt: doc.updated_at.toISOString()
1507
2416
  };
@@ -1755,6 +2664,7 @@ async function createCollector(config) {
1755
2664
  if (allowed) {
1756
2665
  res.setHeader?.("Access-Control-Allow-Origin", origin || "*");
1757
2666
  res.setHeader?.("Access-Control-Allow-Methods", methods);
2667
+ res.setHeader?.("Access-Control-Allow-Credentials", "true");
1758
2668
  const headers = ["Content-Type", extraHeaders].filter(Boolean).join(", ");
1759
2669
  res.setHeader?.("Access-Control-Allow-Headers", headers);
1760
2670
  }
@@ -1772,6 +2682,51 @@ async function createCollector(config) {
1772
2682
  return { ...event, ip, geo, device };
1773
2683
  });
1774
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
+ }
1775
2730
  function extractIp(req) {
1776
2731
  if (config.trustProxy ?? true) {
1777
2732
  const forwarded = req.headers?.["x-forwarded-for"];
@@ -1785,7 +2740,14 @@ async function createCollector(config) {
1785
2740
  }
1786
2741
  function handler() {
1787
2742
  return async (req, res) => {
1788
- if (setCors(req, res, "POST, OPTIONS")) return;
2743
+ res.setHeader?.("Access-Control-Allow-Origin", "*");
2744
+ res.setHeader?.("Access-Control-Allow-Methods", "POST, OPTIONS");
2745
+ res.setHeader?.("Access-Control-Allow-Headers", "Content-Type");
2746
+ if (req.method === "OPTIONS") {
2747
+ res.writeHead?.(204);
2748
+ res.end?.();
2749
+ return;
2750
+ }
1789
2751
  if (req.method !== "POST") {
1790
2752
  sendJson(res, 405, { ok: false, error: "Method not allowed" });
1791
2753
  return;
@@ -1826,11 +2788,13 @@ async function createCollector(config) {
1826
2788
  sendJson(res, 200, { ok: true });
1827
2789
  return;
1828
2790
  }
2791
+ await processIdentity(filtered);
1829
2792
  await db.insertEvents(filtered);
1830
2793
  sendJson(res, 200, { ok: true });
1831
2794
  return;
1832
2795
  }
1833
2796
  }
2797
+ await processIdentity(enriched);
1834
2798
  await db.insertEvents(enriched);
1835
2799
  sendJson(res, 200, { ok: true });
1836
2800
  } catch (err) {
@@ -1864,8 +2828,13 @@ async function createCollector(config) {
1864
2828
  period: params.period,
1865
2829
  dateFrom: params.dateFrom,
1866
2830
  dateTo: params.dateTo,
1867
- granularity: q.granularity
2831
+ granularity: q.granularity,
2832
+ filters: q.filters ? JSON.parse(q.filters) : void 0
1868
2833
  };
2834
+ if (tsParams.metric === "conversions") {
2835
+ const site = await db.getSite(params.siteId);
2836
+ tsParams.conversionEvents = site?.conversionEvents ?? [];
2837
+ }
1869
2838
  const result2 = await db.queryTimeSeries(tsParams);
1870
2839
  sendJson(res, 200, result2);
1871
2840
  return;
@@ -1881,7 +2850,15 @@ async function createCollector(config) {
1881
2850
  sendJson(res, 200, result2);
1882
2851
  return;
1883
2852
  }
1884
- const result = await db.query(params);
2853
+ const isConversionMetric = params.metric === "conversions" || params.metric === "top_conversions";
2854
+ let result;
2855
+ if (isConversionMetric) {
2856
+ const site = await db.getSite(params.siteId);
2857
+ const conversionEvents = site?.conversionEvents ?? [];
2858
+ result = await db.query({ ...params, conversionEvents });
2859
+ } else {
2860
+ result = await db.query(params);
2861
+ }
1885
2862
  sendJson(res, 200, result);
1886
2863
  } catch (err) {
1887
2864
  sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
@@ -1978,10 +2955,13 @@ async function createCollector(config) {
1978
2955
  sendJson(res, 401, { ok: false, error: "Invalid or missing secret key" });
1979
2956
  return;
1980
2957
  }
2958
+ const eventNames = typeof q.eventNames === "string" ? q.eventNames.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
1981
2959
  const params = {
1982
2960
  siteId: q.siteId,
1983
2961
  type: q.type,
1984
2962
  eventName: q.eventName,
2963
+ eventNames,
2964
+ eventSource: q.eventSource,
1985
2965
  visitorId: q.visitorId,
1986
2966
  userId: q.userId,
1987
2967
  period: q.period,
@@ -2021,9 +3001,13 @@ async function createCollector(config) {
2021
3001
  const visitorId = usersIdx >= 0 ? pathSegments[usersIdx + 1] : void 0;
2022
3002
  const action = usersIdx >= 0 ? pathSegments[usersIdx + 2] : void 0;
2023
3003
  if (visitorId && action === "events") {
3004
+ const eventNames = typeof q.eventNames === "string" ? q.eventNames.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2024
3005
  const params2 = {
2025
3006
  siteId: q.siteId,
2026
3007
  type: q.type,
3008
+ eventName: q.eventName,
3009
+ eventNames,
3010
+ eventSource: q.eventSource,
2027
3011
  period: q.period,
2028
3012
  dateFrom: q.dateFrom,
2029
3013
  dateTo: q.dateTo,
@@ -2071,11 +3055,11 @@ async function createCollector(config) {
2071
3055
  async listUsers(params) {
2072
3056
  return db.listUsers(params);
2073
3057
  },
2074
- async getUserDetail(siteId, visitorId) {
2075
- return db.getUserDetail(siteId, visitorId);
3058
+ async getUserDetail(siteId, identifier) {
3059
+ return db.getUserDetail(siteId, identifier);
2076
3060
  },
2077
- async getUserEvents(siteId, visitorId, params) {
2078
- return db.getUserEvents(siteId, visitorId, params);
3061
+ async getUserEvents(siteId, identifier, params) {
3062
+ return db.getUserEvents(siteId, identifier, params);
2079
3063
  },
2080
3064
  async track(siteId, name, properties, options) {
2081
3065
  const event = {
@@ -2130,7 +3114,8 @@ function createAdapter(config) {
2130
3114
  }
2131
3115
  }
2132
3116
  async function parseBody(req) {
2133
- if (req.body) return req.body;
3117
+ if (req.body && typeof req.body === "object") return req.body;
3118
+ if (typeof req.body === "string") return JSON.parse(req.body);
2134
3119
  return new Promise((resolve, reject) => {
2135
3120
  let data = "";
2136
3121
  req.on("data", (chunk) => {