@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.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(),
@@ -192,6 +204,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
192
204
  title Nullable(String),
193
205
  event_name Nullable(String),
194
206
  properties Nullable(String),
207
+ event_source LowCardinality(Nullable(String)),
208
+ event_subtype LowCardinality(Nullable(String)),
209
+ page_path Nullable(String),
210
+ target_url_path Nullable(String),
211
+ element_selector Nullable(String),
212
+ element_text Nullable(String),
213
+ scroll_depth_pct Nullable(UInt8),
195
214
  user_id Nullable(String),
196
215
  traits Nullable(String),
197
216
  country LowCardinality(Nullable(String)),
@@ -223,6 +242,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
223
242
  name String,
224
243
  domain Nullable(String),
225
244
  allowed_origins Nullable(String),
245
+ conversion_events Nullable(String),
226
246
  created_at DateTime64(3),
227
247
  updated_at DateTime64(3),
228
248
  version UInt64,
@@ -235,6 +255,39 @@ function toCHDateTime(d) {
235
255
  const iso = typeof d === "string" ? d : d.toISOString();
236
256
  return iso.replace("T", " ").replace("Z", "");
237
257
  }
258
+ function buildFilterConditions(filters) {
259
+ if (!filters) return { conditions: [], params: {} };
260
+ const map = {
261
+ "geo.country": "country",
262
+ "geo.city": "city",
263
+ "geo.region": "region",
264
+ "language": "language",
265
+ "device.type": "device_type",
266
+ "device.browser": "browser",
267
+ "device.os": "os",
268
+ "utm.source": "utm_source",
269
+ "utm.medium": "utm_medium",
270
+ "utm.campaign": "utm_campaign",
271
+ "utm.term": "utm_term",
272
+ "utm.content": "utm_content",
273
+ "referrer": "referrer",
274
+ "event_source": "event_source",
275
+ "event_subtype": "event_subtype",
276
+ "page_path": "page_path",
277
+ "target_url_path": "target_url_path",
278
+ "event_name": "event_name",
279
+ "type": "type"
280
+ };
281
+ const conditions = [];
282
+ const params = {};
283
+ for (const [key, value] of Object.entries(filters)) {
284
+ if (!value || !map[key]) continue;
285
+ const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
286
+ conditions.push(`${map[key]} = {${paramKey}:String}`);
287
+ params[paramKey] = value;
288
+ }
289
+ return { conditions, params };
290
+ }
238
291
  var ClickHouseAdapter = class {
239
292
  client;
240
293
  constructor(url) {
@@ -248,6 +301,17 @@ var ClickHouseAdapter = class {
248
301
  async init() {
249
302
  await this.client.command({ query: CREATE_EVENTS_TABLE });
250
303
  await this.client.command({ query: CREATE_SITES_TABLE });
304
+ await this.client.command({ query: CREATE_IDENTITY_MAP_TABLE });
305
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
306
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
307
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
308
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS target_url_path Nullable(String)` });
309
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS element_selector Nullable(String)` });
310
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS element_text Nullable(String)` });
311
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS scroll_depth_pct Nullable(UInt8)` });
312
+ await this.client.command({
313
+ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
314
+ });
251
315
  }
252
316
  async close() {
253
317
  await this.client.close();
@@ -266,6 +330,13 @@ var ClickHouseAdapter = class {
266
330
  title: e.title ?? null,
267
331
  event_name: e.name ?? null,
268
332
  properties: e.properties ? JSON.stringify(e.properties) : null,
333
+ event_source: e.eventSource ?? null,
334
+ event_subtype: e.eventSubtype ?? null,
335
+ page_path: e.pagePath ?? null,
336
+ target_url_path: e.targetUrlPath ?? null,
337
+ element_selector: e.elementSelector ?? null,
338
+ element_text: e.elementText ?? null,
339
+ scroll_depth_pct: e.scrollDepthPct ?? null,
269
340
  user_id: e.userId ?? null,
270
341
  traits: e.traits ? JSON.stringify(e.traits) : null,
271
342
  country: e.geo?.country ?? null,
@@ -302,6 +373,8 @@ var ClickHouseAdapter = class {
302
373
  to: toCHDateTime(dateRange.to),
303
374
  limit
304
375
  };
376
+ const filter = buildFilterConditions(q.filters);
377
+ const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
305
378
  let data = [];
306
379
  let total = 0;
307
380
  switch (q.metric) {
@@ -311,8 +384,8 @@ var ClickHouseAdapter = class {
311
384
  WHERE site_id = {siteId:String}
312
385
  AND timestamp >= {from:String}
313
386
  AND timestamp <= {to:String}
314
- AND type = 'pageview'`,
315
- params
387
+ AND type = 'pageview'${filterSql}`,
388
+ { ...params, ...filter.params }
316
389
  );
317
390
  total = Number(rows[0]?.value ?? 0);
318
391
  data = [{ key: "pageviews", value: total }];
@@ -323,8 +396,8 @@ var ClickHouseAdapter = class {
323
396
  `SELECT uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
324
397
  WHERE site_id = {siteId:String}
325
398
  AND timestamp >= {from:String}
326
- AND timestamp <= {to:String}`,
327
- params
399
+ AND timestamp <= {to:String}${filterSql}`,
400
+ { ...params, ...filter.params }
328
401
  );
329
402
  total = Number(rows[0]?.value ?? 0);
330
403
  data = [{ key: "visitors", value: total }];
@@ -335,8 +408,8 @@ var ClickHouseAdapter = class {
335
408
  `SELECT uniq(session_id) AS value FROM ${EVENTS_TABLE}
336
409
  WHERE site_id = {siteId:String}
337
410
  AND timestamp >= {from:String}
338
- AND timestamp <= {to:String}`,
339
- params
411
+ AND timestamp <= {to:String}${filterSql}`,
412
+ { ...params, ...filter.params }
340
413
  );
341
414
  total = Number(rows[0]?.value ?? 0);
342
415
  data = [{ key: "sessions", value: total }];
@@ -348,13 +421,33 @@ var ClickHouseAdapter = class {
348
421
  WHERE site_id = {siteId:String}
349
422
  AND timestamp >= {from:String}
350
423
  AND timestamp <= {to:String}
351
- AND type = 'event'`,
352
- params
424
+ AND type = 'event'${filterSql}`,
425
+ { ...params, ...filter.params }
353
426
  );
354
427
  total = Number(rows[0]?.value ?? 0);
355
428
  data = [{ key: "events", value: total }];
356
429
  break;
357
430
  }
431
+ case "conversions": {
432
+ const conversionEvents = q.conversionEvents ?? [];
433
+ if (conversionEvents.length === 0) {
434
+ total = 0;
435
+ data = [{ key: "conversions", value: 0 }];
436
+ break;
437
+ }
438
+ const rows = await this.queryRows(
439
+ `SELECT count() AS value FROM ${EVENTS_TABLE}
440
+ WHERE site_id = {siteId:String}
441
+ AND timestamp >= {from:String}
442
+ AND timestamp <= {to:String}
443
+ AND type = 'event'
444
+ AND event_name IN {eventNames:Array(String)}${filterSql}`,
445
+ { ...params, eventNames: conversionEvents, ...filter.params }
446
+ );
447
+ total = Number(rows[0]?.value ?? 0);
448
+ data = [{ key: "conversions", value: total }];
449
+ break;
450
+ }
358
451
  case "top_pages": {
359
452
  const rows = await this.queryRows(
360
453
  `SELECT url AS key, count() AS value FROM ${EVENTS_TABLE}
@@ -362,11 +455,11 @@ var ClickHouseAdapter = class {
362
455
  AND timestamp >= {from:String}
363
456
  AND timestamp <= {to:String}
364
457
  AND type = 'pageview'
365
- AND url IS NOT NULL
458
+ AND url IS NOT NULL${filterSql}
366
459
  GROUP BY url
367
460
  ORDER BY value DESC
368
461
  LIMIT {limit:UInt32}`,
369
- params
462
+ { ...params, ...filter.params }
370
463
  );
371
464
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
372
465
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -380,11 +473,11 @@ var ClickHouseAdapter = class {
380
473
  AND timestamp <= {to:String}
381
474
  AND type = 'pageview'
382
475
  AND referrer IS NOT NULL
383
- AND referrer != ''
476
+ AND referrer != ''${filterSql}
384
477
  GROUP BY referrer
385
478
  ORDER BY value DESC
386
479
  LIMIT {limit:UInt32}`,
387
- params
480
+ { ...params, ...filter.params }
388
481
  );
389
482
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
390
483
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -396,11 +489,11 @@ var ClickHouseAdapter = class {
396
489
  WHERE site_id = {siteId:String}
397
490
  AND timestamp >= {from:String}
398
491
  AND timestamp <= {to:String}
399
- AND country IS NOT NULL
492
+ AND country IS NOT NULL${filterSql}
400
493
  GROUP BY country
401
494
  ORDER BY value DESC
402
495
  LIMIT {limit:UInt32}`,
403
- params
496
+ { ...params, ...filter.params }
404
497
  );
405
498
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
406
499
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -412,11 +505,11 @@ var ClickHouseAdapter = class {
412
505
  WHERE site_id = {siteId:String}
413
506
  AND timestamp >= {from:String}
414
507
  AND timestamp <= {to:String}
415
- AND city IS NOT NULL
508
+ AND city IS NOT NULL${filterSql}
416
509
  GROUP BY city
417
510
  ORDER BY value DESC
418
511
  LIMIT {limit:UInt32}`,
419
- params
512
+ { ...params, ...filter.params }
420
513
  );
421
514
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
422
515
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -430,10 +523,132 @@ var ClickHouseAdapter = class {
430
523
  AND timestamp <= {to:String}
431
524
  AND type = 'event'
432
525
  AND event_name IS NOT NULL
526
+ ${filterSql}
527
+ GROUP BY event_name
528
+ ORDER BY value DESC
529
+ LIMIT {limit:UInt32}`,
530
+ { ...params, ...filter.params }
531
+ );
532
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
533
+ total = data.reduce((sum, d) => sum + d.value, 0);
534
+ break;
535
+ }
536
+ case "top_conversions": {
537
+ const conversionEvents = q.conversionEvents ?? [];
538
+ if (conversionEvents.length === 0) {
539
+ total = 0;
540
+ data = [];
541
+ break;
542
+ }
543
+ const rows = await this.queryRows(
544
+ `SELECT event_name AS key, count() AS value FROM ${EVENTS_TABLE}
545
+ WHERE site_id = {siteId:String}
546
+ AND timestamp >= {from:String}
547
+ AND timestamp <= {to:String}
548
+ AND type = 'event'
549
+ AND event_name IN {eventNames:Array(String)}
550
+ ${filterSql}
433
551
  GROUP BY event_name
434
552
  ORDER BY value DESC
435
553
  LIMIT {limit:UInt32}`,
436
- params
554
+ { ...params, eventNames: conversionEvents, ...filter.params }
555
+ );
556
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
557
+ total = data.reduce((sum, d) => sum + d.value, 0);
558
+ break;
559
+ }
560
+ case "top_exit_pages": {
561
+ const rows = await this.queryRows(
562
+ `SELECT exit_url AS key, count() AS value FROM (
563
+ SELECT session_id, argMax(url, timestamp) AS exit_url
564
+ FROM ${EVENTS_TABLE}
565
+ WHERE site_id = {siteId:String}
566
+ AND timestamp >= {from:String}
567
+ AND timestamp <= {to:String}
568
+ AND type = 'pageview'
569
+ AND url IS NOT NULL${filterSql}
570
+ GROUP BY session_id
571
+ )
572
+ GROUP BY exit_url
573
+ ORDER BY value DESC
574
+ LIMIT {limit:UInt32}`,
575
+ { ...params, ...filter.params }
576
+ );
577
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
578
+ total = data.reduce((sum, d) => sum + d.value, 0);
579
+ break;
580
+ }
581
+ case "top_transitions": {
582
+ const rows = await this.queryRows(
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
586
+ FROM ${EVENTS_TABLE}
587
+ WHERE site_id = {siteId:String}
588
+ AND timestamp >= {from:String}
589
+ AND timestamp <= {to:String}
590
+ AND type = 'pageview'
591
+ AND url IS NOT NULL${filterSql}
592
+ )
593
+ WHERE prev_url IS NOT NULL AND prev_url != ''
594
+ GROUP BY key
595
+ ORDER BY value DESC
596
+ LIMIT {limit:UInt32}`,
597
+ { ...params, ...filter.params }
598
+ );
599
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
600
+ total = data.reduce((sum, d) => sum + d.value, 0);
601
+ break;
602
+ }
603
+ case "top_scroll_pages": {
604
+ const rows = await this.queryRows(
605
+ `SELECT page_path AS key, count() AS value FROM ${EVENTS_TABLE}
606
+ WHERE site_id = {siteId:String}
607
+ AND timestamp >= {from:String}
608
+ AND timestamp <= {to:String}
609
+ AND type = 'event'
610
+ AND event_subtype = 'scroll_depth'
611
+ AND page_path IS NOT NULL${filterSql}
612
+ GROUP BY page_path
613
+ ORDER BY value DESC
614
+ LIMIT {limit:UInt32}`,
615
+ { ...params, ...filter.params }
616
+ );
617
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
618
+ total = data.reduce((sum, d) => sum + d.value, 0);
619
+ break;
620
+ }
621
+ case "top_button_clicks": {
622
+ const rows = await this.queryRows(
623
+ `SELECT ifNull(element_text, element_selector) AS key, count() AS value FROM ${EVENTS_TABLE}
624
+ WHERE site_id = {siteId:String}
625
+ AND timestamp >= {from:String}
626
+ AND timestamp <= {to:String}
627
+ AND type = 'event'
628
+ AND event_subtype = 'button_click'
629
+ AND (element_text IS NOT NULL OR element_selector IS NOT NULL)${filterSql}
630
+ GROUP BY key
631
+ ORDER BY value DESC
632
+ LIMIT {limit:UInt32}`,
633
+ { ...params, ...filter.params }
634
+ );
635
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
636
+ total = data.reduce((sum, d) => sum + d.value, 0);
637
+ break;
638
+ }
639
+ case "top_link_targets": {
640
+ const rows = await this.queryRows(
641
+ `SELECT target_url_path AS key, count() AS value FROM ${EVENTS_TABLE}
642
+ WHERE site_id = {siteId:String}
643
+ AND timestamp >= {from:String}
644
+ AND timestamp <= {to:String}
645
+ AND type = 'event'
646
+ AND event_subtype IN ('link_click','outbound_click')
647
+ AND target_url_path IS NOT NULL${filterSql}
648
+ GROUP BY target_url_path
649
+ ORDER BY value DESC
650
+ LIMIT {limit:UInt32}`,
651
+ { ...params, ...filter.params }
437
652
  );
438
653
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
439
654
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -446,10 +661,11 @@ var ClickHouseAdapter = class {
446
661
  AND timestamp >= {from:String}
447
662
  AND timestamp <= {to:String}
448
663
  AND device_type IS NOT NULL
664
+ ${filterSql}
449
665
  GROUP BY device_type
450
666
  ORDER BY value DESC
451
667
  LIMIT {limit:UInt32}`,
452
- params
668
+ { ...params, ...filter.params }
453
669
  );
454
670
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
455
671
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -462,10 +678,11 @@ var ClickHouseAdapter = class {
462
678
  AND timestamp >= {from:String}
463
679
  AND timestamp <= {to:String}
464
680
  AND browser IS NOT NULL
681
+ ${filterSql}
465
682
  GROUP BY browser
466
683
  ORDER BY value DESC
467
684
  LIMIT {limit:UInt32}`,
468
- params
685
+ { ...params, ...filter.params }
469
686
  );
470
687
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
471
688
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -478,10 +695,62 @@ var ClickHouseAdapter = class {
478
695
  AND timestamp >= {from:String}
479
696
  AND timestamp <= {to:String}
480
697
  AND os IS NOT NULL
698
+ ${filterSql}
481
699
  GROUP BY os
482
700
  ORDER BY value DESC
483
701
  LIMIT {limit:UInt32}`,
484
- params
702
+ { ...params, ...filter.params }
703
+ );
704
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
705
+ total = data.reduce((sum, d) => sum + d.value, 0);
706
+ break;
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 }
485
754
  );
486
755
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
487
756
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -489,7 +758,7 @@ var ClickHouseAdapter = class {
489
758
  }
490
759
  }
491
760
  const result = { metric: q.metric, period, data, total };
492
- if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
761
+ if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
493
762
  const prevRange = previousPeriodRange(dateRange);
494
763
  const prevResult = await this.query({
495
764
  ...q,
@@ -519,7 +788,12 @@ var ClickHouseAdapter = class {
519
788
  const granularity = params.granularity ?? autoGranularity(period);
520
789
  const bucketFn = this.granularityToClickHouseFunc(granularity);
521
790
  const dateFormat = granularityToDateFormat(granularity);
791
+ const filter = buildFilterConditions(params.filters);
792
+ const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
522
793
  const typeFilter = params.metric === "pageviews" ? `AND type = 'pageview'` : "";
794
+ const eventsFilter = params.metric === "events" ? `AND type = 'event'` : "";
795
+ const conversionsFilter = params.metric === "conversions" ? `AND type = 'event' AND event_name IN {eventNames:Array(String)}` : "";
796
+ const extraFilters = [typeFilter, eventsFilter, conversionsFilter, filterSql].filter(Boolean).join(" ");
523
797
  let sql;
524
798
  if (params.metric === "visitors" || params.metric === "sessions") {
525
799
  const field = params.metric === "visitors" ? "visitor_id" : "session_id";
@@ -529,7 +803,7 @@ var ClickHouseAdapter = class {
529
803
  WHERE site_id = {siteId:String}
530
804
  AND timestamp >= {from:String}
531
805
  AND timestamp <= {to:String}
532
- ${typeFilter}
806
+ ${extraFilters}
533
807
  GROUP BY bucket
534
808
  ORDER BY bucket ASC
535
809
  `;
@@ -540,7 +814,7 @@ var ClickHouseAdapter = class {
540
814
  WHERE site_id = {siteId:String}
541
815
  AND timestamp >= {from:String}
542
816
  AND timestamp <= {to:String}
543
- ${typeFilter}
817
+ ${extraFilters}
544
818
  GROUP BY bucket
545
819
  ORDER BY bucket ASC
546
820
  `;
@@ -548,7 +822,9 @@ var ClickHouseAdapter = class {
548
822
  const rows = await this.queryRows(sql, {
549
823
  siteId: params.siteId,
550
824
  from: toCHDateTime(dateRange.from),
551
- to: toCHDateTime(dateRange.to)
825
+ to: toCHDateTime(dateRange.to),
826
+ eventNames: params.conversionEvents ?? [],
827
+ ...filter.params
552
828
  });
553
829
  const mappedRows = rows.map((r) => ({
554
830
  _id: this.convertClickHouseBucket(r.bucket, granularity),
@@ -666,6 +942,14 @@ var ClickHouseAdapter = class {
666
942
  conditions.push(`event_name = {eventName:String}`);
667
943
  queryParams.eventName = params.eventName;
668
944
  }
945
+ if (params.eventSource) {
946
+ conditions.push(`event_source = {eventSource:String}`);
947
+ queryParams.eventSource = params.eventSource;
948
+ }
949
+ if (params.eventNames && params.eventNames.length > 0) {
950
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
951
+ queryParams.eventNames = params.eventNames;
952
+ }
669
953
  if (params.visitorId) {
670
954
  conditions.push(`visitor_id = {visitorId:String}`);
671
955
  queryParams.visitorId = params.visitorId;
@@ -688,7 +972,9 @@ var ClickHouseAdapter = class {
688
972
  const [events, countRows] = await Promise.all([
689
973
  this.queryRows(
690
974
  `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
691
- event_name, properties, user_id, traits, country, city, region,
975
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
976
+ element_selector, element_text, scroll_depth_pct,
977
+ user_id, traits, country, city, region,
692
978
  device_type, browser, os, language,
693
979
  utm_source, utm_medium, utm_campaign, utm_term, utm_content
694
980
  FROM ${EVENTS_TABLE}
@@ -723,36 +1009,59 @@ var ClickHouseAdapter = class {
723
1009
  const where = conditions.join(" AND ");
724
1010
  const [userRows, countRows] = await Promise.all([
725
1011
  this.queryRows(
726
- `SELECT
727
- visitor_id,
728
- anyLast(user_id) AS userId,
729
- anyLast(traits) AS traits,
730
- min(timestamp) AS firstSeen,
731
- 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,
732
1024
  count() AS totalEvents,
733
- countIf(type = 'pageview') AS totalPageviews,
734
- uniq(session_id) AS totalSessions,
735
- anyLast(url) AS lastUrl,
736
- anyLast(device_type) AS device_type,
737
- anyLast(browser) AS browser,
738
- anyLast(os) AS os,
739
- anyLast(country) AS country,
740
- anyLast(city) AS city,
741
- anyLast(region) AS region,
742
- anyLast(language) AS language
743
- FROM ${EVENTS_TABLE}
744
- WHERE ${where}
745
- 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
746
1048
  ORDER BY lastSeen DESC
747
1049
  LIMIT {limit:UInt32}
748
1050
  OFFSET {offset:UInt32}`,
749
1051
  queryParams
750
1052
  ),
751
1053
  this.queryRows(
752
- `SELECT count() AS total FROM (
753
- SELECT visitor_id FROM ${EVENTS_TABLE}
754
- WHERE ${where}
755
- 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
756
1065
  )`,
757
1066
  queryParams
758
1067
  )
@@ -767,9 +1076,19 @@ var ClickHouseAdapter = class {
767
1076
  totalPageviews: Number(u.totalPageviews),
768
1077
  totalSessions: Number(u.totalSessions),
769
1078
  lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
1079
+ referrer: u.referrer ? String(u.referrer) : void 0,
770
1080
  device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
771
1081
  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,
772
- language: u.language ? String(u.language) : void 0
1082
+ language: u.language ? String(u.language) : void 0,
1083
+ timezone: u.timezone ? String(u.timezone) : void 0,
1084
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
1085
+ utm: u.utm_source ? {
1086
+ source: String(u.utm_source),
1087
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
1088
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
1089
+ term: u.utm_term ? String(u.utm_term) : void 0,
1090
+ content: u.utm_content ? String(u.utm_content) : void 0
1091
+ } : void 0
773
1092
  }));
774
1093
  return {
775
1094
  users,
@@ -778,13 +1097,178 @@ var ClickHouseAdapter = class {
778
1097
  offset
779
1098
  };
780
1099
  }
781
- async getUserDetail(siteId, visitorId) {
782
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
783
- 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);
784
1112
  return user ?? null;
785
1113
  }
786
- async getUserEvents(siteId, visitorId, params) {
787
- 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
+ };
788
1272
  }
789
1273
  // ─── Site Management ──────────────────────────────────────
790
1274
  async createSite(data) {
@@ -797,6 +1281,7 @@ var ClickHouseAdapter = class {
797
1281
  name: data.name,
798
1282
  domain: data.domain,
799
1283
  allowedOrigins: data.allowedOrigins,
1284
+ conversionEvents: data.conversionEvents,
800
1285
  createdAt: nowISO,
801
1286
  updatedAt: nowISO
802
1287
  };
@@ -808,6 +1293,7 @@ var ClickHouseAdapter = class {
808
1293
  name: site.name,
809
1294
  domain: site.domain ?? null,
810
1295
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1296
+ conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
811
1297
  created_at: nowCH,
812
1298
  updated_at: nowCH,
813
1299
  version: 1,
@@ -819,7 +1305,7 @@ var ClickHouseAdapter = class {
819
1305
  }
820
1306
  async getSite(siteId) {
821
1307
  const rows = await this.queryRows(
822
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1308
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
823
1309
  FROM ${SITES_TABLE} FINAL
824
1310
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
825
1311
  { siteId }
@@ -828,7 +1314,7 @@ var ClickHouseAdapter = class {
828
1314
  }
829
1315
  async getSiteBySecret(secretKey) {
830
1316
  const rows = await this.queryRows(
831
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1317
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
832
1318
  FROM ${SITES_TABLE} FINAL
833
1319
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
834
1320
  { secretKey }
@@ -837,7 +1323,7 @@ var ClickHouseAdapter = class {
837
1323
  }
838
1324
  async listSites() {
839
1325
  const rows = await this.queryRows(
840
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1326
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
841
1327
  FROM ${SITES_TABLE} FINAL
842
1328
  WHERE is_deleted = 0
843
1329
  ORDER BY created_at DESC`,
@@ -847,7 +1333,7 @@ var ClickHouseAdapter = class {
847
1333
  }
848
1334
  async updateSite(siteId, data) {
849
1335
  const currentRows = await this.queryRows(
850
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at, version
1336
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
851
1337
  FROM ${SITES_TABLE} FINAL
852
1338
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
853
1339
  { siteId }
@@ -861,6 +1347,7 @@ var ClickHouseAdapter = class {
861
1347
  const newName = data.name !== void 0 ? data.name : String(current.name);
862
1348
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
863
1349
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1350
+ const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
864
1351
  await this.client.insert({
865
1352
  table: SITES_TABLE,
866
1353
  values: [{
@@ -869,6 +1356,7 @@ var ClickHouseAdapter = class {
869
1356
  name: newName,
870
1357
  domain: newDomain,
871
1358
  allowed_origins: newOrigins,
1359
+ conversion_events: newConversions,
872
1360
  created_at: toCHDateTime(String(current.created_at)),
873
1361
  updated_at: nowCH,
874
1362
  version: newVersion,
@@ -882,13 +1370,14 @@ var ClickHouseAdapter = class {
882
1370
  name: newName,
883
1371
  domain: newDomain ?? void 0,
884
1372
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1373
+ conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
885
1374
  createdAt: String(current.created_at),
886
1375
  updatedAt: nowISO
887
1376
  };
888
1377
  }
889
1378
  async deleteSite(siteId) {
890
1379
  const currentRows = await this.queryRows(
891
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
1380
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
892
1381
  FROM ${SITES_TABLE} FINAL
893
1382
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
894
1383
  { siteId }
@@ -904,6 +1393,7 @@ var ClickHouseAdapter = class {
904
1393
  name: String(current.name),
905
1394
  domain: current.domain ? String(current.domain) : null,
906
1395
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1396
+ conversion_events: current.conversion_events ? String(current.conversion_events) : null,
907
1397
  created_at: toCHDateTime(String(current.created_at)),
908
1398
  updated_at: nowCH,
909
1399
  version: Number(current.version) + 1,
@@ -915,7 +1405,7 @@ var ClickHouseAdapter = class {
915
1405
  }
916
1406
  async regenerateSecret(siteId) {
917
1407
  const currentRows = await this.queryRows(
918
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
1408
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
919
1409
  FROM ${SITES_TABLE} FINAL
920
1410
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
921
1411
  { siteId }
@@ -934,6 +1424,7 @@ var ClickHouseAdapter = class {
934
1424
  name: String(current.name),
935
1425
  domain: current.domain ? String(current.domain) : null,
936
1426
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1427
+ conversion_events: current.conversion_events ? String(current.conversion_events) : null,
937
1428
  created_at: toCHDateTime(String(current.created_at)),
938
1429
  updated_at: nowCH,
939
1430
  version: Number(current.version) + 1,
@@ -947,6 +1438,7 @@ var ClickHouseAdapter = class {
947
1438
  name: String(current.name),
948
1439
  domain: current.domain ? String(current.domain) : void 0,
949
1440
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1441
+ conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
950
1442
  createdAt: String(current.created_at),
951
1443
  updatedAt: nowISO
952
1444
  };
@@ -967,6 +1459,7 @@ var ClickHouseAdapter = class {
967
1459
  name: String(row.name),
968
1460
  domain: row.domain ? String(row.domain) : void 0,
969
1461
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1462
+ conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
970
1463
  createdAt: new Date(String(row.created_at)).toISOString(),
971
1464
  updatedAt: new Date(String(row.updated_at)).toISOString()
972
1465
  };
@@ -983,6 +1476,13 @@ var ClickHouseAdapter = class {
983
1476
  title: row.title ? String(row.title) : void 0,
984
1477
  name: row.event_name ? String(row.event_name) : void 0,
985
1478
  properties: this.parseJSON(row.properties),
1479
+ eventSource: row.event_source ? String(row.event_source) : void 0,
1480
+ eventSubtype: row.event_subtype ? String(row.event_subtype) : void 0,
1481
+ pagePath: row.page_path ? String(row.page_path) : void 0,
1482
+ targetUrlPath: row.target_url_path ? String(row.target_url_path) : void 0,
1483
+ elementSelector: row.element_selector ? String(row.element_selector) : void 0,
1484
+ elementText: row.element_text ? String(row.element_text) : void 0,
1485
+ scrollDepthPct: row.scroll_depth_pct !== null && row.scroll_depth_pct !== void 0 ? Number(row.scroll_depth_pct) : void 0,
986
1486
  userId: row.user_id ? String(row.user_id) : void 0,
987
1487
  traits: this.parseJSON(row.traits),
988
1488
  geo: row.country ? {
@@ -1019,11 +1519,43 @@ var ClickHouseAdapter = class {
1019
1519
  var import_mongodb = require("mongodb");
1020
1520
  var EVENTS_COLLECTION = "litemetrics_events";
1021
1521
  var SITES_COLLECTION = "litemetrics_sites";
1522
+ var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1523
+ function buildFilterMatch(filters) {
1524
+ if (!filters) return {};
1525
+ const map = {
1526
+ "geo.country": "country",
1527
+ "geo.city": "city",
1528
+ "geo.region": "region",
1529
+ "language": "language",
1530
+ "device.type": "device_type",
1531
+ "device.browser": "browser",
1532
+ "device.os": "os",
1533
+ "utm.source": "utm_source",
1534
+ "utm.medium": "utm_medium",
1535
+ "utm.campaign": "utm_campaign",
1536
+ "utm.term": "utm_term",
1537
+ "utm.content": "utm_content",
1538
+ "referrer": "referrer",
1539
+ "event_source": "event_source",
1540
+ "event_subtype": "event_subtype",
1541
+ "page_path": "page_path",
1542
+ "target_url_path": "target_url_path",
1543
+ "event_name": "event_name",
1544
+ "type": "type"
1545
+ };
1546
+ const match = {};
1547
+ for (const [key, value] of Object.entries(filters)) {
1548
+ if (!value || !map[key]) continue;
1549
+ match[map[key]] = value;
1550
+ }
1551
+ return match;
1552
+ }
1022
1553
  var MongoDBAdapter = class {
1023
1554
  client;
1024
1555
  db;
1025
1556
  collection;
1026
1557
  sites;
1558
+ identityMap;
1027
1559
  constructor(url) {
1028
1560
  this.client = new import_mongodb.MongoClient(url);
1029
1561
  }
@@ -1032,13 +1564,16 @@ var MongoDBAdapter = class {
1032
1564
  this.db = this.client.db();
1033
1565
  this.collection = this.db.collection(EVENTS_COLLECTION);
1034
1566
  this.sites = this.db.collection(SITES_COLLECTION);
1567
+ this.identityMap = this.db.collection(IDENTITY_MAP_COLLECTION);
1035
1568
  await Promise.all([
1036
1569
  this.collection.createIndex({ site_id: 1, timestamp: -1 }),
1037
1570
  this.collection.createIndex({ site_id: 1, type: 1 }),
1038
1571
  this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1039
1572
  this.collection.createIndex({ site_id: 1, session_id: 1 }),
1040
1573
  this.sites.createIndex({ site_id: 1 }, { unique: true }),
1041
- 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 })
1042
1577
  ]);
1043
1578
  }
1044
1579
  async insertEvents(events) {
@@ -1054,6 +1589,13 @@ var MongoDBAdapter = class {
1054
1589
  title: e.title ?? null,
1055
1590
  event_name: e.name ?? null,
1056
1591
  properties: e.properties ?? null,
1592
+ event_source: e.eventSource ?? null,
1593
+ event_subtype: e.eventSubtype ?? null,
1594
+ page_path: e.pagePath ?? null,
1595
+ target_url_path: e.targetUrlPath ?? null,
1596
+ element_selector: e.elementSelector ?? null,
1597
+ element_text: e.elementText ?? null,
1598
+ scroll_depth_pct: e.scrollDepthPct ?? null,
1057
1599
  user_id: e.userId ?? null,
1058
1600
  traits: e.traits ?? null,
1059
1601
  country: e.geo?.country ?? null,
@@ -1084,12 +1626,13 @@ var MongoDBAdapter = class {
1084
1626
  site_id: siteId,
1085
1627
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1086
1628
  };
1629
+ const filterMatch = buildFilterMatch(q.filters);
1087
1630
  let data = [];
1088
1631
  let total = 0;
1089
1632
  switch (q.metric) {
1090
1633
  case "pageviews": {
1091
1634
  const [result2] = await this.collection.aggregate([
1092
- { $match: { ...baseMatch, type: "pageview" } },
1635
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1093
1636
  { $count: "count" }
1094
1637
  ]).toArray();
1095
1638
  total = result2?.count ?? 0;
@@ -1098,7 +1641,7 @@ var MongoDBAdapter = class {
1098
1641
  }
1099
1642
  case "visitors": {
1100
1643
  const [result2] = await this.collection.aggregate([
1101
- { $match: baseMatch },
1644
+ { $match: { ...baseMatch, ...filterMatch } },
1102
1645
  { $group: { _id: "$visitor_id" } },
1103
1646
  { $count: "count" }
1104
1647
  ]).toArray();
@@ -1108,7 +1651,7 @@ var MongoDBAdapter = class {
1108
1651
  }
1109
1652
  case "sessions": {
1110
1653
  const [result2] = await this.collection.aggregate([
1111
- { $match: baseMatch },
1654
+ { $match: { ...baseMatch, ...filterMatch } },
1112
1655
  { $group: { _id: "$session_id" } },
1113
1656
  { $count: "count" }
1114
1657
  ]).toArray();
@@ -1118,16 +1661,31 @@ var MongoDBAdapter = class {
1118
1661
  }
1119
1662
  case "events": {
1120
1663
  const [result2] = await this.collection.aggregate([
1121
- { $match: { ...baseMatch, type: "event" } },
1664
+ { $match: { ...baseMatch, ...filterMatch, type: "event" } },
1122
1665
  { $count: "count" }
1123
1666
  ]).toArray();
1124
1667
  total = result2?.count ?? 0;
1125
1668
  data = [{ key: "events", value: total }];
1126
1669
  break;
1127
1670
  }
1671
+ case "conversions": {
1672
+ const conversionEvents = q.conversionEvents ?? [];
1673
+ if (conversionEvents.length === 0) {
1674
+ total = 0;
1675
+ data = [{ key: "conversions", value: 0 }];
1676
+ break;
1677
+ }
1678
+ const [result2] = await this.collection.aggregate([
1679
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1680
+ { $count: "count" }
1681
+ ]).toArray();
1682
+ total = result2?.count ?? 0;
1683
+ data = [{ key: "conversions", value: total }];
1684
+ break;
1685
+ }
1128
1686
  case "top_pages": {
1129
1687
  const rows = await this.collection.aggregate([
1130
- { $match: { ...baseMatch, type: "pageview", url: { $ne: null } } },
1688
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1131
1689
  { $group: { _id: "$url", value: { $sum: 1 } } },
1132
1690
  { $sort: { value: -1 } },
1133
1691
  { $limit: limit }
@@ -1138,7 +1696,7 @@ var MongoDBAdapter = class {
1138
1696
  }
1139
1697
  case "top_referrers": {
1140
1698
  const rows = await this.collection.aggregate([
1141
- { $match: { ...baseMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1699
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1142
1700
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1143
1701
  { $sort: { value: -1 } },
1144
1702
  { $limit: limit }
@@ -1149,7 +1707,7 @@ var MongoDBAdapter = class {
1149
1707
  }
1150
1708
  case "top_countries": {
1151
1709
  const rows = await this.collection.aggregate([
1152
- { $match: { ...baseMatch, country: { $ne: null } } },
1710
+ { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
1153
1711
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1154
1712
  { $project: { _id: 1, value: { $size: "$value" } } },
1155
1713
  { $sort: { value: -1 } },
@@ -1161,7 +1719,7 @@ var MongoDBAdapter = class {
1161
1719
  }
1162
1720
  case "top_cities": {
1163
1721
  const rows = await this.collection.aggregate([
1164
- { $match: { ...baseMatch, city: { $ne: null } } },
1722
+ { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
1165
1723
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1166
1724
  { $project: { _id: 1, value: { $size: "$value" } } },
1167
1725
  { $sort: { value: -1 } },
@@ -1173,7 +1731,24 @@ var MongoDBAdapter = class {
1173
1731
  }
1174
1732
  case "top_events": {
1175
1733
  const rows = await this.collection.aggregate([
1176
- { $match: { ...baseMatch, type: "event", event_name: { $ne: null } } },
1734
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
1735
+ { $group: { _id: "$event_name", value: { $sum: 1 } } },
1736
+ { $sort: { value: -1 } },
1737
+ { $limit: limit }
1738
+ ]).toArray();
1739
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1740
+ total = data.reduce((sum, d) => sum + d.value, 0);
1741
+ break;
1742
+ }
1743
+ case "top_conversions": {
1744
+ const conversionEvents = q.conversionEvents ?? [];
1745
+ if (conversionEvents.length === 0) {
1746
+ total = 0;
1747
+ data = [];
1748
+ break;
1749
+ }
1750
+ const rows = await this.collection.aggregate([
1751
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1177
1752
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1178
1753
  { $sort: { value: -1 } },
1179
1754
  { $limit: limit }
@@ -1182,9 +1757,92 @@ var MongoDBAdapter = class {
1182
1757
  total = data.reduce((sum, d) => sum + d.value, 0);
1183
1758
  break;
1184
1759
  }
1760
+ case "top_exit_pages": {
1761
+ const rows = await this.collection.aggregate([
1762
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1763
+ { $sort: { timestamp: 1 } },
1764
+ { $group: { _id: "$session_id", url: { $last: "$url" } } },
1765
+ { $group: { _id: "$url", value: { $sum: 1 } } },
1766
+ { $sort: { value: -1 } },
1767
+ { $limit: limit }
1768
+ ]).toArray();
1769
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1770
+ total = data.reduce((sum, d) => sum + d.value, 0);
1771
+ break;
1772
+ }
1773
+ case "top_transitions": {
1774
+ const rows = await this.collection.aggregate([
1775
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1776
+ {
1777
+ $setWindowFields: {
1778
+ partitionBy: "$session_id",
1779
+ sortBy: { timestamp: 1 },
1780
+ output: {
1781
+ prev_url: { $shift: { output: "$url", by: -1 } }
1782
+ }
1783
+ }
1784
+ },
1785
+ { $match: { prev_url: { $ne: null } } },
1786
+ { $group: { _id: { $concat: ["$prev_url", " \u2192 ", "$url"] }, value: { $sum: 1 } } },
1787
+ { $sort: { value: -1 } },
1788
+ { $limit: limit }
1789
+ ]).toArray();
1790
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1791
+ total = data.reduce((sum, d) => sum + d.value, 0);
1792
+ break;
1793
+ }
1794
+ case "top_scroll_pages": {
1795
+ const rows = await this.collection.aggregate([
1796
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
1797
+ { $group: { _id: "$page_path", value: { $sum: 1 } } },
1798
+ { $sort: { value: -1 } },
1799
+ { $limit: limit }
1800
+ ]).toArray();
1801
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1802
+ total = data.reduce((sum, d) => sum + d.value, 0);
1803
+ break;
1804
+ }
1805
+ case "top_button_clicks": {
1806
+ const rows = await this.collection.aggregate([
1807
+ {
1808
+ $match: {
1809
+ ...baseMatch,
1810
+ ...filterMatch,
1811
+ type: "event",
1812
+ event_subtype: "button_click",
1813
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1814
+ }
1815
+ },
1816
+ { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1817
+ { $sort: { value: -1 } },
1818
+ { $limit: limit }
1819
+ ]).toArray();
1820
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1821
+ total = data.reduce((sum, d) => sum + d.value, 0);
1822
+ break;
1823
+ }
1824
+ case "top_link_targets": {
1825
+ const rows = await this.collection.aggregate([
1826
+ {
1827
+ $match: {
1828
+ ...baseMatch,
1829
+ ...filterMatch,
1830
+ type: "event",
1831
+ event_subtype: { $in: ["link_click", "outbound_click"] },
1832
+ target_url_path: { $ne: null }
1833
+ }
1834
+ },
1835
+ { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1836
+ { $sort: { value: -1 } },
1837
+ { $limit: limit }
1838
+ ]).toArray();
1839
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1840
+ total = data.reduce((sum, d) => sum + d.value, 0);
1841
+ break;
1842
+ }
1185
1843
  case "top_devices": {
1186
1844
  const rows = await this.collection.aggregate([
1187
- { $match: { ...baseMatch, device_type: { $ne: null } } },
1845
+ { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
1188
1846
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1189
1847
  { $project: { _id: 1, value: { $size: "$value" } } },
1190
1848
  { $sort: { value: -1 } },
@@ -1196,7 +1854,7 @@ var MongoDBAdapter = class {
1196
1854
  }
1197
1855
  case "top_browsers": {
1198
1856
  const rows = await this.collection.aggregate([
1199
- { $match: { ...baseMatch, browser: { $ne: null } } },
1857
+ { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
1200
1858
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1201
1859
  { $project: { _id: 1, value: { $size: "$value" } } },
1202
1860
  { $sort: { value: -1 } },
@@ -1208,7 +1866,7 @@ var MongoDBAdapter = class {
1208
1866
  }
1209
1867
  case "top_os": {
1210
1868
  const rows = await this.collection.aggregate([
1211
- { $match: { ...baseMatch, os: { $ne: null } } },
1869
+ { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
1212
1870
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1213
1871
  { $project: { _id: 1, value: { $size: "$value" } } },
1214
1872
  { $sort: { value: -1 } },
@@ -1218,9 +1876,45 @@ var MongoDBAdapter = class {
1218
1876
  total = data.reduce((sum, d) => sum + d.value, 0);
1219
1877
  break;
1220
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
+ }
1221
1915
  }
1222
1916
  const result = { metric: q.metric, period, data, total };
1223
- if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
1917
+ if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
1224
1918
  const prevRange = previousPeriodRange(dateRange);
1225
1919
  const prevResult = await this.query({
1226
1920
  ...q,
@@ -1252,15 +1946,34 @@ var MongoDBAdapter = class {
1252
1946
  site_id: params.siteId,
1253
1947
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1254
1948
  };
1949
+ const filterMatch = buildFilterMatch(params.filters);
1255
1950
  if (params.metric === "pageviews") {
1256
1951
  baseMatch.type = "pageview";
1257
1952
  }
1953
+ if (params.metric === "events") {
1954
+ baseMatch.type = "event";
1955
+ }
1956
+ if (params.metric === "conversions") {
1957
+ baseMatch.type = "event";
1958
+ const conversionEvents = params.conversionEvents ?? [];
1959
+ if (conversionEvents.length === 0) {
1960
+ const data2 = fillBuckets(
1961
+ new Date(dateRange.from),
1962
+ new Date(dateRange.to),
1963
+ granularity,
1964
+ granularityToDateFormat(granularity),
1965
+ []
1966
+ );
1967
+ return { metric: params.metric, granularity, data: data2 };
1968
+ }
1969
+ baseMatch.event_name = { $in: conversionEvents };
1970
+ }
1258
1971
  const dateFormat = granularityToDateFormat(granularity);
1259
1972
  let pipeline;
1260
1973
  if (params.metric === "visitors" || params.metric === "sessions") {
1261
1974
  const groupField = params.metric === "visitors" ? "$visitor_id" : "$session_id";
1262
1975
  pipeline = [
1263
- { $match: baseMatch },
1976
+ { $match: { ...baseMatch, ...filterMatch } },
1264
1977
  {
1265
1978
  $group: {
1266
1979
  _id: {
@@ -1279,7 +1992,7 @@ var MongoDBAdapter = class {
1279
1992
  ];
1280
1993
  } else {
1281
1994
  pipeline = [
1282
- { $match: baseMatch },
1995
+ { $match: { ...baseMatch, ...filterMatch } },
1283
1996
  {
1284
1997
  $group: {
1285
1998
  _id: { $dateToString: { format: dateFormat, date: "$timestamp" } },
@@ -1360,7 +2073,12 @@ var MongoDBAdapter = class {
1360
2073
  const offset = params.offset ?? 0;
1361
2074
  const match = { site_id: params.siteId };
1362
2075
  if (params.type) match.type = params.type;
1363
- if (params.eventName) match.event_name = params.eventName;
2076
+ if (params.eventName) {
2077
+ match.event_name = params.eventName;
2078
+ } else if (params.eventNames && params.eventNames.length > 0) {
2079
+ match.event_name = { $in: params.eventNames };
2080
+ }
2081
+ if (params.eventSource) match.event_source = params.eventSource;
1364
2082
  if (params.visitorId) match.visitor_id = params.visitorId;
1365
2083
  if (params.userId) match.user_id = params.userId;
1366
2084
  if (params.period || params.dateFrom) {
@@ -1382,23 +2100,72 @@ var MongoDBAdapter = class {
1382
2100
  offset
1383
2101
  };
1384
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
+ }
1385
2122
  // ─── User Listing ──────────────────────────────────────
1386
2123
  async listUsers(params) {
1387
2124
  const limit = Math.min(params.limit ?? 50, 200);
1388
2125
  const offset = params.offset ?? 0;
1389
2126
  const match = { site_id: params.siteId };
1390
- if (params.search) {
1391
- match.$or = [
1392
- { visitor_id: { $regex: params.search, $options: "i" } },
1393
- { user_id: { $regex: params.search, $options: "i" } }
1394
- ];
1395
- }
1396
2127
  const pipeline = [
1397
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(
2163
+ { $sort: { timestamp: 1 } },
1398
2164
  {
1399
2165
  $group: {
1400
- _id: "$visitor_id",
1401
- userId: { $last: "$user_id" },
2166
+ _id: "$_resolved_id",
2167
+ visitorIds: { $addToSet: "$visitor_id" },
2168
+ userId: { $last: { $ifNull: ["$_resolved_user_id", "$user_id"] } },
1402
2169
  traits: { $last: "$traits" },
1403
2170
  firstSeen: { $min: "$timestamp" },
1404
2171
  lastSeen: { $max: "$timestamp" },
@@ -1406,13 +2173,22 @@ var MongoDBAdapter = class {
1406
2173
  totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
1407
2174
  sessions: { $addToSet: "$session_id" },
1408
2175
  lastUrl: { $last: "$url" },
2176
+ referrer: { $last: "$referrer" },
1409
2177
  device_type: { $last: "$device_type" },
1410
2178
  browser: { $last: "$browser" },
1411
2179
  os: { $last: "$os" },
1412
2180
  country: { $last: "$country" },
1413
2181
  city: { $last: "$city" },
1414
2182
  region: { $last: "$region" },
1415
- language: { $last: "$language" }
2183
+ language: { $last: "$language" },
2184
+ timezone: { $last: "$timezone" },
2185
+ screen_width: { $last: "$screen_width" },
2186
+ screen_height: { $last: "$screen_height" },
2187
+ utm_source: { $last: "$utm_source" },
2188
+ utm_medium: { $last: "$utm_medium" },
2189
+ utm_campaign: { $last: "$utm_campaign" },
2190
+ utm_term: { $last: "$utm_term" },
2191
+ utm_content: { $last: "$utm_content" }
1416
2192
  }
1417
2193
  },
1418
2194
  { $sort: { lastSeen: -1 } },
@@ -1422,10 +2198,11 @@ var MongoDBAdapter = class {
1422
2198
  count: [{ $count: "total" }]
1423
2199
  }
1424
2200
  }
1425
- ];
2201
+ );
1426
2202
  const [result] = await this.collection.aggregate(pipeline).toArray();
1427
2203
  const users = (result?.data ?? []).map((u) => ({
1428
- visitorId: u._id,
2204
+ visitorId: u.visitorIds[0] ?? u._id,
2205
+ visitorIds: u.visitorIds.length > 1 ? u.visitorIds : void 0,
1429
2206
  userId: u.userId ?? void 0,
1430
2207
  traits: u.traits ?? void 0,
1431
2208
  firstSeen: u.firstSeen.toISOString(),
@@ -1434,9 +2211,19 @@ var MongoDBAdapter = class {
1434
2211
  totalPageviews: u.totalPageviews,
1435
2212
  totalSessions: u.sessions.length,
1436
2213
  lastUrl: u.lastUrl ?? void 0,
2214
+ referrer: u.referrer ?? void 0,
1437
2215
  device: u.device_type ? { type: u.device_type, browser: u.browser ?? "", os: u.os ?? "" } : void 0,
1438
2216
  geo: u.country ? { country: u.country, city: u.city ?? void 0, region: u.region ?? void 0 } : void 0,
1439
- language: u.language ?? void 0
2217
+ language: u.language ?? void 0,
2218
+ timezone: u.timezone ?? void 0,
2219
+ screen: u.screen_width || u.screen_height ? { width: u.screen_width ?? 0, height: u.screen_height ?? 0 } : void 0,
2220
+ utm: u.utm_source ? {
2221
+ source: u.utm_source ?? void 0,
2222
+ medium: u.utm_medium ?? void 0,
2223
+ campaign: u.utm_campaign ?? void 0,
2224
+ term: u.utm_term ?? void 0,
2225
+ content: u.utm_content ?? void 0
2226
+ } : void 0
1440
2227
  }));
1441
2228
  return {
1442
2229
  users,
@@ -1445,13 +2232,125 @@ var MongoDBAdapter = class {
1445
2232
  offset
1446
2233
  };
1447
2234
  }
1448
- async getUserDetail(siteId, visitorId) {
1449
- const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1450
- const user = result.users.find((u) => u.visitorId === visitorId);
1451
- 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);
1452
2259
  }
1453
- async getUserEvents(siteId, visitorId, params) {
1454
- return this.listEvents({ ...params, siteId, visitorId });
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
+ };
2321
+ }
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
+ };
1455
2354
  }
1456
2355
  toEventListItem(doc) {
1457
2356
  return {
@@ -1465,6 +2364,13 @@ var MongoDBAdapter = class {
1465
2364
  title: doc.title ?? void 0,
1466
2365
  name: doc.event_name ?? void 0,
1467
2366
  properties: doc.properties ?? void 0,
2367
+ eventSource: doc.event_source ? doc.event_source : void 0,
2368
+ eventSubtype: doc.event_subtype ? doc.event_subtype : void 0,
2369
+ pagePath: doc.page_path ?? void 0,
2370
+ targetUrlPath: doc.target_url_path ?? void 0,
2371
+ elementSelector: doc.element_selector ?? void 0,
2372
+ elementText: doc.element_text ?? void 0,
2373
+ scrollDepthPct: doc.scroll_depth_pct ?? void 0,
1468
2374
  userId: doc.user_id ?? void 0,
1469
2375
  traits: doc.traits ?? void 0,
1470
2376
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
@@ -1488,6 +2394,7 @@ var MongoDBAdapter = class {
1488
2394
  name: data.name,
1489
2395
  domain: data.domain ?? null,
1490
2396
  allowed_origins: data.allowedOrigins ?? null,
2397
+ conversion_events: data.conversionEvents ?? null,
1491
2398
  created_at: now,
1492
2399
  updated_at: now
1493
2400
  };
@@ -1511,6 +2418,7 @@ var MongoDBAdapter = class {
1511
2418
  if (data.name !== void 0) updates.name = data.name;
1512
2419
  if (data.domain !== void 0) updates.domain = data.domain || null;
1513
2420
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
2421
+ if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
1514
2422
  const result = await this.sites.findOneAndUpdate(
1515
2423
  { site_id: siteId },
1516
2424
  { $set: updates },
@@ -1541,6 +2449,7 @@ var MongoDBAdapter = class {
1541
2449
  name: doc.name,
1542
2450
  domain: doc.domain ?? void 0,
1543
2451
  allowedOrigins: doc.allowed_origins ?? void 0,
2452
+ conversionEvents: doc.conversion_events ?? void 0,
1544
2453
  createdAt: doc.created_at.toISOString(),
1545
2454
  updatedAt: doc.updated_at.toISOString()
1546
2455
  };
@@ -1794,6 +2703,7 @@ async function createCollector(config) {
1794
2703
  if (allowed) {
1795
2704
  res.setHeader?.("Access-Control-Allow-Origin", origin || "*");
1796
2705
  res.setHeader?.("Access-Control-Allow-Methods", methods);
2706
+ res.setHeader?.("Access-Control-Allow-Credentials", "true");
1797
2707
  const headers = ["Content-Type", extraHeaders].filter(Boolean).join(", ");
1798
2708
  res.setHeader?.("Access-Control-Allow-Headers", headers);
1799
2709
  }
@@ -1811,6 +2721,51 @@ async function createCollector(config) {
1811
2721
  return { ...event, ip, geo, device };
1812
2722
  });
1813
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
+ }
1814
2769
  function extractIp(req) {
1815
2770
  if (config.trustProxy ?? true) {
1816
2771
  const forwarded = req.headers?.["x-forwarded-for"];
@@ -1824,7 +2779,14 @@ async function createCollector(config) {
1824
2779
  }
1825
2780
  function handler() {
1826
2781
  return async (req, res) => {
1827
- if (setCors(req, res, "POST, OPTIONS")) return;
2782
+ res.setHeader?.("Access-Control-Allow-Origin", "*");
2783
+ res.setHeader?.("Access-Control-Allow-Methods", "POST, OPTIONS");
2784
+ res.setHeader?.("Access-Control-Allow-Headers", "Content-Type");
2785
+ if (req.method === "OPTIONS") {
2786
+ res.writeHead?.(204);
2787
+ res.end?.();
2788
+ return;
2789
+ }
1828
2790
  if (req.method !== "POST") {
1829
2791
  sendJson(res, 405, { ok: false, error: "Method not allowed" });
1830
2792
  return;
@@ -1865,11 +2827,13 @@ async function createCollector(config) {
1865
2827
  sendJson(res, 200, { ok: true });
1866
2828
  return;
1867
2829
  }
2830
+ await processIdentity(filtered);
1868
2831
  await db.insertEvents(filtered);
1869
2832
  sendJson(res, 200, { ok: true });
1870
2833
  return;
1871
2834
  }
1872
2835
  }
2836
+ await processIdentity(enriched);
1873
2837
  await db.insertEvents(enriched);
1874
2838
  sendJson(res, 200, { ok: true });
1875
2839
  } catch (err) {
@@ -1903,8 +2867,13 @@ async function createCollector(config) {
1903
2867
  period: params.period,
1904
2868
  dateFrom: params.dateFrom,
1905
2869
  dateTo: params.dateTo,
1906
- granularity: q.granularity
2870
+ granularity: q.granularity,
2871
+ filters: q.filters ? JSON.parse(q.filters) : void 0
1907
2872
  };
2873
+ if (tsParams.metric === "conversions") {
2874
+ const site = await db.getSite(params.siteId);
2875
+ tsParams.conversionEvents = site?.conversionEvents ?? [];
2876
+ }
1908
2877
  const result2 = await db.queryTimeSeries(tsParams);
1909
2878
  sendJson(res, 200, result2);
1910
2879
  return;
@@ -1920,7 +2889,15 @@ async function createCollector(config) {
1920
2889
  sendJson(res, 200, result2);
1921
2890
  return;
1922
2891
  }
1923
- const result = await db.query(params);
2892
+ const isConversionMetric = params.metric === "conversions" || params.metric === "top_conversions";
2893
+ let result;
2894
+ if (isConversionMetric) {
2895
+ const site = await db.getSite(params.siteId);
2896
+ const conversionEvents = site?.conversionEvents ?? [];
2897
+ result = await db.query({ ...params, conversionEvents });
2898
+ } else {
2899
+ result = await db.query(params);
2900
+ }
1924
2901
  sendJson(res, 200, result);
1925
2902
  } catch (err) {
1926
2903
  sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
@@ -2017,10 +2994,13 @@ async function createCollector(config) {
2017
2994
  sendJson(res, 401, { ok: false, error: "Invalid or missing secret key" });
2018
2995
  return;
2019
2996
  }
2997
+ const eventNames = typeof q.eventNames === "string" ? q.eventNames.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2020
2998
  const params = {
2021
2999
  siteId: q.siteId,
2022
3000
  type: q.type,
2023
3001
  eventName: q.eventName,
3002
+ eventNames,
3003
+ eventSource: q.eventSource,
2024
3004
  visitorId: q.visitorId,
2025
3005
  userId: q.userId,
2026
3006
  period: q.period,
@@ -2060,9 +3040,13 @@ async function createCollector(config) {
2060
3040
  const visitorId = usersIdx >= 0 ? pathSegments[usersIdx + 1] : void 0;
2061
3041
  const action = usersIdx >= 0 ? pathSegments[usersIdx + 2] : void 0;
2062
3042
  if (visitorId && action === "events") {
3043
+ const eventNames = typeof q.eventNames === "string" ? q.eventNames.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2063
3044
  const params2 = {
2064
3045
  siteId: q.siteId,
2065
3046
  type: q.type,
3047
+ eventName: q.eventName,
3048
+ eventNames,
3049
+ eventSource: q.eventSource,
2066
3050
  period: q.period,
2067
3051
  dateFrom: q.dateFrom,
2068
3052
  dateTo: q.dateTo,
@@ -2110,11 +3094,11 @@ async function createCollector(config) {
2110
3094
  async listUsers(params) {
2111
3095
  return db.listUsers(params);
2112
3096
  },
2113
- async getUserDetail(siteId, visitorId) {
2114
- return db.getUserDetail(siteId, visitorId);
3097
+ async getUserDetail(siteId, identifier) {
3098
+ return db.getUserDetail(siteId, identifier);
2115
3099
  },
2116
- async getUserEvents(siteId, visitorId, params) {
2117
- return db.getUserEvents(siteId, visitorId, params);
3100
+ async getUserEvents(siteId, identifier, params) {
3101
+ return db.getUserEvents(siteId, identifier, params);
2118
3102
  },
2119
3103
  async track(siteId, name, properties, options) {
2120
3104
  const event = {
@@ -2169,7 +3153,8 @@ function createAdapter(config) {
2169
3153
  }
2170
3154
  }
2171
3155
  async function parseBody(req) {
2172
- if (req.body) return req.body;
3156
+ if (req.body && typeof req.body === "object") return req.body;
3157
+ if (typeof req.body === "string") return JSON.parse(req.body);
2173
3158
  return new Promise((resolve, reject) => {
2174
3159
  let data = "";
2175
3160
  req.on("data", (chunk) => {